initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,572 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild).
-behaviour(gen_server).
-export([start_link/1, update_counts/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-import(guild_permissions, [
get_member_permissions/3,
get_max_role_position/2,
can_view_channel/4
]).
-import(type_conv, [to_integer/1]).
-import(guild_voice, [
voice_state_update/2,
get_voice_state/2,
update_member_voice/2,
disconnect_voice_user/2,
disconnect_voice_user_if_in_channel/2,
disconnect_all_voice_users_in_channel/2,
confirm_voice_connection_from_livekit/2,
move_member/2
]).
-import(guild_data, [
get_guild_data/2,
get_guild_member/2,
list_guild_members/2,
get_vanity_url_channel/1,
get_first_viewable_text_channel/1
]).
-import(guild_members, [
get_users_to_mention_by_roles/2,
get_users_to_mention_by_user_ids/2,
get_all_users_to_mention/2,
resolve_all_mentions/2,
get_members_with_role/2,
can_manage_roles/2,
get_assignable_roles/2,
check_target_member/2,
get_viewable_channels/2
]).
-import(guild_sessions, [
handle_session_connect/3,
handle_session_down/2,
set_session_active_guild/3,
set_session_passive_guild/3
]).
-import(guild_dispatch, [
handle_dispatch/3
]).
start_link(GuildState) ->
gen_server:start_link(?MODULE, GuildState, []).
init(GuildState) ->
process_flag(trap_exit, true),
StateWithVoice =
case maps:is_key(voice_states, GuildState) of
true -> GuildState;
false -> maps:put(voice_states, #{}, GuildState)
end,
StateWithPresenceSubs = maps:put(presence_subscriptions, #{}, StateWithVoice),
StateWithMemberListSubs = maps:put(member_list_subscriptions, #{}, StateWithPresenceSubs),
StateWithMemberSubs = maps:put(
member_subscriptions, guild_subscriptions:init_state(), StateWithMemberListSubs
),
Data = maps:get(data, StateWithMemberSubs, #{}),
Members = maps:get(<<"members">>, Data, []),
MemberCount = length(Members),
OnlineCount = count_online_members(Members),
StateWithCounts = maps:put(member_count, MemberCount, StateWithMemberSubs),
StateWithPresences = maps:put(presences, #{}, maps:put(online_count, OnlineCount, StateWithCounts)),
guild_passive_sync:schedule_passive_sync(StateWithPresences),
{ok, StateWithPresences}.
handle_call({session_connect, Request}, {CallerPid, _}, State) ->
SessionPid = maps:get(session_pid, Request, CallerPid),
guild_sessions:handle_session_connect(Request, SessionPid, State);
handle_call({get_counts}, _From, State) ->
MemberCount = maps:get(member_count, State, 0),
OnlineCount = maps:get(online_count, State, 0),
{reply, #{member_count => MemberCount, presence_count => OnlineCount}, State};
handle_call({get_large_guild_metadata}, _From, State) ->
MemberCount = maps:get(member_count, State, 0),
Data = maps:get(data, State, #{}),
Guild = maps:get(<<"guild">>, Data, #{}),
Features = maps:get(<<"features">>, Guild, []),
{reply, #{member_count => MemberCount, features => Features}, State};
handle_call({get_users_to_mention_by_roles, Request}, _From, State) ->
guild_members:get_users_to_mention_by_roles(Request, State);
handle_call({get_users_to_mention_by_user_ids, Request}, _From, State) ->
guild_members:get_users_to_mention_by_user_ids(Request, State);
handle_call({get_all_users_to_mention, Request}, _From, State) ->
guild_members:get_all_users_to_mention(Request, State);
handle_call({resolve_all_mentions, Request}, _From, State) ->
guild_members:resolve_all_mentions(Request, State);
handle_call({get_members_with_role, Request}, _From, State) ->
guild_members:get_members_with_role(Request, State);
handle_call({check_permission, Request}, _From, State) ->
#{user_id := UserId, permission := Permission, channel_id := ChannelId} = Request,
true = is_integer(Permission),
HasPermission =
case owner_id(State) =:= UserId of
true ->
true;
false ->
Permissions = get_member_permissions(UserId, ChannelId, State),
(Permissions band Permission) =:= Permission
end,
{reply, #{has_permission => HasPermission}, State};
handle_call({get_user_permissions, Request}, _From, State) ->
#{user_id := UserId, channel_id := ChannelId} = Request,
Permissions = get_member_permissions(UserId, ChannelId, State),
{reply, #{permissions => Permissions}, State};
handle_call({can_manage_roles, Request}, _From, State) ->
guild_members:can_manage_roles(Request, State);
handle_call({can_manage_role, Request}, _From, State) ->
guild_members:can_manage_role(Request, State);
handle_call({get_guild_data, Request}, _From, State) ->
guild_data:get_guild_data(Request, State);
handle_call({get_assignable_roles, Request}, _From, State) ->
guild_members:get_assignable_roles(Request, State);
handle_call({get_user_max_role_position, Request}, _From, State) ->
#{user_id := UserId} = Request,
Position = get_max_role_position(UserId, State),
{reply, #{position => Position}, State};
handle_call({check_target_member, Request}, _From, State) ->
guild_members:check_target_member(Request, State);
handle_call({get_viewable_channels, Request}, _From, State) ->
guild_members:get_viewable_channels(Request, State);
handle_call({get_guild_member, Request}, _From, State) ->
guild_data:get_guild_member(Request, State);
handle_call({has_member, Request}, _From, State) ->
guild_data:has_member(Request, State);
handle_call({list_guild_members, Request}, _From, State) ->
guild_data:list_guild_members(Request, State);
handle_call({get_vanity_url_channel}, _From, State) ->
guild_data:get_vanity_url_channel(State);
handle_call({get_first_viewable_text_channel}, _From, State) ->
guild_data:get_first_viewable_text_channel(State);
handle_call({voice_state_update, Request}, _From, State) ->
guild_voice:voice_state_update(Request, State);
handle_call({get_voice_state, Request}, _From, State) ->
guild_voice:get_voice_state(Request, State);
handle_call({update_member_voice, Request}, _From, State) ->
guild_voice:update_member_voice(Request, State);
handle_call({disconnect_voice_user, Request}, _From, State) ->
guild_voice:disconnect_voice_user(Request, State);
handle_call({disconnect_voice_user_if_in_channel, Request}, _From, State) ->
guild_voice:disconnect_voice_user_if_in_channel(Request, State);
handle_call({disconnect_all_voice_users_in_channel, Request}, _From, State) ->
guild_voice:disconnect_all_voice_users_in_channel(Request, State);
handle_call({confirm_voice_connection_from_livekit, Request}, _From, State) ->
guild_voice:confirm_voice_connection_from_livekit(Request, State);
handle_call({move_member, Request}, _From, State) ->
guild_voice:move_member(Request, State);
handle_call({switch_voice_region, Request}, _From, State) ->
guild_voice:switch_voice_region_handler(Request, State);
handle_call({get_sessions}, _From, State) ->
{reply, State, State};
handle_call({get_category_channel_count, Request}, _From, State) ->
#{category_id := CategoryId} = Request,
Data = maps:get(data, State),
Channels = maps:get(<<"channels">>, Data, []),
Count = length([
Ch
|| Ch <- Channels,
map_utils:get_integer(Ch, <<"parent_id">>, undefined) =:= CategoryId
]),
{reply, #{count => Count}, State};
handle_call({get_channel_count}, _From, State) ->
Data = maps:get(data, State),
Channels = maps:get(<<"channels">>, Data, []),
Count = length(Channels),
{reply, #{count => Count}, State};
handle_call({reload, NewData}, _From, State) ->
OldData = maps:get(data, State),
NewState0 = maps:put(data, NewData, State),
GuildId = maps:get(id, State),
NewGuild = maps:get(<<"guild">>, NewData, #{}),
Sessions = maps:get(sessions, State, #{}),
Pids = [maps:get(pid, S) || {_Sid, S} <- maps:to_list(Sessions)],
EventData = maps:put(<<"guild_id">>, integer_to_binary(GuildId), NewGuild),
lists:foreach(
fun(Pid) ->
gen_server:cast(Pid, {dispatch, guild_update, EventData})
end,
Pids
),
NewState = cleanup_removed_member_subscriptions(OldData, NewData, NewState0),
{reply, ok, NewState};
handle_call({dispatch, Request}, _From, State) ->
#{event := Event, data := EventData} = Request,
ParsedEventData =
case is_binary(EventData) of
true -> jsx:decode(EventData, [{return_maps, true}]);
false -> EventData
end,
{noreply, NewState} = handle_dispatch(Event, ParsedEventData, State),
StateAfterPrune = prune_invalid_member_subscriptions(NewState),
{reply, ok, StateAfterPrune};
handle_call({terminate}, _From, State) ->
{stop, normal, ok, State};
handle_call({lazy_subscribe, Request}, _From, State) ->
#{session_id := SessionId, channel_id := ChannelId, ranges := Ranges} = Request,
Sessions0 = maps:get(sessions, State, #{}),
SessionUserId =
case maps:get(SessionId, Sessions0, undefined) of
#{user_id := Uid} -> Uid;
_ -> undefined
end,
case is_integer(SessionUserId) andalso
can_view_channel(SessionUserId, ChannelId, undefined, State) of
true ->
GuildId = maps:get(id, State),
ListId = guild_member_list:calculate_list_id(ChannelId, State),
{NewState, ShouldSendSync, NormalizedRanges} =
guild_member_list:subscribe_ranges(SessionId, ListId, Ranges, State),
case {ShouldSendSync, NormalizedRanges} of
{true, []} ->
{reply, ok, NewState};
{true, RangesToSend} ->
SyncResponse = guild_member_list:build_sync_response(GuildId, ListId, RangesToSend, NewState),
SyncResponseWithChannel = maps:put(<<"channel_id">>, integer_to_binary(ChannelId), SyncResponse),
Sessions = maps:get(sessions, NewState, #{}),
case maps:get(SessionId, Sessions, undefined) of
#{pid := SessionPid} when is_pid(SessionPid) ->
gen_server:cast(SessionPid, {dispatch, guild_member_list_update, SyncResponseWithChannel});
_ ->
ok
end,
{reply, ok, NewState};
_ ->
{reply, ok, NewState}
end;
false ->
{reply, ok, State}
end;
handle_call(_, _From, State) ->
{reply, ok, State}.
handle_cast({dispatch, Request}, State) ->
#{event := Event, data := EventData} = Request,
ParsedEventData =
case is_binary(EventData) of
true -> jsx:decode(EventData, [{return_maps, true}]);
false -> EventData
end,
handle_dispatch(Event, ParsedEventData, State);
handle_cast({store_pending_connection, ConnectionId, Metadata}, State) ->
PendingConnections = maps:get(pending_voice_connections, State, #{}),
NewPendingConnections = maps:put(ConnectionId, Metadata, PendingConnections),
NewState = maps:put(pending_voice_connections, NewPendingConnections, State),
{noreply, NewState};
handle_cast({add_virtual_channel_access, UserId, ChannelId}, State) ->
NewState = guild_virtual_channel_access:add_virtual_access(UserId, ChannelId, State),
guild_virtual_channel_access:dispatch_channel_visibility_change(
UserId, ChannelId, add, NewState
),
{noreply, NewState};
handle_cast({remove_virtual_channel_access, UserId, ChannelId}, State) ->
guild_virtual_channel_access:dispatch_channel_visibility_change(
UserId, ChannelId, remove, State
),
NewState = guild_virtual_channel_access:remove_virtual_access(UserId, ChannelId, State),
{noreply, NewState};
handle_cast({cleanup_virtual_access_for_user, UserId}, State) ->
NewState = guild_voice_disconnect:cleanup_virtual_channel_access_for_user(UserId, State),
{noreply, NewState};
handle_cast({set_session_active, SessionId}, State) ->
GuildId = maps:get(id, State),
NewState = set_session_active_guild(SessionId, GuildId, State),
{noreply, NewState};
handle_cast({set_session_passive, SessionId}, State) ->
GuildId = maps:get(id, State),
NewState = set_session_passive_guild(SessionId, GuildId, State),
{noreply, NewState};
handle_cast({update_member_subscriptions, SessionId, MemberIds}, State) ->
MemberSubs = maps:get(member_subscriptions, State, guild_subscriptions:init_state()),
Sessions = maps:get(sessions, State, #{}),
SessionUserId =
case maps:get(SessionId, Sessions, undefined) of
undefined -> undefined;
SessionData -> maps:get(user_id, SessionData, undefined)
end,
FilteredMemberIds = filter_member_ids_with_mutual_channels(SessionUserId, MemberIds, State),
OldSubscriptions = guild_subscriptions:get_user_ids_for_session(SessionId, MemberSubs),
NewMemberSubs = guild_subscriptions:update_subscriptions(SessionId, FilteredMemberIds, MemberSubs),
NewSubscriptions = guild_subscriptions:get_user_ids_for_session(SessionId, NewMemberSubs),
Added = sets:to_list(sets:subtract(NewSubscriptions, OldSubscriptions)),
Removed = sets:to_list(sets:subtract(OldSubscriptions, NewSubscriptions)),
State1 = maps:put(member_subscriptions, NewMemberSubs, State),
State2 = lists:foldl(
fun(UserId, Acc) ->
StateWithPresence = guild_sessions:subscribe_to_user_presence(UserId, Acc),
guild_presence:send_cached_presence_to_session(UserId, SessionId, StateWithPresence)
end,
State1,
Added
),
State3 = lists:foldl(
fun(UserId, Acc) -> guild_sessions:unsubscribe_from_user_presence(UserId, Acc) end,
State2,
Removed
),
{noreply, State3};
handle_cast({set_session_typing_override, SessionId, TypingFlag}, State) ->
GuildId = maps:get(id, State),
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
{noreply, State};
SessionData ->
NewSessionData = session_passive:set_typing_override(GuildId, TypingFlag, SessionData),
NewSessions = maps:put(SessionId, NewSessionData, Sessions),
NewState = maps:put(sessions, NewSessions, State),
logger:debug("[guild] Set typing override to ~p for session ~p in guild ~p", [
TypingFlag, SessionId, GuildId
]),
{noreply, NewState}
end;
handle_cast({send_guild_sync, SessionId}, State) ->
GuildId = maps:get(id, State),
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
logger:warning("[guild] Session ~p not found for send_guild_sync", [SessionId]),
{noreply, State};
SessionData ->
case session_passive:is_guild_synced(GuildId, SessionData) of
true ->
logger:debug("[guild] Guild ~p already synced for session ~p, skipping", [GuildId, SessionId]),
{noreply, State};
false ->
UserId = maps:get(user_id, SessionData),
SessionPid = maps:get(pid, SessionData),
GuildData = guild_data:get_guild_state(UserId, State),
gen_server:cast(SessionPid, {dispatch, guild_sync, GuildData}),
NewSessionData = session_passive:mark_guild_synced(GuildId, SessionData),
NewSessions = maps:put(SessionId, NewSessionData, Sessions),
{noreply, maps:put(sessions, NewSessions, State)}
end
end;
handle_cast({send_members_chunk, SessionId, ChunkData}, State) ->
GuildId = maps:get(id, State),
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
logger:warning("[guild] Session ~p not found for send_members_chunk", [SessionId]),
{noreply, State};
SessionData ->
SessionPid = maps:get(pid, SessionData),
ChunkWithGuildId = maps:put(<<"guild_id">>, integer_to_binary(GuildId), ChunkData),
gen_server:cast(SessionPid, {dispatch, guild_members_chunk, ChunkWithGuildId}),
{noreply, State}
end;
handle_cast(_, State) ->
{noreply, State}.
handle_info({presence, UserId, Payload}, State) ->
guild_presence:handle_bus_presence(UserId, Payload, State);
handle_info({'DOWN', Ref, process, _Pid, _Reason}, State) ->
guild_sessions:handle_session_down(Ref, State);
handle_info(passive_sync, State) ->
guild_passive_sync:handle_passive_sync(State);
handle_info(_, State) ->
{noreply, State}.
filter_member_ids_with_mutual_channels(SessionUserId, MemberIds, State) ->
case SessionUserId of
undefined ->
[];
_ ->
SessionChannels = guild_visibility:viewable_channel_set(SessionUserId, State),
lists:filtermap(
fun(MemberId) ->
case MemberId =:= SessionUserId of
true -> false;
false ->
case has_shared_channels(SessionChannels, MemberId, State) of
true -> {true, MemberId};
false -> false
end
end
end,
MemberIds
)
end.
has_shared_channels(_, MemberId, _) when not is_integer(MemberId) ->
false;
has_shared_channels(SessionChannels, MemberId, State) ->
CandidateChannels = guild_visibility:viewable_channel_set(MemberId, State),
not sets:is_empty(sets:intersection(SessionChannels, CandidateChannels)).
prune_invalid_member_subscriptions(State) ->
MemberSubs = maps:get(member_subscriptions, State, guild_subscriptions:init_state()),
Sessions = maps:get(sessions, State, #{}),
InvalidPairs = build_invalid_subscription_pairs(MemberSubs, Sessions, State),
lists:foldl(
fun({SessionId, UserId}, AccState) ->
remove_member_subscription(SessionId, UserId, AccState)
end,
State,
InvalidPairs
).
build_invalid_subscription_pairs(MemberSubs, Sessions, State) ->
lists:foldl(
fun({SessionId, SessionData}, Acc) ->
SessionUserId = maps:get(user_id, SessionData, undefined),
case SessionUserId of
undefined ->
Acc;
_ ->
SessionChannels = guild_visibility:viewable_channel_set(SessionUserId, State),
SubscriptionIds = guild_subscriptions:get_user_ids_for_session(SessionId, MemberSubs),
InvalidIds =
[MemberId
|| MemberId <- sets:to_list(SubscriptionIds),
not has_shared_channels(SessionChannels, MemberId, State)
],
lists:foldl(
fun(MemberId, Pairs) -> [{SessionId, MemberId} | Pairs] end,
Acc,
InvalidIds
)
end
end,
[],
maps:to_list(Sessions)
).
remove_member_subscription(SessionId, UserId, State) ->
MemberSubs = maps:get(member_subscriptions, State, guild_subscriptions:init_state()),
NewMemberSubs = guild_subscriptions:unsubscribe(SessionId, UserId, MemberSubs),
State1 = maps:put(member_subscriptions, NewMemberSubs, State),
guild_sessions:unsubscribe_from_user_presence(UserId, State1).
terminate(Reason, State) when is_map(State) ->
PresenceSubs = maps:get(presence_subscriptions, State, #{}),
lists:foreach(
fun(UserId) ->
presence_bus:unsubscribe(UserId)
end,
maps:keys(PresenceSubs)
),
maybe_report_crash(Reason, State),
ok;
terminate(Reason, State) ->
maybe_report_crash(Reason, State),
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
maybe_report_crash(normal, _State) ->
ok;
maybe_report_crash(shutdown, _State) ->
ok;
maybe_report_crash({shutdown, _}, _State) ->
ok;
maybe_report_crash(Reason, State) ->
GuildId =
case State of
#{id := Id} ->
integer_to_binary(Id);
#{data := Data} when is_map(Data) ->
case maps:get(<<"id">>, Data, undefined) of
undefined -> <<"unknown">>;
Id -> Id
end;
_ ->
<<"unknown">>
end,
Stacktrace = iolist_to_binary(io_lib:format("~p", [Reason])),
metrics_client:crash(GuildId, Stacktrace),
ok.
cleanup_removed_member_subscriptions(OldData, NewData, State) ->
OldMembers = maps:get(<<"members">>, OldData, []),
NewMembers = maps:get(<<"members">>, NewData, []),
OldMemberIds = sets:from_list([member_user_id(M) || M <- OldMembers]),
NewMemberIds = sets:from_list([member_user_id(M) || M <- NewMembers]),
RemovedIds = sets:subtract(OldMemberIds, NewMemberIds),
PresenceSubs = maps:get(presence_subscriptions, State, #{}),
NewPresenceSubs = lists:foldl(
fun(UserId, Subs) ->
case maps:is_key(UserId, Subs) of
true ->
presence_bus:unsubscribe(UserId),
maps:remove(UserId, Subs);
false ->
Subs
end
end,
PresenceSubs,
sets:to_list(RemovedIds)
),
maps:put(presence_subscriptions, NewPresenceSubs, State).
member_user_id(Member) ->
User = maps:get(<<"user">>, Member, #{}),
map_utils:get_integer(User, <<"id">>, undefined).
owner_id(State) ->
case resolve_data_map(State) of
undefined ->
0;
Data ->
Guild = maps:get(<<"guild">>, Data, #{}),
to_integer(maps:get(<<"owner_id">>, Guild, <<"0">>))
end.
resolve_data_map(State) when is_map(State) ->
case maps:find(data, State) of
{ok, Data} when is_map(Data) ->
Data;
{ok, Data} when is_map(Data) =:= false ->
Data;
error ->
case State of
#{<<"members">> := _} ->
State;
_ ->
undefined
end
end;
resolve_data_map(_) ->
undefined.
count_online_members(Members) ->
lists:foldl(
fun(Member, Count) ->
Presence = maps:get(<<"presence">>, Member, <<"offline">>),
case Presence of
<<"offline">> -> Count;
_ -> Count + 1
end
end,
0,
Members
).
update_counts(State) ->
Data = maps:get(data, State, #{}),
Members = maps:get(<<"members">>, Data, []),
MemberCount = length(Members),
OnlineCount = count_online_members(Members),
maps:put(member_count, MemberCount, maps:put(online_count, OnlineCount, State)).

View File

@@ -0,0 +1,143 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_availability).
-export([
is_guild_unavailable_for_user/2,
is_user_staff/2,
check_unavailability_transition/2,
handle_unavailability_transition/2
]).
-import(guild_permissions, [find_member_by_user_id/2]).
-import(guild_data, [get_guild_state/2]).
is_guild_unavailable_for_user(UserId, State) ->
Data = maps:get(data, State),
Guild = maps:get(<<"guild">>, Data),
Features = maps:get(<<"features">>, Guild, []),
HasUnavailableForEveryone = lists:member(<<"UNAVAILABLE_FOR_EVERYONE">>, Features),
HasUnavailableForEveryoneButStaff =
lists:member(<<"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF">>, Features),
case {HasUnavailableForEveryone, HasUnavailableForEveryoneButStaff} of
{true, _} ->
true;
{false, true} ->
not is_user_staff(UserId, State);
{false, false} ->
false
end.
is_user_staff(UserId, State) ->
case find_member_by_user_id(UserId, State) of
undefined ->
false;
Member ->
User = maps:get(<<"user">>, Member, #{}),
Flags = utils:binary_to_integer_safe(maps:get(<<"flags">>, User, <<"0">>)),
(Flags band 16#1) =:= 16#1
end.
check_unavailability_transition(OldState, NewState) ->
OldData = maps:get(data, OldState),
OldGuild = maps:get(<<"guild">>, OldData),
OldFeatures = maps:get(<<"features">>, OldGuild, []),
NewData = maps:get(data, NewState),
NewGuild = maps:get(<<"guild">>, NewData),
NewFeatures = maps:get(<<"features">>, NewGuild, []),
OldUnavailableForEveryone = lists:member(<<"UNAVAILABLE_FOR_EVERYONE">>, OldFeatures),
NewUnavailableForEveryone = lists:member(<<"UNAVAILABLE_FOR_EVERYONE">>, NewFeatures),
OldUnavailableForEveryoneButStaff =
lists:member(<<"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF">>, OldFeatures),
NewUnavailableForEveryoneButStaff =
lists:member(<<"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF">>, NewFeatures),
OldIsUnavailable = OldUnavailableForEveryone orelse OldUnavailableForEveryoneButStaff,
NewIsUnavailable = NewUnavailableForEveryone orelse NewUnavailableForEveryoneButStaff,
case {OldIsUnavailable, NewIsUnavailable} of
{false, true} ->
{unavailable_enabled, NewUnavailableForEveryoneButStaff};
{true, false} ->
unavailable_disabled;
_ ->
case
{OldUnavailableForEveryoneButStaff, NewUnavailableForEveryoneButStaff,
OldUnavailableForEveryone, NewUnavailableForEveryone}
of
{true, false, false, true} ->
{unavailable_enabled, false};
{false, true, true, false} ->
{unavailable_enabled, true};
_ ->
no_change
end
end.
handle_unavailability_transition(OldState, NewState) ->
GuildId = maps:get(id, NewState),
UnavailablePayload = #{
<<"id">> => integer_to_binary(GuildId),
<<"unavailable">> => true
},
case check_unavailability_transition(OldState, NewState) of
{unavailable_enabled, StaffOnly} ->
Sessions = maps:get(sessions, NewState, #{}),
lists:foreach(
fun({_SessionId, SessionData}) ->
UserId = maps:get(user_id, SessionData),
Pid = maps:get(pid, SessionData),
ShouldBeUnavailable =
case StaffOnly of
true -> not is_user_staff(UserId, NewState);
false -> true
end,
case ShouldBeUnavailable of
true ->
gen_server:cast(Pid, {dispatch, guild_delete, UnavailablePayload});
false ->
ok
end
end,
maps:to_list(Sessions)
);
unavailable_disabled ->
Sessions = maps:get(sessions, NewState, #{}),
GuildId = maps:get(id, NewState),
BulkPresences = presence_utils:collect_guild_member_presences(NewState),
lists:foreach(
fun({_SessionId, SessionData}) ->
UserId = maps:get(user_id, SessionData),
Pid = maps:get(pid, SessionData),
GuildState = get_guild_state(UserId, NewState),
gen_server:cast(Pid, {dispatch, guild_create, GuildState}),
presence_utils:send_presence_bulk(Pid, GuildId, UserId, BulkPresences)
end,
maps:to_list(Sessions)
);
no_change ->
ok
end.

View File

@@ -0,0 +1,75 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_client).
-export([
voice_state_update/3
]).
-export_type([
voice_state_update_success/0,
voice_state_update_error/0,
voice_state_update_result/0
]).
-define(DEFAULT_TIMEOUT, 12000).
-type voice_state_update_success() :: #{
success := true,
token => binary(),
endpoint => binary(),
connection_id => binary(),
voice_state => map(),
needs_token => boolean()
}.
-type voice_state_update_error() :: {error, atom(), atom()}.
-type voice_state_update_result() ::
{ok, voice_state_update_success()}
| {error, timeout}
| {error, noproc}
| {error, atom(), atom()}.
-spec voice_state_update(pid(), map(), timeout()) -> voice_state_update_result().
voice_state_update(GuildPid, Request, Timeout) ->
try gen_server:call(GuildPid, {voice_state_update, Request}, Timeout) of
Response when is_map(Response) ->
case maps:get(success, Response, false) of
true -> {ok, Response};
false -> {error, unknown, internal_error}
end;
{error, Category, ErrorAtom} when is_atom(Category), is_atom(ErrorAtom) ->
{error, Category, ErrorAtom}
catch
exit:{timeout, _} ->
{error, timeout};
exit:{noproc, _} ->
{error, noproc};
exit:{normal, _} ->
{error, noproc}
end.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
module_exports_test() ->
Exports = guild_client:module_info(exports),
?assert(lists:member({voice_state_update, 3}, Exports)).
-endif.

View File

@@ -0,0 +1,313 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_data).
-export([get_guild_data/2]).
-export([get_guild_member/2]).
-export([has_member/2]).
-export([list_guild_members/2]).
-export([get_vanity_url_channel/1]).
-export([get_first_viewable_text_channel/1]).
-export([get_guild_state/2]).
-export([find_everyone_viewable_text_channel/2]).
-type guild_state() :: map().
-type guild_reply(T) :: {reply, T, guild_state()}.
-type guild_data_map() :: map().
-type guild_member() :: map().
-type channel_list() :: [map()].
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec get_guild_data(map(), guild_state()) -> guild_reply(map()).
get_guild_data(#{user_id := UserId}, State) ->
Data = guild_data_map(State),
case UserId of
null ->
GuildData = build_complete_guild_data(Data, State),
Reply = #{guild_data => GuildData},
{reply, Reply, State};
_ ->
Members = map_utils:ensure_list(maps:get(<<"members">>, Data, [])),
case member_in_list(UserId, Members) of
false ->
{reply, #{guild_data => null, error_reason => <<"forbidden">>}, State};
true ->
GuildData = build_complete_guild_data(Data, State),
{reply, #{guild_data => GuildData}, State}
end
end.
-spec get_guild_member(map(), guild_state()) -> guild_reply(map()).
get_guild_member(#{user_id := UserId}, State) ->
case find_member_by_user_id(UserId, State) of
undefined ->
{reply, #{success => false, member_data => null}, State};
Member ->
{reply, #{success => true, member_data => Member}, State}
end.
-spec has_member(map(), guild_state()) -> guild_reply(map()).
has_member(#{user_id := UserId}, State) ->
case find_member_by_user_id(UserId, State) of
undefined ->
{reply, #{has_member => false}, State};
_ ->
{reply, #{has_member => true}, State}
end.
-spec list_guild_members(map(), guild_state()) -> guild_reply(map()).
list_guild_members(#{limit := Limit, offset := Offset}, State) ->
Data = guild_data_map(State),
AllMembers = map_utils:ensure_list(maps:get(<<"members">>, Data, [])),
TotalCount = length(AllMembers),
PaginatedMembers = paginate_members(AllMembers, Limit, Offset),
{reply, #{members => PaginatedMembers, total => TotalCount}, State}.
-spec get_vanity_url_channel(guild_state()) -> guild_reply(map()).
get_vanity_url_channel(State) ->
Channels = channels_from_state(State),
EveryoneChannelId = find_everyone_viewable_text_channel(Channels, State),
{reply, #{channel_id => EveryoneChannelId}, State}.
-spec get_first_viewable_text_channel(guild_state()) -> guild_reply(map()).
get_first_viewable_text_channel(State) ->
Channels = channels_from_state(State),
EveryoneChannelId = find_everyone_viewable_text_channel(Channels, State),
{reply, #{channel_id => EveryoneChannelId}, State}.
-spec get_guild_state(integer(), guild_state()) -> map().
get_guild_state(UserId, State) ->
Data = guild_data_map(State),
GuildId = map_utils:get_integer(State, id, 0),
AllChannels = channels_from_data(Data),
AllMembers = map_utils:ensure_list(maps:get(<<"members">>, Data, [])),
Member = find_member_by_user_id(UserId, State),
{ViewableChannels, JoinedAt} = derive_member_view(UserId, Member, State, AllChannels),
OnlineCount = guild_member_list:get_online_count(State),
OwnMemberList = case Member of
undefined -> [];
M -> [M]
end,
#{
<<"id">> => integer_to_binary(GuildId),
<<"properties">> => maps:get(<<"guild">>, Data, #{}),
<<"roles">> => map_utils:ensure_list(maps:get(<<"roles">>, Data, [])),
<<"channels">> => ViewableChannels,
<<"emojis">> => maps:get(<<"emojis">>, Data, []),
<<"stickers">> => maps:get(<<"stickers">>, Data, []),
<<"members">> => OwnMemberList,
<<"member_count">> => length(AllMembers),
<<"online_count">> => OnlineCount,
<<"presences">> => [],
<<"voice_states">> => guild_voice:get_voice_states_list(State),
<<"joined_at">> => JoinedAt
}.
-spec find_everyone_viewable_text_channel(channel_list(), guild_state()) -> integer() | null.
find_everyone_viewable_text_channel(Channels, State) ->
GuildId = map_utils:get_integer(State, id, 0),
Data = guild_data_map(State),
Roles = map_utils:ensure_list(maps:get(<<"roles">>, Data, [])),
EveryonePerms = role_permissions_for_id(Roles, GuildId),
lists:foldl(
fun(Channel, Acc) ->
case Acc of
null ->
select_first_viewable(Channel, GuildId, EveryonePerms);
_ ->
Acc
end
end,
null,
map_utils:ensure_list(Channels)
).
find_member_by_user_id(UserId, State) ->
guild_permissions:find_member_by_user_id(UserId, State).
-spec guild_data_map(guild_state()) -> guild_data_map().
guild_data_map(State) ->
map_utils:ensure_map(map_utils:get_safe(State, data, #{})).
-spec build_complete_guild_data(guild_data_map(), guild_state()) -> map().
build_complete_guild_data(Data, _State) ->
GuildProperties = maps:get(<<"guild">>, Data, #{}),
maps:merge(GuildProperties, #{
<<"roles">> => map_utils:ensure_list(maps:get(<<"roles">>, Data, [])),
<<"channels">> => map_utils:ensure_list(maps:get(<<"channels">>, Data, [])),
<<"emojis">> => map_utils:ensure_list(maps:get(<<"emojis">>, Data, [])),
<<"stickers">> => map_utils:ensure_list(maps:get(<<"stickers">>, Data, []))
}).
-spec channels_from_state(guild_state()) -> channel_list().
channels_from_state(State) ->
Data = guild_data_map(State),
channels_from_data(Data).
-spec channels_from_data(guild_data_map()) -> channel_list().
channels_from_data(Data) ->
map_utils:ensure_list(maps:get(<<"channels">>, Data, [])).
-spec member_in_list(integer(), [guild_member()]) -> boolean().
member_in_list(UserId, Members) ->
lists:any(fun(Member) -> member_matches(UserId, Member) end, Members).
-spec member_matches(integer(), guild_member()) -> boolean().
member_matches(UserId, Member) ->
MemberUser = map_utils:ensure_map(maps:get(<<"user">>, Member, #{})),
case map_utils:get_integer(MemberUser, <<"id">>, undefined) of
undefined -> false;
Id -> Id =:= UserId
end.
-spec paginate_members([guild_member()], non_neg_integer(), non_neg_integer()) -> [guild_member()].
paginate_members(Members, Limit, Offset) ->
case Offset >= length(Members) of
true ->
[];
false ->
Remaining = lists:nthtail(Offset, Members),
case length(Remaining) > Limit of
true -> lists:sublist(Remaining, Limit);
false -> Remaining
end
end.
-spec derive_member_view(integer(), guild_member() | undefined, guild_state(), channel_list()) ->
{channel_list(), term()}.
derive_member_view(_UserId, undefined, _State, _Channels) ->
{[], null};
derive_member_view(UserId, Member, State, Channels) ->
Filtered =
lists:filter(
fun(Channel) ->
ChannelId = map_utils:get_integer(Channel, <<"id">>, undefined),
case ChannelId of
undefined -> false;
_ -> guild_permissions:can_view_channel(UserId, ChannelId, Member, State)
end
end,
Channels
),
JoinedAt = maps:get(<<"joined_at">>, Member, null),
{Filtered, JoinedAt}.
-spec role_permissions_for_id(list(), integer()) -> integer().
role_permissions_for_id(Roles, GuildId) ->
lists:foldl(
fun(Role, Acc) ->
case map_utils:get_integer(Role, <<"id">>, undefined) of
GuildId -> map_utils:get_integer(Role, <<"permissions">>, 0);
_ -> Acc
end
end,
0,
map_utils:ensure_list(Roles)
).
-spec select_first_viewable(map(), integer(), integer()) -> integer() | null.
select_first_viewable(Channel, GuildId, BasePerms) ->
ChannelType = map_utils:get_integer(Channel, <<"type">>, undefined),
ChannelId = map_utils:get_integer(Channel, <<"id">>, undefined),
select_first_viewable(ChannelType, ChannelId, Channel, GuildId, BasePerms).
select_first_viewable(0, ChannelId, Channel, GuildId, BasePerms) when is_integer(ChannelId) ->
case (BasePerms band constants:administrator_permission()) =/= 0 of
true ->
ChannelId;
false ->
FinalPerms = guild_permissions:apply_channel_overwrites(
BasePerms, GuildId, [GuildId], Channel, GuildId
),
case (FinalPerms band constants:view_channel_permission()) =/= 0 of
true -> ChannelId;
false -> null
end
end;
select_first_viewable(_, _, _, _, _) ->
null.
-ifdef(TEST).
get_guild_data_membership_gate_test() ->
State = test_state(),
{reply, Reply1, _} = get_guild_data(#{user_id => 999}, State),
?assertEqual(null, maps:get(guild_data, Reply1)),
?assertEqual(<<"forbidden">>, maps:get(error_reason, Reply1)),
{reply, Reply2, _} = get_guild_data(#{user_id => 200}, State),
Guild = maps:get(guild_data, Reply2),
?assertEqual(<<"Fluxer">>, maps:get(<<"name">>, Guild)),
Roles = maps:get(<<"roles">>, Guild, []),
?assert(length(Roles) > 0).
get_guild_state_filters_channels_test() ->
State = test_state(),
GuildState = get_guild_state(200, State),
Channels = maps:get(<<"channels">>, GuildState),
?assert(lists:any(fun(Chan) -> maps:get(<<"id">>, Chan) =:= <<"500">> end, Channels)),
?assertEqual(<<"2024-01-01T00:00:00Z">>, maps:get(<<"joined_at">>, GuildState)).
find_everyone_viewable_text_channel_test() ->
State = test_state(),
Data = guild_data_map(State),
Channels = maps:get(<<"channels">>, Data),
ChannelId = find_everyone_viewable_text_channel(Channels, State),
?assertEqual(500, ChannelId).
test_state() ->
GuildId = 100,
ViewPerm = constants:view_channel_permission(),
#{
id => GuildId,
data => #{
<<"guild">> => #{<<"name">> => <<"Fluxer">>},
<<"roles">> => [
#{
<<"id">> => integer_to_binary(GuildId),
<<"permissions">> => integer_to_binary(ViewPerm)
}
],
<<"channels">> => [
#{
<<"id">> => <<"500">>,
<<"type">> => 0,
<<"permission_overwrites">> => []
},
#{
<<"id">> => <<"501">>,
<<"type">> => 2,
<<"permission_overwrites">> => []
}
],
<<"members">> => [
#{
<<"user">> => #{<<"id">> => <<"200">>},
<<"roles">> => [integer_to_binary(GuildId)],
<<"joined_at">> => <<"2024-01-01T00:00:00Z">>
}
],
<<"emojis">> => [],
<<"stickers">> => []
}
}.
-endif.

View File

@@ -0,0 +1,510 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_dispatch).
-export([
handle_dispatch/3,
extract_and_remove_session_id/1,
decorate_member_data/3,
extract_member_for_event/3,
collect_and_send_push_notifications/3,
normalize_event/1
]).
-import(guild_permissions, [find_member_by_user_id/2]).
-import(guild_state, [update_state/3]).
-import(guild_sessions, [
filter_sessions_for_channel/4,
filter_sessions_for_manage_channels/4,
filter_sessions_exclude_session/2
]).
-import(session_passive, [should_receive_event/5]).
normalize_event(Event) when is_atom(Event) -> Event;
normalize_event(<<"MESSAGE_CREATE">>) -> message_create;
normalize_event(<<"MESSAGE_UPDATE">>) -> message_update;
normalize_event(<<"MESSAGE_DELETE">>) -> message_delete;
normalize_event(<<"MESSAGE_DELETE_BULK">>) -> message_delete_bulk;
normalize_event(<<"MESSAGE_REACTION_ADD">>) -> message_reaction_add;
normalize_event(<<"MESSAGE_REACTION_REMOVE">>) -> message_reaction_remove;
normalize_event(<<"MESSAGE_REACTION_REMOVE_ALL">>) -> message_reaction_remove_all;
normalize_event(<<"MESSAGE_REACTION_REMOVE_EMOJI">>) -> message_reaction_remove_emoji;
normalize_event(<<"CHANNEL_CREATE">>) -> channel_create;
normalize_event(<<"CHANNEL_UPDATE">>) -> channel_update;
normalize_event(<<"CHANNEL_UPDATE_BULK">>) -> channel_update_bulk;
normalize_event(<<"CHANNEL_DELETE">>) -> channel_delete;
normalize_event(<<"CHANNEL_PINS_UPDATE">>) -> channel_pins_update;
normalize_event(<<"TYPING_START">>) -> typing_start;
normalize_event(<<"INVITE_CREATE">>) -> invite_create;
normalize_event(<<"INVITE_DELETE">>) -> invite_delete;
normalize_event(<<"GUILD_UPDATE">>) -> guild_update;
normalize_event(EventBinary) when is_binary(EventBinary) -> EventBinary.
handle_dispatch(Event, EventData, State) ->
case should_skip_dispatch(Event, State) of
true ->
{noreply, State};
false ->
NormalizedEvent = normalize_event(Event),
process_dispatch(NormalizedEvent, EventData, State)
end.
should_skip_dispatch(guild_update, _State) ->
false;
should_skip_dispatch(_Event, State) ->
Data = maps:get(data, State),
Guild = maps:get(<<"guild">>, Data),
Features = maps:get(<<"features">>, Guild, []),
lists:member(<<"UNAVAILABLE_FOR_EVERYONE">>, Features) orelse
lists:member(<<"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF">>, Features).
process_dispatch(Event, EventData, State) ->
GuildId = maps:get(id, State),
{SessionIdOpt, CleanData} = extract_session_id_if_needed(Event, EventData),
DecoratedData = maps:put(<<"guild_id">>, integer_to_binary(GuildId), CleanData),
FinalData = decorate_member_data(Event, DecoratedData, State),
UpdatedState = update_state(Event, FinalData, State),
Sessions = maps:get(sessions, UpdatedState, #{}),
FilteredSessions = filter_sessions_for_event(
Event, FinalData, SessionIdOpt, Sessions, UpdatedState
),
dispatch_to_sessions(FilteredSessions, Event, FinalData, UpdatedState),
maybe_send_push_notifications(Event, FinalData, GuildId, UpdatedState),
maybe_broadcast_member_list_update(Event, FinalData, State, UpdatedState),
{noreply, UpdatedState}.
extract_session_id_if_needed(Event, EventData) ->
case Event of
message_reaction_add -> extract_and_remove_session_id(EventData);
message_reaction_remove -> extract_and_remove_session_id(EventData);
_ -> {undefined, EventData}
end.
filter_sessions_for_event(Event, FinalData, SessionIdOpt, Sessions, UpdatedState) ->
case is_channel_scoped_event(Event) of
true ->
ChannelId = extract_channel_id(Event, FinalData),
filter_sessions_for_channel(Sessions, ChannelId, SessionIdOpt, UpdatedState);
false ->
case is_invite_event(Event) of
true ->
ChannelIdBin = maps:get(<<"channel_id">>, FinalData, <<"0">>),
ChannelId = validation:snowflake_or_default(<<"channel_id">>, ChannelIdBin, 0),
filter_sessions_for_manage_channels(
Sessions, ChannelId, SessionIdOpt, UpdatedState
);
false ->
case is_bulk_update_event(Event) of
true ->
filter_sessions_exclude_session(Sessions, SessionIdOpt);
false ->
filter_sessions_exclude_session(Sessions, SessionIdOpt)
end
end
end.
is_channel_scoped_event(channel_create) -> true;
is_channel_scoped_event(channel_update) -> true;
is_channel_scoped_event(message_create) -> true;
is_channel_scoped_event(message_update) -> true;
is_channel_scoped_event(message_delete) -> true;
is_channel_scoped_event(message_delete_bulk) -> true;
is_channel_scoped_event(message_reaction_add) -> true;
is_channel_scoped_event(message_reaction_remove) -> true;
is_channel_scoped_event(message_reaction_remove_all) -> true;
is_channel_scoped_event(message_reaction_remove_emoji) -> true;
is_channel_scoped_event(typing_start) -> true;
is_channel_scoped_event(channel_pins_update) -> true;
is_channel_scoped_event(_) -> false.
is_invite_event(invite_create) -> true;
is_invite_event(invite_delete) -> true;
is_invite_event(_) -> false.
is_bulk_update_event(channel_update_bulk) -> true;
is_bulk_update_event(_) -> false.
extract_channel_id(Event, FinalData) ->
case Event of
channel_create ->
ChannelIdBin = maps:get(<<"id">>, FinalData, <<"0">>),
validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0);
channel_update ->
ChannelIdBin = maps:get(<<"id">>, FinalData, <<"0">>),
validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0);
_ ->
ChannelIdBin = maps:get(<<"channel_id">>, FinalData, <<"0">>),
validation:snowflake_or_default(<<"channel_id">>, ChannelIdBin, 0)
end.
dispatch_to_sessions(FilteredSessions, Event, FinalData, UpdatedState) ->
GuildId = maps:get(id, UpdatedState),
case is_bulk_update_event(Event) of
true ->
dispatch_bulk_update(FilteredSessions, Event, FinalData, UpdatedState);
false ->
dispatch_standard(FilteredSessions, Event, FinalData, GuildId, UpdatedState)
end.
dispatch_bulk_update(FilteredSessions, Event, FinalData, UpdatedState) ->
GuildId = maps:get(id, UpdatedState),
BulkChannels = maps:get(<<"channels">>, FinalData, []),
lists:foreach(
fun({_Sid, SessionData}) ->
Pid = maps:get(pid, SessionData),
UserId = maps:get(user_id, SessionData),
Member = find_member_by_user_id(UserId, UpdatedState),
case should_receive_event(Event, FinalData, GuildId, SessionData, UpdatedState) of
false ->
ok;
true ->
FilteredChannels = lists:filter(
fun(Channel) ->
ChannelIdBin = maps:get(<<"id">>, Channel, <<"0">>),
ChannelId = validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0),
case Member of
undefined ->
false;
_ ->
guild_permissions:can_view_channel(
UserId, ChannelId, Member, UpdatedState
)
end
end,
BulkChannels
),
case FilteredChannels of
[] ->
ok;
_ when is_pid(Pid) ->
CustomData = maps:put(<<"channels">>, FilteredChannels, FinalData),
gen_server:cast(Pid, {dispatch, Event, CustomData})
end
end
end,
FilteredSessions
).
dispatch_standard(FilteredSessions, Event, FinalData, GuildId, State) ->
lists:foreach(
fun({_Sid, SessionData}) ->
Pid = maps:get(pid, SessionData),
case is_pid(Pid) andalso should_receive_event(Event, FinalData, GuildId, SessionData, State) of
true ->
gen_server:cast(Pid, {dispatch, Event, FinalData});
false ->
ok
end
end,
FilteredSessions
).
maybe_send_push_notifications(message_create, FinalData, GuildId, UpdatedState) ->
spawn(fun() ->
collect_and_send_push_notifications(FinalData, GuildId, UpdatedState)
end);
maybe_send_push_notifications(_Event, _FinalData, _GuildId, _UpdatedState) ->
ok.
maybe_broadcast_member_list_update(guild_member_add, EventData, OldState, UpdatedState) ->
UserId = extract_user_id_from_event(EventData),
guild_member_list:broadcast_member_list_updates(UserId, OldState, UpdatedState);
maybe_broadcast_member_list_update(guild_member_remove, EventData, OldState, UpdatedState) ->
UserId = extract_user_id_from_event(EventData),
guild_member_list:broadcast_member_list_updates(UserId, OldState, UpdatedState);
maybe_broadcast_member_list_update(guild_member_update, EventData, OldState, UpdatedState) ->
UserId = extract_user_id_from_event(EventData),
guild_member_list:broadcast_member_list_updates(UserId, OldState, UpdatedState);
maybe_broadcast_member_list_update(guild_role_create, _EventData, _OldState, UpdatedState) ->
guild_member_list:broadcast_all_member_list_updates(UpdatedState);
maybe_broadcast_member_list_update(guild_role_update, _EventData, _OldState, UpdatedState) ->
guild_member_list:broadcast_all_member_list_updates(UpdatedState);
maybe_broadcast_member_list_update(guild_role_update_bulk, _EventData, _OldState, UpdatedState) ->
guild_member_list:broadcast_all_member_list_updates(UpdatedState);
maybe_broadcast_member_list_update(guild_role_delete, _EventData, _OldState, UpdatedState) ->
guild_member_list:broadcast_all_member_list_updates(UpdatedState);
maybe_broadcast_member_list_update(channel_update, EventData, _OldState, UpdatedState) ->
ChannelIdBin = maps:get(<<"id">>, EventData, <<"0">>),
ChannelId = validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0),
guild_member_list:broadcast_member_list_updates_for_channel(ChannelId, UpdatedState);
maybe_broadcast_member_list_update(channel_update_bulk, EventData, _OldState, UpdatedState) ->
Channels = maps:get(<<"channels">>, EventData, []),
lists:foreach(
fun(Channel) ->
ChannelIdBin = maps:get(<<"id">>, Channel, <<"0">>),
ChannelId = validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0),
guild_member_list:broadcast_member_list_updates_for_channel(ChannelId, UpdatedState)
end,
Channels
);
maybe_broadcast_member_list_update(_Event, _FinalData, _OldState, _UpdatedState) ->
ok.
extract_user_id_from_event(EventData) ->
MUser = maps:get(<<"user">>, EventData, #{}),
utils:binary_to_integer_safe(maps:get(<<"id">>, MUser, <<"0">>)).
extract_and_remove_session_id(Data) ->
case maps:get(<<"session_id">>, Data, undefined) of
undefined -> {undefined, Data};
SessionId -> {SessionId, maps:remove(<<"session_id">>, Data)}
end.
decorate_member_data(Event, Data, State) ->
case extract_member_for_event(Event, Data, State) of
undefined ->
Data;
MemberData ->
add_member_to_data(Event, Data, MemberData)
end.
add_member_to_data(Event, Data, MemberData) ->
case is_message_event(Event) of
true ->
case maps:is_key(<<"author">>, Data) of
true ->
CleanMemberData = maps:remove(<<"user">>, MemberData),
maps:put(<<"member">>, CleanMemberData, Data);
false ->
Data
end;
false ->
case is_user_event(Event) of
true ->
case maps:is_key(<<"user_id">>, Data) of
true -> maps:put(<<"member">>, MemberData, Data);
false -> Data
end;
false ->
Data
end
end.
is_message_event(message_create) -> true;
is_message_event(message_update) -> true;
is_message_event(_) -> false.
is_user_event(typing_start) -> true;
is_user_event(message_reaction_add) -> true;
is_user_event(message_reaction_remove) -> true;
is_user_event(_) -> false.
extract_member_for_event(Event, Data, State) ->
UserId = extract_user_id_for_event(Event, Data),
case UserId of
undefined -> undefined;
Id -> find_member_by_user_id(Id, State)
end.
extract_user_id_for_event(Event, Data) ->
case is_message_event(Event) of
true ->
AuthorId = maps:get(<<"id">>, maps:get(<<"author">>, Data, #{}), undefined),
case AuthorId of
undefined ->
undefined;
_ ->
case validation:validate_snowflake(<<"author.id">>, AuthorId) of
{ok, Id} ->
Id;
{error, _, Reason} ->
logger:warning("[guild_dispatch] Invalid field: ~p", [Reason]),
undefined
end
end;
false ->
case is_user_event(Event) of
true ->
UserId = maps:get(<<"user_id">>, Data, undefined),
case UserId of
undefined ->
undefined;
_ ->
case validation:validate_snowflake(<<"user_id">>, UserId) of
{ok, Id} ->
Id;
{error, _, Reason} ->
logger:warning("[guild_dispatch] Invalid field: ~p", [Reason]),
undefined
end
end;
false ->
undefined
end
end.
collect_and_send_push_notifications(MessageData, GuildId, State) ->
case should_send_push_notifications(State) of
false ->
ok;
true ->
send_push_notifications(MessageData, GuildId, State)
end.
should_send_push_notifications(State) ->
Data = maps:get(data, State),
Guild = maps:get(<<"guild">>, Data),
DisabledOperationsBin = maps:get(<<"disabled_operations">>, Guild, <<"0">>),
DisabledOperations = validation:snowflake_or_default(
<<"disabled_operations">>, DisabledOperationsBin, 0
),
(DisabledOperations band 1) =:= 0.
send_push_notifications(MessageData, GuildId, State) ->
Data = maps:get(data, State),
Members = maps:get(<<"members">>, Data, []),
Sessions = maps:get(sessions, State, #{}),
ChannelIdBin = maps:get(<<"channel_id">>, MessageData),
ChannelId = validation:snowflake_or_default(<<"channel_id">>, ChannelIdBin, 0),
case find_eligible_users_for_push(Members, Sessions, ChannelId, State) of
[] ->
ok;
EligibleUserIds ->
UserRolesMap = build_user_roles_map(Members, EligibleUserIds),
send_push_to_eligible_users(MessageData, GuildId, EligibleUserIds, UserRolesMap, Data)
end.
find_eligible_users_for_push(Members, Sessions, ChannelId, State) ->
lists:filtermap(
fun(Member) ->
is_user_eligible_for_push(Member, Sessions, ChannelId, State)
end,
Members
).
is_user_eligible_for_push(Member, Sessions, ChannelId, State) ->
MUser = maps:get(<<"user">>, Member, #{}),
UserIdBin = maps:get(<<"id">>, MUser, <<"0">>),
UserId = validation:snowflake_or_default(<<"user.id">>, UserIdBin, 0),
case guild_permissions:can_view_channel(UserId, ChannelId, Member, State) of
false ->
false;
true ->
check_user_session_eligibility(UserId, Sessions)
end.
check_user_session_eligibility(UserId, Sessions) ->
UserSessions = maps:filter(
fun(_Sid, Session) ->
maps:get(user_id, Session) =:= UserId
end,
Sessions
),
case map_size(UserSessions) of
0 ->
{true, UserId};
_ ->
HasMobile = lists:any(
fun(Session) -> maps:get(mobile, Session, false) end,
maps:values(UserSessions)
),
AllAfk = lists:all(
fun(Session) -> maps:get(afk, Session, false) end,
maps:values(UserSessions)
),
case (not HasMobile) andalso AllAfk of
true -> {true, UserId};
false -> false
end
end.
send_push_to_eligible_users(MessageData, GuildId, EligibleUserIds, UserRolesMap, Data) ->
Guild = maps:get(<<"guild">>, Data),
AuthorIdBin = maps:get(<<"id">>, maps:get(<<"author">>, MessageData, #{}), <<"0">>),
AuthorId = validation:snowflake_or_default(<<"author.id">>, AuthorIdBin, 0),
DefaultMessageNotifications = maps:get(<<"default_message_notifications">>, Guild, 0),
GuildName = maps:get(<<"name">>, Guild, <<"Unknown">>),
ChannelIdBin = maps:get(<<"channel_id">>, MessageData),
ChannelName = find_channel_name(ChannelIdBin, Data),
push:handle_message_create(#{
message_data => MessageData,
user_ids => EligibleUserIds,
guild_id => GuildId,
author_id => AuthorId,
guild_default_notifications => DefaultMessageNotifications,
guild_name => GuildName,
channel_name => ChannelName,
user_roles => UserRolesMap
}).
find_channel_name(ChannelIdBin, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
lists:foldl(
fun(Channel, Acc) ->
case maps:get(<<"id">>, Channel, <<"">>) of
ChannelIdBin -> maps:get(<<"name">>, Channel, <<"unknown">>);
_ -> Acc
end
end,
<<"unknown">>,
Channels
).
build_user_roles_map(Members, EligibleUserIds) ->
EligibleSet = sets:from_list(EligibleUserIds),
lists:foldl(
fun(Member, Acc) ->
case get_member_user_id(Member) of
0 -> Acc;
UserId ->
case sets:is_element(UserId, EligibleSet) of
true ->
Roles = extract_role_ids(Member),
maps:put(UserId, Roles, Acc);
false ->
Acc
end
end
end,
#{},
Members
).
get_member_user_id(Member) ->
User = maps:get(<<"user">>, Member, #{}),
case maps:get(<<"id">>, User, undefined) of
undefined ->
0;
Id ->
validation:snowflake_or_default(<<"member.user.id">>, Id, 0)
end.
extract_role_ids(Member) ->
Roles = maps:get(<<"roles">>, Member, []),
lists:foldl(
fun(Role, Acc) ->
case validation:validate_snowflake(<<"role">>, Role) of
{ok, RoleId} -> [RoleId | Acc];
_ -> Acc
end
end,
[],
Roles
).

View File

@@ -0,0 +1,368 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_manager).
-behaviour(gen_server).
-include_lib("fluxer_gateway/include/timeout_config.hrl").
-export([start_link/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-type guild_id() :: integer().
-type shard_map() :: #{pid => pid(), ref => reference()}.
-type state() :: #{
shards => #{non_neg_integer() => shard_map()},
shard_count => pos_integer()
}.
-record(shard, {
pid :: pid(),
ref :: reference()
}).
-record(state, {
shards = #{} :: #{non_neg_integer() => #shard{}},
shard_count = 1 :: pos_integer()
}).
-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-spec init(list()) -> {ok, state()}.
init([]) ->
process_flag(trap_exit, true),
{ShardCount, Source} = determine_shard_count(),
ShardMap = start_shards(ShardCount, #{}),
maybe_log_shard_source(guild_manager, ShardCount, Source),
{ok, #{shards => ShardMap, shard_count => ShardCount}}.
-spec handle_call(term(), gen_server:from(), state()) -> {reply, term(), state()}.
handle_call({start_or_lookup, GuildId} = Request, _From, State) ->
{Reply, NewState} = forward_call(GuildId, Request, State),
{reply, Reply, NewState};
handle_call({stop_guild, GuildId} = Request, _From, State) ->
{Reply, NewState} = forward_call(GuildId, Request, State),
{reply, Reply, NewState};
handle_call({reload_guild, GuildId} = Request, _From, State) ->
{Reply, NewState} = forward_call(GuildId, Request, State),
{reply, Reply, NewState};
handle_call({shutdown_guild, GuildId} = Request, _From, State) ->
{Reply, NewState} = forward_call(GuildId, Request, State),
{reply, Reply, NewState};
handle_call({reload_all_guilds, GuildIds}, _From, State) ->
{Reply, NewState} = handle_reload_all(GuildIds, State),
{reply, Reply, NewState};
handle_call(get_local_count, _From, State) ->
{Count, NewState} = aggregate_counts(get_local_count, State),
{reply, {ok, Count}, NewState};
handle_call(get_global_count, _From, State) ->
{Count, NewState} = aggregate_counts(get_global_count, State),
{reply, {ok, Count}, NewState};
handle_call(Request, _From, State) ->
logger:warning("[guild_manager] unknown request ~p", [Request]),
{reply, ok, State}.
-spec handle_cast(term(), state()) -> {noreply, state()}.
handle_cast(_Msg, State) ->
{noreply, State}.
-spec handle_info(term(), state()) -> {noreply, state()}.
handle_info({'DOWN', Ref, process, _Pid, Reason}, State) ->
Shards = maps:get(shards, State),
case find_shard_by_ref(Ref, Shards) of
{ok, Index} ->
logger:warning("[guild_manager] shard ~p crashed: ~p", [Index, Reason]),
{_Shard, NewState} = restart_shard(Index, State),
{noreply, NewState};
not_found ->
{noreply, State}
end;
handle_info({'EXIT', Pid, Reason}, State) ->
Shards = maps:get(shards, State),
case find_shard_by_pid(Pid, Shards) of
{ok, Index} ->
logger:warning("[guild_manager] shard ~p exited: ~p", [Index, Reason]),
{_Shard, NewState} = restart_shard(Index, State),
{noreply, NewState};
not_found ->
{noreply, State}
end;
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(term(), state()) -> ok.
terminate(_Reason, State) ->
Shards = maps:get(shards, State),
lists:foreach(
fun(ShardMap) ->
Pid = maps:get(pid, ShardMap),
catch gen_server:stop(Pid, shutdown, 5000)
end,
maps:values(Shards)
),
ok.
-spec code_change(term(), term(), term()) -> {ok, state()}.
code_change(_OldVsn, #state{shards = OldShards, shard_count = ShardCount}, _Extra) ->
NewShards = maps:map(
fun(_Index, #shard{pid = Pid, ref = Ref}) ->
#{pid => Pid, ref => Ref}
end,
OldShards
),
{ok, #{shards => NewShards, shard_count => ShardCount}};
code_change(_OldVsn, State, _Extra) when is_map(State) ->
{ok, State}.
-spec determine_shard_count() -> {pos_integer(), configured | auto}.
determine_shard_count() ->
case fluxer_gateway_env:get(guild_shards) of
Value when is_integer(Value), Value > 0 ->
{Value, configured};
_ ->
{default_shard_count(), auto}
end.
-spec start_shards(pos_integer(), #{}) -> #{non_neg_integer() => shard_map()}.
start_shards(Count, Acc) ->
lists:foldl(
fun(Index, MapAcc) ->
case start_shard(Index) of
{ok, Shard} ->
maps:put(Index, Shard, MapAcc);
{error, Reason} ->
logger:warning("[guild_manager] failed to start shard ~p: ~p", [Index, Reason]),
MapAcc
end
end,
Acc,
lists:seq(0, Count - 1)
).
-spec start_shard(non_neg_integer()) -> {ok, shard_map()} | {error, term()}.
start_shard(Index) ->
case guild_manager_shard:start_link(Index) of
{ok, Pid} ->
Ref = erlang:monitor(process, Pid),
{ok, #{pid => Pid, ref => Ref}};
Error ->
Error
end.
-spec restart_shard(non_neg_integer(), state()) -> {shard_map(), state()}.
restart_shard(Index, State) ->
Shards = maps:get(shards, State),
case start_shard(Index) of
{ok, Shard} ->
Updated = State#{shards => maps:put(Index, Shard, Shards)},
{Shard, Updated};
{error, Reason} ->
logger:error("[guild_manager] failed to restart shard ~p: ~p", [Index, Reason]),
Dummy = #{pid => spawn(fun() -> exit(normal) end), ref => make_ref()},
{Dummy, State}
end.
-spec forward_call(guild_id(), term(), state()) -> {term(), state()}.
forward_call(GuildId, Request, State) ->
{Index, State1} = ensure_shard(GuildId, State),
Shards = maps:get(shards, State1),
ShardMap = maps:get(Index, Shards),
Pid = maps:get(pid, ShardMap),
case catch gen_server:call(Pid, Request, ?DEFAULT_GEN_SERVER_TIMEOUT) of
{'EXIT', _} ->
{_Shard, State2} = restart_shard(Index, State1),
forward_call(GuildId, Request, State2);
Reply ->
{Reply, State1}
end.
-spec ensure_shard(guild_id(), state()) -> {non_neg_integer(), state()}.
ensure_shard(GuildId, State) ->
Count = maps:get(shard_count, State),
Index = select_shard(GuildId, Count),
ensure_shard_for_index(Index, State).
-spec ensure_shard_for_index(non_neg_integer(), state()) -> {non_neg_integer(), state()}.
ensure_shard_for_index(Index, State) ->
Shards = maps:get(shards, State),
case maps:get(Index, Shards, undefined) of
undefined ->
{_Shard, NewState} = restart_shard(Index, State),
{Index, NewState};
ShardMap when is_map(ShardMap) ->
Pid = maps:get(pid, ShardMap),
case erlang:is_process_alive(Pid) of
true ->
{Index, State};
false ->
{_Shard, NewState} = restart_shard(Index, State),
{Index, NewState}
end
end.
-spec select_shard(guild_id(), pos_integer()) -> non_neg_integer().
select_shard(GuildId, Count) when Count > 0 ->
rendezvous_router:select(GuildId, Count).
-spec aggregate_counts(term(), state()) -> {non_neg_integer(), state()}.
aggregate_counts(Request, State) ->
Shards = maps:get(shards, State),
Counts =
[
begin
Pid = maps:get(pid, ShardMap),
case catch gen_server:call(Pid, Request, ?DEFAULT_GEN_SERVER_TIMEOUT) of
{ok, Count} -> Count;
_ -> 0
end
end
|| ShardMap <- maps:values(Shards)
],
{lists:sum(Counts), State}.
-spec handle_reload_all([guild_id()], state()) -> {#{count => non_neg_integer()}, state()}.
handle_reload_all([], State) ->
Shards = maps:get(shards, State),
{Replies, FinalState} =
lists:foldl(
fun({_Index, ShardMap}, {AccReplies, AccState}) ->
Pid = maps:get(pid, ShardMap),
case catch gen_server:call(Pid, {reload_all_guilds, []}, 60000) of
Reply ->
{AccReplies ++ [Reply], AccState}
end
end,
{[], State},
maps:to_list(Shards)
),
Count = lists:sum([maps:get(count, Reply, 0) || Reply <- Replies]),
{#{count => Count}, FinalState};
handle_reload_all(GuildIds, State) ->
Count = maps:get(shard_count, State),
Groups = group_ids_by_shard(GuildIds, Count),
{TotalCount, FinalState} =
lists:foldl(
fun({Index, Ids}, {AccCount, AccState}) ->
{ShardIdx, State1} = ensure_shard_for_index(Index, AccState),
Shards = maps:get(shards, State1),
ShardMap = maps:get(ShardIdx, Shards),
Pid = maps:get(pid, ShardMap),
case catch gen_server:call(Pid, {reload_all_guilds, Ids}, 60000) of
#{count := CountReply} ->
{AccCount + CountReply, State1};
_ ->
{AccCount, State1}
end
end,
{0, State},
Groups
),
{#{count => TotalCount}, FinalState}.
-spec group_ids_by_shard([guild_id()], pos_integer()) -> [{non_neg_integer(), [guild_id()]}].
group_ids_by_shard(GuildIds, ShardCount) ->
lists:foldl(
fun(GuildId, Acc) ->
Index = select_shard(GuildId, ShardCount),
case lists:keytake(Index, 1, Acc) of
{value, {Index, Ids}, Rest} ->
[{Index, [GuildId | Ids]} | Rest];
false ->
[{Index, [GuildId]} | Acc]
end
end,
[],
GuildIds
).
-spec find_shard_by_ref(reference(), #{non_neg_integer() => shard_map()}) ->
{ok, non_neg_integer()} | not_found.
find_shard_by_ref(Ref, Shards) ->
maps:fold(
fun
(Index, ShardMap, _) when is_map(ShardMap) ->
case maps:get(ref, ShardMap) of
R when R =:= Ref -> {ok, Index};
_ -> not_found
end;
(_, _, Acc) ->
Acc
end,
not_found,
Shards
).
-spec find_shard_by_pid(pid(), #{non_neg_integer() => shard_map()}) ->
{ok, non_neg_integer()} | not_found.
find_shard_by_pid(Pid, Shards) ->
maps:fold(
fun
(Index, ShardMap, _) when is_map(ShardMap) ->
case maps:get(pid, ShardMap) of
P when P =:= Pid -> {ok, Index};
_ -> not_found
end;
(_, _, Acc) ->
Acc
end,
not_found,
Shards
).
-spec default_shard_count() -> pos_integer().
default_shard_count() ->
Candidates = [
erlang:system_info(logical_processors_available), erlang:system_info(schedulers_online)
],
lists:max([C || C <- Candidates, is_integer(C), C > 0] ++ [1]).
-spec maybe_log_shard_source(atom(), pos_integer(), configured | auto) -> ok.
maybe_log_shard_source(Name, Count, configured) ->
logger:info("[~p] starting with ~p shards (configured)", [Name, Count]),
ok;
maybe_log_shard_source(Name, Count, auto) ->
logger:info("[~p] starting with ~p shards (auto)", [Name, Count]),
ok.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
determine_shard_count_configured_test() ->
with_runtime_config(guild_shards, 4, fun() ->
?assertMatch({4, configured}, determine_shard_count())
end).
determine_shard_count_auto_test() ->
with_runtime_config(guild_shards, undefined, fun() ->
{Count, auto} = determine_shard_count(),
?assert(Count > 0)
end).
with_runtime_config(Key, Value, Fun) ->
Original = fluxer_gateway_env:get(Key),
fluxer_gateway_env:patch(#{Key => Value}),
Result = Fun(),
fluxer_gateway_env:update(fun(Map) ->
case Original of
undefined -> maps:remove(Key, Map);
Val -> maps:put(Key, Val, Map)
end
end),
Result.
-endif.

View File

@@ -0,0 +1,530 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_manager_shard).
-behaviour(gen_server).
-include_lib("fluxer_gateway/include/timeout_config.hrl").
-define(GUILD_API_CANARY_PERCENTAGE, 5).
-export([start_link/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-type guild_id() :: integer().
-type guild_ref() :: {pid(), reference()}.
-type guild_data() :: #{binary() => term()}.
-type fetch_result() :: {ok, guild_data()} | {error, term()}.
-type state() :: #{
guilds => #{guild_id() => guild_ref() | loading},
api_host => string(),
api_canary_host => undefined | string(),
pending_requests => #{guild_id() => [gen_server:from()]}
}.
-record(state, {
guilds = #{} :: #{guild_id() => guild_ref() | loading},
api_host :: string(),
api_canary_host :: undefined | string(),
pending_requests = #{} :: #{guild_id() => [gen_server:from()]}
}).
-spec start_link(non_neg_integer()) -> {ok, pid()} | {error, term()}.
start_link(ShardIndex) ->
gen_server:start_link(?MODULE, #{shard_index => ShardIndex}, []).
-spec init(map()) -> {ok, state()}.
init(_Args) ->
process_flag(trap_exit, true),
fluxer_gateway_env:load(),
ApiHost = fluxer_gateway_env:get(api_host),
ApiCanaryHost = fluxer_gateway_env:get(api_canary_host),
{ok, #{
guilds => #{},
api_host => ApiHost,
api_canary_host => ApiCanaryHost,
pending_requests => #{}
}}.
-spec handle_call(Request, From, State) -> Result when
Request ::
{start_or_lookup, guild_id()}
| {stop_guild, guild_id()}
| {reload_guild, guild_id()}
| {reload_all_guilds, [guild_id()]}
| {shutdown_guild, guild_id()}
| get_local_count
| get_global_count
| term(),
From :: gen_server:from(),
State :: state(),
Result ::
{reply, Reply, state()}
| {noreply, state()},
Reply ::
{ok, pid()}
| {error, term()}
| ok
| {ok, non_neg_integer()}.
handle_call({start_or_lookup, GuildId}, From, State) ->
do_start_or_lookup(GuildId, From, State);
handle_call({stop_guild, GuildId}, _From, State) ->
do_stop_guild(GuildId, State);
handle_call({reload_guild, GuildId}, From, State) ->
do_reload_guild(GuildId, From, State);
handle_call({reload_all_guilds, GuildIds}, From, State) ->
Guilds = maps:get(guilds, State),
GuildsToReload =
case GuildIds of
[] ->
[{GuildId, Pid} || {GuildId, {Pid, _Ref}} <- maps:to_list(Guilds)];
Ids ->
lists:filtermap(
fun(GuildId) ->
case maps:get(GuildId, Guilds, undefined) of
{Pid, _Ref} -> {true, {GuildId, Pid}};
_ -> false
end
end,
Ids
)
end,
Manager = self(),
spawn(fun() ->
try
reload_guilds_in_batches(GuildsToReload, Manager, State, 10, 100),
gen_server:cast(Manager, {all_guilds_reloaded, From, length(GuildsToReload)})
catch
Class:Error:Stacktrace ->
logger:error(
"[guild_manager] Spawned process failed: ~p:~p~n~p",
[Class, Error, Stacktrace]
),
gen_server:cast(Manager, {all_guilds_reloaded, From, 0})
end
end),
{noreply, State};
handle_call({shutdown_guild, GuildId}, _From, State) ->
do_shutdown_guild(GuildId, State);
handle_call(get_local_count, _From, State) ->
Guilds = maps:get(guilds, State),
Count = process_registry:get_count(Guilds),
{reply, {ok, Count}, State};
handle_call(get_global_count, _From, State) ->
Guilds = maps:get(guilds, State),
Count = process_registry:get_count(Guilds),
{reply, {ok, Count}, State};
handle_call(_Unknown, _From, State) ->
{reply, ok, State}.
-spec handle_cast(Request, State) -> {noreply, state()} when
Request ::
{guild_data_fetched, guild_id(), fetch_result()}
| {guild_data_reloaded, guild_id(), pid(), gen_server:from(), fetch_result()}
| {all_guilds_reloaded, gen_server:from(), non_neg_integer()}
| term(),
State :: state().
handle_cast({guild_data_fetched, GuildId, Result}, State) ->
Pending = maps:get(pending_requests, State),
Requests = maps:get(GuildId, Pending, []),
Guilds = maps:get(guilds, State),
case Result of
{ok, Data} ->
case start_guild(GuildId, Data, State) of
{ok, Pid, NewState} ->
lists:foreach(fun(From) -> gen_server:reply(From, {ok, Pid}) end, Requests),
NewPending = maps:remove(GuildId, Pending),
NewGuilds = maps:get(guilds, NewState),
CleanGuilds = maps:remove(GuildId, NewGuilds),
{noreply, NewState#{pending_requests => NewPending, guilds => CleanGuilds}};
{error, Reason} ->
logger:error("[guild_manager] Failed to start guild ~p: ~p", [GuildId, Reason]),
lists:foreach(
fun(From) -> gen_server:reply(From, {error, Reason}) end, Requests
),
NewGuilds = maps:remove(GuildId, Guilds),
NewPending = maps:remove(GuildId, Pending),
{noreply, State#{guilds => NewGuilds, pending_requests => NewPending}}
end;
_ ->
lists:foreach(fun(From) -> gen_server:reply(From, {error, not_found}) end, Requests),
NewGuilds = maps:remove(GuildId, Guilds),
NewPending = maps:remove(GuildId, Pending),
{noreply, State#{guilds => NewGuilds, pending_requests => NewPending}}
end;
handle_cast({guild_data_reloaded, _GuildId, Pid, From, Result}, State) ->
case Result of
{ok, Data} ->
gen_server:call(Pid, {reload, Data}, ?GUILD_CALL_TIMEOUT),
gen_server:reply(From, ok),
{noreply, State};
_ ->
gen_server:reply(From, {error, fetch_failed}),
{noreply, State}
end;
handle_cast({all_guilds_reloaded, From, Count}, State) ->
gen_server:reply(From, #{count => Count}),
{noreply, State};
handle_cast(_Unknown, State) ->
{noreply, State}.
-spec handle_info(Info, State) -> {noreply, state()} when
Info :: {'DOWN', reference(), process, pid(), term()} | term(),
State :: state().
handle_info({'DOWN', _Ref, process, Pid, _Reason}, State) ->
Guilds = maps:get(guilds, State),
NewGuilds = process_registry:cleanup_on_down(Pid, Guilds),
{noreply, State#{guilds => NewGuilds}};
handle_info(_Unknown, State) ->
{noreply, State}.
-spec terminate(Reason, State) -> ok when
Reason :: term(),
State :: state().
terminate(_Reason, _State) ->
ok.
-spec code_change(term(), term(), term()) -> {ok, state()}.
code_change(_OldVsn, #state{guilds = Guilds, api_host = ApiHost, api_canary_host = ApiCanaryHost, pending_requests = Pending}, _Extra) ->
{ok, #{
guilds => Guilds,
api_host => ApiHost,
api_canary_host => ApiCanaryHost,
pending_requests => Pending
}};
code_change(_OldVsn, State, _Extra) when is_map(State) ->
{ok, State}.
-spec fetch_guild_data(guild_id(), string()) -> fetch_result().
fetch_guild_data(GuildId, ApiHost) ->
RpcRequest = #{
<<"type">> => <<"guild">>,
<<"guild_id">> => type_conv:to_binary(GuildId),
<<"version">> => 1
},
Url = rpc_client:get_rpc_url(ApiHost),
Headers =
rpc_client:get_rpc_headers() ++ [{<<"content-type">>, <<"application/json">>}],
Body = jsx:encode(RpcRequest),
case
hackney:request(post, Url, Headers, Body, [{recv_timeout, 30000}, {connect_timeout, 5000}])
of
{ok, 200, _RespHeaders, ClientRef} ->
case hackney:body(ClientRef) of
{ok, RespBody} ->
hackney:close(ClientRef),
Response = jsx:decode(RespBody, [return_maps]),
Data = maps:get(<<"data">>, Response, #{}),
{ok, Data};
{error, BodyReason} ->
hackney:close(ClientRef),
logger:error("[guild_manager] Failed to read guild response body: ~p", [
BodyReason
]),
{error, fetch_failed}
end;
{ok, StatusCode, _RespHeaders, ClientRef} ->
ErrorBody =
case hackney:body(ClientRef) of
{ok, Body2} -> Body2;
{error, _} -> <<"<unable to read error body>">>
end,
hackney:close(ClientRef),
logger:error(
"[guild_manager] Guild RPC failed with status ~p: ~s",
[StatusCode, ErrorBody]
),
{error, fetch_failed};
{error, Reason} ->
logger:error("[guild_manager] Guild RPC request failed: ~p", [Reason]),
{error, fetch_failed}
end.
-spec select_api_host(state()) -> {string(), boolean()}.
select_api_host(State) ->
case maps:get(api_canary_host, State) of
undefined ->
{maps:get(api_host, State), false};
_ ->
case should_use_canary_api() of
true -> {maps:get(api_canary_host, State), true};
false -> {maps:get(api_host, State), false}
end
end.
-spec should_use_canary_api() -> boolean().
should_use_canary_api() ->
erlang:unique_integer([positive]) rem 100 < ?GUILD_API_CANARY_PERCENTAGE.
-spec fetch_guild_data_with_fallback(
guild_id(),
{string(), boolean()},
state()
) -> fetch_result().
fetch_guild_data_with_fallback(GuildId, {ApiHost, false}, _) ->
fetch_guild_data(GuildId, ApiHost);
fetch_guild_data_with_fallback(GuildId, {ApiHost, true}, State) ->
case fetch_guild_data(GuildId, ApiHost) of
{ok, Data} ->
{ok, Data};
Error ->
StableHost = maps:get(api_host, State),
case StableHost == ApiHost of
true ->
Error;
false ->
logger:warning(
"[guild_manager] Canary API request failed for ~p, retrying against stable host",
[GuildId]
),
fetch_guild_data(GuildId, StableHost)
end
end.
-spec start_guild(guild_id(), guild_data(), state()) -> {ok, pid(), state()} | {error, term()}.
start_guild(GuildId, Data, State) ->
GuildName = process_registry:build_process_name(guild, GuildId),
case whereis(GuildName) of
undefined ->
GuildState = #{
id => GuildId,
data => Data,
sessions => #{},
presences => #{}
},
Guilds = maps:get(guilds, State),
case guild:start_link(GuildState) of
{ok, Pid} ->
case process_registry:register_and_monitor(GuildName, Pid, Guilds) of
{ok, RegisteredPid, Ref, NewGuilds0} ->
CleanGuilds = maps:remove(GuildName, NewGuilds0),
NewGuilds = maps:put(GuildId, {RegisteredPid, Ref}, CleanGuilds),
{ok, RegisteredPid, State#{guilds => NewGuilds}};
{error, Reason} ->
{error, Reason}
end;
Error ->
Error
end;
_ExistingPid ->
Guilds = maps:get(guilds, State),
case process_registry:lookup_or_monitor(GuildName, GuildId, Guilds) of
{ok, Pid, _Ref, NewGuilds} ->
{ok, Pid, State#{guilds => NewGuilds}};
{error, not_found} ->
{error, process_died}
end
end.
-spec reload_guilds_in_batches(
[{guild_id(), pid()}],
pid(),
state(),
pos_integer(),
non_neg_integer()
) -> ok.
reload_guilds_in_batches([], _Manager, _State, _BatchSize, _DelayMs) ->
ok;
reload_guilds_in_batches(Guilds, Manager, State, BatchSize, DelayMs) ->
{Batch, Remaining} = lists:split(min(BatchSize, length(Guilds)), Guilds),
lists:foreach(
fun({GuildId, Pid}) ->
ApiHostInfo = select_api_host(State),
spawn(fun() ->
try
case fetch_guild_data_with_fallback(GuildId, ApiHostInfo, State) of
{ok, Data} ->
gen_server:call(Pid, {reload, Data}, ?GUILD_CALL_TIMEOUT);
{error, Reason} ->
logger:error("[guild_manager] Failed to reload guild ~p: ~p", [
GuildId, Reason
])
end
catch
Class:Error:Stacktrace ->
logger:error(
"[guild_manager] Spawned process failed: ~p:~p~n~p",
[Class, Error, Stacktrace]
)
end
end)
end,
Batch
),
case Remaining of
[] ->
ok;
_ ->
timer:sleep(DelayMs),
reload_guilds_in_batches(Remaining, Manager, State, BatchSize, DelayMs)
end.
-spec do_start_or_lookup(guild_id(), gen_server:from(), state()) ->
{reply, {ok, pid()} | {error, term()}, state()} | {noreply, state()}.
do_start_or_lookup(GuildId, From, State) ->
Guilds = maps:get(guilds, State),
case maps:get(GuildId, Guilds, undefined) of
{Pid, _Ref} ->
{reply, {ok, Pid}, State};
loading ->
Pending = maps:get(pending_requests, State),
Requests = maps:get(GuildId, Pending, []),
NewPending = maps:put(GuildId, [From | Requests], Pending),
{noreply, State#{pending_requests => NewPending}};
undefined ->
GuildName = process_registry:build_process_name(guild, GuildId),
case whereis(GuildName) of
undefined ->
NewGuilds = maps:put(GuildId, loading, Guilds),
Pending = maps:get(pending_requests, State),
NewPending = maps:put(GuildId, [From], Pending),
NewState = State#{guilds => NewGuilds, pending_requests => NewPending},
Manager = self(),
ApiHostInfo = select_api_host(State),
spawn(fun() ->
try
Result = fetch_guild_data_with_fallback(GuildId, ApiHostInfo, State),
gen_server:cast(Manager, {guild_data_fetched, GuildId, Result})
catch
Class:Error:Stacktrace ->
logger:error(
"[guild_manager] Spawned process failed: ~p:~p~n~p",
[Class, Error, Stacktrace]
),
gen_server:cast(
Manager, {guild_data_fetched, GuildId, {error, fetch_failed}}
)
end
end),
{noreply, NewState};
_ExistingPid ->
case process_registry:lookup_or_monitor(GuildName, GuildId, Guilds) of
{ok, Pid, _Ref, NewGuilds} ->
{reply, {ok, Pid}, State#{guilds => NewGuilds}};
{error, not_found} ->
{reply, {error, process_died}, State}
end
end
end.
-spec do_stop_guild(guild_id(), state()) -> {reply, ok, state()}.
do_stop_guild(GuildId, State) ->
Guilds = maps:get(guilds, State),
GuildName = process_registry:build_process_name(guild, GuildId),
case maps:get(GuildId, Guilds, undefined) of
{Pid, Ref} ->
demonitor(Ref, [flush]),
gen_server:stop(Pid, normal, ?SHUTDOWN_TIMEOUT),
process_registry:safe_unregister(GuildName),
NewGuilds = maps:remove(GuildId, Guilds),
{reply, ok, State#{guilds => NewGuilds}};
_ ->
case whereis(GuildName) of
undefined ->
{reply, ok, State};
ExistingPid ->
gen_server:stop(ExistingPid, normal, ?SHUTDOWN_TIMEOUT),
process_registry:safe_unregister(GuildName),
{reply, ok, State}
end
end.
-spec do_reload_guild(guild_id(), gen_server:from(), state()) ->
{reply, {error, not_found}, state()} | {noreply, state()}.
do_reload_guild(GuildId, From, State) ->
Guilds = maps:get(guilds, State),
GuildName = process_registry:build_process_name(guild, GuildId),
case maps:get(GuildId, Guilds, undefined) of
{Pid, _Ref} ->
Manager = self(),
ApiHostInfo = select_api_host(State),
spawn(fun() ->
try
Result = fetch_guild_data_with_fallback(GuildId, ApiHostInfo, State),
gen_server:cast(Manager, {guild_data_reloaded, GuildId, Pid, From, Result})
catch
Class:Error:Stacktrace ->
logger:error(
"[guild_manager] Spawned process failed: ~p:~p~n~p",
[Class, Error, Stacktrace]
),
gen_server:cast(
Manager,
{guild_data_reloaded, GuildId, Pid, From, {error, fetch_failed}}
)
end
end),
{noreply, State};
_ ->
case whereis(GuildName) of
undefined ->
{reply, {error, not_found}, State};
_ExistingPid ->
case process_registry:lookup_or_monitor(GuildName, GuildId, Guilds) of
{ok, Pid, _Ref, NewGuilds} ->
NewState = State#{guilds => NewGuilds},
Manager = self(),
ApiHostInfo = select_api_host(NewState),
spawn(fun() ->
try
Result = fetch_guild_data_with_fallback(
GuildId, ApiHostInfo, NewState
),
gen_server:cast(
Manager, {guild_data_reloaded, GuildId, Pid, From, Result}
)
catch
Class:Error:Stacktrace ->
logger:error(
"[guild_manager] Spawned process failed: ~p:~p~n~p",
[Class, Error, Stacktrace]
),
gen_server:cast(
Manager,
{guild_data_reloaded, GuildId, Pid, From,
{error, fetch_failed}}
)
end
end),
{noreply, NewState};
{error, not_found} ->
{reply, {error, not_found}, State}
end
end
end.
-spec do_shutdown_guild(guild_id(), state()) -> {reply, ok, state()}.
do_shutdown_guild(GuildId, State) ->
Guilds = maps:get(guilds, State),
GuildName = process_registry:build_process_name(guild, GuildId),
case maps:get(GuildId, Guilds, undefined) of
{Pid, Ref} ->
demonitor(Ref, [flush]),
gen_server:call(Pid, {terminate}, ?SHUTDOWN_TIMEOUT),
process_registry:safe_unregister(GuildName),
NewGuilds = maps:remove(GuildId, Guilds),
{reply, ok, State#{guilds => NewGuilds}};
_ ->
case whereis(GuildName) of
undefined ->
{reply, ok, State};
ExistingPid ->
catch gen_server:call(ExistingPid, {terminate}, ?SHUTDOWN_TIMEOUT),
process_registry:safe_unregister(GuildName),
{reply, ok, State}
end
end.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,328 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_member_storage).
-export([
new/0,
insert_member/2,
remove_member/2,
get_member/2,
get_members_by_ids/2,
search_members/3,
get_range/3,
count/1,
compute_list_id/1
]).
-record(member_storage, {
members_table :: ets:tid(),
display_name_index :: gb_trees:tree()
}).
-type storage() :: #member_storage{}.
-type user_id() :: integer().
-type member() :: map().
-export_type([storage/0]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec new() -> storage().
new() ->
MembersTable = ets:new(members, [set, private]),
DisplayNameIndex = gb_trees:empty(),
#member_storage{
members_table = MembersTable,
display_name_index = DisplayNameIndex
}.
-spec insert_member(member(), storage()) -> storage().
insert_member(Member, Storage) ->
UserId = extract_user_id(Member),
case UserId of
undefined ->
Storage;
_ ->
OldMember = get_member(UserId, Storage),
Storage1 = remove_from_index(OldMember, Storage),
ets:insert(Storage1#member_storage.members_table, {UserId, Member}),
add_to_index(UserId, Member, Storage1)
end.
-spec remove_member(user_id(), storage()) -> storage().
remove_member(UserId, Storage) ->
case get_member(UserId, Storage) of
undefined ->
Storage;
Member ->
Storage1 = remove_from_index(Member, Storage),
ets:delete(Storage1#member_storage.members_table, UserId),
Storage1
end.
-spec get_member(user_id(), storage()) -> member() | undefined.
get_member(UserId, Storage) ->
case ets:lookup(Storage#member_storage.members_table, UserId) of
[{UserId, Member}] -> Member;
[] -> undefined
end.
-spec get_members_by_ids([user_id()], storage()) -> [member()].
get_members_by_ids(UserIds, Storage) ->
lists:filtermap(
fun(UserId) ->
case get_member(UserId, Storage) of
undefined -> false;
Member -> {true, Member}
end
end,
UserIds
).
-spec search_members(binary(), non_neg_integer(), storage()) -> [member()].
search_members(Query, Limit, Storage) when is_binary(Query), Limit > 0 ->
NormalizedQuery = normalize_display_name(Query),
case NormalizedQuery of
<<>> ->
[];
_ ->
search_by_prefix(NormalizedQuery, Limit, Storage)
end;
search_members(_, _, _) ->
[].
-spec get_range(non_neg_integer(), non_neg_integer(), storage()) -> [member()].
get_range(Offset, Limit, Storage) when is_integer(Offset), is_integer(Limit), Limit > 0 ->
Index = Storage#member_storage.display_name_index,
case gb_trees:size(Index) of
Size when Offset >= Size ->
[];
Size ->
AllKeys = gb_trees:keys(Index),
EndIdx = min(Offset + Limit, Size),
SelectedKeys = lists:sublist(AllKeys, Offset + 1, EndIdx - Offset),
lists:filtermap(
fun(Key) ->
UserId = gb_trees:get(Key, Index),
case get_member(UserId, Storage) of
undefined -> false;
Member -> {true, Member}
end
end,
SelectedKeys
)
end;
get_range(_, _, _) ->
[].
-spec count(storage()) -> non_neg_integer().
count(Storage) ->
ets:info(Storage#member_storage.members_table, size).
-spec compute_list_id([user_id()]) -> integer().
compute_list_id(UserIds) ->
SortedIds = lists:sort(UserIds),
Combined = lists:foldl(
fun(Id, Acc) -> <<Acc/binary, (integer_to_binary(Id))/binary, ",">> end,
<<>>,
SortedIds
),
erlang:phash2(Combined, 16#FFFFFFFF).
-spec extract_user_id(member()) -> user_id() | undefined.
extract_user_id(Member) when is_map(Member) ->
User = maps:get(<<"user">>, Member, #{}),
map_utils:get_integer(User, <<"id">>, undefined);
extract_user_id(_) ->
undefined.
-spec get_display_name(member()) -> binary().
get_display_name(Member) when is_map(Member) ->
Nick = maps:get(<<"nick">>, Member, undefined),
case Nick of
undefined ->
User = maps:get(<<"user">>, Member, #{}),
GlobalName = maps:get(<<"global_name">>, User, undefined),
case GlobalName of
undefined ->
maps:get(<<"username">>, User, <<>>);
_ ->
GlobalName
end;
_ ->
Nick
end.
-spec normalize_display_name(binary()) -> binary().
normalize_display_name(Name) when is_binary(Name) ->
LowerName = string:lowercase(binary_to_list(Name)),
list_to_binary(LowerName).
-spec add_to_index(user_id(), member(), storage()) -> storage().
add_to_index(UserId, Member, Storage) ->
DisplayName = get_display_name(Member),
NormalizedName = normalize_display_name(DisplayName),
Key = make_index_key(NormalizedName, UserId),
Index = Storage#member_storage.display_name_index,
NewIndex = gb_trees:enter(Key, UserId, Index),
Storage#member_storage{display_name_index = NewIndex}.
-spec remove_from_index(member() | undefined, storage()) -> storage().
remove_from_index(undefined, Storage) ->
Storage;
remove_from_index(Member, Storage) ->
UserId = extract_user_id(Member),
DisplayName = get_display_name(Member),
NormalizedName = normalize_display_name(DisplayName),
Key = make_index_key(NormalizedName, UserId),
Index = Storage#member_storage.display_name_index,
case gb_trees:is_defined(Key, Index) of
true ->
NewIndex = gb_trees:delete(Key, Index),
Storage#member_storage{display_name_index = NewIndex};
false ->
Storage
end.
-spec make_index_key(binary(), user_id()) -> {binary(), user_id()}.
make_index_key(NormalizedName, UserId) ->
{NormalizedName, UserId}.
-spec search_by_prefix(binary(), non_neg_integer(), storage()) -> [member()].
search_by_prefix(Prefix, Limit, Storage) ->
Index = Storage#member_storage.display_name_index,
AllKeys = gb_trees:keys(Index),
Matches = lists:filtermap(
fun({Name, UserId}) ->
PrefixLen = byte_size(Prefix),
case Name of
<<Prefix:PrefixLen/binary, _/binary>> ->
case get_member(UserId, Storage) of
undefined -> false;
Member -> {true, Member}
end;
_ ->
false
end
end,
AllKeys
),
lists:sublist(Matches, Limit).
-ifdef(TEST).
new_creates_empty_storage_test() ->
Storage = new(),
?assertEqual(0, count(Storage)).
insert_and_get_member_test() ->
Storage = new(),
Member = #{
<<"user">> => #{
<<"id">> => <<"123">>,
<<"username">> => <<"testuser">>
},
<<"roles">> => []
},
Storage1 = insert_member(Member, Storage),
?assertEqual(1, count(Storage1)),
Retrieved = get_member(123, Storage1),
?assertEqual(Member, Retrieved).
remove_member_test() ->
Storage = new(),
Member = #{
<<"user">> => #{
<<"id">> => <<"123">>,
<<"username">> => <<"testuser">>
}
},
Storage1 = insert_member(Member, Storage),
Storage2 = remove_member(123, Storage1),
?assertEqual(0, count(Storage2)),
?assertEqual(undefined, get_member(123, Storage2)).
get_members_by_ids_test() ->
Storage = new(),
Member1 = #{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>}},
Member2 = #{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"bob">>}},
Storage1 = insert_member(Member1, Storage),
Storage2 = insert_member(Member2, Storage1),
Members = get_members_by_ids([1, 2, 999], Storage2),
?assertEqual(2, length(Members)).
search_members_by_prefix_test() ->
Storage = new(),
Member1 = #{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>}},
Member2 = #{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"bob">>}},
Member3 = #{<<"user">> => #{<<"id">> => <<"3">>, <<"username">> => <<"alicia">>}},
Storage1 = insert_member(Member1, Storage),
Storage2 = insert_member(Member2, Storage1),
Storage3 = insert_member(Member3, Storage2),
Results = search_members(<<"ali">>, 10, Storage3),
?assertEqual(2, length(Results)).
display_name_nick_priority_test() ->
Member = #{
<<"user">> => #{
<<"id">> => <<"1">>,
<<"username">> => <<"user">>,
<<"global_name">> => <<"Global">>
},
<<"nick">> => <<"Nickname">>
},
?assertEqual(<<"Nickname">>, get_display_name(Member)).
display_name_global_name_fallback_test() ->
Member = #{
<<"user">> => #{
<<"id">> => <<"1">>,
<<"username">> => <<"user">>,
<<"global_name">> => <<"Global">>
}
},
?assertEqual(<<"Global">>, get_display_name(Member)).
display_name_username_fallback_test() ->
Member = #{
<<"user">> => #{
<<"id">> => <<"1">>,
<<"username">> => <<"user">>
}
},
?assertEqual(<<"user">>, get_display_name(Member)).
compute_list_id_test() ->
Id1 = compute_list_id([1, 2, 3]),
Id2 = compute_list_id([3, 2, 1]),
?assertEqual(Id1, Id2).
get_range_test() ->
Storage = new(),
Member1 = #{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>}},
Member2 = #{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"bob">>}},
Member3 = #{<<"user">> => #{<<"id">> => <<"3">>, <<"username">> => <<"charlie">>}},
Storage1 = insert_member(Member1, Storage),
Storage2 = insert_member(Member2, Storage1),
Storage3 = insert_member(Member3, Storage2),
Results = get_range(1, 2, Storage3),
?assertEqual(2, length(Results)).
-endif.

View File

@@ -0,0 +1,535 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_members).
-export([get_users_to_mention_by_roles/2]).
-export([get_users_to_mention_by_user_ids/2]).
-export([get_all_users_to_mention/2]).
-export([resolve_all_mentions/2]).
-export([get_members_with_role/2]).
-export([can_manage_roles/2]).
-export([can_manage_role/2]).
-export([get_assignable_roles/2]).
-export([check_target_member/2]).
-export([get_viewable_channels/2]).
-type guild_state() :: map().
-type guild_reply(T) :: {reply, T, guild_state()}.
-type member() :: map().
-type role() :: map().
-type channel() :: map().
-type user_id() :: integer().
-type role_id() :: integer().
-type channel_id() :: integer().
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec get_users_to_mention_by_roles(map(), guild_state()) -> guild_reply(map()).
get_users_to_mention_by_roles(
#{channel_id := ChannelId, role_ids := RoleIds, author_id := AuthorId}, State
) ->
Members = guild_members(State),
RoleIdSet = normalize_int_list(RoleIds),
UserIds = collect_mentions(
Members,
AuthorId,
ChannelId,
State,
fun(Member) -> member_has_any_role(Member, RoleIdSet) end
),
{reply, #{user_ids => UserIds}, State}.
-spec get_users_to_mention_by_user_ids(map(), guild_state()) -> guild_reply(map()).
get_users_to_mention_by_user_ids(
#{channel_id := ChannelId, user_ids := UserIdsReq, author_id := AuthorId}, State
) ->
Members = guild_members(State),
TargetIds = normalize_int_list(UserIdsReq),
UserIds = collect_mentions(
Members,
AuthorId,
ChannelId,
State,
fun(Member) ->
case member_user_id(Member) of
undefined -> false;
Id -> lists:member(Id, TargetIds)
end
end
),
{reply, #{user_ids => UserIds}, State}.
-spec get_all_users_to_mention(map(), guild_state()) -> guild_reply(map()).
get_all_users_to_mention(#{channel_id := ChannelId, author_id := AuthorId}, State) ->
Members = guild_members(State),
UserIds = collect_mentions(Members, AuthorId, ChannelId, State, fun(_) -> true end),
{reply, #{user_ids => UserIds}, State}.
-spec resolve_all_mentions(map(), guild_state()) -> guild_reply(map()).
resolve_all_mentions(
#{
channel_id := ChannelId,
author_id := AuthorId,
mention_everyone := MentionEveryone,
mention_here := MentionHere,
role_ids := RoleIds,
user_ids := DirectUserIds
},
State
) ->
Members = guild_members(State),
Sessions = maps:get(sessions, State, #{}),
RoleIdSet = gb_sets:from_list(normalize_int_list(RoleIds)),
DirectUserIdSet = gb_sets:from_list(normalize_int_list(DirectUserIds)),
HasRoleMentions = not gb_sets:is_empty(RoleIdSet),
HasDirectMentions = not gb_sets:is_empty(DirectUserIdSet),
ConnectedUserIds =
case MentionHere of
true ->
gb_sets:from_list([
maps:get(user_id, S)
|| {_Sid, S} <- maps:to_list(Sessions)
]);
false ->
gb_sets:empty()
end,
UserIds = lists:filtermap(
fun(Member) ->
case member_user_id(Member) of
undefined ->
false;
UserId when UserId =:= AuthorId ->
false;
UserId ->
case is_member_bot(Member) of
true ->
false;
false ->
ShouldMention =
MentionEveryone orelse
(MentionHere andalso
gb_sets:is_member(UserId, ConnectedUserIds)) orelse
(HasRoleMentions andalso
member_has_any_role_set(Member, RoleIdSet)) orelse
(HasDirectMentions andalso
gb_sets:is_member(UserId, DirectUserIdSet)),
case
ShouldMention andalso
member_can_view_channel(UserId, ChannelId, Member, State)
of
true -> {true, UserId};
false -> false
end
end
end
end,
Members
),
{reply, #{user_ids => UserIds}, State}.
-spec get_members_with_role(map(), guild_state()) -> guild_reply(map()).
get_members_with_role(#{role_id := RoleId}, State) ->
Members = guild_members(State),
TargetRoles = [RoleId],
UserIds = lists:filtermap(
fun(Member) ->
case member_user_id(Member) of
undefined ->
false;
UserId ->
case member_has_any_role(Member, TargetRoles) of
true -> {true, UserId};
false -> false
end
end
end,
Members
),
{reply, #{user_ids => UserIds}, State}.
-spec can_manage_roles(map(), guild_state()) -> guild_reply(map()).
can_manage_roles(#{user_id := UserId, role_id := RoleId}, State) ->
Data = guild_data(State),
OwnerId = owner_id(State),
Reply =
if
UserId =:= OwnerId ->
true;
true ->
UserPermissions = guild_permissions:get_member_permissions(
UserId, undefined, State
),
case (UserPermissions band constants:manage_roles_permission()) =/= 0 of
false ->
false;
true ->
Roles = maps:get(<<"roles">>, Data, []),
case find_role_by_id(RoleId, Roles) of
undefined ->
false;
Role ->
UserMax = guild_permissions:get_max_role_position(UserId, State),
UserMax > role_position(Role)
end
end
end,
{reply, #{can_manage => Reply}, State}.
-spec can_manage_role(map(), guild_state()) -> guild_reply(map()).
can_manage_role(#{user_id := UserId, role_id := RoleId}, State) ->
Data = guild_data(State),
Roles = maps:get(<<"roles">>, Data, []),
Reply =
case find_role_by_id(RoleId, Roles) of
undefined ->
false;
Role ->
UserMax = guild_permissions:get_max_role_position(UserId, State),
RolePos = role_position(Role),
UserMax > RolePos orelse
(UserMax =:= RolePos andalso
compare_role_ids_for_equal_position(UserId, RoleId, State))
end,
{reply, #{can_manage => Reply}, State}.
compare_role_ids_for_equal_position(UserId, TargetRoleId, State) ->
case guild_permissions:find_member_by_user_id(UserId, State) of
undefined ->
false;
Member ->
MemberRoles = member_roles(Member),
Data = guild_data(State),
Roles = maps:get(<<"roles">>, Data, []),
UserHighestRole = get_highest_role(MemberRoles, Roles),
case UserHighestRole of
undefined ->
false;
HighestRole ->
HighestRoleId = map_utils:get_integer(HighestRole, <<"id">>, 0),
HighestRoleId < TargetRoleId
end
end.
get_highest_role(MemberRoleIds, Roles) ->
lists:foldl(
fun(RoleId, Acc) ->
case find_role_by_id(RoleId, Roles) of
undefined ->
Acc;
Role ->
case Acc of
undefined ->
Role;
AccRole ->
AccPos = role_position(AccRole),
RolePos = role_position(Role),
if
RolePos > AccPos ->
Role;
RolePos =:= AccPos ->
AccId = map_utils:get_integer(AccRole, <<"id">>, 0),
RId = map_utils:get_integer(Role, <<"id">>, 0),
if
RId < AccId -> Role;
true -> AccRole
end;
true ->
AccRole
end
end
end
end,
undefined,
MemberRoleIds
).
-spec get_assignable_roles(map(), guild_state()) -> guild_reply(map()).
get_assignable_roles(#{user_id := UserId}, State) ->
Roles = guild_roles(State),
OwnerId = owner_id(State),
RoleIds = get_assignable_role_ids(UserId, OwnerId, Roles, State),
{reply, #{role_ids => RoleIds}, State}.
get_assignable_role_ids(OwnerId, OwnerId, Roles, _State) ->
role_ids_from_roles(Roles);
get_assignable_role_ids(UserId, _OwnerId, Roles, State) ->
UserMaxPosition = guild_permissions:get_max_role_position(UserId, State),
lists:filtermap(
fun(Role) -> filter_assignable_role(Role, UserMaxPosition) end,
Roles
).
filter_assignable_role(Role, UserMaxPosition) ->
case role_position(Role) < UserMaxPosition of
true ->
case map_utils:get_integer(Role, <<"id">>, undefined) of
undefined -> false;
RoleId -> {true, RoleId}
end;
false ->
false
end.
-spec check_target_member(map(), guild_state()) -> guild_reply(map()).
check_target_member(#{user_id := UserId, target_user_id := TargetUserId}, State) ->
OwnerId = owner_id(State),
CanManage =
if
UserId =:= OwnerId ->
true;
TargetUserId =:= OwnerId ->
false;
true ->
UserMaxPos = guild_permissions:get_max_role_position(UserId, State),
TargetMaxPos = guild_permissions:get_max_role_position(TargetUserId, State),
UserMaxPos > TargetMaxPos
end,
{reply, #{can_manage => CanManage}, State}.
-spec get_viewable_channels(map(), guild_state()) -> guild_reply(map()).
get_viewable_channels(#{user_id := UserId}, State) ->
Channels = guild_channels(State),
case find_member_by_user_id(UserId, State) of
undefined ->
{reply, #{channel_ids => []}, State};
Member ->
ChannelIds = lists:filtermap(
fun(Channel) ->
ChannelId = map_utils:get_integer(Channel, <<"id">>, undefined),
case ChannelId of
undefined ->
false;
_ ->
case
guild_permissions:can_view_channel(UserId, ChannelId, Member, State)
of
true -> {true, ChannelId};
false -> false
end
end
end,
Channels
),
{reply, #{channel_ids => ChannelIds}, State}
end.
find_member_by_user_id(UserId, State) ->
guild_permissions:find_member_by_user_id(UserId, State).
find_role_by_id(RoleId, Roles) ->
guild_permissions:find_role_by_id(RoleId, Roles).
-spec guild_data(guild_state()) -> map().
guild_data(State) ->
map_utils:ensure_map(map_utils:get_safe(State, data, #{})).
-spec guild_members(guild_state()) -> [member()].
guild_members(State) ->
map_utils:ensure_list(maps:get(<<"members">>, guild_data(State), [])).
-spec guild_roles(guild_state()) -> [role()].
guild_roles(State) ->
map_utils:ensure_list(maps:get(<<"roles">>, guild_data(State), [])).
-spec guild_channels(guild_state()) -> [channel()].
guild_channels(State) ->
map_utils:ensure_list(maps:get(<<"channels">>, guild_data(State), [])).
-spec owner_id(guild_state()) -> user_id().
owner_id(State) ->
Guild = map_utils:ensure_map(maps:get(<<"guild">>, guild_data(State), #{})),
map_utils:get_integer(Guild, <<"owner_id">>, 0).
-spec member_user_id(member()) -> user_id() | undefined.
member_user_id(Member) ->
User = map_utils:ensure_map(maps:get(<<"user">>, Member, #{})),
map_utils:get_integer(User, <<"id">>, undefined).
-spec member_roles(member()) -> [role_id()].
member_roles(Member) ->
normalize_int_list(map_utils:ensure_list(maps:get(<<"roles">>, Member, []))).
-spec member_has_any_role(member(), [role_id()]) -> boolean().
member_has_any_role(Member, RoleIds) ->
MemberRoles = member_roles(Member),
lists:any(fun(RoleId) -> lists:member(RoleId, MemberRoles) end, RoleIds).
-spec member_has_any_role_set(member(), gb_sets:set(role_id())) -> boolean().
member_has_any_role_set(Member, RoleIdSet) ->
MemberRoles = member_roles(Member),
lists:any(fun(RoleId) -> gb_sets:is_member(RoleId, RoleIdSet) end, MemberRoles).
-spec is_member_bot(member()) -> boolean().
is_member_bot(Member) ->
User = map_utils:ensure_map(maps:get(<<"user">>, Member, #{})),
maps:get(<<"bot">>, User, false) =:= true.
-spec member_can_view_channel(user_id(), channel_id(), member(), guild_state()) -> boolean().
member_can_view_channel(UserId, ChannelId, Member, State) when is_integer(ChannelId) ->
guild_permissions:can_view_channel(UserId, ChannelId, Member, State);
member_can_view_channel(_, _, _, _) ->
false.
-spec collect_mentions([member()], user_id(), channel_id(), guild_state(), fun(
(member()) -> boolean()
)) ->
[user_id()].
collect_mentions(Members, AuthorId, ChannelId, State, Predicate) ->
lists:filtermap(
fun(Member) ->
case member_user_id(Member) of
undefined ->
false;
UserId when UserId =:= AuthorId -> false;
UserId ->
case
Predicate(Member) andalso
member_can_view_channel(UserId, ChannelId, Member, State)
of
true -> {true, UserId};
false -> false
end
end
end,
Members
).
-spec normalize_int_list(list()) -> [integer()].
normalize_int_list(List) ->
lists:reverse(
lists:foldl(
fun(Value, Acc) ->
case type_conv:to_integer(Value) of
undefined -> Acc;
Int -> [Int | Acc]
end
end,
[],
map_utils:ensure_list(List)
)
).
-spec role_ids_from_roles([role()]) -> [role_id()].
role_ids_from_roles(Roles) ->
lists:filtermap(
fun(Role) ->
case map_utils:get_integer(Role, <<"id">>, undefined) of
undefined -> false;
RoleId -> {true, RoleId}
end
end,
Roles
).
-spec role_position(role()) -> integer().
role_position(Role) ->
maps:get(<<"position">>, Role, 0).
-ifdef(TEST).
get_users_to_mention_by_roles_basic_test() ->
State = test_state(),
ChannelId = 500,
RoleMod = 200,
Request = #{channel_id => ChannelId, role_ids => [RoleMod], author_id => 1},
{reply, #{user_ids := UserIds}, _} = get_users_to_mention_by_roles(Request, State),
?assertEqual([2], UserIds).
get_assignable_roles_owner_test() ->
State = test_state(),
{reply, #{role_ids := RoleIds}, _} = get_assignable_roles(#{user_id => 1}, State),
?assertEqual(lists:sort([100, 200, 201]), lists:sort(RoleIds)).
get_assignable_roles_member_test() ->
State = test_state(),
{reply, #{role_ids := RoleIds}, _} = get_assignable_roles(#{user_id => 2}, State),
?assertEqual([100], RoleIds).
get_viewable_channels_filters_test() ->
State = test_state(),
{reply, #{channel_ids := ChannelIds}, _} = get_viewable_channels(#{user_id => 2}, State),
?assert(lists:member(500, ChannelIds)).
test_state() ->
GuildId = 100,
OwnerId = 1,
MemberId = 2,
OtherId = 3,
ChannelId = 500,
RoleMod = 200,
RoleHigh = 201,
ViewPerm = constants:view_channel_permission(),
ManageRoles = constants:manage_roles_permission(),
#{
id => GuildId,
data => #{
<<"guild">> => #{<<"owner_id">> => integer_to_binary(OwnerId)},
<<"roles">> => [
#{
<<"id">> => integer_to_binary(GuildId),
<<"permissions">> => integer_to_binary(ViewPerm bor ManageRoles),
<<"position">> => 0
},
#{
<<"id">> => integer_to_binary(RoleMod),
<<"permissions">> => integer_to_binary(ViewPerm),
<<"position">> => 10
},
#{
<<"id">> => integer_to_binary(RoleHigh),
<<"permissions">> => integer_to_binary(ViewPerm),
<<"position">> => 20
}
],
<<"channels">> => [
#{
<<"id">> => integer_to_binary(ChannelId),
<<"type">> => 0,
<<"permission_overwrites">> => []
},
#{
<<"id">> => integer_to_binary(ChannelId + 1),
<<"type">> => 2,
<<"permission_overwrites">> => []
}
],
<<"members">> => [
#{
<<"user">> => #{<<"id">> => integer_to_binary(OwnerId)},
<<"roles">> => [integer_to_binary(GuildId)]
},
#{
<<"user">> => #{<<"id">> => integer_to_binary(MemberId)},
<<"roles">> => [integer_to_binary(RoleMod)],
<<"joined_at">> => <<"2024-01-01T00:00:00Z">>
},
#{
<<"user">> => #{<<"id">> => integer_to_binary(OtherId)},
<<"roles">> => [integer_to_binary(RoleHigh)],
<<"joined_at">> => <<"2024-01-02T00:00:00Z">>
}
]
}
}.
-endif.

View File

@@ -0,0 +1,175 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_passive_sync).
-export([
schedule_passive_sync/1,
handle_passive_sync/1,
send_passive_updates_to_sessions/1,
compute_delta/2
]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-define(PASSIVE_SYNC_INTERVAL, 30000).
schedule_passive_sync(State) ->
erlang:send_after(?PASSIVE_SYNC_INTERVAL, self(), passive_sync),
State.
handle_passive_sync(State) ->
NewState = send_passive_updates_to_sessions(State),
schedule_passive_sync(NewState),
{noreply, NewState}.
send_passive_updates_to_sessions(State) ->
GuildId = maps:get(id, State),
Sessions = maps:get(sessions, State, #{}),
Data = maps:get(data, State, #{}),
Channels = maps:get(<<"channels">>, Data, []),
MemberCount = maps:get(member_count, State, undefined),
IsLargeGuild = case MemberCount of
undefined -> false;
Count when is_integer(Count) -> Count > 250
end,
PassiveSessions = maps:filter(
fun(_SessionId, SessionData) ->
IsLargeGuild andalso session_passive:is_passive(GuildId, SessionData)
end,
Sessions
),
case map_size(PassiveSessions) of
0 ->
State;
_ ->
UpdatedSessions = lists:foldl(
fun({SessionId, SessionData}, AccSessions) ->
Pid = maps:get(pid, SessionData),
UserId = maps:get(user_id, SessionData),
Member = guild_permissions:find_member_by_user_id(UserId, State),
CurrentLastMessageIds = build_last_message_ids(Channels, UserId, Member, State),
PreviousLastMessageIds = maps:get(previous_passive_updates, SessionData, #{}),
Delta = compute_delta(CurrentLastMessageIds, PreviousLastMessageIds),
case {map_size(Delta), is_pid(Pid)} of
{0, _} ->
AccSessions;
{_, true} ->
EventData = #{
<<"guild_id">> => integer_to_binary(GuildId),
<<"channels">> => Delta
},
gen_server:cast(Pid, {dispatch, passive_updates, EventData}),
MergedLastMessageIds = maps:merge(PreviousLastMessageIds, Delta),
UpdatedSessionData = maps:put(previous_passive_updates, MergedLastMessageIds, SessionData),
maps:put(SessionId, UpdatedSessionData, AccSessions);
_ ->
AccSessions
end
end,
Sessions,
maps:to_list(PassiveSessions)
),
maps:put(sessions, UpdatedSessions, State)
end.
compute_delta(CurrentLastMessageIds, PreviousLastMessageIds) ->
maps:filter(
fun(ChannelId, CurrentValue) ->
case maps:get(ChannelId, PreviousLastMessageIds, undefined) of
undefined -> true;
PreviousValue -> CurrentValue =/= PreviousValue
end
end,
CurrentLastMessageIds
).
build_last_message_ids(Channels, UserId, Member, State) ->
lists:foldl(
fun(Channel, Acc) ->
ChannelIdBin = maps:get(<<"id">>, Channel, undefined),
LastMessageId = maps:get(<<"last_message_id">>, Channel, null),
case {ChannelIdBin, LastMessageId} of
{undefined, _} ->
Acc;
{_, null} ->
Acc;
_ ->
ChannelId = validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0),
case Member of
undefined ->
Acc;
_ ->
case guild_permissions:can_view_channel(UserId, ChannelId, Member, State) of
true ->
maps:put(ChannelIdBin, LastMessageId, Acc);
false ->
Acc
end
end
end
end,
#{},
Channels
).
-ifdef(TEST).
compute_delta_empty_previous_test() ->
Current = #{<<"1">> => <<"100">>, <<"2">> => <<"200">>},
Previous = #{},
Delta = compute_delta(Current, Previous),
?assertEqual(Current, Delta),
ok.
compute_delta_no_changes_test() ->
Current = #{<<"1">> => <<"100">>, <<"2">> => <<"200">>},
Previous = #{<<"1">> => <<"100">>, <<"2">> => <<"200">>},
Delta = compute_delta(Current, Previous),
?assertEqual(#{}, Delta),
ok.
compute_delta_partial_changes_test() ->
Current = #{<<"1">> => <<"101">>, <<"2">> => <<"200">>, <<"3">> => <<"300">>},
Previous = #{<<"1">> => <<"100">>, <<"2">> => <<"200">>},
Delta = compute_delta(Current, Previous),
?assertEqual(#{<<"1">> => <<"101">>, <<"3">> => <<"300">>}, Delta),
ok.
compute_delta_only_new_channels_test() ->
Current = #{<<"1">> => <<"100">>, <<"2">> => <<"200">>, <<"3">> => <<"300">>},
Previous = #{<<"1">> => <<"100">>, <<"2">> => <<"200">>},
Delta = compute_delta(Current, Previous),
?assertEqual(#{<<"3">> => <<"300">>}, Delta),
ok.
compute_delta_ignores_removed_channels_test() ->
Current = #{<<"1">> => <<"100">>},
Previous = #{<<"1">> => <<"100">>, <<"2">> => <<"200">>},
Delta = compute_delta(Current, Previous),
?assertEqual(#{}, Delta),
ok.
-endif.

View File

@@ -0,0 +1,560 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_permissions).
-define(ALL_PERMISSIONS, 16#FFFFFFFFFFFFFFFF).
-export([get_member_permissions/3]).
-export([can_view_channel/4]).
-export([can_view_channel_by_permissions/4]).
-export([can_manage_channel/3]).
-export([apply_channel_overwrites/5]).
-export([get_max_role_position/2]).
-export([find_member_by_user_id/2]).
-export([find_role_by_id/2]).
-export([find_channel_by_id/2]).
-export_type([permission/0]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-type permission() :: non_neg_integer().
-type user_id() :: integer().
-type role_id() :: integer().
-type channel_id() :: integer().
-type maybe_channel_id() :: channel_id() | undefined.
-type guild_state() :: map().
-type guild_data() :: map().
-type member() :: map().
-type role() :: map().
-type channel() :: map().
-type overwrite() :: map().
-type member_roles() :: [role_id()].
-type maybe_member() :: member() | undefined.
-spec get_member_permissions(user_id(), maybe_channel_id(), guild_state()) -> permission().
get_member_permissions(UserId, ChannelId, State) ->
compute_member_permissions(UserId, ChannelId, undefined, State).
-spec can_view_channel(user_id(), channel_id(), maybe_member(), guild_state()) -> boolean().
can_view_channel(UserId, ChannelId, Member, State) ->
guild_virtual_channel_access:has_virtual_access(UserId, ChannelId, State) orelse
can_view_channel_by_permissions(UserId, ChannelId, Member, State).
-spec can_view_channel_by_permissions(user_id(), channel_id(), maybe_member(), guild_state()) ->
boolean().
can_view_channel_by_permissions(UserId, ChannelId, Member, State) ->
(compute_member_permissions(UserId, ChannelId, Member, State) band
constants:view_channel_permission()) =/= 0.
-spec can_manage_channel(user_id(), maybe_channel_id(), guild_state()) -> boolean().
can_manage_channel(UserId, ChannelId, State) ->
(get_member_permissions(UserId, ChannelId, State) band
constants:manage_channels_permission()) =/= 0.
-spec apply_channel_overwrites(permission(), user_id(), member_roles(), channel(), role_id()) ->
permission().
apply_channel_overwrites(BasePerms, UserId, MemberRoles, Channel, EveryoneRoleId) ->
Overwrites = channel_overwrites(Channel),
EveryonePerms = apply_everyone_overwrites(BasePerms, Overwrites, EveryoneRoleId),
{RoleAllow, RoleDeny} = accumulate_role_overwrites(MemberRoles, Overwrites),
RolePerms = (EveryonePerms band bnot RoleDeny) bor RoleAllow,
apply_user_overwrites(RolePerms, Overwrites, UserId).
-spec get_max_role_position(user_id(), guild_state()) -> integer().
get_max_role_position(UserId, State) ->
case {find_member_by_user_id(UserId, State), resolve_data_map(State)} of
{undefined, _} ->
-1;
{_, undefined} ->
-1;
{Member, Data} ->
Roles = ensure_list(maps:get(<<"roles">>, Data, [])),
lists:foldl(
fun(RoleId, MaxPos) ->
case find_role_by_id(RoleId, Roles) of
undefined ->
MaxPos;
Role ->
Position = maps:get(<<"position">>, Role, 0),
max(Position, MaxPos)
end
end,
-1,
member_role_ids(Member)
)
end.
-spec find_member_by_user_id(user_id(), guild_state()) -> member() | undefined.
find_member_by_user_id(UserId, State) when is_integer(UserId) ->
case resolve_data_map(State) of
undefined ->
undefined;
Data ->
Members = ensure_list(maps:get(<<"members">>, Data, [])),
lists:foldl(
fun(Member, Acc) ->
case Acc of
undefined ->
MUser = maps:get(<<"user">>, Member, #{}),
MemberId = to_int(maps:get(<<"id">>, MUser, <<"0">>)),
case MemberId =:= UserId of
true -> Member;
false -> undefined
end;
Found ->
Found
end
end,
undefined,
Members
)
end;
find_member_by_user_id(_, _) ->
undefined.
-spec find_role_by_id(role_id(), list()) -> role() | undefined.
find_role_by_id(RoleId, Roles) ->
TargetId = to_int(RoleId),
lists:foldl(
fun(Role, Acc) ->
case Acc of
undefined ->
case role_id(Role) =:= TargetId of
true -> Role;
false -> undefined
end;
Found ->
Found
end
end,
undefined,
ensure_list(Roles)
).
-spec find_channel_by_id(channel_id(), guild_state()) -> channel() | undefined.
find_channel_by_id(ChannelId, State) when is_integer(ChannelId) ->
case resolve_data_map(State) of
undefined ->
undefined;
Data ->
Channels = ensure_list(maps:get(<<"channels">>, Data, [])),
lists:foldl(
fun(Channel, Acc) ->
case Acc of
undefined ->
ChanId = to_int(maps:get(<<"id">>, Channel, <<"0">>)),
case ChanId =:= ChannelId of
true -> Channel;
false -> undefined
end;
Found ->
Found
end
end,
undefined,
Channels
)
end;
find_channel_by_id(_, _) ->
undefined.
-spec compute_member_permissions(user_id(), maybe_channel_id(), maybe_member(), guild_state()) ->
permission().
compute_member_permissions(UserId, ChannelId, ProvidedMember, State) when is_integer(UserId) ->
case resolve_data_map(State) of
undefined ->
0;
Data ->
OwnerId = guild_owner_id(Data),
case UserId =:= OwnerId of
true ->
?ALL_PERMISSIONS;
false ->
case resolve_member(UserId, ProvidedMember, State) of
undefined ->
0;
Member ->
GuildId = guild_id(State),
Roles = ensure_list(maps:get(<<"roles">>, Data, [])),
BasePermissions = base_role_permissions(GuildId, Roles),
MemberRoles = member_role_ids(Member),
Permissions = aggregate_role_permissions(
MemberRoles, Roles, BasePermissions
),
case (Permissions band constants:administrator_permission()) =/= 0 of
true ->
?ALL_PERMISSIONS;
false ->
maybe_apply_channel_overwrites(
Permissions,
UserId,
MemberRoles,
ChannelId,
GuildId,
State
)
end
end
end
end;
compute_member_permissions(_, _, _, _) ->
0.
-spec resolve_member(user_id(), maybe_member(), guild_state()) -> maybe_member().
resolve_member(_UserId, Member, _State) when is_map(Member) ->
Member;
resolve_member(UserId, _Member, State) ->
find_member_by_user_id(UserId, State).
-spec guild_owner_id(guild_data()) -> user_id().
guild_owner_id(Data) ->
Guild = maps:get(<<"guild">>, Data, #{}),
to_int(maps:get(<<"owner_id">>, Guild, <<"0">>)).
-spec guild_id(guild_state()) -> integer().
guild_id(State) ->
case maps:get(id, State, undefined) of
undefined ->
to_int(maps:get(<<"id">>, State, 0));
GuildId when is_integer(GuildId) ->
GuildId;
GuildId ->
to_int(GuildId)
end.
-spec base_role_permissions(role_id(), list()) -> permission().
base_role_permissions(GuildId, Roles) ->
lists:foldl(
fun(Role, Acc) ->
case role_id(Role) =:= GuildId of
true -> role_permissions(Role);
false -> Acc
end
end,
0,
ensure_list(Roles)
).
-spec aggregate_role_permissions(member_roles(), list(), permission()) -> permission().
aggregate_role_permissions(MemberRoles, Roles, BasePermissions) ->
lists:foldl(
fun(RoleId, Acc) ->
case find_role_by_id(RoleId, Roles) of
undefined ->
Acc;
Role ->
Acc bor role_permissions(Role)
end
end,
BasePermissions,
MemberRoles
).
-spec maybe_apply_channel_overwrites(
permission(), user_id(), member_roles(), maybe_channel_id(), role_id(), guild_state()
) -> permission().
maybe_apply_channel_overwrites(Permissions, _UserId, _MemberRoles, undefined, _GuildId, _State) ->
Permissions;
maybe_apply_channel_overwrites(Permissions, UserId, MemberRoles, ChannelId, GuildId, State) when
is_integer(ChannelId)
->
case find_channel_by_id(ChannelId, State) of
undefined ->
Permissions;
Channel ->
apply_channel_overwrites(Permissions, UserId, MemberRoles, Channel, GuildId)
end;
maybe_apply_channel_overwrites(Permissions, _UserId, _MemberRoles, _ChannelId, _GuildId, _State) ->
Permissions.
-spec member_role_ids(member()) -> member_roles().
member_role_ids(Member) ->
RoleIds = maps:get(<<"roles">>, Member, []),
extract_integer_list(RoleIds).
-spec role_permissions(role()) -> permission().
role_permissions(Role) ->
to_int(maps:get(<<"permissions">>, Role, <<"0">>)).
-spec role_id(role()) -> role_id().
role_id(Role) ->
to_int(maps:get(<<"id">>, Role, <<"0">>)).
-spec channel_overwrites(channel()) -> [overwrite()].
channel_overwrites(Channel) ->
case maps:get(<<"permission_overwrites">>, Channel, []) of
Overwrites when is_list(Overwrites) -> Overwrites;
_ -> []
end.
-spec apply_everyone_overwrites(permission(), [overwrite()], role_id()) -> permission().
apply_everyone_overwrites(BasePerms, Overwrites, EveryoneRoleId) ->
lists:foldl(
fun(Overwrite, Acc) ->
case overwrite_matches_role(Overwrite, EveryoneRoleId) of
true ->
apply_allow_deny(Acc, overwrite_allow(Overwrite), overwrite_deny(Overwrite));
false ->
Acc
end
end,
BasePerms,
Overwrites
).
-spec accumulate_role_overwrites(member_roles(), [overwrite()]) -> {permission(), permission()}.
accumulate_role_overwrites(MemberRoles, Overwrites) ->
lists:foldl(
fun(RoleId, {AllowAcc, DenyAcc}) ->
lists:foldl(
fun(Overwrite, {A, D}) ->
case overwrite_matches_role(Overwrite, RoleId) of
true ->
{A bor overwrite_allow(Overwrite), D bor overwrite_deny(Overwrite)};
false ->
{A, D}
end
end,
{AllowAcc, DenyAcc},
Overwrites
)
end,
{0, 0},
MemberRoles
).
-spec apply_user_overwrites(permission(), [overwrite()], user_id()) -> permission().
apply_user_overwrites(Perms, Overwrites, UserId) ->
lists:foldl(
fun(Overwrite, Acc) ->
case overwrite_matches_user(Overwrite, UserId) of
true ->
apply_allow_deny(Acc, overwrite_allow(Overwrite), overwrite_deny(Overwrite));
false ->
Acc
end
end,
Perms,
Overwrites
).
-spec overwrite_matches_role(overwrite(), role_id()) -> boolean().
overwrite_matches_role(Overwrite, RoleId) when is_map(Overwrite), is_integer(RoleId) ->
overwrite_type(Overwrite) =:= 0 andalso overwrite_id(Overwrite) =:= RoleId;
overwrite_matches_role(_, _) ->
false.
-spec overwrite_matches_user(overwrite(), user_id()) -> boolean().
overwrite_matches_user(Overwrite, UserId) when is_map(Overwrite), is_integer(UserId) ->
overwrite_type(Overwrite) =:= 1 andalso overwrite_id(Overwrite) =:= UserId;
overwrite_matches_user(_, _) ->
false.
-spec overwrite_id(overwrite()) -> integer().
overwrite_id(Overwrite) ->
to_int(maps:get(<<"id">>, Overwrite, <<"0">>)).
-spec overwrite_type(overwrite()) -> integer().
overwrite_type(Overwrite) ->
maps:get(<<"type">>, Overwrite, 0).
-spec overwrite_allow(overwrite()) -> permission().
overwrite_allow(Overwrite) ->
to_int(maps:get(<<"allow">>, Overwrite, <<"0">>)).
-spec overwrite_deny(overwrite()) -> permission().
overwrite_deny(Overwrite) ->
to_int(maps:get(<<"deny">>, Overwrite, <<"0">>)).
-spec apply_allow_deny(permission(), permission(), permission()) -> permission().
apply_allow_deny(Acc, Allow, Deny) ->
(Acc band bnot Deny) bor Allow.
-spec extract_integer_list(list()) -> [integer()].
extract_integer_list(List) when is_list(List) ->
lists:reverse(
lists:foldl(
fun(Value, Acc) ->
case type_conv:to_integer(Value) of
undefined -> Acc;
Int -> [Int | Acc]
end
end,
[],
List
)
);
extract_integer_list(_) ->
[].
-spec ensure_list(term()) -> list().
ensure_list(List) when is_list(List) ->
List;
ensure_list(_) ->
[].
-spec to_int(term()) -> integer().
to_int(Value) ->
case type_conv:to_integer(Value) of
undefined -> 0;
Int -> Int
end.
-spec resolve_data_map(guild_state() | map()) -> guild_data() | undefined.
resolve_data_map(State) when is_map(State) ->
case maps:find(data, State) of
{ok, Data} when is_map(Data) ->
Data;
{ok, Data} when is_map(Data) =:= false ->
Data;
error ->
case State of
#{<<"members">> := _} ->
State;
_ ->
undefined
end
end;
resolve_data_map(_) ->
undefined.
-ifdef(TEST).
owner_receives_full_permissions_test() ->
OwnerId = 1,
GuildId = 100,
State = #{
id => GuildId,
data => #{
<<"guild">> => #{<<"owner_id">> => integer_to_binary(OwnerId)},
<<"roles">> => [#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => <<"0">>}]
}
},
?assertEqual(?ALL_PERMISSIONS, get_member_permissions(OwnerId, undefined, State)).
channel_scope_permissions_test() ->
GuildId = 42,
UserId = 600,
ChannelId = 700,
RoleId = 800,
View = constants:view_channel_permission(),
State = #{
id => GuildId,
data => #{
<<"guild">> => #{<<"owner_id">> => integer_to_binary(GuildId + 1)},
<<"roles">> => [
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => <<"0">>},
#{<<"id">> => integer_to_binary(RoleId), <<"permissions">> => <<"0">>}
],
<<"members">> => [
#{
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
<<"roles">> => [integer_to_binary(RoleId)]
}
],
<<"channels">> => [
#{
<<"id">> => integer_to_binary(ChannelId),
<<"permission_overwrites">> => [
#{
<<"id">> => integer_to_binary(GuildId),
<<"type">> => 0,
<<"allow">> => <<"0">>,
<<"deny">> => <<"0">>
},
#{
<<"id">> => integer_to_binary(RoleId),
<<"type">> => 0,
<<"allow">> => integer_to_binary(View),
<<"deny">> => <<"0">>
}
]
}
]
}
},
?assertEqual(0, get_member_permissions(UserId, undefined, State)),
ChannelPerms = get_member_permissions(UserId, ChannelId, State),
?assert((ChannelPerms band View) =/= 0).
apply_channel_overwrites_e2e_test() ->
View = constants:view_channel_permission(),
GuildId = 5,
RoleId = 9,
UserId = 11,
Channel = #{
<<"permission_overwrites">> => [
#{
<<"id">> => integer_to_binary(GuildId),
<<"type">> => 0,
<<"allow">> => <<"0">>,
<<"deny">> => integer_to_binary(View)
},
#{
<<"id">> => integer_to_binary(RoleId),
<<"type">> => 0,
<<"allow">> => integer_to_binary(View),
<<"deny">> => <<"0">>
},
#{
<<"id">> => integer_to_binary(UserId),
<<"type">> => 1,
<<"allow">> => <<"0">>,
<<"deny">> => integer_to_binary(View)
}
]
},
Base = View,
Result = apply_channel_overwrites(Base, UserId, [RoleId], Channel, GuildId),
?assertEqual(0, Result).
administrator_role_grants_all_permissions_test() ->
Admin = constants:administrator_permission(),
GuildId = 100,
UserId = 200,
ChannelId = 300,
OwnerId = 999,
State = #{
id => GuildId,
data => #{
<<"guild">> => #{<<"owner_id">> => integer_to_binary(OwnerId)},
<<"roles">> => [
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => integer_to_binary(Admin)}
],
<<"members">> => [
#{
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
<<"roles">> => []
}
],
<<"channels">> => [
#{
<<"id">> => integer_to_binary(ChannelId),
<<"permission_overwrites">> => []
}
]
}
},
?assertEqual(?ALL_PERMISSIONS, get_member_permissions(UserId, undefined, State)),
?assertEqual(?ALL_PERMISSIONS, get_member_permissions(UserId, ChannelId, State)),
?assert(can_view_channel(UserId, ChannelId, undefined, State)).
-endif.

View File

@@ -0,0 +1,302 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_presence).
-export([handle_bus_presence/3, send_cached_presence_to_session/3]).
-export([broadcast_presence_update/3]).
-import(guild_sessions, [handle_user_offline/2]).
-type guild_state() :: map().
-type member() :: map().
-type user_id() :: integer().
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec handle_bus_presence(user_id(), map(), guild_state()) -> {noreply, guild_state()}.
-spec send_cached_presence_to_session(user_id(), binary(), guild_state()) -> guild_state().
handle_bus_presence(UserId, Payload, State) ->
case maps:get(<<"user_update">>, Payload, false) of
true ->
UserData = maps:get(<<"user">>, Payload, #{}),
UpdatedState = handle_user_data_update(UserId, UserData, State),
guild_member_list:broadcast_member_list_updates(UserId, State, UpdatedState),
{noreply, UpdatedState};
false ->
Member = find_member_by_user_id(UserId, State),
case Member of
undefined ->
{noreply, State};
_ ->
StatusBin = maps:get(<<"status">>, Payload, <<"offline">>),
NormalizedStatusBin = normalize_presence_status(StatusBin),
Status = constants:status_type_atom(NormalizedStatusBin),
Mobile = maps:get(<<"mobile">>, Payload, false),
Afk = maps:get(<<"afk">>, Payload, false),
logger:debug("[guild_presence] Presence update for UserId=~p, Status=~p", [UserId, Status]),
MemberUser = maps:get(<<"user">>, Member, #{}),
CustomStatus = maps:get(<<"custom_status">>, Payload, null),
PresenceMap = presence_payload:build(
MemberUser,
NormalizedStatusBin,
Mobile,
Afk,
CustomStatus
),
Presences = maps:get(presences, State, #{}),
UpdatedPresences = maps:put(UserId, PresenceMap, Presences),
StateWithPresences = maps:put(presences, UpdatedPresences, State),
broadcast_presence_update(UserId, PresenceMap, StateWithPresences),
logger:debug("[guild_presence] Broadcasting member list updates for UserId=~p", [UserId]),
guild_member_list:broadcast_member_list_updates(UserId, State, StateWithPresences),
StateAfterOffline =
case Status of
offline ->
handle_user_offline(UserId, StateWithPresences);
_ ->
StateWithPresences
end,
{noreply, StateAfterOffline}
end
end.
-spec broadcast_presence_update(user_id(), map(), guild_state()) -> ok.
broadcast_presence_update(UserId, Payload, State) ->
case find_member_by_user_id(UserId, State) of
undefined ->
ok;
_Member ->
GuildId = map_utils:get_integer(State, id, 0),
PresenceUpdate = maps:put(<<"guild_id">>, integer_to_binary(GuildId), Payload),
Sessions = maps:get(sessions, State, #{}),
MemberSubs = maps:get(member_subscriptions, State, guild_subscriptions:init_state()),
SubscribedSessionIds = guild_subscriptions:get_subscribed_sessions(UserId, MemberSubs),
TargetChannels = guild_visibility:viewable_channel_set(UserId, State),
{ValidSessionIds, InvalidSessionIds} =
partition_subscribed_sessions(SubscribedSessionIds, Sessions, TargetChannels, UserId, State),
StateAfterInvalidRemovals =
lists:foldl(
fun(SessionId, AccState) ->
remove_session_member_subscription(SessionId, UserId, AccState)
end,
State,
sets:to_list(sets:from_list(InvalidSessionIds))
),
FinalSessions = maps:get(sessions, StateAfterInvalidRemovals, #{}),
ValidSessionSet = sets:from_list(ValidSessionIds),
SessionsToNotify = lists:filter(
fun({SessionId, _}) -> sets:is_element(SessionId, ValidSessionSet) end,
maps:to_list(FinalSessions)
),
lists:foreach(
fun({_SessionId, SessionData}) ->
SessionPid = maps:get(pid, SessionData),
case is_pid(SessionPid) of
true ->
gen_server:cast(
SessionPid, {dispatch, presence_update, PresenceUpdate}
);
false ->
ok
end
end,
SessionsToNotify
),
ok
end.
normalize_presence_status(<<"invisible">>) -> <<"offline">>;
normalize_presence_status(Status) when is_binary(Status) -> Status;
normalize_presence_status(_) -> <<"offline">>.
send_cached_presence_to_session(UserId, SessionId, State) ->
case presence_cache:get(UserId) of
{ok, Payload} ->
send_presence_payload_to_session(UserId, SessionId, Payload, State);
_ ->
State
end.
send_presence_payload_to_session(UserId, SessionId, Payload, State) ->
GuildId = map_utils:get_integer(State, id, 0),
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
#{pid := SessionPid} when is_pid(SessionPid) ->
Member = find_member_by_user_id(UserId, State),
case Member of
undefined ->
State;
_ ->
StatusBin = maps:get(<<"status">>, Payload, <<"offline">>),
Mobile = maps:get(<<"mobile">>, Payload, false),
Afk = maps:get(<<"afk">>, Payload, false),
MemberUser = maps:get(<<"user">>, Member, #{}),
CustomStatus = maps:get(<<"custom_status">>, Payload, null),
PresenceBase =
presence_payload:build(MemberUser, StatusBin, Mobile, Afk, CustomStatus),
PresenceUpdate = maps:put(<<"guild_id">>, integer_to_binary(GuildId), PresenceBase),
gen_server:cast(SessionPid, {dispatch, presence_update, PresenceUpdate}),
State
end;
_ ->
State
end.
-spec handle_user_data_update(user_id(), map(), guild_state()) -> guild_state().
handle_user_data_update(UserId, UserData, State) ->
Data = guild_data(State),
Members = guild_members(State),
case find_member_by_user_id(UserId, State) of
undefined ->
State;
Member ->
CurrentUserData = maps:get(<<"user">>, Member, #{}),
case check_user_data_differs(CurrentUserData, UserData) of
false ->
State;
true ->
UpdatedMembers = lists:map(
fun(M) ->
maybe_replace_member(M, UserId, UserData)
end,
Members
),
UpdatedData = maps:put(<<"members">>, UpdatedMembers, Data),
UpdatedState = maps:put(data, UpdatedData, State),
maybe_dispatch_member_update(UserId, UpdatedState),
UpdatedState
end
end.
-spec maybe_replace_member(member(), user_id(), map()) -> member().
maybe_replace_member(Member, UserId, UserData) ->
case member_id(Member) of
UserId ->
maps:put(<<"user">>, UserData, Member);
_ ->
Member
end.
-spec maybe_dispatch_member_update(user_id(), guild_state()) -> ok.
maybe_dispatch_member_update(UserId, State) ->
case find_member_by_user_id(UserId, State) of
undefined ->
ok;
Member ->
GuildId = map_utils:get_integer(State, id, 0),
MemberUpdate = maps:put(<<"guild_id">>, integer_to_binary(GuildId), Member),
gen_server:cast(
self(), {dispatch, #{event => guild_member_update, data => MemberUpdate}}
)
end.
-spec guild_data(guild_state()) -> map().
guild_data(State) ->
map_utils:ensure_map(map_utils:get_safe(State, data, #{})).
-spec guild_members(guild_state()) -> [map()].
guild_members(State) ->
map_utils:ensure_list(maps:get(<<"members">>, guild_data(State), [])).
-spec member_id(map()) -> user_id() | undefined.
member_id(Member) ->
User = map_utils:ensure_map(maps:get(<<"user">>, Member, #{})),
map_utils:get_integer(User, <<"id">>, undefined).
partition_subscribed_sessions(SessionIds, Sessions, TargetChannels, TargetUserId, State) ->
lists:foldl(
fun(SessionId, {Valids, Invalids}) ->
case maps:get(SessionId, Sessions, undefined) of
undefined ->
{Valids, [SessionId | Invalids]};
SessionData ->
SessionUserId = maps:get(user_id, SessionData, undefined),
Shared =
case SessionUserId of
undefined ->
false;
UserId when UserId =:= TargetUserId ->
false;
_ ->
SessionChannels = guild_visibility:viewable_channel_set(SessionUserId, State),
not sets:is_empty(sets:intersection(SessionChannels, TargetChannels))
end,
case Shared of
true -> {[SessionId | Valids], Invalids};
false -> {Valids, [SessionId | Invalids]}
end
end
end,
{[], []},
SessionIds
).
remove_session_member_subscription(SessionId, UserId, State) ->
MemberSubs = maps:get(member_subscriptions, State, guild_subscriptions:init_state()),
NewMemberSubs = guild_subscriptions:unsubscribe(SessionId, UserId, MemberSubs),
State1 = maps:put(member_subscriptions, NewMemberSubs, State),
guild_sessions:unsubscribe_from_user_presence(UserId, State1).
-ifdef(TEST).
handle_bus_presence_non_member_noop_test() ->
Payload = #{<<"status">> => <<"online">>, <<"user">> => #{<<"id">> => <<"99">>}},
State = #{data => #{<<"members">> => []}, sessions => #{}},
{noreply, NewState} = handle_bus_presence(99, Payload, State),
?assertEqual(State, NewState).
handle_bus_presence_broadcasts_test() ->
State = presence_test_state(),
Payload = #{
<<"status">> => <<"online">>,
<<"mobile">> => true,
<<"afk">> => false,
<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"Alpha">>}
},
{noreply, _NewState} = handle_bus_presence(1, Payload, State),
ok.
handle_bus_presence_user_update_test() ->
State = presence_test_state(),
UserData = #{<<"id">> => <<"1">>, <<"username">> => <<"Updated">>},
Payload = #{<<"user">> => UserData, <<"user_update">> => true},
{noreply, NewState} = handle_bus_presence(1, Payload, State),
Data = maps:get(data, NewState),
[Member | _] = maps:get(<<"members">>, Data),
?assertEqual(<<"Updated">>, maps:get(<<"username">>, maps:get(<<"user">>, Member))).
presence_test_state() ->
#{
id => 42,
data => #{
<<"members">> => [
#{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"Alpha">>}}
]
},
sessions => #{}
}.
-endif.
check_user_data_differs(CurrentUserData, NewUserData) ->
utils:check_user_data_differs(CurrentUserData, NewUserData).
find_member_by_user_id(UserId, State) ->
guild_permissions:find_member_by_user_id(UserId, State).

View File

@@ -0,0 +1,505 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_request_members).
-export([
handle_request/3
]).
-define(CHUNK_SIZE, 1000).
-define(MAX_USER_IDS, 100).
-define(MAX_NONCE_LENGTH, 32).
-type session_state() :: map().
-type request_data() :: map().
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec handle_request(request_data(), pid(), session_state()) -> ok | {error, atom()}.
handle_request(Data, SocketPid, SessionState) when is_map(Data), is_pid(SocketPid) ->
logger:debug("[guild_request_members] Handling guild members request: ~p", [Data]),
case parse_request(Data) of
{ok, Request} ->
logger:debug("[guild_request_members] Request parsed successfully: ~p", [Request]),
process_request(Request, SocketPid, SessionState);
{error, Reason} ->
logger:warning("[guild_request_members] Failed to parse request: ~p", [Reason]),
{error, Reason}
end;
handle_request(_, _, _) ->
{error, invalid_request}.
-spec parse_request(request_data()) -> {ok, map()} | {error, atom()}.
parse_request(Data) ->
GuildIdRaw = maps:get(<<"guild_id">>, Data, undefined),
Query = maps:get(<<"query">>, Data, <<>>),
Limit = maps:get(<<"limit">>, Data, 0),
UserIdsRaw = maps:get(<<"user_ids">>, Data, []),
Presences = maps:get(<<"presences">>, Data, false),
Nonce = maps:get(<<"nonce">>, Data, null),
NormalizedNonce = normalize_nonce(Nonce),
case validate_guild_id(GuildIdRaw) of
{ok, GuildId} ->
case validate_user_ids(UserIdsRaw) of
{ok, UserIds} ->
{ok, #{
guild_id => GuildId,
query => ensure_binary(Query),
limit => ensure_limit(Limit),
user_ids => UserIds,
presences => Presences =:= true,
nonce => NormalizedNonce
}};
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end.
-spec validate_guild_id(term()) -> {ok, integer()} | {error, atom()}.
validate_guild_id(GuildId) when is_integer(GuildId), GuildId > 0 ->
{ok, GuildId};
validate_guild_id(GuildId) when is_binary(GuildId) ->
case validation:validate_snowflake(<<"guild_id">>, GuildId) of
{ok, Id} -> {ok, Id};
{error, _, _} -> {error, invalid_guild_id}
end;
validate_guild_id(_) ->
{error, invalid_guild_id}.
-spec validate_user_ids(term()) -> {ok, [integer()]} | {error, atom()}.
validate_user_ids(UserIds) when is_list(UserIds) ->
case length(UserIds) > ?MAX_USER_IDS of
true ->
{error, too_many_user_ids};
false ->
ParsedIds = lists:filtermap(
fun(Id) ->
case parse_user_id(Id) of
{ok, ParsedId} -> {true, ParsedId};
error -> false
end
end,
UserIds
),
{ok, ParsedIds}
end;
validate_user_ids(_) ->
{ok, []}.
-spec parse_user_id(term()) -> {ok, integer()} | error.
parse_user_id(Id) when is_integer(Id), Id > 0 ->
{ok, Id};
parse_user_id(Id) when is_binary(Id) ->
case type_conv:to_integer(Id) of
undefined -> error;
ParsedId when ParsedId > 0 -> {ok, ParsedId};
_ -> error
end;
parse_user_id(_) ->
error.
-spec ensure_binary(term()) -> binary().
ensure_binary(Value) when is_binary(Value) -> Value;
ensure_binary(_) -> <<>>.
-spec ensure_limit(term()) -> non_neg_integer().
ensure_limit(Limit) when is_integer(Limit), Limit >= 0 -> Limit;
ensure_limit(_) -> 0.
-spec normalize_nonce(term()) -> binary() | null.
normalize_nonce(Nonce) when is_binary(Nonce), byte_size(Nonce) =< ?MAX_NONCE_LENGTH ->
Nonce;
normalize_nonce(_) ->
null.
-spec process_request(map(), pid(), session_state()) -> ok | {error, atom()}.
process_request(Request, SocketPid, SessionState) ->
#{guild_id := GuildId, query := Query, limit := Limit, user_ids := UserIds} = Request,
UserIdBin = maps:get(user_id, SessionState),
UserId = type_conv:to_integer(UserIdBin),
logger:debug(
"[guild_request_members] Processing request for guild ~p, user ~p, user_ids: ~p",
[GuildId, UserId, UserIds]
),
case check_permission(UserId, GuildId, Query, Limit, UserIds, SessionState) of
ok ->
logger:debug("[guild_request_members] Permission check passed, fetching members"),
fetch_and_send_members(Request, SocketPid, SessionState);
{error, Reason} ->
logger:warning(
"[guild_request_members] Permission check failed: ~p",
[Reason]
),
{error, Reason}
end.
-spec check_permission(integer(), integer(), binary(), non_neg_integer(), [integer()], session_state()) ->
ok | {error, atom()}.
check_permission(UserId, GuildId, Query, Limit, UserIds, SessionState) ->
RequiresPermission = Query =:= <<>> andalso Limit =:= 0 andalso UserIds =:= [],
case RequiresPermission of
false ->
ok;
true ->
case lookup_guild(GuildId, SessionState) of
{ok, GuildPid} ->
check_management_permission(UserId, GuildId, GuildPid);
{error, _} ->
{error, guild_not_found}
end
end.
-spec check_management_permission(integer(), integer(), pid()) -> ok | {error, atom()}.
check_management_permission(UserId, _GuildId, GuildPid) ->
ManageRoles = constants:manage_roles_permission(),
KickMembers = constants:kick_members_permission(),
BanMembers = constants:ban_members_permission(),
RequiredPermission = ManageRoles bor KickMembers bor BanMembers,
PermRequest = #{
user_id => UserId,
permission => RequiredPermission,
channel_id => undefined
},
case gen_server:call(GuildPid, {check_permission, PermRequest}, 5000) of
#{has_permission := true} -> ok;
#{has_permission := false} -> {error, missing_permission};
_ -> {error, permission_check_failed}
end.
-spec lookup_guild(integer(), session_state()) -> {ok, pid()} | {error, not_found}.
lookup_guild(GuildId, SessionState) ->
Guilds = maps:get(guilds, SessionState, #{}),
case maps:get(GuildId, Guilds, undefined) of
{Pid, _Ref} when is_pid(Pid) ->
{ok, Pid};
undefined ->
case gen_server:call(guild_manager, {lookup, GuildId}, 5000) of
{ok, Pid} when is_pid(Pid) -> {ok, Pid};
_ -> {error, not_found}
end;
_ ->
{error, not_found}
end.
-spec fetch_and_send_members(map(), pid(), session_state()) -> ok | {error, atom()}.
fetch_and_send_members(Request, _SocketPid, SessionState) ->
#{
guild_id := GuildId,
query := Query,
limit := Limit,
user_ids := UserIds,
presences := Presences,
nonce := Nonce
} = Request,
SessionId = maps:get(session_id, SessionState),
logger:debug(
"[guild_request_members] Looking up guild ~p for member request",
[GuildId]
),
case lookup_guild(GuildId, SessionState) of
{ok, GuildPid} ->
logger:debug("[guild_request_members] Guild ~p found, fetching members", [GuildId]),
Members = fetch_members(GuildPid, Query, Limit, UserIds),
logger:debug("[guild_request_members] Found ~p members", [length(Members)]),
PresencesList = maybe_fetch_presences(Presences, GuildPid, Members),
send_member_chunks(GuildPid, SessionId, Members, PresencesList, Nonce),
logger:debug(
"[guild_request_members] Sent ~p member chunks for guild ~p with nonce ~p",
[max(1, (length(Members) + ?CHUNK_SIZE - 1) div ?CHUNK_SIZE), GuildId, Nonce]
),
ok;
{error, Reason} ->
logger:warning(
"[guild_request_members] Failed to lookup guild ~p: ~p",
[GuildId, Reason]
),
{error, Reason}
end.
-spec fetch_members(pid(), binary(), non_neg_integer(), [integer()]) -> [map()].
fetch_members(GuildPid, _Query, _Limit, UserIds) when UserIds =/= [] ->
logger:debug("[guild_request_members] Fetching members by user_ids: ~p", [UserIds]),
case gen_server:call(GuildPid, {list_guild_members, #{limit => 100000, offset => 0}}, 10000) of
#{members := AllMembers} ->
logger:debug("[guild_request_members] Got ~p members from guild, filtering by user_ids", [length(AllMembers)]),
Filtered = filter_members_by_ids(AllMembers, UserIds),
logger:debug("[guild_request_members] Filtered to ~p members", [length(Filtered)]),
Filtered;
Other ->
logger:warning("[guild_request_members] Unexpected response from guild: ~p", [Other]),
[]
end;
fetch_members(GuildPid, Query, Limit, []) ->
ActualLimit = case Limit of 0 -> 100000; L -> L end,
logger:debug("[guild_request_members] Fetching members with query '~s', limit ~p", [Query, ActualLimit]),
case gen_server:call(GuildPid, {list_guild_members, #{limit => ActualLimit, offset => 0}}, 10000) of
#{members := AllMembers} ->
logger:debug("[guild_request_members] Got ~p members from guild", [length(AllMembers)]),
Result = case Query of
<<>> ->
lists:sublist(AllMembers, ActualLimit);
_ ->
filter_members_by_query(AllMembers, Query, ActualLimit)
end,
logger:debug("[guild_request_members] Returning ~p members after query/filter", [length(Result)]),
Result;
Other ->
logger:warning("[guild_request_members] Unexpected response from guild: ~p", [Other]),
[]
end.
-spec filter_members_by_ids([map()], [integer()]) -> [map()].
filter_members_by_ids(Members, UserIds) ->
UserIdSet = sets:from_list(UserIds),
lists:filter(
fun(Member) ->
UserId = extract_user_id(Member),
UserId =/= undefined andalso sets:is_element(UserId, UserIdSet)
end,
Members
).
-spec filter_members_by_query([map()], binary(), non_neg_integer()) -> [map()].
filter_members_by_query(Members, Query, Limit) ->
NormalizedQuery = string:lowercase(binary_to_list(Query)),
Matches = lists:filter(
fun(Member) ->
DisplayName = get_display_name(Member),
NormalizedName = string:lowercase(binary_to_list(DisplayName)),
lists:prefix(NormalizedQuery, NormalizedName)
end,
Members
),
lists:sublist(Matches, Limit).
-spec get_display_name(map()) -> binary().
get_display_name(Member) when is_map(Member) ->
Nick = maps:get(<<"nick">>, Member, undefined),
case Nick of
undefined -> nick_isundefined(Member);
null -> nick_isundefined(Member);
_ when is_binary(Nick) -> Nick;
_ -> nick_isundefined(Member)
end;
get_display_name(_) ->
<<>>.
nick_isundefined(Member) ->
User = maps:get(<<"user">>, Member, #{}),
GlobalName = maps:get(<<"global_name">>, User, undefined),
case GlobalName of
undefined ->
Username = maps:get(<<"username">>, User, <<>>),
case Username of
null -> <<>>;
undefined -> <<>>;
_ when is_binary(Username) -> Username;
_ -> <<>>
end;
null ->
Username = maps:get(<<"username">>, User, <<>>),
case Username of
null -> <<>>;
undefined -> <<>>;
_ when is_binary(Username) -> Username;
_ -> <<>>
end;
_ when is_binary(GlobalName) -> GlobalName;
_ -> <<>>
end.
-spec extract_user_id(map()) -> integer() | undefined.
extract_user_id(Member) when is_map(Member) ->
User = maps:get(<<"user">>, Member, #{}),
map_utils:get_integer(User, <<"id">>, undefined);
extract_user_id(_) ->
undefined.
-spec maybe_fetch_presences(boolean(), pid(), [map()]) -> [map()].
maybe_fetch_presences(false, _GuildPid, _Members) ->
[];
maybe_fetch_presences(true, _GuildPid, Members) ->
UserIds = lists:filtermap(
fun(Member) ->
case extract_user_id(Member) of
undefined -> false;
UserId -> {true, UserId}
end
end,
Members
),
case UserIds of
[] ->
[];
_ ->
Cached = presence_cache:bulk_get(UserIds),
[P || P <- Cached, presence_visible(P)]
end.
-spec presence_visible(map()) -> boolean().
presence_visible(P) ->
Status = maps:get(<<"status">>, P, <<"offline">>),
Status =/= <<"offline">> andalso Status =/= <<"invisible">>.
-spec send_member_chunks(pid(), binary(), [map()], [map()], term()) -> ok.
send_member_chunks(GuildPid, SessionId, Members, Presences, Nonce) ->
TotalChunks = max(1, (length(Members) + ?CHUNK_SIZE - 1) div ?CHUNK_SIZE),
MemberChunks = chunk_list(Members, ?CHUNK_SIZE),
PresenceChunks = chunk_presences(Presences, MemberChunks),
logger:debug(
"[guild_request_members] Sending ~p member chunks (total members: ~p, nonce: ~p)",
[TotalChunks, length(Members), Nonce]
),
lists:foldl(
fun({MemberChunk, PresenceChunk}, ChunkIndex) ->
ChunkData = build_chunk_data(
MemberChunk, PresenceChunk, ChunkIndex, TotalChunks, Nonce
),
logger:debug(
"[guild_request_members] Sending chunk ~p/~p with ~p members, nonce: ~p",
[ChunkIndex + 1, TotalChunks, length(MemberChunk), Nonce]
),
gen_server:cast(GuildPid, {send_members_chunk, SessionId, ChunkData}),
ChunkIndex + 1
end,
0,
lists:zip(MemberChunks, PresenceChunks)
),
logger:debug("[guild_request_members] All chunks sent successfully"),
ok.
-spec build_chunk_data([map()], [map()], non_neg_integer(), non_neg_integer(), term()) ->
map().
build_chunk_data(Members, Presences, ChunkIndex, TotalChunks, Nonce) ->
Base = #{
<<"members">> => Members,
<<"chunk_index">> => ChunkIndex,
<<"chunk_count">> => TotalChunks
},
WithPresences = case Presences of
[] -> Base;
_ -> maps:put(<<"presences">>, Presences, Base)
end,
WithNonce = case Nonce of
null -> WithPresences;
_ -> maps:put(<<"nonce">>, Nonce, WithPresences)
end,
WithNonce.
-spec chunk_list([T], pos_integer()) -> [[T]] when T :: term().
chunk_list([], _Size) ->
[[]];
chunk_list(List, Size) ->
chunk_list(List, Size, []).
chunk_list([], _Size, Acc) ->
lists:reverse(Acc);
chunk_list(List, Size, Acc) ->
{Chunk, Rest} = lists:split(min(Size, length(List)), List),
chunk_list(Rest, Size, [Chunk | Acc]).
-spec chunk_presences([map()], [[map()]]) -> [[map()]].
chunk_presences(Presences, MemberChunks) ->
lists:map(
fun(MemberChunk) ->
ChunkUserIds = sets:from_list([extract_user_id(M) || M <- MemberChunk]),
lists:filter(
fun(Presence) ->
User = maps:get(<<"user">>, Presence, #{}),
UserId = map_utils:get_integer(User, <<"id">>, undefined),
UserId =/= undefined andalso sets:is_element(UserId, ChunkUserIds)
end,
Presences
)
end,
MemberChunks
).
-ifdef(TEST).
parse_request_valid_test() ->
Data = #{
<<"guild_id">> => <<"123456789">>,
<<"query">> => <<"test">>,
<<"limit">> => 10,
<<"presences">> => true,
<<"nonce">> => <<"abc123">>
},
{ok, Request} = parse_request(Data),
?assertEqual(123456789, maps:get(guild_id, Request)),
?assertEqual(<<"test">>, maps:get(query, Request)),
?assertEqual(10, maps:get(limit, Request)),
?assertEqual(true, maps:get(presences, Request)),
?assertEqual(<<"abc123">>, maps:get(nonce, Request)).
parse_request_with_user_ids_test() ->
Data = #{
<<"guild_id">> => <<"123">>,
<<"user_ids">> => [<<"1">>, <<"2">>, <<"3">>]
},
{ok, Request} = parse_request(Data),
?assertEqual([1, 2, 3], maps:get(user_ids, Request)).
parse_request_invalid_guild_id_test() ->
Data = #{<<"guild_id">> => <<"invalid">>},
{error, invalid_guild_id} = parse_request(Data).
chunk_list_test() ->
?assertEqual([[1, 2], [3, 4], [5]], chunk_list([1, 2, 3, 4, 5], 2)),
?assertEqual([[1, 2, 3]], chunk_list([1, 2, 3], 5)),
?assertEqual([[]], chunk_list([], 5)).
filter_members_by_query_test() ->
Members = [
#{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>}},
#{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"bob">>}},
#{<<"user">> => #{<<"id">> => <<"3">>, <<"username">> => <<"alicia">>}}
],
Results = filter_members_by_query(Members, <<"ali">>, 10),
?assertEqual(2, length(Results)).
display_name_priority_test() ->
MemberWithNick = #{
<<"user">> => #{<<"username">> => <<"user">>, <<"global_name">> => <<"Global">>},
<<"nick">> => <<"Nick">>
},
?assertEqual(<<"Nick">>, get_display_name(MemberWithNick)),
MemberWithGlobal = #{
<<"user">> => #{<<"username">> => <<"user">>, <<"global_name">> => <<"Global">>}
},
?assertEqual(<<"Global">>, get_display_name(MemberWithGlobal)),
MemberWithUsername = #{
<<"user">> => #{<<"username">> => <<"user">>}
},
?assertEqual(<<"user">>, get_display_name(MemberWithUsername)).
-endif.

View File

@@ -0,0 +1,348 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_sessions).
-export([
handle_session_connect/3,
handle_session_down/2,
filter_sessions_for_channel/4,
filter_sessions_for_manage_channels/4,
filter_sessions_exclude_session/2,
handle_user_offline/2,
set_session_active_guild/3,
set_session_passive_guild/3,
build_initial_last_message_ids/1,
is_session_active/2,
subscribe_to_user_presence/2,
unsubscribe_from_user_presence/2
]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-import(guild_permissions, [can_view_channel/4, can_manage_channel/3, find_member_by_user_id/2]).
-import(guild_data, [get_guild_state/2]).
-import(guild_availability, [is_guild_unavailable_for_user/2]).
handle_session_connect(Request, Pid, State) ->
#{session_id := SessionId, user_id := UserId} = Request,
Sessions = maps:get(sessions, State, #{}),
ActiveGuilds = maps:get(active_guilds, Request, sets:new()),
InitialGuildId = maps:get(initial_guild_id, Request, undefined),
UserRoles = session_passive:get_user_roles_for_guild(UserId, State),
Bot = maps:get(bot, Request, false),
GuildId = maps:get(id, State),
case maps:is_key(SessionId, Sessions) of
true ->
{reply, {ok, get_guild_state(UserId, State)}, State};
false ->
Ref = monitor(process, Pid),
GuildState = get_guild_state(UserId, State),
InitialLastMessageIds = build_initial_last_message_ids(GuildState),
SessionData = #{
session_id => SessionId,
user_id => UserId,
pid => Pid,
mref => Ref,
active_guilds => ActiveGuilds,
user_roles => UserRoles,
bot => Bot,
previous_passive_updates => InitialLastMessageIds
},
NewSessions = maps:put(SessionId, SessionData, Sessions),
State1 = maps:put(sessions, NewSessions, State),
State2 = subscribe_to_user_presence(UserId, State1),
case is_guild_unavailable_for_user(UserId, State2) of
true ->
GuildId = maps:get(id, State2),
UnavailableResponse = #{
<<"id">> => integer_to_binary(GuildId),
<<"unavailable">> => true
},
{reply, {ok, unavailable, UnavailableResponse}, State2};
false ->
SyncedState = maybe_auto_sync_initial_guild(
SessionId,
GuildId,
InitialGuildId,
State2
),
{reply, {ok, GuildState}, SyncedState}
end
end.
build_initial_last_message_ids(GuildState) ->
Channels = maps:get(<<"channels">>, GuildState, []),
lists:foldl(
fun(Channel, Acc) ->
ChannelIdBin = maps:get(<<"id">>, Channel, undefined),
LastMessageId = maps:get(<<"last_message_id">>, Channel, null),
case {ChannelIdBin, LastMessageId} of
{undefined, _} -> Acc;
{_, null} -> Acc;
_ -> maps:put(ChannelIdBin, LastMessageId, Acc)
end
end,
#{},
Channels
).
handle_session_down(Ref, State) ->
Sessions = maps:get(sessions, State, #{}),
DisconnectingSession = maps:fold(
fun(_K, S, Acc) ->
case maps:get(mref, S) =:= Ref of
true -> S;
false -> Acc
end
end,
undefined,
Sessions
),
State1 =
case DisconnectingSession of
undefined ->
State;
Session ->
UserId = maps:get(user_id, Session),
SessionId = maps:get(session_id, Session),
StateAfterPresence = unsubscribe_from_user_presence(UserId, State),
StateAfterMemberList = guild_member_list:unsubscribe_session(
SessionId, StateAfterPresence
),
MemberSubs = maps:get(
member_subscriptions, StateAfterMemberList, guild_subscriptions:init_state()
),
NewMemberSubs = guild_subscriptions:unsubscribe_session(SessionId, MemberSubs),
maps:put(member_subscriptions, NewMemberSubs, StateAfterMemberList)
end,
NewSessions = maps:filter(fun(_K, S) -> maps:get(mref, S) =/= Ref end, Sessions),
NewState = maps:put(sessions, NewSessions, State1),
case map_size(NewSessions) of
0 ->
{stop, normal, NewState};
_ ->
{noreply, NewState}
end.
filter_sessions_for_channel(Sessions, ChannelId, SessionIdOpt, State) ->
GuildId = maps:get(id, State, 0),
lists:filter(
fun({Sid, S}) ->
UserId = maps:get(user_id, S),
Member = find_member_by_user_id(UserId, State),
ExcludeSession =
case SessionIdOpt of
undefined -> false;
SessionId -> Sid =:= SessionId
end,
case {ExcludeSession, Member} of
{true, _} ->
false;
{_, undefined} ->
logger:warning(
"[guild_sessions] Filtering out session with no member: "
"guild_id=~p session_id=~p user_id=~p",
[GuildId, Sid, UserId]
),
false;
{false, _} ->
can_view_channel(UserId, ChannelId, Member, State)
end
end,
maps:to_list(Sessions)
).
filter_sessions_for_manage_channels(Sessions, ChannelId, SessionIdOpt, State) ->
lists:filter(
fun({Sid, S}) ->
UserId = maps:get(user_id, S),
ExcludeSession =
case SessionIdOpt of
undefined -> false;
SessionId -> Sid =:= SessionId
end,
case ExcludeSession of
true ->
false;
false ->
can_manage_channel(UserId, ChannelId, State)
end
end,
maps:to_list(Sessions)
).
filter_sessions_exclude_session(Sessions, SessionIdOpt) ->
case SessionIdOpt of
undefined ->
maps:to_list(Sessions);
SessionId ->
[{Sid, S} || {Sid, S} <- maps:to_list(Sessions), Sid =/= SessionId]
end.
subscribe_to_user_presence(UserId, State) ->
PresenceSubs = maps:get(presence_subscriptions, State, #{}),
CurrentCount = maps:get(UserId, PresenceSubs, 0),
case CurrentCount of
0 ->
presence_bus:subscribe(UserId),
NewSubs = maps:put(UserId, 1, PresenceSubs),
StateWithSubs = maps:put(presence_subscriptions, NewSubs, State),
maybe_send_cached_presence(UserId, StateWithSubs);
_ ->
NewSubs = maps:put(UserId, CurrentCount + 1, PresenceSubs),
maps:put(presence_subscriptions, NewSubs, State)
end.
unsubscribe_from_user_presence(UserId, State) ->
PresenceSubs = maps:get(presence_subscriptions, State, #{}),
CurrentCount = maps:get(UserId, PresenceSubs, 0),
case CurrentCount of
0 ->
State;
1 ->
NewSubs = maps:put(UserId, 0, PresenceSubs),
maps:put(presence_subscriptions, NewSubs, State);
_ ->
NewSubs = maps:put(UserId, CurrentCount - 1, PresenceSubs),
maps:put(presence_subscriptions, NewSubs, State)
end.
handle_user_offline(UserId, State) ->
PresenceSubs = maps:get(presence_subscriptions, State, #{}),
case maps:get(UserId, PresenceSubs, undefined) of
0 ->
presence_bus:unsubscribe(UserId),
NewSubs = maps:remove(UserId, PresenceSubs),
maps:put(presence_subscriptions, NewSubs, State);
undefined ->
State;
_ ->
State
end.
maybe_send_cached_presence(UserId, State) ->
case presence_cache:get(UserId) of
{ok, Payload} ->
case guild_presence:handle_bus_presence(UserId, Payload, State) of
{noreply, UpdatedState} ->
UpdatedState
end;
_ ->
State
end.
set_session_active_guild(SessionId, GuildId, State) ->
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
State;
SessionData ->
NewSessionData = session_passive:set_active(GuildId, SessionData),
NewSessions = maps:put(SessionId, NewSessionData, Sessions),
maps:put(sessions, NewSessions, State)
end.
set_session_passive_guild(SessionId, GuildId, State) ->
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
State;
SessionData ->
NewSessionData = session_passive:set_passive(GuildId, SessionData),
NewSessionData2 = session_passive:clear_guild_synced(GuildId, NewSessionData),
NewSessions = maps:put(SessionId, NewSessionData2, Sessions),
maps:put(sessions, NewSessions, State)
end.
is_session_active(SessionId, State) ->
GuildId = maps:get(id, State, 0),
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
false;
SessionData ->
not session_passive:is_passive(GuildId, SessionData)
end.
maybe_auto_sync_initial_guild(SessionId, GuildId, InitialGuildId, State) ->
case InitialGuildId of
GuildId ->
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
State;
SessionData ->
SyncedSessionData = session_passive:mark_guild_synced(GuildId, SessionData),
NewSessions = maps:put(SessionId, SyncedSessionData, Sessions),
maps:put(sessions, NewSessions, State)
end;
_ ->
State
end.
-ifdef(TEST).
build_initial_last_message_ids_empty_channels_test() ->
GuildState = #{<<"channels">> => []},
Result = build_initial_last_message_ids(GuildState),
?assertEqual(#{}, Result),
ok.
build_initial_last_message_ids_with_channels_test() ->
GuildState = #{
<<"channels">> => [
#{<<"id">> => <<"100">>, <<"last_message_id">> => <<"500">>},
#{<<"id">> => <<"101">>, <<"last_message_id">> => <<"600">>}
]
},
Result = build_initial_last_message_ids(GuildState),
?assertEqual(#{<<"100">> => <<"500">>, <<"101">> => <<"600">>}, Result),
ok.
build_initial_last_message_ids_filters_null_test() ->
GuildState = #{
<<"channels">> => [
#{<<"id">> => <<"100">>, <<"last_message_id">> => <<"500">>},
#{<<"id">> => <<"101">>, <<"last_message_id">> => null},
#{<<"id">> => <<"102">>}
]
},
Result = build_initial_last_message_ids(GuildState),
?assertEqual(#{<<"100">> => <<"500">>}, Result),
ok.
build_initial_last_message_ids_no_channels_key_test() ->
GuildState = #{},
Result = build_initial_last_message_ids(GuildState),
?assertEqual(#{}, Result),
ok.
-endif.

View File

@@ -0,0 +1,466 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_state).
-export([
update_state/3
]).
-import(guild_user_data, [maybe_update_cached_user_data/3]).
-import(guild_availability, [handle_unavailability_transition/2]).
-import(guild_visibility, [compute_and_dispatch_visibility_changes/2]).
-import(guild, [update_counts/1]).
update_state(Event, EventData, State) ->
StateWithUpdatedUser = maybe_update_cached_user_data(Event, EventData, State),
Data = maps:get(data, StateWithUpdatedUser),
UpdatedData = update_data_for_event(Event, EventData, Data, State),
UpdatedState = maps:put(data, UpdatedData, StateWithUpdatedUser),
handle_post_update(Event, StateWithUpdatedUser, UpdatedState).
update_data_for_event(guild_update, EventData, Data, _State) ->
handle_guild_update(EventData, Data);
update_data_for_event(guild_member_add, EventData, Data, _State) ->
handle_member_add(EventData, Data);
update_data_for_event(guild_member_update, EventData, Data, _State) ->
handle_member_update(EventData, Data);
update_data_for_event(guild_member_remove, EventData, Data, State) ->
handle_member_remove(EventData, Data, State);
update_data_for_event(guild_role_create, EventData, Data, _State) ->
handle_role_create(EventData, Data);
update_data_for_event(guild_role_update, EventData, Data, _State) ->
handle_role_update(EventData, Data);
update_data_for_event(guild_role_update_bulk, EventData, Data, _State) ->
handle_role_update_bulk(EventData, Data);
update_data_for_event(guild_role_delete, EventData, Data, _State) ->
handle_role_delete(EventData, Data);
update_data_for_event(channel_create, EventData, Data, _State) ->
handle_channel_create(EventData, Data);
update_data_for_event(channel_update, EventData, Data, _State) ->
handle_channel_update(EventData, Data);
update_data_for_event(channel_update_bulk, EventData, Data, _State) ->
handle_channel_update_bulk(EventData, Data);
update_data_for_event(channel_delete, EventData, Data, _State) ->
handle_channel_delete(EventData, Data);
update_data_for_event(message_create, EventData, Data, _State) ->
handle_message_create(EventData, Data);
update_data_for_event(channel_pins_update, EventData, Data, _State) ->
handle_channel_pins_update(EventData, Data);
update_data_for_event(guild_emojis_update, EventData, Data, _State) ->
handle_emojis_update(EventData, Data);
update_data_for_event(guild_stickers_update, EventData, Data, _State) ->
handle_stickers_update(EventData, Data);
update_data_for_event(_Event, _EventData, Data, _State) ->
Data.
handle_post_update(guild_update, StateWithUpdatedUser, UpdatedState) ->
handle_unavailability_transition(StateWithUpdatedUser, UpdatedState),
UpdatedState;
handle_post_update(guild_member_add, _StateWithUpdatedUser, UpdatedState) ->
update_counts(UpdatedState);
handle_post_update(guild_member_remove, _StateWithUpdatedUser, UpdatedState) ->
State1 = cleanup_removed_member_sessions(UpdatedState),
update_counts(State1);
handle_post_update(Event, StateWithUpdatedUser, UpdatedState) ->
case needs_visibility_check(Event) of
true ->
compute_and_dispatch_visibility_changes(StateWithUpdatedUser, UpdatedState),
UpdatedState;
false ->
UpdatedState
end.
needs_visibility_check(guild_role_create) -> true;
needs_visibility_check(guild_role_update) -> true;
needs_visibility_check(guild_role_update_bulk) -> true;
needs_visibility_check(guild_role_delete) -> true;
needs_visibility_check(guild_member_update) -> true;
needs_visibility_check(channel_update) -> true;
needs_visibility_check(channel_update_bulk) -> true;
needs_visibility_check(_) -> false.
handle_guild_update(EventData, Data) ->
Guild = maps:get(<<"guild">>, Data),
UpdatedGuild = maps:merge(Guild, EventData),
maps:put(<<"guild">>, UpdatedGuild, Data).
handle_member_add(EventData, Data) ->
Members = maps:get(<<"members">>, Data, []),
UpdatedData = maps:put(<<"members">>, Members ++ [EventData], Data),
UpdatedData.
handle_member_update(EventData, Data) ->
Members = maps:get(<<"members">>, Data, []),
UserId = extract_user_id(EventData),
UpdatedMembers = replace_member_by_id(Members, UserId, EventData),
maps:put(<<"members">>, UpdatedMembers, Data).
handle_member_remove(EventData, Data, _State) ->
Members = maps:get(<<"members">>, Data, []),
UserId = extract_user_id(EventData),
FilteredMembers = remove_member_by_id(Members, UserId),
maps:put(<<"members">>, FilteredMembers, Data).
cleanup_removed_member_sessions(State) ->
Data = maps:get(data, State),
Members = maps:get(<<"members">>, Data, []),
MemberUserIds = sets:from_list([extract_user_id_from_member(M) || M <- Members]),
Sessions = maps:get(sessions, State, #{}),
FilteredSessions = maps:filter(
fun(_K, S) ->
UserId = maps:get(user_id, S),
sets:is_element(UserId, MemberUserIds)
end,
Sessions
),
Presences = maps:get(presences, State, #{}),
FilteredPresences = maps:filter(
fun(UserId, _V) ->
sets:is_element(UserId, MemberUserIds)
end,
Presences
),
State1 = maps:put(sessions, FilteredSessions, State),
maps:put(presences, FilteredPresences, State1).
extract_user_id_from_member(Member) when is_map(Member) ->
MUser = maps:get(<<"user">>, Member, #{}),
utils:binary_to_integer_safe(maps:get(<<"id">>, MUser, <<"0">>));
extract_user_id_from_member(_) ->
0.
extract_user_id(EventData) ->
MUser = maps:get(<<"user">>, EventData, #{}),
utils:binary_to_integer_safe(maps:get(<<"id">>, MUser, <<"0">>)).
replace_member_by_id(Members, UserId, NewMember) ->
lists:map(
fun(M) when is_map(M) ->
MMUser = maps:get(<<"user">>, M, #{}),
MUserId = utils:binary_to_integer_safe(maps:get(<<"id">>, MMUser, <<"0">>)),
case MUserId =:= UserId of
true -> NewMember;
false -> M
end
end,
Members
).
remove_member_by_id(Members, UserId) ->
lists:filter(
fun(M) when is_map(M) ->
MMUser = maps:get(<<"user">>, M, #{}),
MUserId = utils:binary_to_integer_safe(maps:get(<<"id">>, MMUser, <<"0">>)),
MUserId =/= UserId
end,
Members
).
handle_role_create(EventData, Data) ->
Roles = maps:get(<<"roles">>, Data, []),
RoleData = maps:get(<<"role">>, EventData),
maps:put(<<"roles">>, Roles ++ [RoleData], Data).
handle_role_update(EventData, Data) ->
Roles = maps:get(<<"roles">>, Data, []),
RoleData = maps:get(<<"role">>, EventData),
RoleId = maps:get(<<"id">>, RoleData),
UpdatedRoles = replace_item_by_id(Roles, RoleId, RoleData),
maps:put(<<"roles">>, UpdatedRoles, Data).
handle_role_update_bulk(EventData, Data) ->
Roles = maps:get(<<"roles">>, Data, []),
BulkRoles = maps:get(<<"roles">>, EventData, []),
UpdatedRoles = bulk_update_items(Roles, BulkRoles),
maps:put(<<"roles">>, UpdatedRoles, Data).
handle_role_delete(EventData, Data) ->
Roles = maps:get(<<"roles">>, Data, []),
RoleId = maps:get(<<"role_id">>, EventData),
FilteredRoles = remove_item_by_id(Roles, RoleId),
Data1 = maps:put(<<"roles">>, FilteredRoles, Data),
Data2 = strip_role_from_members(RoleId, Data1),
strip_role_from_channel_overwrites(RoleId, Data2).
strip_role_from_members(RoleId, Data) ->
Members = maps:get(<<"members">>, Data, []),
UpdatedMembers = lists:map(
fun(Member) when is_map(Member) ->
MemberRoles = maps:get(<<"roles">>, Member, []),
FilteredRoles = lists:filter(
fun(R) ->
RoleIdInt = utils:binary_to_integer_safe(RoleId),
RInt = utils:binary_to_integer_safe(R),
RInt =/= RoleIdInt
end,
MemberRoles
),
maps:put(<<"roles">>, FilteredRoles, Member);
(Member) ->
Member
end,
Members
),
maps:put(<<"members">>, UpdatedMembers, Data).
strip_role_from_channel_overwrites(RoleId, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
RoleIdInt = utils:binary_to_integer_safe(RoleId),
UpdatedChannels = lists:map(
fun(Channel) when is_map(Channel) ->
Overwrites = maps:get(<<"permission_overwrites">>, Channel, []),
FilteredOverwrites = lists:filter(
fun(Overwrite) when is_map(Overwrite) ->
OverwriteType = maps:get(<<"type">>, Overwrite, 0),
OverwriteId = utils:binary_to_integer_safe(maps:get(<<"id">>, Overwrite, <<"0">>)),
not (OverwriteType =:= 0 andalso OverwriteId =:= RoleIdInt);
(_) ->
true
end,
Overwrites
),
maps:put(<<"permission_overwrites">>, FilteredOverwrites, Channel);
(Channel) ->
Channel
end,
Channels
),
maps:put(<<"channels">>, UpdatedChannels, Data).
handle_channel_create(EventData, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
maps:put(<<"channels">>, Channels ++ [EventData], Data).
handle_channel_update(EventData, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
ChannelId = maps:get(<<"id">>, EventData),
UpdatedChannels = replace_item_by_id(Channels, ChannelId, EventData),
maps:put(<<"channels">>, UpdatedChannels, Data).
handle_channel_update_bulk(EventData, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
BulkChannels = maps:get(<<"channels">>, EventData, []),
UpdatedChannels = bulk_update_items(Channels, BulkChannels),
maps:put(<<"channels">>, UpdatedChannels, Data).
handle_channel_delete(EventData, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
ChannelId = maps:get(<<"id">>, EventData),
FilteredChannels = remove_item_by_id(Channels, ChannelId),
maps:put(<<"channels">>, FilteredChannels, Data).
handle_message_create(EventData, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
ChannelId = maps:get(<<"channel_id">>, EventData),
MessageId = maps:get(<<"id">>, EventData),
UpdatedChannels = update_channel_field(Channels, ChannelId, <<"last_message_id">>, MessageId),
maps:put(<<"channels">>, UpdatedChannels, Data).
handle_channel_pins_update(EventData, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
ChannelId = maps:get(<<"channel_id">>, EventData),
LastPin = maps:get(<<"last_pin_timestamp">>, EventData),
UpdatedChannels = update_channel_field(Channels, ChannelId, <<"last_pin_timestamp">>, LastPin),
maps:put(<<"channels">>, UpdatedChannels, Data).
update_channel_field(Channels, ChannelId, Field, Value) ->
lists:map(
fun(C) when is_map(C) ->
case maps:get(<<"id">>, C) =:= ChannelId of
true -> maps:put(Field, Value, C);
false -> C
end
end,
Channels
).
handle_emojis_update(EventData, Data) ->
maps:put(<<"emojis">>, maps:get(<<"emojis">>, EventData, []), Data).
handle_stickers_update(EventData, Data) ->
maps:put(<<"stickers">>, maps:get(<<"stickers">>, EventData, []), Data).
replace_item_by_id(Items, Id, NewItem) ->
lists:map(
fun(Item) when is_map(Item) ->
case maps:get(<<"id">>, Item) of
Id -> NewItem;
_ -> Item
end
end,
Items
).
remove_item_by_id(Items, Id) ->
lists:filter(
fun(Item) when is_map(Item) ->
maps:get(<<"id">>, Item) =/= Id
end,
Items
).
bulk_update_items(Items, BulkItems) ->
BulkMap = lists:foldl(
fun
(Item, Acc) when is_map(Item) ->
case maps:get(<<"id">>, Item, undefined) of
undefined -> Acc;
ItemId -> maps:put(ItemId, Item, Acc)
end;
(_, Acc) ->
Acc
end,
#{},
BulkItems
),
lists:map(
fun
(Item) when is_map(Item) ->
ItemId = maps:get(<<"id">>, Item, undefined),
case maps:get(ItemId, BulkMap, undefined) of
undefined -> Item;
UpdatedItem -> UpdatedItem
end;
(Item) ->
Item
end,
Items
).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
handle_role_delete_strips_from_members_test() ->
RoleIdToDelete = <<"200">>,
Data = #{
<<"roles">> => [
#{<<"id">> => <<"100">>, <<"name">> => <<"Admin">>},
#{<<"id">> => <<"200">>, <<"name">> => <<"Moderator">>}
],
<<"members">> => [
#{
<<"user">> => #{<<"id">> => <<"1">>},
<<"roles">> => [<<"100">>, <<"200">>]
},
#{
<<"user">> => #{<<"id">> => <<"2">>},
<<"roles">> => [<<"200">>]
},
#{
<<"user">> => #{<<"id">> => <<"3">>},
<<"roles">> => [<<"100">>]
}
],
<<"channels">> => []
},
EventData = #{<<"role_id">> => RoleIdToDelete},
Result = handle_role_delete(EventData, Data),
Members = maps:get(<<"members">>, Result),
[M1, M2, M3] = Members,
?assertEqual([<<"100">>], maps:get(<<"roles">>, M1)),
?assertEqual([], maps:get(<<"roles">>, M2)),
?assertEqual([<<"100">>], maps:get(<<"roles">>, M3)).
handle_role_delete_strips_from_channel_overwrites_test() ->
RoleIdToDelete = <<"200">>,
Data = #{
<<"roles">> => [
#{<<"id">> => <<"100">>, <<"name">> => <<"Everyone">>},
#{<<"id">> => <<"200">>, <<"name">> => <<"Moderator">>}
],
<<"members">> => [],
<<"channels">> => [
#{
<<"id">> => <<"500">>,
<<"permission_overwrites">> => [
#{<<"id">> => <<"100">>, <<"type">> => 0, <<"allow">> => <<"0">>, <<"deny">> => <<"1024">>},
#{<<"id">> => <<"200">>, <<"type">> => 0, <<"allow">> => <<"1024">>, <<"deny">> => <<"0">>},
#{<<"id">> => <<"1">>, <<"type">> => 1, <<"allow">> => <<"2048">>, <<"deny">> => <<"0">>}
]
},
#{
<<"id">> => <<"501">>,
<<"permission_overwrites">> => [
#{<<"id">> => <<"200">>, <<"type">> => 0, <<"allow">> => <<"1024">>, <<"deny">> => <<"0">>}
]
}
]
},
EventData = #{<<"role_id">> => RoleIdToDelete},
Result = handle_role_delete(EventData, Data),
Channels = maps:get(<<"channels">>, Result),
[Ch1, Ch2] = Channels,
Ch1Overwrites = maps:get(<<"permission_overwrites">>, Ch1),
Ch2Overwrites = maps:get(<<"permission_overwrites">>, Ch2),
?assertEqual(2, length(Ch1Overwrites)),
?assertEqual(0, length(Ch2Overwrites)),
OverwriteIds = [maps:get(<<"id">>, O) || O <- Ch1Overwrites],
?assert(lists:member(<<"100">>, OverwriteIds)),
?assert(lists:member(<<"1">>, OverwriteIds)),
?assertNot(lists:member(<<"200">>, OverwriteIds)).
handle_role_delete_preserves_user_overwrites_test() ->
RoleIdToDelete = <<"200">>,
Data = #{
<<"roles">> => [
#{<<"id">> => <<"200">>, <<"name">> => <<"Moderator">>}
],
<<"members">> => [],
<<"channels">> => [
#{
<<"id">> => <<"500">>,
<<"permission_overwrites">> => [
#{<<"id">> => <<"200">>, <<"type">> => 0, <<"allow">> => <<"1024">>, <<"deny">> => <<"0">>},
#{<<"id">> => <<"200">>, <<"type">> => 1, <<"allow">> => <<"2048">>, <<"deny">> => <<"0">>}
]
}
]
},
EventData = #{<<"role_id">> => RoleIdToDelete},
Result = handle_role_delete(EventData, Data),
Channels = maps:get(<<"channels">>, Result),
[Ch1] = Channels,
Overwrites = maps:get(<<"permission_overwrites">>, Ch1),
?assertEqual(1, length(Overwrites)),
[RemainingOverwrite] = Overwrites,
?assertEqual(1, maps:get(<<"type">>, RemainingOverwrite)).
handle_role_delete_removes_role_from_roles_list_test() ->
RoleIdToDelete = <<"200">>,
Data = #{
<<"roles">> => [
#{<<"id">> => <<"100">>, <<"name">> => <<"Admin">>},
#{<<"id">> => <<"200">>, <<"name">> => <<"Moderator">>}
],
<<"members">> => [],
<<"channels">> => []
},
EventData = #{<<"role_id">> => RoleIdToDelete},
Result = handle_role_delete(EventData, Data),
Roles = maps:get(<<"roles">>, Result),
?assertEqual(1, length(Roles)),
[RemainingRole] = Roles,
?assertEqual(<<"100">>, maps:get(<<"id">>, RemainingRole)).
-endif.

View File

@@ -0,0 +1,185 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_subscriptions).
-export([
init_state/0,
subscribe/3,
unsubscribe/3,
unsubscribe_session/2,
update_subscriptions/3,
get_subscribed_sessions/2,
is_subscribed/3,
get_user_ids_for_session/2
]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-type session_id() :: binary().
-type user_id() :: integer().
-type subscription_state() :: #{user_id() => sets:set(session_id())}.
-spec init_state() -> subscription_state().
init_state() -> #{}.
-spec subscribe(session_id(), user_id(), subscription_state()) -> subscription_state().
subscribe(SessionId, UserId, State) ->
Subscribers = maps:get(UserId, State, sets:new()),
NewSubscribers = sets:add_element(SessionId, Subscribers),
maps:put(UserId, NewSubscribers, State).
-spec unsubscribe(session_id(), user_id(), subscription_state()) -> subscription_state().
unsubscribe(SessionId, UserId, State) ->
case maps:get(UserId, State, undefined) of
undefined ->
State;
Subscribers ->
NewSubscribers = sets:del_element(SessionId, Subscribers),
case sets:size(NewSubscribers) of
0 -> maps:remove(UserId, State);
_ -> maps:put(UserId, NewSubscribers, State)
end
end.
-spec unsubscribe_session(session_id(), subscription_state()) -> subscription_state().
unsubscribe_session(SessionId, State) ->
maps:fold(
fun(UserId, Subscribers, Acc) ->
NewSubscribers = sets:del_element(SessionId, Subscribers),
case sets:size(NewSubscribers) of
0 -> Acc;
_ -> maps:put(UserId, NewSubscribers, Acc)
end
end,
#{},
State
).
-spec update_subscriptions(session_id(), [user_id()], subscription_state()) ->
subscription_state().
update_subscriptions(SessionId, NewMemberIds, State) ->
CurrentlySubscribed = get_user_ids_for_session(SessionId, State),
NewMemberIdSet = sets:from_list(NewMemberIds),
ToRemove = sets:subtract(CurrentlySubscribed, NewMemberIdSet),
ToAdd = sets:subtract(NewMemberIdSet, CurrentlySubscribed),
State1 = sets:fold(
fun(UserId, AccState) ->
unsubscribe(SessionId, UserId, AccState)
end,
State,
ToRemove
),
sets:fold(
fun(UserId, AccState) ->
subscribe(SessionId, UserId, AccState)
end,
State1,
ToAdd
).
-spec get_subscribed_sessions(user_id(), subscription_state()) -> [session_id()].
get_subscribed_sessions(UserId, State) ->
case maps:get(UserId, State, undefined) of
undefined -> [];
Subscribers -> sets:to_list(Subscribers)
end.
-spec is_subscribed(session_id(), user_id(), subscription_state()) -> boolean().
is_subscribed(SessionId, UserId, State) ->
case maps:get(UserId, State, undefined) of
undefined -> false;
Subscribers -> sets:is_element(SessionId, Subscribers)
end.
-spec get_user_ids_for_session(session_id(), subscription_state()) -> sets:set(user_id()).
get_user_ids_for_session(SessionId, State) ->
maps:fold(
fun(UserId, Subscribers, Acc) ->
case sets:is_element(SessionId, Subscribers) of
true -> sets:add_element(UserId, Acc);
false -> Acc
end
end,
sets:new(),
State
).
-ifdef(TEST).
init_state_test() ->
?assertEqual(#{}, init_state()).
subscribe_test() ->
State0 = init_state(),
State1 = subscribe(<<"session1">>, 123, State0),
?assert(is_subscribed(<<"session1">>, 123, State1)),
?assertNot(is_subscribed(<<"session2">>, 123, State1)).
subscribe_multiple_sessions_test() ->
State0 = init_state(),
State1 = subscribe(<<"session1">>, 123, State0),
State2 = subscribe(<<"session2">>, 123, State1),
?assert(is_subscribed(<<"session1">>, 123, State2)),
?assert(is_subscribed(<<"session2">>, 123, State2)).
unsubscribe_test() ->
State0 = init_state(),
State1 = subscribe(<<"session1">>, 123, State0),
State2 = unsubscribe(<<"session1">>, 123, State1),
?assertNot(is_subscribed(<<"session1">>, 123, State2)).
unsubscribe_one_of_many_test() ->
State0 = init_state(),
State1 = subscribe(<<"session1">>, 123, State0),
State2 = subscribe(<<"session2">>, 123, State1),
State3 = unsubscribe(<<"session1">>, 123, State2),
?assertNot(is_subscribed(<<"session1">>, 123, State3)),
?assert(is_subscribed(<<"session2">>, 123, State3)).
unsubscribe_session_test() ->
State0 = init_state(),
State1 = subscribe(<<"session1">>, 123, State0),
State2 = subscribe(<<"session1">>, 456, State1),
State3 = subscribe(<<"session2">>, 123, State2),
State4 = unsubscribe_session(<<"session1">>, State3),
?assertNot(is_subscribed(<<"session1">>, 123, State4)),
?assertNot(is_subscribed(<<"session1">>, 456, State4)),
?assert(is_subscribed(<<"session2">>, 123, State4)).
get_subscribed_sessions_test() ->
State0 = init_state(),
State1 = subscribe(<<"session1">>, 123, State0),
State2 = subscribe(<<"session2">>, 123, State1),
Sessions = lists:sort(get_subscribed_sessions(123, State2)),
?assertEqual([<<"session1">>, <<"session2">>], Sessions).
update_subscriptions_test() ->
State0 = init_state(),
State1 = subscribe(<<"session1">>, 100, State0),
State2 = subscribe(<<"session1">>, 200, State1),
State3 = update_subscriptions(<<"session1">>, [200, 300], State2),
?assertNot(is_subscribed(<<"session1">>, 100, State3)),
?assert(is_subscribed(<<"session1">>, 200, State3)),
?assert(is_subscribed(<<"session1">>, 300, State3)).
-endif.

View File

@@ -0,0 +1,23 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_sync).
-export([send_guild_sync/2]).
send_guild_sync(GuildPid, SessionId) ->
gen_server:cast(GuildPid, {send_guild_sync, SessionId}).

View File

@@ -0,0 +1,236 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_unified_subscriptions).
-export([handle_subscriptions/3]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec handle_subscriptions(map(), pid(), map()) -> ok.
handle_subscriptions(Data, SocketPid, SessionState) ->
Subscriptions = maps:get(<<"subscriptions">>, Data, #{}),
Guilds = maps:get(guilds, SessionState, #{}),
SessionId = maps:get(id, SessionState, undefined),
logger:debug("[guild_unified_subscriptions] Processing ~p guild subscriptions for session ~p", [
map_size(Subscriptions), SessionId
]),
maps:foreach(
fun(GuildIdBin, GuildSubData) ->
process_guild_subscription(GuildIdBin, GuildSubData, Guilds, SessionId, SocketPid, SessionState)
end,
Subscriptions
),
ok.
-spec process_guild_subscription(binary(), map(), map(), binary() | undefined, pid(), map()) -> ok.
process_guild_subscription(GuildIdBin, GuildSubData, Guilds, SessionId, SocketPid, SessionState) ->
case validation:validate_snowflake(<<"guild_id">>, GuildIdBin) of
{ok, GuildId} ->
case maps:get(GuildId, Guilds, undefined) of
{GuildPid, _Ref} when is_pid(GuildPid) ->
process_guild_sub_options(GuildId, GuildPid, GuildSubData, SessionId, SocketPid, SessionState);
undefined ->
logger:warning("[guild_unified_subscriptions] Guild ~p not found in session state", [GuildId]),
ok;
_ ->
ok
end;
{error, _, Reason} ->
logger:warning("[guild_unified_subscriptions] Invalid guild_id ~p: ~p", [GuildIdBin, Reason]),
ok
end.
-spec process_guild_sub_options(integer(), pid(), map(), binary() | undefined, pid(), map()) -> ok.
process_guild_sub_options(GuildId, GuildPid, GuildSubData, SessionId, SocketPid, SessionState) ->
WasActive = not session_passive:is_passive(GuildId, SessionState),
ActiveChanged = process_active_flag(GuildSubData, GuildPid, SessionId, WasActive),
process_sync_flag(GuildSubData, GuildId, GuildPid, SessionId, ActiveChanged),
process_member_list_channels(GuildSubData, GuildId, GuildPid, SessionId, SocketPid),
process_member_subscriptions(GuildSubData, GuildPid, SessionId),
process_typing_flag(GuildSubData, GuildPid, SessionId),
ok.
-spec process_active_flag(map(), pid(), binary() | undefined, boolean()) -> boolean().
process_active_flag(GuildSubData, GuildPid, SessionId, WasActive) ->
case maps:get(<<"active">>, GuildSubData, undefined) of
undefined ->
false;
true ->
gen_server:cast(GuildPid, {set_session_active, SessionId}),
logger:debug("[guild_unified_subscriptions] Set session ~p active", [SessionId]),
not WasActive;
false ->
gen_server:cast(GuildPid, {set_session_passive, SessionId}),
logger:debug("[guild_unified_subscriptions] Set session ~p passive", [SessionId]),
WasActive
end.
-spec process_sync_flag(map(), integer(), pid(), binary() | undefined, boolean()) -> ok.
process_sync_flag(GuildSubData, GuildId, GuildPid, SessionId, ActiveChanged) ->
ShouldSync = maps:get(<<"sync">>, GuildSubData, false) =:= true orelse ActiveChanged,
case ShouldSync of
true ->
guild_sync:send_guild_sync(GuildPid, SessionId),
logger:debug("[guild_unified_subscriptions] Sent guild sync for guild ~p", [GuildId]);
false ->
ok
end.
-spec process_member_list_channels(map(), integer(), pid(), binary() | undefined, pid()) -> ok.
process_member_list_channels(GuildSubData, GuildId, GuildPid, SessionId, SocketPid) ->
case maps:get(<<"member_list_channels">>, GuildSubData, undefined) of
undefined ->
ok;
MemberListChannels when is_map(MemberListChannels) ->
logger:debug("[guild_unified_subscriptions] Processing ~p member list channels for guild ~p", [
map_size(MemberListChannels), GuildId
]),
maps:foreach(
fun(ChannelIdBin, Ranges) ->
process_channel_lazy_subscribe(ChannelIdBin, Ranges, GuildId, GuildPid, SessionId, SocketPid)
end,
MemberListChannels
);
_ ->
ok
end.
-spec process_channel_lazy_subscribe(binary(), list(), integer(), pid(), binary() | undefined, pid()) -> ok.
process_channel_lazy_subscribe(ChannelIdBin, Ranges, _GuildId, GuildPid, SessionId, _SocketPid) ->
case validation:validate_snowflake(<<"channel_id">>, ChannelIdBin) of
{ok, ChannelId} ->
ParsedRanges = parse_ranges(Ranges),
logger:debug("[guild_unified_subscriptions] Lazy subscribe channel ~p with ranges ~p", [
ChannelId, ParsedRanges
]),
case gen_server:call(GuildPid, {lazy_subscribe, #{
session_id => SessionId,
channel_id => ChannelId,
ranges => ParsedRanges
}}, 10000) of
ok ->
ok;
Error ->
logger:error("[guild_unified_subscriptions] lazy_subscribe failed for channel ~p: ~p", [
ChannelId, Error
])
end;
{error, _, Reason} ->
logger:warning("[guild_unified_subscriptions] Invalid channel_id ~p: ~p", [ChannelIdBin, Reason])
end,
ok.
-spec parse_ranges(list()) -> [{non_neg_integer(), non_neg_integer()}].
parse_ranges(Ranges) when is_list(Ranges) ->
lists:filtermap(
fun(Range) ->
case Range of
[Start, End] when is_integer(Start), is_integer(End), Start >= 0, End >= Start ->
{true, {Start, End}};
_ ->
false
end
end,
Ranges
);
parse_ranges(_) ->
[].
-spec process_member_subscriptions(map(), pid(), binary() | undefined) -> ok.
process_member_subscriptions(GuildSubData, GuildPid, SessionId) ->
case maps:get(<<"members">>, GuildSubData, undefined) of
undefined ->
ok;
Members when is_list(Members) ->
MemberIds = parse_member_ids(Members),
logger:debug("[guild_unified_subscriptions] Updating member subscriptions with ~p members", [
length(MemberIds)
]),
gen_server:cast(GuildPid, {update_member_subscriptions, SessionId, MemberIds});
_ ->
ok
end.
-spec parse_member_ids(list()) -> [integer()].
parse_member_ids(Members) when is_list(Members) ->
lists:filtermap(
fun(MemberIdRaw) ->
case validation:validate_snowflake(<<"member_id">>, MemberIdRaw) of
{ok, MemberId} -> {true, MemberId};
{error, _, _} -> false
end
end,
Members
);
parse_member_ids(_) ->
[].
-spec process_typing_flag(map(), pid(), binary() | undefined) -> ok.
process_typing_flag(GuildSubData, GuildPid, SessionId) ->
case maps:get(<<"typing">>, GuildSubData, undefined) of
undefined ->
ok;
TypingFlag when is_boolean(TypingFlag) ->
gen_server:cast(GuildPid, {set_session_typing_override, SessionId, TypingFlag}),
logger:debug("[guild_unified_subscriptions] Set typing override to ~p for session ~p", [
TypingFlag, SessionId
]);
_ ->
ok
end.
-ifdef(TEST).
parse_ranges_valid_test() ->
?assertEqual([{0, 99}, {100, 199}], parse_ranges([[0, 99], [100, 199]])).
parse_ranges_invalid_test() ->
?assertEqual([], parse_ranges([[100, 50]])),
?assertEqual([], parse_ranges([[-1, 99]])),
?assertEqual([], parse_ranges([[<<"0">>, 99]])).
parse_ranges_mixed_test() ->
?assertEqual([{0, 99}], parse_ranges([[0, 99], [100, 50], <<"invalid">>])).
parse_ranges_non_list_test() ->
?assertEqual([], parse_ranges(undefined)),
?assertEqual([], parse_ranges(#{})).
parse_member_ids_valid_test() ->
?assertEqual([123, 456], parse_member_ids([<<"123">>, <<"456">>])).
parse_member_ids_invalid_test() ->
?assertEqual([], parse_member_ids([<<"not_a_number">>])).
parse_member_ids_mixed_test() ->
?assertEqual([123], parse_member_ids([<<"123">>, <<"invalid">>])).
parse_member_ids_non_list_test() ->
?assertEqual([], parse_member_ids(undefined)),
?assertEqual([], parse_member_ids(#{})).
-endif.

View File

@@ -0,0 +1,152 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_user_data).
-export([
update_user_data/2,
maybe_update_cached_user_data/3,
handle_user_data_update/3,
check_user_data_differs/2
]).
-import(guild_permissions, [find_member_by_user_id/2]).
update_user_data(EventData, State) ->
UserId = utils:binary_to_integer_safe(maps:get(<<"id">>, EventData)),
Data = maps:get(data, State),
Members = maps:get(<<"members">>, Data, []),
UpdatedMembers = lists:map(
fun(Member) when is_map(Member) ->
MUser = maps:get(<<"user">>, Member, #{}),
MemberId =
case is_map(MUser) of
true ->
utils:binary_to_integer_safe(maps:get(<<"id">>, MUser, <<"0">>));
false ->
undefined
end,
if
MemberId =:= UserId ->
maps:put(<<"user">>, EventData, Member);
true ->
Member
end
end,
Members
),
UpdatedData = maps:put(<<"members">>, UpdatedMembers, Data),
UpdatedState = maps:put(data, UpdatedData, State),
UpdatedMember = find_member_by_user_id(UserId, UpdatedState),
case UpdatedMember of
undefined -> ok;
M -> gen_server:cast(self(), {dispatch, #{event => guild_member_update, data => M}})
end,
{noreply, UpdatedState}.
handle_user_data_update(UserId, UserData, State) ->
Data = maps:get(data, State),
Members = maps:get(<<"members">>, Data, []),
CurrentMember = find_member_by_user_id(UserId, State),
case CurrentMember of
undefined ->
State;
Member ->
CurrentUserData = maps:get(<<"user">>, Member, #{}),
IsDifferent = check_user_data_differs(CurrentUserData, UserData),
if
IsDifferent ->
UpdatedMembers = lists:map(
fun(M) when is_map(M) ->
MUser = maps:get(<<"user">>, M, #{}),
MemberId =
case is_map(MUser) of
true ->
utils:binary_to_integer_safe(
maps:get(<<"id">>, MUser, <<"0">>)
);
false ->
undefined
end,
if
MemberId =:= UserId ->
maps:put(<<"user">>, UserData, M);
true ->
M
end
end,
Members
),
UpdatedData = maps:put(<<"members">>, UpdatedMembers, Data),
UpdatedState = maps:put(data, UpdatedData, State),
UpdatedMember = find_member_by_user_id(UserId, UpdatedState),
case UpdatedMember of
undefined ->
ok;
M ->
GuildId = maps:get(id, UpdatedState),
MemberUpdateData = maps:put(
<<"guild_id">>, integer_to_binary(GuildId), M
),
gen_server:cast(
self(),
{dispatch, #{
event => guild_member_update, data => MemberUpdateData
}}
)
end,
UpdatedState;
true ->
State
end
end.
check_user_data_differs(CurrentUserData, NewUserData) ->
utils:check_user_data_differs(CurrentUserData, NewUserData).
maybe_update_cached_user_data(Event, EventData, State) ->
case Event of
E when E =:= message_create; E =:= message_update ->
case maps:get(<<"author">>, EventData, undefined) of
undefined ->
State;
AuthorData ->
UserId = utils:binary_to_integer_safe(maps:get(<<"id">>, AuthorData, <<"0">>)),
case find_member_by_user_id(UserId, State) of
undefined ->
State;
Member ->
CurrentUserData = maps:get(<<"user">>, Member, #{}),
case check_user_data_differs(CurrentUserData, AuthorData) of
true ->
handle_user_data_update(UserId, AuthorData, State);
false ->
State
end
end
end;
_ ->
State
end.

View File

@@ -0,0 +1,127 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_virtual_channel_access).
-export([
add_virtual_access/3,
remove_virtual_access/3,
has_virtual_access/3,
get_virtual_channels_for_user/2,
get_users_with_virtual_access/2,
dispatch_channel_visibility_change/4
]).
-import(guild_permissions, [find_channel_by_id/2]).
add_virtual_access(UserId, ChannelId, State) ->
VirtualAccess = maps:get(virtual_channel_access, State, #{}),
UserChannels = maps:get(UserId, VirtualAccess, sets:new()),
UpdatedUserChannels = sets:add_element(ChannelId, UserChannels),
UpdatedVirtualAccess = maps:put(UserId, UpdatedUserChannels, VirtualAccess),
maps:put(virtual_channel_access, UpdatedVirtualAccess, State).
remove_virtual_access(UserId, ChannelId, State) ->
VirtualAccess = maps:get(virtual_channel_access, State, #{}),
case maps:get(UserId, VirtualAccess, undefined) of
undefined ->
State;
UserChannels ->
UpdatedUserChannels = sets:del_element(ChannelId, UserChannels),
case sets:size(UpdatedUserChannels) of
0 ->
UpdatedVirtualAccess = maps:remove(UserId, VirtualAccess),
maps:put(virtual_channel_access, UpdatedVirtualAccess, State);
_ ->
UpdatedVirtualAccess = maps:put(UserId, UpdatedUserChannels, VirtualAccess),
maps:put(virtual_channel_access, UpdatedVirtualAccess, State)
end
end.
has_virtual_access(UserId, ChannelId, State) ->
VirtualAccess = maps:get(virtual_channel_access, State, #{}),
case maps:get(UserId, VirtualAccess, undefined) of
undefined ->
false;
UserChannels ->
sets:is_element(ChannelId, UserChannels)
end.
get_virtual_channels_for_user(UserId, State) ->
VirtualAccess = maps:get(virtual_channel_access, State, #{}),
case maps:get(UserId, VirtualAccess, undefined) of
undefined ->
[];
UserChannels ->
sets:to_list(UserChannels)
end.
get_users_with_virtual_access(ChannelId, State) ->
VirtualAccess = maps:get(virtual_channel_access, State, #{}),
maps:fold(
fun(UserId, UserChannels, Acc) ->
case sets:is_element(ChannelId, UserChannels) of
true -> [UserId | Acc];
false -> Acc
end
end,
[],
VirtualAccess
).
dispatch_channel_visibility_change(UserId, ChannelId, Action, State) ->
Channel = find_channel_by_id(ChannelId, State),
case Channel of
undefined ->
ok;
_ ->
Sessions = maps:get(sessions, State, #{}),
GuildId = maps:get(id, State),
UserSessions = maps:filter(
fun(_Sid, SessionData) ->
maps:get(user_id, SessionData) =:= UserId
end,
Sessions
),
case Action of
add ->
ChannelWithGuild = maps:put(
<<"guild_id">>, integer_to_binary(GuildId), Channel
),
maps:foreach(
fun(_Sid, SessionData) ->
Pid = maps:get(pid, SessionData),
gen_server:cast(Pid, {dispatch, channel_create, ChannelWithGuild})
end,
UserSessions
);
remove ->
ChannelDelete = #{
<<"id">> => integer_to_binary(ChannelId),
<<"guild_id">> => integer_to_binary(GuildId)
},
maps:foreach(
fun(_Sid, SessionData) ->
Pid = maps:get(pid, SessionData),
gen_server:cast(Pid, {dispatch, channel_delete, ChannelDelete})
end,
UserSessions
)
end
end.

View File

@@ -0,0 +1,170 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_visibility).
-export([
get_user_viewable_channels/2,
compute_and_dispatch_visibility_changes/2,
viewable_channel_set/2,
have_shared_viewable_channel/3
]).
-import(guild_member_list, [calculate_list_id/2, build_sync_response/4]).
-import(guild_permissions, [can_view_channel/4, find_member_by_user_id/2, find_channel_by_id/2]).
-spec get_user_viewable_channels(integer(), map()) -> [integer()].
get_user_viewable_channels(UserId, State) ->
Data = map_utils:ensure_map(map_utils:get_safe(State, data, #{})),
Channels = map_utils:ensure_list(maps:get(<<"channels">>, Data, [])),
Member = find_member_by_user_id(UserId, State),
case Member of
undefined ->
[];
_ ->
lists:filtermap(
fun(Channel) ->
ChannelId = map_utils:get_integer(Channel, <<"id">>, undefined),
case ChannelId of
undefined ->
false;
_ ->
case can_view_channel(UserId, ChannelId, Member, State) of
true -> {true, ChannelId};
false -> false
end
end
end,
Channels
)
end.
-spec viewable_channel_set(integer(), map()) -> sets:set().
viewable_channel_set(UserId, State) when is_integer(UserId) ->
sets:from_list(get_user_viewable_channels(UserId, State));
viewable_channel_set(_, _) ->
sets:new().
-spec have_shared_viewable_channel(integer(), integer(), map()) -> boolean().
have_shared_viewable_channel(UserId, OtherUserId, State) when is_integer(UserId), is_integer(OtherUserId), UserId =/= OtherUserId ->
SetA = viewable_channel_set(UserId, State),
SetB = viewable_channel_set(OtherUserId, State),
not sets:is_empty(sets:intersection(SetA, SetB));
have_shared_viewable_channel(_, _, _) ->
false.
-spec compute_and_dispatch_visibility_changes(map(), map()) -> ok.
compute_and_dispatch_visibility_changes(OldState, NewState) ->
Sessions = maps:get(sessions, NewState, #{}),
GuildId = maps:get(id, NewState, 0),
lists:foreach(
fun({SessionId, SessionData}) ->
UserId = maps:get(user_id, SessionData),
Pid = maps:get(pid, SessionData),
OldViewable = get_user_viewable_channels(UserId, OldState),
NewViewable = get_user_viewable_channels(UserId, NewState),
OldSet = sets:from_list(OldViewable),
NewSet = sets:from_list(NewViewable),
Removed = sets:subtract(OldSet, NewSet),
Added = sets:subtract(NewSet, OldSet),
lists:foreach(
fun(ChannelId) ->
dispatch_channel_delete(ChannelId, Pid, OldState, GuildId)
end,
sets:to_list(Removed)
),
lists:foreach(
fun(ChannelId) ->
dispatch_channel_create(ChannelId, Pid, NewState, GuildId),
send_member_list_sync(SessionId, SessionData, ChannelId, GuildId, NewState)
end,
sets:to_list(Added)
)
end,
maps:to_list(Sessions)
),
ok.
dispatch_channel_delete(ChannelId, SessionPid, OldState, GuildId) ->
case is_pid(SessionPid) of
true ->
case find_channel_by_id(ChannelId, OldState) of
undefined ->
ok;
_Channel ->
ChannelDelete = #{
<<"id">> => integer_to_binary(ChannelId),
<<"guild_id">> => integer_to_binary(GuildId)
},
gen_server:cast(SessionPid, {dispatch, channel_delete, ChannelDelete})
end;
false ->
ok
end.
dispatch_channel_create(ChannelId, SessionPid, NewState, GuildId) ->
case is_pid(SessionPid) of
true ->
case find_channel_by_id(ChannelId, NewState) of
undefined ->
ok;
Channel ->
ChannelWithGuild = maps:put(
<<"guild_id">>, integer_to_binary(GuildId), Channel
),
gen_server:cast(SessionPid, {dispatch, channel_create, ChannelWithGuild})
end;
false ->
ok
end.
send_member_list_sync(SessionId, SessionData, ChannelId, GuildId, State) ->
SessionPid = maps:get(pid, SessionData),
case is_pid(SessionPid) of
false ->
ok;
true ->
ListId = calculate_list_id(ChannelId, State),
MemberListSubs = maps:get(member_list_subscriptions, State, #{}),
ListSubs = maps:get(ListId, MemberListSubs, #{}),
Ranges = maps:get(SessionId, ListSubs, []),
case Ranges of
[] ->
ok;
_ ->
SessionUserId = maps:get(user_id, SessionData),
case can_send_member_list(SessionUserId, ChannelId, State) of
true ->
SyncResponse = build_sync_response(GuildId, ListId, Ranges, State),
SyncResponseWithChannel = maps:put(<<"channel_id">>, integer_to_binary(ChannelId), SyncResponse),
gen_server:cast(SessionPid, {dispatch, guild_member_list_update, SyncResponseWithChannel});
false ->
ok
end
end
end.
can_send_member_list(UserId, ChannelId, State) ->
is_integer(UserId) andalso
guild_permissions:can_view_channel(UserId, ChannelId, undefined, State).

View File

@@ -0,0 +1,626 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(dm_voice).
-export([voice_state_update/2]).
-export([get_voice_state/2]).
-export([get_voice_token/6]).
-export([disconnect_voice_user/2]).
-export([broadcast_voice_state_update/3]).
-export([join_or_create_call/5, join_or_create_call/6]).
voice_state_update(Request, State) ->
#{
user_id := UserId,
channel_id := ChannelId
} = Request,
ConnectionId = maps:get(connection_id, Request, undefined),
VoiceStates = maps:get(dm_voice_states, State, #{}),
case ChannelId of
null ->
handle_dm_disconnect(ConnectionId, UserId, VoiceStates, State);
ChannelIdValue ->
Channels = maps:get(channels, State, #{}),
UserId = maps:get(user_id, State),
logger:info(
"[dm_voice] Looking up channel ~p for user ~p, channels map has ~p entries",
[ChannelIdValue, UserId, maps:size(Channels)]
),
case maps:get(ChannelIdValue, Channels, undefined) of
undefined ->
logger:info(
"[dm_voice] Channel ~p not found locally for user ~p, trying RPC fallback",
[ChannelIdValue, UserId]
),
case fetch_dm_channel_via_rpc(ChannelIdValue, UserId) of
{ok, Channel} ->
NewChannels = maps:put(ChannelIdValue, Channel, Channels),
NewState = maps:put(channels, NewChannels, State),
logger:info(
"[dm_voice] RPC fallback found channel ~p for user ~p, added to local map",
[ChannelIdValue, UserId]
),
handle_dm_voice_with_channel(
Channel, ChannelIdValue, UserId, Request, NewState
);
{error, Reason} ->
logger:warning(
"[dm_voice] Channel ~p not found for user ~p via RPC: ~p",
[ChannelIdValue, UserId, Reason]
),
{reply, gateway_errors:error(dm_channel_not_found), State}
end;
Channel ->
logger:info(
"[dm_voice] Found channel ~p for user ~p, type: ~p",
[ChannelIdValue, UserId, maps:get(<<"type">>, Channel, 0)]
),
handle_dm_voice_with_channel(Channel, ChannelIdValue, UserId, Request, State)
end
end.
handle_dm_voice_with_channel(Channel, ChannelIdValue, UserId, Request, State) ->
#{
session_id := SessionId,
self_mute := SelfMute,
self_deaf := SelfDeaf,
self_video := SelfVideo
} = Request,
SelfStream = maps:get(self_stream, Request, false),
ConnectionId = maps:get(connection_id, Request, undefined),
IsMobile = maps:get(is_mobile, Request, false),
ViewerStreamKey = maps:get(viewer_stream_key, Request, undefined),
Latitude = maps:get(latitude, Request, null),
Longitude = maps:get(longitude, Request, null),
VoiceStates = maps:get(dm_voice_states, State, #{}),
ChannelType = maps:get(<<"type">>, Channel, 0),
case is_dm_channel_type(ChannelType) of
false ->
{reply, gateway_errors:error(dm_invalid_channel_type), State};
true ->
case check_recipient(UserId, ChannelIdValue, State) of
false ->
{reply, gateway_errors:error(dm_not_recipient), State};
true ->
handle_dm_connect_or_update(
ConnectionId,
ChannelIdValue,
UserId,
SessionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
Latitude,
Longitude,
VoiceStates,
State
)
end
end.
handle_dm_disconnect(undefined, _UserId, _VoiceStates, State) ->
{reply, gateway_errors:error(voice_missing_connection_id), State};
handle_dm_disconnect(ConnectionId, _UserId, VoiceStates, State) ->
case maps:get(ConnectionId, VoiceStates, undefined) of
undefined ->
{reply, #{success => true}, State};
OldVoiceState ->
NewVoiceStates = maps:remove(ConnectionId, VoiceStates),
NewState = maps:put(dm_voice_states, NewVoiceStates, State),
OldChannelId = maps:get(<<"channel_id">>, OldVoiceState, null),
DisconnectVoiceState = maps:put(
<<"channel_id">>, null, maps:put(<<"connection_id">>, ConnectionId, OldVoiceState)
),
SessionId = maps:get(id, State),
case OldChannelId of
null ->
ok;
ChannelIdValue ->
SessionPid = maps:get(session_pid, State),
gen_server:cast(SessionPid, {call_unmonitor, ChannelIdValue}),
spawn(fun() ->
try
case gen_server:call(call_manager, {lookup, ChannelIdValue}, 5000) of
{ok, CallPid} ->
gen_server:call(CallPid, {leave, SessionId}, 5000);
_ ->
ok
end
catch
_:_ -> ok
end
end)
end,
case OldChannelId of
null ->
ok;
_ ->
case validation:validate_snowflake(<<"channel_id">>, OldChannelId) of
{ok, OldChannelIdInt} ->
broadcast_voice_state_update(
OldChannelIdInt, DisconnectVoiceState, NewState
);
{error, _, Reason} ->
logger:warning("[dm_voice] Invalid channel_id: ~p", [Reason]),
ok
end
end,
{reply, #{success => true}, NewState}
end.
handle_dm_connect_or_update(
ConnectionId,
ChannelIdValue,
UserId,
SessionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
Latitude,
Longitude,
_VoiceStates,
State
) when ConnectionId =:= undefined; ConnectionId =:= null ->
VoiceStates = maps:get(dm_voice_states, State, #{}),
case validate_dm_viewer_stream_key(ViewerStreamKey, ChannelIdValue, VoiceStates) of
{error, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
{ok, ParsedViewerKey} ->
get_dm_voice_token_and_create_state(
UserId,
ChannelIdValue,
SessionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ParsedViewerKey,
IsMobile,
Latitude,
Longitude,
State
)
end;
handle_dm_connect_or_update(
ConnectionId,
ChannelIdValue,
UserId,
SessionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
_Latitude,
_Longitude,
VoiceStates,
State
) ->
case maps:get(ConnectionId, VoiceStates, undefined) of
undefined ->
{reply, gateway_errors:error(voice_connection_not_found), State};
ExistingVoiceState ->
ExistingSessionId = maps:get(<<"session_id">>, ExistingVoiceState, undefined),
EffectiveSessionId = resolve_effective_session_id(ExistingSessionId, SessionId),
ValidViewerKey = validate_dm_viewer_stream_key(
ViewerStreamKey, ChannelIdValue, VoiceStates
),
case ValidViewerKey of
{error, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
{ok, ParsedViewerKey} ->
UpdatedVoiceState = ExistingVoiceState#{
<<"channel_id">> => integer_to_binary(ChannelIdValue),
<<"session_id">> => EffectiveSessionId,
<<"self_mute">> => SelfMute,
<<"self_deaf">> => SelfDeaf,
<<"self_video">> => SelfVideo,
<<"self_stream">> => SelfStream,
<<"is_mobile">> => IsMobile,
<<"viewer_stream_key">> => ParsedViewerKey
},
NewVoiceStates = maps:put(ConnectionId, UpdatedVoiceState, VoiceStates),
NewState = maps:put(dm_voice_states, NewVoiceStates, State),
broadcast_voice_state_update(ChannelIdValue, UpdatedVoiceState, NewState),
OldChannelId = maps:get(<<"channel_id">>, ExistingVoiceState, null),
NewChannelIdBin = integer_to_binary(ChannelIdValue),
NeedsToken = OldChannelId =/= NewChannelIdBin,
maybe_spawn_join_call(
NeedsToken, ChannelIdValue, UserId, UpdatedVoiceState, SessionId
),
{reply, #{success => true, needs_token => NeedsToken}, NewState}
end
end.
normalize_session_id(undefined) ->
undefined;
normalize_session_id(SessionId) when is_binary(SessionId) ->
SessionId;
normalize_session_id(SessionId) when is_integer(SessionId) ->
integer_to_binary(SessionId);
normalize_session_id(SessionId) when is_list(SessionId) ->
list_to_binary(SessionId);
normalize_session_id(SessionId) ->
try
erlang:iolist_to_binary(SessionId)
catch
_:_ -> SessionId
end.
validate_dm_viewer_stream_key(RawKey, ChannelIdValue, VoiceStates) ->
case RawKey of
undefined ->
{ok, null};
null ->
{ok, null};
_ when not is_binary(RawKey) -> {error, voice_invalid_state};
_ ->
case voice_state_utils:parse_stream_key(RawKey) of
{ok, #{scope := dm, channel_id := ParsedChannelId, connection_id := ConnId}} when
ParsedChannelId =:= ChannelIdValue
->
case maps:get(ConnId, VoiceStates, undefined) of
undefined ->
{error, voice_connection_not_found};
StreamVS ->
case map_utils:get_integer(StreamVS, <<"channel_id">>, undefined) of
ChannelIdValue -> {ok, RawKey};
_ -> {error, voice_invalid_state}
end
end;
_ ->
{error, voice_invalid_state}
end
end.
resolve_effective_session_id(ExistingSessionId, RequestSessionId) ->
ExistingNormalized = normalize_session_id(ExistingSessionId),
RequestNormalized = normalize_session_id(RequestSessionId),
case ExistingNormalized of
undefined -> RequestNormalized;
RequestNormalized -> RequestNormalized;
_ -> ExistingNormalized
end.
maybe_spawn_join_call(false, _ChannelId, _UserId, _VoiceState, _SessionId) ->
ok;
maybe_spawn_join_call(true, ChannelId, UserId, VoiceState, SessionId) ->
spawn(fun() ->
try
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, self())
catch
_:_ -> ok
end
end).
get_voice_token(ChannelId, UserId, _SessionId, SessionPid, Latitude, Longitude) ->
Req = voice_utils:build_voice_token_rpc_request(
null, ChannelId, UserId, null, Latitude, Longitude
),
case rpc_client:call(Req) of
{ok, Data} ->
Token = maps:get(<<"token">>, Data),
Endpoint = maps:get(<<"endpoint">>, Data),
ConnectionId = maps:get(<<"connectionId">>, Data),
SessionPid !
{voice_server_update, #{
channel_id => integer_to_binary(ChannelId),
endpoint => Endpoint,
token => Token,
connection_id => ConnectionId
}},
ok;
{error, _Reason} ->
error
end.
get_dm_voice_token_and_create_state(
UserId,
ChannelId,
SessionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
Latitude,
Longitude,
State
) ->
Req = voice_utils:build_voice_token_rpc_request(
null, ChannelId, UserId, null, Latitude, Longitude
),
case rpc_client:call(Req) of
{ok, Data} ->
handle_dm_token_success(
Data,
UserId,
ChannelId,
SessionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
State
);
{error, _Reason} ->
{reply, gateway_errors:error(voice_token_failed), State}
end.
handle_dm_token_success(
Data,
UserId,
ChannelId,
SessionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
State
) ->
Token = maps:get(<<"token">>, Data),
Endpoint = maps:get(<<"endpoint">>, Data),
ConnectionId = maps:get(<<"connectionId">>, Data),
VoiceState = #{
<<"user_id">> => integer_to_binary(UserId),
<<"channel_id">> => integer_to_binary(ChannelId),
<<"connection_id">> => ConnectionId,
<<"is_mobile">> => IsMobile,
<<"session_id">> => SessionId,
<<"self_mute">> => SelfMute,
<<"self_deaf">> => SelfDeaf,
<<"self_video">> => SelfVideo,
<<"self_stream">> => SelfStream,
<<"viewer_stream_key">> => ViewerStreamKey
},
VoiceStates = maps:get(dm_voice_states, State, #{}),
NewVoiceStates = maps:put(ConnectionId, VoiceState, VoiceStates),
NewState = maps:put(dm_voice_states, NewVoiceStates, State),
broadcast_voice_state_update(ChannelId, VoiceState, NewState),
SessionPid = maps:get(session_pid, State),
VoiceServerUpdate = #{
<<"token">> => Token,
<<"endpoint">> => Endpoint,
<<"channel_id">> => integer_to_binary(ChannelId),
<<"connection_id">> => ConnectionId
},
gen_server:cast(SessionPid, {dispatch, voice_server_update, VoiceServerUpdate}),
GatewaySessionId = maps:get(id, State),
spawn(fun() ->
try
join_or_create_call(ChannelId, UserId, VoiceState, GatewaySessionId, SessionPid)
catch
_:_ -> ok
end
end),
{reply, #{success => true, needs_token => false, connection_id => ConnectionId}, NewState}.
get_voice_state(ConnectionId, State) ->
VoiceStates = maps:get(dm_voice_states, State, #{}),
maps:get(ConnectionId, VoiceStates, undefined).
disconnect_voice_user(UserId, State) ->
VoiceStates = maps:get(dm_voice_states, State, #{}),
UserVoiceStates = maps:filter(
fun(_ConnectionId, VoiceState) ->
maps:get(<<"user_id">>, VoiceState) =:= integer_to_binary(UserId)
end,
VoiceStates
),
case maps:size(UserVoiceStates) of
0 ->
{reply, #{success => true}, State};
_ ->
NewVoiceStates = maps:fold(
fun(ConnectionId, _VoiceState, Acc) ->
maps:remove(ConnectionId, Acc)
end,
VoiceStates,
UserVoiceStates
),
NewState = maps:put(dm_voice_states, NewVoiceStates, State),
maps:foreach(
fun(_ConnectionId, VoiceState) ->
ChannelId = maps:get(<<"channel_id">>, VoiceState, null),
DisconnectVoiceState = maps:put(
<<"channel_id">>,
null,
maps:put(<<"connection_id">>, _ConnectionId, VoiceState)
),
case ChannelId of
null ->
ok;
_ ->
case validation:validate_snowflake(<<"channel_id">>, ChannelId) of
{ok, ChannelIdInt} ->
broadcast_voice_state_update(
ChannelIdInt, DisconnectVoiceState, NewState
);
{error, _, Reason} ->
logger:warning(
"[dm_voice] Invalid channel_id in voice state: ~p", [Reason]
),
ok
end
end
end,
UserVoiceStates
),
{reply, #{success => true}, NewState}
end.
broadcast_voice_state_update(ChannelId, VoiceState, State) ->
Channels = maps:get(channels, State, #{}),
case maps:get(ChannelId, Channels, undefined) of
undefined ->
ok;
Channel ->
Recipients = maps:get(<<"recipient_ids">>, Channel, []),
UserId = maps:get(user_id, State),
AllRecipients = lists:usort([UserId | Recipients]),
Event = voice_state_update,
lists:foreach(
fun(RecipientId) ->
presence_manager:dispatch_to_user(RecipientId, Event, VoiceState)
end,
AllRecipients
)
end.
check_recipient(UserId, ChannelId, State) ->
Channels = maps:get(channels, State, #{}),
case maps:get(ChannelId, Channels, undefined) of
undefined ->
false;
Channel ->
ChannelType = maps:get(<<"type">>, Channel, 0),
is_dm_channel_type(ChannelType) andalso is_channel_recipient(UserId, Channel, State)
end.
is_dm_channel_type(1) -> true;
is_dm_channel_type(3) -> true;
is_dm_channel_type(_) -> false.
is_channel_recipient(UserId, Channel, State) ->
Recipients = maps:get(<<"recipient_ids">>, Channel, []),
CurrentUserId = maps:get(user_id, State),
lists:member(UserId, [CurrentUserId | Recipients]).
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid) ->
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, 10).
join_or_create_call(_ChannelId, UserId, _VoiceState, _SessionId, _SessionPid, 0) ->
logger:warning("[dm_voice] Failed to join call after retries, user ~p could not join", [UserId]),
ok;
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, Retries) ->
ConnectionId = maps:get(<<"connection_id">>, VoiceState, undefined),
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
{ok, CallPid} ->
JoinMsg =
case ConnectionId of
undefined ->
{join, UserId, VoiceState, SessionId, SessionPid};
_ ->
{join, UserId, VoiceState, SessionId, SessionPid, ConnectionId}
end,
case gen_server:call(CallPid, JoinMsg, 5000) of
ok ->
gen_server:cast(SessionPid, {call_monitor, ChannelId, CallPid}),
ok;
Error ->
Error
end;
{error, not_found} ->
timer:sleep(300),
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, Retries - 1);
not_found ->
timer:sleep(300),
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, Retries - 1)
end.
fetch_dm_channel_via_rpc(ChannelId, UserId) ->
Req = #{
<<"type">> => <<"get_dm_channel">>,
<<"channel_id">> => ChannelId,
<<"user_id">> => UserId
},
case rpc_client:call(Req) of
{ok, #{<<"channel">> := null}} ->
{error, not_found};
{ok, #{<<"channel">> := Channel}} when is_map(Channel) ->
{ok, convert_api_channel_to_gateway_format(Channel, UserId)};
{ok, _} ->
{error, not_found};
{error, Reason} ->
{error, Reason}
end.
convert_api_channel_to_gateway_format(Channel, CurrentUserId) ->
ChannelType = maps:get(<<"type">>, Channel, 0),
Recipients = maps:get(<<"recipients">>, Channel, []),
RecipientIds = lists:filtermap(
fun(R) -> extract_recipient_id(R, CurrentUserId) end,
Recipients
),
#{
<<"id">> => maps:get(<<"id">>, Channel),
<<"type">> => ChannelType,
<<"recipient_ids">> => RecipientIds
}.
extract_recipient_id(Recipient, CurrentUserId) when is_map(Recipient) ->
case maps:get(<<"id">>, Recipient, undefined) of
undefined -> false;
Id -> filter_recipient_id(parse_id(Id), CurrentUserId)
end;
extract_recipient_id(Id, CurrentUserId) ->
filter_recipient_id(parse_id(Id), CurrentUserId).
parse_id(Id) when is_integer(Id) -> Id;
parse_id(Id) when is_binary(Id) ->
case validation:validate_snowflake(<<"id">>, Id) of
{ok, IntId} -> IntId;
{error, _, _} -> null
end;
parse_id(_) ->
null.
filter_recipient_id(null, _CurrentUserId) -> false;
filter_recipient_id(Id, Id) -> false;
filter_recipient_id(Id, _CurrentUserId) -> {true, Id}.

View File

@@ -0,0 +1,125 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_voice).
-export([voice_state_update/2]).
-export([get_voice_state/2]).
-export([update_member_voice/2]).
-export([disconnect_voice_user/2]).
-export([disconnect_voice_user_if_in_channel/2]).
-export([disconnect_all_voice_users_in_channel/2]).
-export([confirm_voice_connection_from_livekit/2]).
-export([move_member/2]).
-export([broadcast_voice_state_update/3]).
-export([broadcast_voice_server_update_to_session/6]).
-export([send_voice_server_update_for_move/5]).
-export([send_voice_server_updates_for_move/4]).
-export([switch_voice_region_handler/2]).
-export([switch_voice_region/3]).
-export([get_voice_states_list/1]).
-export([handle_virtual_channel_access_for_move/4]).
-export([cleanup_virtual_access_on_disconnect/2]).
voice_state_update(Request, State) ->
case guild_voice_connection:voice_state_update(Request, State) of
{reply, Response, NewState} ->
{reply, Response, NewState};
{error, Category, Message} ->
{reply, {error, Category, Message}, State}
end.
get_voice_state(Request, State) ->
guild_voice_state:get_voice_state(Request, State).
get_voice_states_list(State) ->
guild_voice_state:get_voice_states_list(State).
update_member_voice(Request, State) ->
guild_voice_member:update_member_voice(Request, State).
disconnect_voice_user(Request, State) ->
guild_voice_disconnect:disconnect_voice_user(Request, State).
disconnect_voice_user_if_in_channel(Request, State) ->
guild_voice_disconnect:disconnect_voice_user_if_in_channel(Request, State).
disconnect_all_voice_users_in_channel(Request, State) ->
guild_voice_disconnect:disconnect_all_voice_users_in_channel(Request, State).
confirm_voice_connection_from_livekit(Request, State) ->
case guild_voice_connection:confirm_voice_connection_from_livekit(Request, State) of
{reply, Response, NewState} ->
{reply, Response, NewState};
{error, Category, Message} ->
{reply, {error, Category, Message}, State}
end.
move_member(Request, State) ->
guild_voice_move:move_member(Request, State).
send_voice_server_update_for_move(GuildId, ChannelId, UserId, SessionId, GuildPid) ->
guild_voice_move:send_voice_server_update_for_move(
GuildId, ChannelId, UserId, SessionId, GuildPid
).
send_voice_server_updates_for_move(GuildId, ChannelId, SessionDataList, GuildPid) ->
guild_voice_move:send_voice_server_updates_for_move(
GuildId, ChannelId, SessionDataList, GuildPid
).
broadcast_voice_state_update(VoiceState, State, OldChannelIdBin) ->
guild_voice_broadcast:broadcast_voice_state_update(VoiceState, State, OldChannelIdBin).
broadcast_voice_server_update_to_session(GuildId, SessionId, Token, Endpoint, ConnectionId, State) ->
guild_voice_broadcast:broadcast_voice_server_update_to_session(
GuildId, SessionId, Token, Endpoint, ConnectionId, State
).
switch_voice_region_handler(Request, State) ->
guild_voice_region:switch_voice_region_handler(Request, State).
switch_voice_region(GuildId, ChannelId, GuildPid) ->
guild_voice_region:switch_voice_region(GuildId, ChannelId, GuildPid).
handle_virtual_channel_access_for_move(UserId, ChannelId, _ConnectionsToMove, GuildPid) ->
case gen_server:call(GuildPid, {get_sessions}, 10000) of
State when is_map(State) ->
Member = guild_permissions:find_member_by_user_id(UserId, State),
case Member of
undefined ->
ok;
_ ->
HasViewPermission = guild_permissions:can_view_channel_by_permissions(
UserId, ChannelId, Member, State
),
case HasViewPermission of
true ->
ok;
false ->
gen_server:cast(
GuildPid,
{add_virtual_channel_access, UserId, ChannelId}
)
end
end;
_ ->
ok
end.
cleanup_virtual_access_on_disconnect(UserId, GuildPid) ->
gen_server:cast(GuildPid, {cleanup_virtual_access_for_user, UserId}).

View File

@@ -0,0 +1,117 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_voice_broadcast).
-export([broadcast_voice_state_update/3]).
-export([broadcast_voice_server_update_to_session/6]).
-ifdef(TEST).
-define(WARN_MISSING_CONN(_VoiceState), ok).
-else.
-define(WARN_MISSING_CONN(VoiceState),
logger:warning(
"[guild_voice_broadcast] Skipping VOICE_STATE_UPDATE broadcast - missing connection_id: ~p",
[VoiceState]
)
).
-endif.
broadcast_voice_state_update(VoiceState, State, OldChannelIdBin) ->
case maps:get(<<"connection_id">>, VoiceState, undefined) of
undefined ->
?WARN_MISSING_CONN(VoiceState),
ok;
ConnectionId ->
Sessions = maps:get(sessions, State, #{}),
ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
FilterChannelIdBin =
case ChannelIdBin of
null ->
OldChannelIdBin;
_ ->
ChannelIdBin
end,
FilterChannelId = utils:binary_to_integer_safe(FilterChannelIdBin),
UserId = maps:get(<<"user_id">>, VoiceState, <<"unknown">>),
GuildId = maps:get(id, State, 0),
AllSessionDetails = [{Sid, maps:get(user_id, S)} || {Sid, S} <- maps:to_list(Sessions)],
logger:info(
"[guild_voice_broadcast] Broadcasting voice state update: "
"guild_id=~p user_id=~p channel_id=~p connection_id=~p "
"total_sessions=~p all_sessions=~p filter_channel_id=~p",
[
GuildId,
UserId,
ChannelIdBin,
ConnectionId,
maps:size(Sessions),
AllSessionDetails,
FilterChannelId
]
),
FilteredSessions = guild_sessions:filter_sessions_for_channel(
Sessions, FilterChannelId, undefined, State
),
SessionDetails = [{Sid, maps:get(user_id, S)} || {Sid, S} <- FilteredSessions],
Pids = [maps:get(pid, S) || {_Sid, S} <- FilteredSessions],
logger:info(
"[guild_voice_broadcast] Filtered sessions: "
"guild_id=~p user_id=~p filtered_count=~p session_details=~p pids=~p",
[GuildId, UserId, length(FilteredSessions), SessionDetails, Pids]
),
lists:foreach(
fun(Pid) when is_pid(Pid) ->
logger:info(
"[guild_voice_broadcast] Sending voice_state_update to session pid ~p",
[Pid]
),
gen_server:cast(Pid, {dispatch, voice_state_update, VoiceState})
end,
Pids
)
end.
broadcast_voice_server_update_to_session(GuildId, SessionId, Token, Endpoint, ConnectionId, State) ->
VoiceServerUpdate = #{
<<"token">> => Token,
<<"endpoint">> => Endpoint,
<<"guild_id">> => integer_to_binary(GuildId),
<<"connection_id">> => ConnectionId
},
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
ok;
SessionData ->
SessionPid = maps:get(pid, SessionData, null),
case SessionPid of
Pid when is_pid(Pid) ->
gen_server:cast(Pid, {dispatch, voice_server_update, VoiceServerUpdate});
_ ->
ok
end
end.

View File

@@ -0,0 +1,699 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_voice_connection).
-include_lib("fluxer_gateway/include/voice_state.hrl").
-export([voice_state_update/2]).
-export([confirm_voice_connection_from_livekit/2]).
-export([request_voice_token/4]).
-type guild_state() :: map().
-type voice_state() :: map().
-type voice_state_map() :: #{binary() => voice_state()}.
-type pending_voice_connections() :: #{binary() => map()}.
-type context() :: #{
user_id := integer() | undefined,
channel_id := integer() | null | undefined,
session_id := term(),
connection_id := binary() | undefined,
raw_connection_id := term(),
self_mute := boolean(),
self_deaf := boolean(),
self_video := boolean(),
self_stream := boolean(),
is_mobile := boolean(),
viewer_stream_key := term()
}.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-ifdef(TEST).
-define(LOG_INVALID_GUILD_ID(_Value), ok).
-else.
-define(LOG_INVALID_GUILD_ID(Value),
logger:warning(
"[guild_voice_connection] Invalid guild_id value: ~p", [Value]
)
).
-endif.
voice_state_update(Request, State) ->
Context = build_context(Request),
case maps:get(user_id, Context) of
undefined ->
gateway_errors:error(voice_invalid_user_id);
UserId ->
logger:debug(
"[guild_voice_connection] Processing voice state update: UserId=~p, ChannelId=~p, "
"ConnectionId=~p",
[UserId, maps:get(channel_id, Context), maps:get(connection_id, Context)]
),
VoiceStates = voice_state_utils:voice_states(State),
case guild_voice_member:find_member_by_user_id(UserId, State) of
undefined ->
logger:warning("[guild_voice_connection] Member not found for UserId: ~p", [
UserId
]),
gateway_errors:error(voice_member_not_found);
Member ->
handle_member_voice(Context, Member, VoiceStates, State)
end
end.
-spec handle_member_voice(context(), map(), voice_state_map(), guild_state()) ->
{reply, map(), guild_state()} | {error, atom(), binary()}.
handle_member_voice(Context, Member, VoiceStates, State) ->
case maps:get(channel_id, Context) of
undefined ->
gateway_errors:error(voice_invalid_channel_id);
null ->
handle_disconnect(Context, VoiceStates, State);
ChannelIdValue ->
handle_voice_connect_or_update(Context, ChannelIdValue, Member, VoiceStates, State)
end.
handle_disconnect(Context, VoiceStates, State) ->
guild_voice_disconnect:handle_voice_disconnect(
maps:get(raw_connection_id, Context),
maps:get(session_id, Context),
maps:get(user_id, Context),
VoiceStates,
State
).
handle_voice_connect_or_update(Context, ChannelIdValue, Member, VoiceStates, State) ->
ConnectionId = maps:get(connection_id, Context),
Channel = guild_voice_member:find_channel_by_id(ChannelIdValue, State),
case Channel of
undefined ->
logger:warning("[guild_voice_connection] Channel not found: ~p", [ChannelIdValue]),
gateway_errors:error(voice_channel_not_found);
_ ->
case ConnectionId of
undefined ->
handle_new_connection(Context, Member, Channel, VoiceStates, State);
_ ->
handle_update_connection(
Context,
ChannelIdValue,
Member,
Channel,
VoiceStates,
State
)
end
end.
handle_update_connection(Context, ChannelIdValue, Member, Channel, VoiceStates, State) ->
ConnectionId = maps:get(connection_id, Context),
case maps:get(ConnectionId, VoiceStates, undefined) of
undefined ->
gateway_errors:error(voice_connection_not_found);
ExistingVoiceState ->
ExistingChannelIdBin = maps:get(<<"channel_id">>, ExistingVoiceState, null),
NewChannelIdBin = integer_to_binary(ChannelIdValue),
IsChannelChange = ExistingChannelIdBin =/= NewChannelIdBin,
UserId = maps:get(user_id, Context),
GuildId = map_utils:get_integer(State, id, undefined),
ViewerKeyResult =
resolve_viewer_stream_key(
Context, GuildId, ChannelIdValue, VoiceStates, ExistingVoiceState
),
PermCheck =
case IsChannelChange of
true ->
guild_voice_permissions:check_voice_permissions_and_limits(
UserId, ChannelIdValue, Channel, VoiceStates, State, false
);
false ->
{ok, allowed}
end,
case PermCheck of
{error, _Category, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
{ok, allowed} ->
case ViewerKeyResult of
{error, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
{ok, ParsedViewerKey} ->
Flags = voice_state_utils:voice_flags_from_context(Context),
guild_voice_state:update_voice_state_data(
ConnectionId,
NewChannelIdBin,
Flags,
Member,
ExistingVoiceState,
VoiceStates,
State,
false,
ParsedViewerKey
)
end
end
end.
handle_new_connection(Context, Member, Channel, VoiceStates, State) ->
UserId = maps:get(user_id, Context),
ChannelIdValue = maps:get(channel_id, Context),
GuildId = map_utils:get_integer(State, id, undefined),
ViewerKeyResult = resolve_viewer_stream_key(Context, GuildId, ChannelIdValue, VoiceStates, #{}),
PermCheck = guild_voice_permissions:check_voice_permissions_and_limits(
UserId, ChannelIdValue, Channel, VoiceStates, State, false
),
case {PermCheck, ViewerKeyResult} of
{{error, _Category, ErrorAtom}, _} ->
{reply, gateway_errors:error(ErrorAtom), State};
{{ok, allowed}, {error, ErrorAtom}} ->
{reply, gateway_errors:error(ErrorAtom), State};
{{ok, allowed}, {ok, ParsedViewerKey}} ->
get_voice_token_and_create_state(Context, Member, ParsedViewerKey, State)
end.
get_voice_token_and_create_state(Context, Member, ParsedViewerStreamKey, State) ->
ChannelIdValue = maps:get(channel_id, Context),
UserId = maps:get(user_id, Context),
SessionId = maps:get(session_id, Context),
case resolve_guild_identity(State) of
{error, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
{ok, GuildId, GuildIdBin} ->
logger:info(
"[guild_voice_connection] Requesting voice token for GuildId=~p, ChannelId=~p, UserId=~p",
[GuildId, ChannelIdValue, UserId]
),
VoicePermissions = voice_utils:compute_voice_permissions(UserId, ChannelIdValue, State),
logger:debug("[guild_voice_connection] Computed voice permissions: ~p", [
VoicePermissions
]),
case request_voice_token(GuildId, ChannelIdValue, UserId, VoicePermissions) of
{ok, TokenData} ->
logger:debug("[guild_voice_connection] Voice token request succeeded"),
Token = maps:get(token, TokenData),
Endpoint = maps:get(endpoint, TokenData),
ConnectionId = maps:get(connection_id, TokenData),
ChannelIdBin = integer_to_binary(ChannelIdValue),
UserIdBin = integer_to_binary(UserId),
ServerMute = maps:get(<<"mute">>, Member, false),
ServerDeaf = maps:get(<<"deaf">>, Member, false),
Flags = voice_state_utils:voice_flags_from_context(Context),
#voice_flags{
self_mute = SelfMuteFlag,
self_deaf = SelfDeafFlag,
self_video = SelfVideoFlag,
self_stream = SelfStreamFlag,
is_mobile = IsMobileFlag
} = Flags,
SessionIdValue = maps:get(session_id, Context, undefined),
SessionIdBin = normalize_session_id(SessionIdValue),
VoiceState0 = guild_voice_state:create_voice_state(
GuildIdBin,
ChannelIdBin,
UserIdBin,
ConnectionId,
ServerMute,
ServerDeaf,
Flags,
ParsedViewerStreamKey
),
VoiceState1 = maybe_attach_session_id(VoiceState0, SessionIdBin),
VoiceState = maybe_attach_member(VoiceState1, Member),
VoiceStates = voice_state_utils:voice_states(State),
NewVoiceStates = maps:put(ConnectionId, VoiceState, VoiceStates),
StateWithVoiceStates = maps:put(voice_states, NewVoiceStates, State),
guild_voice_broadcast:broadcast_voice_state_update(
VoiceState, StateWithVoiceStates, ChannelIdBin
),
PendingMetadata = #{
user_id => UserId,
guild_id => GuildId,
channel_id => ChannelIdValue,
session_id => SessionIdBin,
self_mute => SelfMuteFlag,
self_deaf => SelfDeafFlag,
self_video => SelfVideoFlag,
self_stream => SelfStreamFlag,
is_mobile => IsMobileFlag,
server_mute => ServerMute,
server_deaf => ServerDeaf,
member => Member,
viewer_stream_key => ParsedViewerStreamKey
},
PendingConnections = pending_voice_connections(StateWithVoiceStates),
NewPendingConnections = maps:put(
ConnectionId,
PendingMetadata,
PendingConnections
),
NewState = maps:put(
pending_voice_connections, NewPendingConnections, StateWithVoiceStates
),
maybe_broadcast_voice_server_update(
SessionId, GuildId, Token, Endpoint, ConnectionId, NewState
),
{reply,
#{
success => true,
token => Token,
endpoint => Endpoint,
connection_id => ConnectionId,
voice_state => VoiceState
},
NewState};
{error, Reason} ->
logger:error("[guild_voice_connection] Failed to request voice token: ~p", [
Reason
]),
{reply, gateway_errors:error(voice_token_failed), State}
end
end.
-spec build_context(map()) -> context().
build_context(Request0) ->
Request = map_utils:ensure_map(Request0),
RawConnectionId = maps:get(connection_id, Request, undefined),
#{
user_id => normalize_user_id(maps:get(user_id, Request, undefined)),
channel_id => normalize_channel_id_value(maps:get(channel_id, Request, null)),
session_id => maps:get(session_id, Request, undefined),
connection_id => normalize_connection_id(RawConnectionId),
raw_connection_id => RawConnectionId,
self_mute => normalize_boolean(maps:get(self_mute, Request, false)),
self_deaf => normalize_boolean(maps:get(self_deaf, Request, false)),
self_video => normalize_boolean(maps:get(self_video, Request, false)),
self_stream => normalize_boolean(maps:get(self_stream, Request, false)),
is_mobile => normalize_boolean(maps:get(is_mobile, Request, false)),
viewer_stream_key => maps:get(viewer_stream_key, Request, undefined)
}.
normalize_connection_id(undefined) ->
undefined;
normalize_connection_id(null) ->
undefined;
normalize_connection_id(ConnectionId) ->
ConnectionId.
-spec normalize_user_id(term()) -> integer() | undefined.
normalize_user_id(Value) ->
type_conv:to_integer(Value).
-spec normalize_channel_id_value(term()) -> integer() | null | undefined.
normalize_channel_id_value(null) ->
null;
normalize_channel_id_value(Value) ->
type_conv:to_integer(Value).
-spec normalize_boolean(term()) -> boolean().
normalize_boolean(true) -> true;
normalize_boolean(<<"true">>) -> true;
normalize_boolean(false) -> false;
normalize_boolean(<<"false">>) -> false;
normalize_boolean(_) -> false.
maybe_attach_session_id(VoiceState, undefined) ->
VoiceState;
maybe_attach_session_id(VoiceState, SessionId) when is_binary(SessionId) ->
maps:put(<<"session_id">>, SessionId, VoiceState).
maybe_attach_member(VoiceState, Member) when is_map(Member) ->
case maps:size(Member) of
0 -> VoiceState;
_ -> maps:put(<<"member">>, Member, VoiceState)
end.
normalize_session_id(undefined) ->
undefined;
normalize_session_id(null) ->
undefined;
normalize_session_id(SessionId) when is_binary(SessionId) ->
SessionId;
normalize_session_id(SessionId) when is_integer(SessionId) ->
integer_to_binary(SessionId);
normalize_session_id(SessionId) when is_list(SessionId) ->
list_to_binary(SessionId);
normalize_session_id(SessionId) ->
try
erlang:iolist_to_binary(SessionId)
catch
_:_ -> undefined
end.
resolve_viewer_stream_key(Context, GuildId, ChannelIdValue, VoiceStates, ExistingVoiceState) ->
RawKey = maps:get(viewer_stream_key, Context, undefined),
case RawKey of
undefined ->
{ok, maps:get(<<"viewer_stream_key">>, ExistingVoiceState, null)};
null ->
{ok, null};
_ when not is_binary(RawKey) ->
{error, voice_invalid_state};
_ ->
case voice_state_utils:parse_stream_key(RawKey) of
{error, _} ->
{error, voice_invalid_state};
{ok, #{
scope := guild,
guild_id := ParsedGuildId,
channel_id := ParsedChannelId,
connection_id := StreamConnId
}} when
is_integer(ChannelIdValue), ParsedChannelId =:= ChannelIdValue
->
GuildScopeCheck =
case GuildId of
undefined -> ok;
ParsedGuildId -> ok;
_ -> error
end,
case GuildScopeCheck of
ok ->
case maps:get(StreamConnId, VoiceStates, undefined) of
undefined ->
{error, voice_connection_not_found};
StreamVS ->
case
map_utils:get_integer(StreamVS, <<"channel_id">>, undefined)
of
ChannelIdValue -> {ok, RawKey};
_ -> {error, voice_invalid_state}
end
end;
error ->
{error, voice_invalid_state}
end;
{ok, #{scope := dm, channel_id := ParsedChannelId}} when
is_integer(ChannelIdValue), ParsedChannelId =:= ChannelIdValue
->
{ok, RawKey};
_ ->
{error, voice_invalid_state}
end
end.
resolve_voice_state_from_pending(ConnectionId, PendingData, State, VoiceStates) ->
case maps:get(ConnectionId, VoiceStates, undefined) of
VoiceState when is_map(VoiceState) ->
VoiceState;
_ ->
case maps:get(voice_state, PendingData, undefined) of
VoiceState when is_map(VoiceState) ->
VoiceState;
_ ->
build_voice_state_from_pending(PendingData, ConnectionId, State)
end
end.
build_voice_state_from_pending(PendingData, ConnectionId, State) ->
GuildIdState = map_utils:get_integer(State, id, undefined),
GuildId0 = pending_get_integer(PendingData, guild_id),
GuildId =
case GuildId0 of
undefined -> GuildIdState;
_ -> GuildId0
end,
ChannelId = pending_get_integer(PendingData, channel_id),
UserId = pending_get_integer(PendingData, user_id),
case {GuildId, ChannelId, UserId} of
{undefined, _, _} ->
undefined;
{_, undefined, _} ->
undefined;
{_, _, undefined} ->
undefined;
{GId, ChId, UId} ->
GuildIdBin = integer_to_binary(GId),
ChannelIdBin = integer_to_binary(ChId),
UserIdBin = integer_to_binary(UId),
Flags = #voice_flags{
self_mute = pending_get_boolean(PendingData, self_mute),
self_deaf = pending_get_boolean(PendingData, self_deaf),
self_video = pending_get_boolean(PendingData, self_video),
self_stream = pending_get_boolean(PendingData, self_stream),
is_mobile = pending_get_boolean(PendingData, is_mobile)
},
ServerMute = pending_get_boolean(PendingData, server_mute),
ServerDeaf = pending_get_boolean(PendingData, server_deaf),
ViewerStreamKey = pending_get_value(PendingData, viewer_stream_key),
VoiceState0 = guild_voice_state:create_voice_state(
GuildIdBin,
ChannelIdBin,
UserIdBin,
ConnectionId,
ServerMute,
ServerDeaf,
Flags,
ViewerStreamKey
),
SessionId = pending_get_binary(PendingData, session_id),
Member = pending_get_map(PendingData, member),
VoiceState1 = maybe_attach_session_id(VoiceState0, SessionId),
maybe_attach_member(VoiceState1, Member)
end.
pending_get_value(PendingData, Key) ->
case maps:get(Key, PendingData, undefined) of
undefined ->
BinKey = atom_to_binary(Key, utf8),
maps:get(BinKey, PendingData, undefined);
Value ->
Value
end.
pending_get_integer(PendingData, Key) ->
case pending_get_value(PendingData, Key) of
undefined -> undefined;
Value -> type_conv:to_integer(Value)
end.
pending_get_boolean(PendingData, Key) ->
case pending_get_value(PendingData, Key) of
true -> true;
false -> false;
_ -> false
end.
pending_get_binary(PendingData, Key) ->
case pending_get_value(PendingData, Key) of
undefined -> undefined;
Value -> type_conv:to_binary(Value)
end.
pending_get_map(PendingData, Key) ->
case pending_get_value(PendingData, Key) of
Map when is_map(Map) -> Map;
_ -> #{}
end.
resolve_guild_identity(State) ->
Data = guild_data(State),
DataGuildIdBin = maps:get(<<"id">>, Data, undefined),
StateGuildId = map_utils:get_integer(State, id, undefined),
GuildMeta = map_utils:ensure_map(maps:get(<<"guild">>, Data, #{})),
GuildMetaIdBin = maps:get(<<"id">>, GuildMeta, undefined),
resolve_guild_id_priority([
{DataGuildIdBin, fun normalize_guild_id/1},
{StateGuildId, fun normalize_guild_id/1},
{GuildMetaIdBin, fun normalize_guild_id/1}
]).
resolve_guild_id_priority([]) ->
logger:error("[guild_voice_connection] Missing guild id in state"),
{error, voice_guild_id_missing};
resolve_guild_id_priority([{undefined, _} | Rest]) ->
resolve_guild_id_priority(Rest);
resolve_guild_id_priority([{Value, NormalizeFun} | _]) ->
NormalizeFun(Value).
normalize_guild_id(Value) ->
case type_conv:to_integer(Value) of
undefined ->
?LOG_INVALID_GUILD_ID(Value),
{error, voice_invalid_guild_id};
Int ->
{ok, Int, guild_id_binary(Value, Int)}
end.
guild_id_binary(Value, Int) ->
case type_conv:to_binary(Value) of
undefined -> integer_to_binary(Int);
Bin -> Bin
end.
maybe_broadcast_voice_server_update(undefined, _GuildId, _Token, _Endpoint, _ConnectionId, _State) ->
ok;
maybe_broadcast_voice_server_update(null, _GuildId, _Token, _Endpoint, _ConnectionId, _State) ->
ok;
maybe_broadcast_voice_server_update(SessionId, GuildId, Token, Endpoint, ConnectionId, State) ->
guild_voice_broadcast:broadcast_voice_server_update_to_session(
GuildId, SessionId, Token, Endpoint, ConnectionId, State
).
guild_data(State) ->
map_utils:ensure_map(maps:get(data, State, #{})).
confirm_voice_connection_from_livekit(Request, State) ->
ConnectionId = maps:get(connection_id, Request, undefined),
logger:info(
"[guild_voice_connection] confirm_voice_connection_from_livekit connection_id=~p pending_count=~p",
[ConnectionId, maps:size(pending_voice_connections(State))]
),
case ConnectionId of
undefined ->
gateway_errors:error(voice_missing_connection_id);
_ ->
PendingConnections = pending_voice_connections(State),
case maps:get(ConnectionId, PendingConnections, undefined) of
undefined ->
logger:warning(
"[guild_voice_connection] confirm_voice_connection_from_livekit missing pending connection_id=~p",
[ConnectionId]
),
gateway_errors:error(voice_connection_not_found);
PendingData ->
logger:info(
"[guild_voice_connection] Found pending connection_id=~p for guild=~p",
[ConnectionId, map_utils:get_integer(State, id, 0)]
),
VoiceStates = voice_state_utils:voice_states(State),
VoiceState = resolve_voice_state_from_pending(
ConnectionId, PendingData, State, VoiceStates
),
NewPendingConnections = maps:remove(ConnectionId, PendingConnections),
StateWithoutPending = maps:put(
pending_voice_connections, NewPendingConnections, State
),
case VoiceState of
undefined ->
logger:warning(
"[guild_voice_connection] Missing voice_state for confirmed connection ~p",
[ConnectionId]
),
{reply, #{success => true}, StateWithoutPending};
_ ->
UpdatedVoiceStates = maps:put(ConnectionId, VoiceState, VoiceStates),
StateWithVoiceStates = maps:put(
voice_states, UpdatedVoiceStates, StateWithoutPending
),
ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
UserId = maps:get(<<"user_id">>, VoiceState, <<"unknown">>),
GuildId = maps:get(id, StateWithVoiceStates, 0),
Sessions = maps:get(sessions, StateWithVoiceStates, #{}),
logger:info(
"[guild_voice_connection] confirm_voice_connection_from_livekit: "
"guild_id=~p user_id=~p channel_id=~p connection_id=~p sessions_count=~p",
[GuildId, UserId, ChannelIdBin, ConnectionId, maps:size(Sessions)]
),
guild_voice_broadcast:broadcast_voice_state_update(
VoiceState, StateWithVoiceStates, ChannelIdBin
),
{reply, #{success => true}, StateWithVoiceStates}
end
end
end.
-spec request_voice_token(integer(), integer(), integer(), map()) ->
{ok, map()} | {error, term()}.
request_voice_token(GuildId, ChannelId, UserId, VoicePermissions) ->
Req = voice_utils:build_voice_token_rpc_request(
GuildId, ChannelId, UserId, null, null, null, VoicePermissions
),
case rpc_client:call(Req) of
{ok, Data} ->
{ok, #{
token => maps:get(<<"token">>, Data),
endpoint => maps:get(<<"endpoint">>, Data),
connection_id => maps:get(<<"connectionId">>, Data)
}};
{error, Reason} ->
logger:error("[guild_voice_connection] RPC request failed: ~p", [Reason]),
{error, Reason}
end.
-spec pending_voice_connections(guild_state()) -> pending_voice_connections().
pending_voice_connections(State) ->
case maps:get(pending_voice_connections, State, undefined) of
Map when is_map(Map) -> Map;
_ -> #{}
end.
-ifdef(TEST).
build_context_normalizes_fields_test() ->
Request = #{
user_id => <<"42">>,
channel_id => <<"99">>,
connection_id => <<"conn">>,
self_mute => true,
self_deaf => <<"nope">>,
self_video => true,
self_stream => false,
is_mobile => <<"yes">>
},
Context = build_context(Request),
?assertEqual(42, maps:get(user_id, Context)),
?assertEqual(99, maps:get(channel_id, Context)),
?assertEqual(<<"conn">>, maps:get(connection_id, Context)),
?assertEqual(true, maps:get(self_mute, Context)),
?assertEqual(false, maps:get(self_deaf, Context)),
?assertEqual(true, maps:get(self_video, Context)),
?assertEqual(false, maps:get(self_stream, Context)),
?assertEqual(false, maps:get(is_mobile, Context)).
resolve_guild_identity_prefers_data_test() ->
State = #{
id => 7,
data => #{
<<"id">> => <<"555">>,
<<"guild">> => #{<<"id">> => <<"111">>}
}
},
?assertMatch({ok, 555, <<"555">>}, resolve_guild_identity(State)).
normalize_guild_id_invalid_test() ->
?assertMatch({error, voice_invalid_guild_id}, normalize_guild_id(foo)).
voice_state_update_invalid_user_id_test() ->
{error, validation_error, voice_invalid_user_id} =
voice_state_update(#{channel_id => null}, #{}).
-endif.

View File

@@ -0,0 +1,319 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_voice_disconnect).
-export([handle_voice_disconnect/5]).
-export([force_disconnect_participant/4]).
-export([disconnect_voice_user/2]).
-export([disconnect_voice_user_if_in_channel/2]).
-export([disconnect_all_voice_users_in_channel/2]).
-export([cleanup_virtual_channel_access_for_user/2]).
-type guild_state() :: map().
-type voice_state() :: map().
-type voice_state_map() :: #{binary() => voice_state()}.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec handle_voice_disconnect(
binary() | undefined,
term(),
integer(),
voice_state_map() | term(),
guild_state()
) -> {reply, map(), guild_state()}.
handle_voice_disconnect(undefined, _SessionId, _UserId, _VoiceStates, State) ->
{reply, gateway_errors:error(voice_missing_connection_id), State};
handle_voice_disconnect(ConnectionId, _SessionId, UserId, VoiceStates0, State) ->
VoiceStates = voice_state_utils:ensure_voice_states(VoiceStates0),
case maps:get(ConnectionId, VoiceStates, undefined) of
undefined ->
{reply, #{success => true}, State};
OldVoiceState ->
case guild_voice_state:user_matches_voice_state(OldVoiceState, UserId) of
false ->
{reply, gateway_errors:error(voice_user_mismatch), State};
true ->
case
{
voice_state_utils:voice_state_guild_id(OldVoiceState),
voice_state_utils:voice_state_channel_id(OldVoiceState)
}
of
{undefined, _} ->
{reply, gateway_errors:error(voice_invalid_state), State};
{_, undefined} ->
{reply, gateway_errors:error(voice_invalid_state), State};
{GuildId, ChannelId} ->
maybe_force_disconnect(GuildId, ChannelId, UserId, ConnectionId, State),
NewVoiceStates = maps:remove(ConnectionId, VoiceStates),
NewState = maps:put(voice_states, NewVoiceStates, State),
voice_state_utils:broadcast_disconnects(
#{ConnectionId => OldVoiceState}, NewState
),
FinalState = cleanup_virtual_channel_access_for_user(UserId, NewState),
{reply, #{success => true}, FinalState}
end
end
end.
-spec disconnect_voice_user(map(), guild_state()) -> {reply, map(), guild_state()}.
disconnect_voice_user(#{user_id := UserId} = Request, State) ->
ConnectionId = maps:get(connection_id, Request, null),
VoiceStates = voice_state_utils:voice_states(State),
case ConnectionId of
null ->
UserVoiceStates = voice_state_utils:filter_voice_states(VoiceStates, fun(_, V) ->
voice_state_utils:voice_state_user_id(V) =:= UserId
end),
case maps:size(UserVoiceStates) of
0 ->
{reply, #{success => true}, State};
_ ->
NewVoiceStates = voice_state_utils:drop_voice_states(
UserVoiceStates, VoiceStates
),
NewState = maps:put(voice_states, NewVoiceStates, State),
voice_state_utils:broadcast_disconnects(UserVoiceStates, NewState),
FinalState = cleanup_virtual_channel_access_for_user(UserId, NewState),
{reply, #{success => true}, FinalState}
end;
SpecificConnection ->
case maps:get(SpecificConnection, VoiceStates, undefined) of
undefined ->
{reply, #{success => true}, State};
VoiceState ->
case voice_state_utils:voice_state_user_id(VoiceState) of
undefined ->
{reply, gateway_errors:error(voice_invalid_state), State};
VoiceStateUserId when VoiceStateUserId =:= UserId ->
NewVoiceStates = maps:remove(SpecificConnection, VoiceStates),
NewState = maps:put(voice_states, NewVoiceStates, State),
voice_state_utils:broadcast_disconnects(
#{SpecificConnection => VoiceState}, NewState
),
FinalState = cleanup_virtual_channel_access_for_user(UserId, NewState),
{reply, #{success => true}, FinalState};
_ ->
{reply, gateway_errors:error(voice_user_mismatch), State}
end
end
end.
disconnect_voice_user_if_in_channel(
#{user_id := UserId, expected_channel_id := ExpectedChannelId} = Request,
State
) ->
ConnectionId = maps:get(connection_id, Request, undefined),
VoiceStates = voice_state_utils:voice_states(State),
case ConnectionId of
undefined ->
UserVoiceStates = voice_state_utils:filter_voice_states(VoiceStates, fun(_, V) ->
voice_state_utils:voice_state_user_id(V) =:= UserId andalso
voice_state_utils:voice_state_channel_id(V) =:= ExpectedChannelId
end),
case maps:size(UserVoiceStates) of
0 ->
{reply,
#{
success => true,
ignored => true,
reason => <<"not_in_expected_channel">>
},
State};
_ ->
NewVoiceStates = voice_state_utils:drop_voice_states(
UserVoiceStates, VoiceStates
),
NewState = maps:put(voice_states, NewVoiceStates, State),
voice_state_utils:broadcast_disconnects(UserVoiceStates, NewState),
{reply, #{success => true}, NewState}
end;
ConnId ->
case maps:get(ConnId, VoiceStates, undefined) of
undefined ->
{reply,
#{success => true, ignored => true, reason => <<"connection_not_found">>},
State};
VoiceState ->
case
{
voice_state_utils:voice_state_user_id(VoiceState),
voice_state_utils:voice_state_channel_id(VoiceState)
}
of
{UserId, ExpectedChannelId} ->
NewVoiceStates = maps:remove(ConnId, VoiceStates),
NewState = maps:put(voice_states, NewVoiceStates, State),
voice_state_utils:broadcast_disconnects(
#{ConnId => VoiceState}, NewState
),
{reply, #{success => true}, NewState};
_ ->
{reply,
#{
success => true,
ignored => true,
reason => <<"user_or_channel_mismatch">>
},
State}
end
end
end.
-spec disconnect_all_voice_users_in_channel(map(), guild_state()) -> {reply, map(), guild_state()}.
disconnect_all_voice_users_in_channel(#{channel_id := ChannelId}, State) ->
VoiceStates = voice_state_utils:voice_states(State),
ChannelVoiceStates = voice_state_utils:filter_voice_states(VoiceStates, fun(_, V) ->
voice_state_utils:voice_state_channel_id(V) =:= ChannelId
end),
case maps:size(ChannelVoiceStates) of
0 ->
{reply, #{success => true, disconnected_count => 0}, State};
Count ->
NewVoiceStates = voice_state_utils:drop_voice_states(ChannelVoiceStates, VoiceStates),
NewState = maps:put(voice_states, NewVoiceStates, State),
voice_state_utils:broadcast_disconnects(ChannelVoiceStates, NewState),
{reply, #{success => true, disconnected_count => Count}, NewState}
end.
-spec force_disconnect_participant(integer(), integer(), integer(), binary()) ->
{ok, map()} | {error, term()}.
force_disconnect_participant(GuildId, ChannelId, UserId, ConnectionId) ->
Req = voice_utils:build_force_disconnect_rpc_request(GuildId, ChannelId, UserId, ConnectionId),
case rpc_client:call(Req) of
{ok, _Data} ->
logger:debug(
"[guild_voice_disconnect] Force disconnected participant via RPC ~p",
[
[
{guildId, GuildId},
{channelId, ChannelId},
{userId, UserId},
{connectionId, ConnectionId}
]
]
),
{ok, #{success => true}};
{error, Reason} ->
logger:error(
"[guild_voice_disconnect] Failed to force disconnect participant via RPC ~p",
[
[
{guildId, GuildId},
{channelId, ChannelId},
{userId, UserId},
{connectionId, ConnectionId},
{error, Reason}
]
]
),
{error, Reason}
end.
cleanup_virtual_channel_access_for_user(UserId, State) ->
VoiceStates = voice_state_utils:voice_states(State),
HasVoiceConnection = maps:fold(
fun(_ConnId, VoiceState, Acc) ->
case Acc of
true -> true;
false -> voice_state_utils:voice_state_user_id(VoiceState) =:= UserId
end
end,
false,
VoiceStates
),
case HasVoiceConnection of
true ->
State;
false ->
VirtualChannels = guild_virtual_channel_access:get_virtual_channels_for_user(
UserId, State
),
lists:foldl(
fun(ChannelId, AccState) ->
Member = guild_permissions:find_member_by_user_id(UserId, AccState),
case Member of
undefined ->
AccState;
_ ->
HasViewPermission = guild_permissions:can_view_channel_by_permissions(
UserId, ChannelId, Member, AccState
),
case HasViewPermission of
true ->
guild_virtual_channel_access:remove_virtual_access(
UserId, ChannelId, AccState
);
false ->
guild_virtual_channel_access:dispatch_channel_visibility_change(
UserId, ChannelId, remove, AccState
),
guild_virtual_channel_access:remove_virtual_access(
UserId, ChannelId, AccState
)
end
end
end,
State,
VirtualChannels
)
end.
maybe_force_disconnect(GuildId, ChannelId, UserId, ConnectionId, State) ->
case maps:get(test_force_disconnect_fun, State, undefined) of
Fun when is_function(Fun, 4) ->
Fun(GuildId, ChannelId, UserId, ConnectionId);
_ ->
force_disconnect_participant(GuildId, ChannelId, UserId, ConnectionId)
end.
-ifdef(TEST).
disconnect_voice_user_removes_all_connections_test() ->
VoiceStates = #{
<<"a">> => voice_state_fixture(5, 10, 20),
<<"b">> => voice_state_fixture(5, 10, 21)
},
State = #{voice_states => VoiceStates},
{reply, #{success := true}, #{voice_states := #{}}} =
disconnect_voice_user(#{user_id => 5, connection_id => null}, State).
handle_voice_disconnect_invalid_state_test() ->
VoiceState = #{<<"user_id">> => <<"5">>},
VoiceStates = #{<<"conn">> => VoiceState},
State = #{voice_states => VoiceStates},
{reply, {error, validation_error, _}, _} =
handle_voice_disconnect(<<"conn">>, undefined, 5, VoiceStates, State).
disconnect_voice_user_if_in_channel_ignored_test() ->
VoiceStates = #{},
State = #{voice_states => VoiceStates},
{reply, #{ignored := true}, _} =
disconnect_voice_user_if_in_channel(#{user_id => 5, expected_channel_id => 99}, State).
voice_state_fixture(UserId, GuildId, ChannelId) ->
#{
<<"user_id">> => integer_to_binary(UserId),
<<"guild_id">> => integer_to_binary(GuildId),
<<"channel_id">> => integer_to_binary(ChannelId)
}.
-endif.

View File

@@ -0,0 +1,278 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_voice_member).
-export([update_member_voice/2]).
-export([find_member_by_user_id/2]).
-export([find_channel_by_id/2]).
-type guild_state() :: map().
-type guild_reply(T) :: {reply, T, guild_state()}.
-type member() :: map().
-type voice_state() :: map().
-type request() :: #{
user_id := integer(),
mute := boolean(),
deaf := boolean()
}.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec update_member_voice(request(), guild_state()) -> guild_reply(map()).
update_member_voice(Request, State) ->
#{user_id := UserId, mute := Mute, deaf := Deaf} = Request,
VoiceStates = voice_state_utils:voice_states(State),
GuildId = map_utils:get_integer(State, id, 0),
case find_member_by_user_id(UserId, State) of
undefined ->
{reply, gateway_errors:error(voice_member_not_found), State};
Member ->
UpdatedMember = set_member_voice_flags(Member, Mute, Deaf),
StateWithUpdatedMember = store_member(UpdatedMember, State),
UserVoiceStates = user_voice_states(UserId, VoiceStates),
case maps:size(UserVoiceStates) of
0 ->
{reply, #{success => true}, StateWithUpdatedMember};
_ ->
maybe_enforce_voice_states(
GuildId, UserId, Mute, Deaf, UserVoiceStates, State
),
{NewVoiceStates, UpdatedStates} =
update_voice_states(UserVoiceStates, VoiceStates, Mute, Deaf),
FinalState = maps:put(voice_states, NewVoiceStates, StateWithUpdatedMember),
broadcast_voice_state_updates(UpdatedStates, FinalState),
{reply, #{success => true}, FinalState}
end
end.
find_member_by_user_id(UserId, State) ->
guild_permissions:find_member_by_user_id(UserId, State).
find_channel_by_id(ChannelId, State) ->
guild_permissions:find_channel_by_id(ChannelId, State).
enforce_participant_state_in_livekit(GuildId, ChannelId, UserId, Mute, Deaf) ->
Req = voice_utils:build_update_participant_rpc_request(GuildId, ChannelId, UserId, Mute, Deaf),
case rpc_client:call(Req) of
{ok, _Data} ->
logger:debug(
"[guild_voice_member] Enforced participant state in LiveKit ~p",
[
[
{guildId, GuildId},
{channelId, ChannelId},
{userId, UserId},
{mute, Mute},
{deaf, Deaf}
]
]
),
ok;
{error, Reason} ->
logger:warning(
"[guild_voice_member] Failed to enforce participant state in LiveKit ~p",
[
[
{guildId, GuildId},
{channelId, ChannelId},
{userId, UserId},
{mute, Mute},
{deaf, Deaf},
{error, Reason}
]
]
),
ok
end.
-spec guild_data(guild_state()) -> map().
guild_data(State) ->
map_utils:ensure_map(map_utils:get_safe(State, data, #{})).
-spec guild_members(guild_state()) -> [member()].
guild_members(State) ->
map_utils:ensure_list(maps:get(<<"members">>, guild_data(State), [])).
-spec member_user_id(member()) -> integer() | undefined.
member_user_id(Member) when is_map(Member) ->
User = map_utils:ensure_map(maps:get(<<"user">>, Member, #{})),
map_utils:get_integer(User, <<"id">>, undefined);
member_user_id(_) ->
undefined.
-spec set_member_voice_flags(member(), boolean(), boolean()) -> member().
set_member_voice_flags(Member, Mute, Deaf) ->
Member#{<<"mute">> => Mute, <<"deaf">> => Deaf}.
-spec store_member(member(), guild_state()) -> guild_state().
store_member(Member, State) ->
case member_user_id(Member) of
undefined ->
State;
TargetId ->
Data = guild_data(State),
Members = guild_members(State),
UpdatedMembers = lists:map(
fun(Current) ->
case member_user_id(Current) of
TargetId -> Member;
_ -> Current
end
end,
Members
),
UpdatedData = maps:put(<<"members">>, UpdatedMembers, Data),
maps:put(data, UpdatedData, State)
end.
-spec user_voice_states(integer(), map()) -> map().
user_voice_states(UserId, VoiceStates) when is_integer(UserId), is_map(VoiceStates) ->
maps:filter(
fun(_ConnId, VoiceState) ->
voice_state_utils:voice_state_user_id(VoiceState) =:= UserId
end,
VoiceStates
);
user_voice_states(_UserId, _VoiceStates) ->
#{}.
-spec update_voice_states(map(), map(), boolean(), boolean()) -> {map(), [voice_state()]}.
update_voice_states(UserVoiceStates, VoiceStates, Mute, Deaf) ->
maps:fold(
fun(ConnId, VoiceState, {AccVoiceStates, AccUpdated}) ->
UpdatedVoiceState = update_voice_state_flags(VoiceState, Mute, Deaf),
{maps:put(ConnId, UpdatedVoiceState, AccVoiceStates), [UpdatedVoiceState | AccUpdated]}
end,
{VoiceStates, []},
UserVoiceStates
).
-spec update_voice_state_flags(voice_state(), boolean(), boolean()) -> voice_state().
update_voice_state_flags(VoiceState, Mute, Deaf) ->
OldVersion = maps:get(<<"version">>, VoiceState, 0),
VoiceState#{<<"mute">> => Mute, <<"deaf">> => Deaf, <<"version">> => OldVersion + 1}.
-spec maybe_enforce_voice_states(integer(), integer(), boolean(), boolean(), map(), guild_state()) ->
ok.
maybe_enforce_voice_states(GuildId, UserId, Mute, Deaf, VoiceStates, State) ->
maps:foreach(
fun(_ConnId, VoiceState) ->
case voice_state_utils:voice_state_channel_id(VoiceState) of
ChannelId when is_integer(ChannelId) ->
dispatch_livekit_enforcement(GuildId, ChannelId, UserId, Mute, Deaf, State);
_ ->
ok
end
end,
VoiceStates
).
-spec dispatch_livekit_enforcement(
integer(), integer(), integer(), boolean(), boolean(), guild_state()
) -> ok.
dispatch_livekit_enforcement(GuildId, ChannelId, UserId, Mute, Deaf, State) ->
case maps:get(test_livekit_fun, State, undefined) of
Fun when is_function(Fun, 5) ->
Fun(GuildId, ChannelId, UserId, Mute, Deaf);
_ ->
spawn(fun() ->
enforce_participant_state_in_livekit(GuildId, ChannelId, UserId, Mute, Deaf)
end)
end.
-spec broadcast_voice_state_updates([voice_state()], guild_state()) -> ok.
broadcast_voice_state_updates([], _State) ->
ok;
broadcast_voice_state_updates(UpdatedStates, State) ->
lists:foreach(
fun(UpdatedVoiceState) ->
ChannelIdBin = maps:get(<<"channel_id">>, UpdatedVoiceState, null),
guild_voice_broadcast:broadcast_voice_state_update(
UpdatedVoiceState, State, ChannelIdBin
)
end,
UpdatedStates
).
-ifdef(TEST).
update_member_voice_updates_member_flags_test() ->
State = voice_member_test_state(#{}),
Request = #{user_id => 10, mute => true, deaf => false},
{reply, #{success := true}, UpdatedState} = update_member_voice(Request, State),
Member = find_member_by_user_id(10, UpdatedState),
?assertEqual(true, maps:get(<<"mute">>, Member)),
?assertEqual(false, maps:get(<<"deaf">>, Member)).
update_member_voice_updates_voice_states_test() ->
Self = self(),
VoiceState = voice_state_fixture(10, 500),
TestFun = fun(GuildId, ChannelId, UserId, Mute, Deaf) ->
Self ! {enforced, GuildId, ChannelId, UserId, Mute, Deaf}
end,
State = voice_member_test_state(#{
voice_states => #{<<"conn">> => VoiceState},
test_livekit_fun => TestFun
}),
Request = #{user_id => 10, mute => true, deaf => true},
{reply, #{success := true}, UpdatedState} = update_member_voice(Request, State),
UpdatedVoiceStates = maps:get(voice_states, UpdatedState),
UpdatedVoiceState = maps:get(<<"conn">>, UpdatedVoiceStates),
?assertEqual(true, maps:get(<<"mute">>, UpdatedVoiceState)),
?assertEqual(true, maps:get(<<"deaf">>, UpdatedVoiceState)),
?assertEqual(1, maps:get(<<"version">>, UpdatedVoiceState)),
receive
{enforced, 42, 500, 10, true, true} -> ok
after 100 ->
?assert(false)
end.
voice_member_test_state(Overrides) ->
BaseData = #{
<<"members">> => [member_fixture(10)]
},
BaseState = #{
id => 42,
data => BaseData,
voice_states => #{}
},
maps:merge(BaseState, Overrides).
member_fixture(UserId) ->
#{
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
<<"mute">> => false,
<<"deaf">> => false
}.
voice_state_fixture(UserId, ChannelId) ->
#{
<<"user_id">> => integer_to_binary(UserId),
<<"channel_id">> => integer_to_binary(ChannelId),
<<"connection_id">> => <<"test-conn">>,
<<"mute">> => false,
<<"deaf">> => false,
<<"version">> => 0,
<<"member">> => member_fixture(UserId)
}.
-endif.

View File

@@ -0,0 +1,378 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_voice_move).
-export([move_member/2]).
-export([send_voice_server_update_for_move/5]).
-export([send_voice_server_updates_for_move/4]).
-type guild_state() :: map().
-type move_request() :: #{
user_id := integer(),
moderator_id := integer(),
channel_id := integer() | null,
connection_id => binary() | null,
mute := boolean(),
deaf := boolean()
}.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec move_member(move_request(), guild_state()) -> {reply, map(), guild_state()}.
move_member(Request, State) ->
#{
user_id := UserId,
moderator_id := ModeratorId,
channel_id := ChannelIdRaw
} = Request,
ConnectionId = maps:get(connection_id, Request, null),
ChannelId = normalize_channel_id(ChannelIdRaw),
VoiceStates = voice_state_utils:voice_states(State),
UserVoiceStates = find_user_voice_states(UserId, VoiceStates),
case maps:size(UserVoiceStates) of
0 ->
{reply, gateway_errors:error(voice_user_not_in_voice), State};
_ ->
ConnectionsToMove = select_connections_to_move(
ConnectionId, UserId, VoiceStates, UserVoiceStates
),
handle_move(
ConnectionsToMove, ChannelId, UserId, ModeratorId, ConnectionId, VoiceStates, State
)
end.
find_user_voice_states(UserId, VoiceStates) ->
maps:filter(
fun(_ConnId, VoiceState) ->
voice_state_utils:voice_state_user_id(VoiceState) =:= UserId
end,
VoiceStates
).
select_connections_to_move(null, _UserId, _VoiceStates, UserVoiceStates) ->
UserVoiceStates;
select_connections_to_move(ConnectionId, UserId, VoiceStates, _UserVoiceStates) ->
case maps:get(ConnectionId, VoiceStates, undefined) of
undefined ->
#{};
VoiceState ->
case voice_state_utils:voice_state_user_id(VoiceState) of
UserId ->
#{ConnectionId => VoiceState};
_ ->
#{}
end
end.
handle_move(ConnectionsToMove, ChannelId, UserId, ModeratorId, ConnectionId, VoiceStates, State) ->
logger:info(
"[guild_voice_move] handle_move user_id=~p moderator_id=~p channel_id=~p connection_id=~p connections=~p",
[UserId, ModeratorId, ChannelId, ConnectionId, maps:keys(ConnectionsToMove)]
),
case maps:size(ConnectionsToMove) of
0 ->
Error =
case ConnectionId of
null -> gateway_errors:error(voice_user_not_in_voice);
_ -> gateway_errors:error(voice_connection_not_found)
end,
{reply, Error, State};
_ ->
case ChannelId of
null ->
handle_disconnect_move(ConnectionsToMove, UserId, VoiceStates, State);
ChannelIdValue ->
handle_channel_move(
ConnectionsToMove, ChannelIdValue, UserId, ModeratorId, VoiceStates, State
)
end
end.
handle_disconnect_move(ConnectionsToMove, UserId, VoiceStates, State) ->
NewVoiceStates = maps:fold(
fun(ConnId, _VoiceState, Acc) -> maps:remove(ConnId, Acc) end,
VoiceStates,
ConnectionsToMove
),
NewState = maps:put(voice_states, NewVoiceStates, State),
maps:foreach(
fun(_ConnId, VoiceState) ->
OldChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
DisconnectVoiceState = maps:put(<<"channel_id">>, null, VoiceState),
guild_voice_broadcast:broadcast_voice_state_update(
DisconnectVoiceState, NewState, OldChannelIdBin
)
end,
ConnectionsToMove
),
{reply, #{success => true, user_id => UserId, connections_moved => ConnectionsToMove},
NewState}.
handle_channel_move(ConnectionsToMove, ChannelIdValue, UserId, ModeratorId, VoiceStates, State) ->
logger:info(
"[guild_voice_move] handle_channel_move user_id=~p moderator_id=~p target_channel_id=~p connections=~p",
[UserId, ModeratorId, ChannelIdValue, maps:keys(ConnectionsToMove)]
),
Channel = guild_voice_member:find_channel_by_id(ChannelIdValue, State),
case Channel of
undefined ->
{reply, gateway_errors:error(voice_channel_not_found), State};
_ ->
ChannelType = maps:get(<<"type">>, Channel, 0),
case ChannelType of
2 ->
check_move_permissions_and_execute(
ConnectionsToMove, ChannelIdValue, UserId, ModeratorId, VoiceStates, State
);
_ ->
{reply, gateway_errors:error(voice_channel_not_voice), State}
end
end.
check_move_permissions_and_execute(
ConnectionsToMove, ChannelIdValue, _UserId, ModeratorId, VoiceStates, State
) ->
ViewPerm = constants:view_channel_permission(),
ConnectPerm = constants:connect_permission(),
ModPerms = guild_permissions:get_member_permissions(ModeratorId, ChannelIdValue, State),
ModHasConnect = (ModPerms band ConnectPerm) =:= ConnectPerm,
ModHasView = (ModPerms band ViewPerm) =:= ViewPerm,
case ModHasConnect andalso ModHasView of
false ->
{reply, gateway_errors:error(voice_moderator_missing_connect), State};
true ->
execute_move(ConnectionsToMove, VoiceStates, State)
end.
execute_move(ConnectionsToMove, VoiceStates, State) ->
NewVoiceStates = maps:fold(
fun(ConnId, _VoiceState, Acc) -> maps:remove(ConnId, Acc) end,
VoiceStates,
ConnectionsToMove
),
StateAfterDisconnect = maps:put(voice_states, NewVoiceStates, State),
maps:foreach(
fun(_ConnId, VoiceState) ->
OldChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
DisconnectVoiceState = maps:put(<<"channel_id">>, null, VoiceState),
guild_voice_broadcast:broadcast_voice_state_update(
DisconnectVoiceState, StateAfterDisconnect, OldChannelIdBin
)
end,
ConnectionsToMove
),
SessionData = extract_session_data(ConnectionsToMove),
{reply,
#{
success => true,
needs_token => true,
session_data => SessionData,
connections_to_move => ConnectionsToMove
},
StateAfterDisconnect}.
extract_session_data(ConnectionsToMove) ->
{_ConnectionIds, SessionData} = maps:fold(
fun(ConnId, VoiceState, {AccConnIds, AccSessionData}) ->
SessionInfo = guild_voice_state:extract_session_info_from_voice_state(
ConnId, VoiceState
),
{[ConnId | AccConnIds], [SessionInfo | AccSessionData]}
end,
{[], []},
ConnectionsToMove
),
SessionData.
-spec normalize_channel_id(term()) -> integer() | null.
normalize_channel_id(null) ->
null;
normalize_channel_id(Value) ->
case type_conv:to_integer(Value) of
undefined -> null;
Int -> Int
end.
-spec member_user_id(map()) -> integer() | undefined.
member_user_id(Member) ->
User = map_utils:ensure_map(maps:get(<<"user">>, map_utils:ensure_map(Member), #{})),
map_utils:get_integer(User, <<"id">>, undefined).
-ifdef(TEST).
move_member_user_not_in_voice_test() ->
Request = #{
user_id => 10,
moderator_id => 20,
channel_id => null,
mute => false,
deaf => false
},
State = test_state(#{}),
{reply, {error, not_found, voice_user_not_in_voice}, _} = move_member(Request, State).
find_user_voice_states_filters_test() ->
VoiceStates = #{
<<"conn-a">> => voice_state_fixture(10, 100, <<"conn-a">>),
<<"conn-b">> => voice_state_fixture(11, 101, <<"conn-b">>)
},
Result = find_user_voice_states(10, VoiceStates),
?assertEqual(#{<<"conn-a">> => maps:get(<<"conn-a">>, VoiceStates)}, Result).
select_connections_to_move_specific_connection_test() ->
VoiceStates = #{
<<"conn-a">> => voice_state_fixture(10, 100, <<"conn-a">>),
<<"conn-b">> => voice_state_fixture(11, 101, <<"conn-b">>)
},
Selected = select_connections_to_move(<<"conn-b">>, 11, VoiceStates, #{}),
?assertEqual(#{<<"conn-b">> => maps:get(<<"conn-b">>, VoiceStates)}, Selected),
?assertEqual(#{}, select_connections_to_move(<<"conn-b">>, 10, VoiceStates, #{})).
test_state(VoiceStates) ->
#{
id => 1,
data => #{
<<"members">> => [],
<<"channels">> => []
},
voice_states => VoiceStates
}.
voice_state_fixture(UserId, ChannelId, ConnId) ->
#{
<<"user_id">> => integer_to_binary(UserId),
<<"channel_id">> => integer_to_binary(ChannelId),
<<"connection_id">> => ConnId,
<<"member">> => #{
<<"user">> => #{<<"id">> => integer_to_binary(UserId)}
}
}.
-endif.
send_voice_server_update_for_move(GuildId, ChannelId, UserId, SessionId, GuildPid) ->
case SessionId of
undefined ->
ok;
_ ->
case gen_server:call(GuildPid, {get_sessions}, 10000) of
State when is_map(State) ->
VoicePermissions = voice_utils:compute_voice_permissions(
UserId, ChannelId, State
),
case
guild_voice_connection:request_voice_token(
GuildId, ChannelId, UserId, VoicePermissions
)
of
{ok, TokenData} ->
Token = maps:get(token, TokenData),
Endpoint = maps:get(endpoint, TokenData),
ConnectionId = maps:get(connection_id, TokenData),
guild_voice_broadcast:broadcast_voice_server_update_to_session(
GuildId, SessionId, Token, Endpoint, ConnectionId, State
);
{error, _Reason} ->
ok
end;
_ ->
ok
end
end.
send_voice_server_updates_for_move(GuildId, ChannelId, SessionDataList, GuildPid) ->
lists:foreach(
fun(SessionInfo) ->
send_single_voice_server_update(GuildId, ChannelId, SessionInfo, GuildPid)
end,
SessionDataList
).
send_single_voice_server_update(GuildId, ChannelId, SessionInfo, GuildPid) ->
SessionId = maps:get(session_id, SessionInfo),
SelfMute = maps:get(self_mute, SessionInfo),
SelfDeaf = maps:get(self_deaf, SessionInfo),
SelfVideo = maps:get(self_video, SessionInfo),
SelfStream = maps:get(self_stream, SessionInfo),
IsMobile = maps:get(is_mobile, SessionInfo),
Member = maps:get(member, SessionInfo),
ServerMute = maps:get(<<"mute">>, Member, false),
ServerDeaf = maps:get(<<"deaf">>, Member, false),
case member_user_id(Member) of
undefined ->
logger:warning(
"[guild_voice_move] Missing user_id in member while sending voice server update: ~p",
[SessionInfo]
),
ok;
UserId ->
case gen_server:call(GuildPid, {get_sessions}, 10000) of
StateData when is_map(StateData) ->
VoicePermissions = voice_utils:compute_voice_permissions(
UserId, ChannelId, StateData
),
case
guild_voice_connection:request_voice_token(
GuildId, ChannelId, UserId, VoicePermissions
)
of
{ok, TokenData} ->
Token = maps:get(token, TokenData),
Endpoint = maps:get(endpoint, TokenData),
NewConnectionId = maps:get(connection_id, TokenData),
PendingMetadata = #{
<<"user_id">> => UserId,
<<"guild_id">> => GuildId,
<<"channel_id">> => ChannelId,
<<"connection_id">> => NewConnectionId,
<<"session_id">> => SessionId,
<<"self_mute">> => SelfMute,
<<"self_deaf">> => SelfDeaf,
<<"self_video">> => SelfVideo,
<<"self_stream">> => SelfStream,
<<"is_mobile">> => IsMobile,
<<"server_mute">> => ServerMute,
<<"server_deaf">> => ServerDeaf,
<<"member">> => Member
},
gen_server:cast(
GuildPid,
{store_pending_connection, NewConnectionId, PendingMetadata}
),
guild_voice_broadcast:broadcast_voice_server_update_to_session(
GuildId, SessionId, Token, Endpoint, NewConnectionId, StateData
);
{error, _Reason} ->
ok
end;
_ ->
ok
end
end.

View File

@@ -0,0 +1,304 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_voice_permission_sync).
-export([
sync_user_voice_permissions/2,
sync_all_voice_permissions_for_channel/2,
maybe_sync_permissions_on_role_update/2,
maybe_sync_permissions_on_member_update/2
]).
-type guild_state() :: map().
-type voice_state() :: map().
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec sync_user_voice_permissions(integer(), guild_state()) -> ok.
sync_user_voice_permissions(UserId, State) ->
VoiceStates = voice_state_utils:voice_states(State),
GuildId = map_utils:get_integer(State, id, 0),
UserVoiceStates = maps:filter(
fun(_ConnId, VoiceState) ->
voice_state_utils:voice_state_user_id(VoiceState) =:= UserId
end,
VoiceStates
),
maps:foreach(
fun(_ConnId, VoiceState) ->
sync_voice_state_permissions(GuildId, UserId, VoiceState, State)
end,
UserVoiceStates
),
ok.
-spec sync_all_voice_permissions_for_channel(integer(), guild_state()) -> ok.
sync_all_voice_permissions_for_channel(ChannelId, State) ->
VoiceStates = voice_state_utils:voice_states(State),
GuildId = map_utils:get_integer(State, id, 0),
ChannelVoiceStates = maps:filter(
fun(_ConnId, VoiceState) ->
voice_state_utils:voice_state_channel_id(VoiceState) =:= ChannelId
end,
VoiceStates
),
maps:foreach(
fun(_ConnId, VoiceState) ->
UserId = voice_state_utils:voice_state_user_id(VoiceState),
case UserId of
undefined -> ok;
_ -> sync_voice_state_permissions(GuildId, UserId, VoiceState, State)
end
end,
ChannelVoiceStates
),
ok.
-spec maybe_sync_permissions_on_role_update(map(), guild_state()) -> ok.
maybe_sync_permissions_on_role_update(RoleUpdate, State) ->
RoleId = maps:get(<<"id">>, RoleUpdate, undefined),
case RoleId of
undefined ->
ok;
_ ->
OldPermissions = maps:get(<<"old_permissions">>, RoleUpdate, 0),
NewPermissions = maps:get(<<"permissions">>, RoleUpdate, 0),
AdminPerm = constants:administrator_permission(),
SpeakPerm = constants:speak_permission(),
StreamPerm = constants:stream_permission(),
VoicePerms = AdminPerm bor SpeakPerm bor StreamPerm,
OldVoicePerms = OldPermissions band VoicePerms,
NewVoicePerms = NewPermissions band VoicePerms,
case OldVoicePerms =/= NewVoicePerms of
true ->
sync_users_with_role(RoleId, State);
false ->
ok
end
end.
-spec maybe_sync_permissions_on_member_update(map(), guild_state()) -> ok.
maybe_sync_permissions_on_member_update(MemberUpdate, State) ->
UserId = get_member_user_id(MemberUpdate),
case UserId of
undefined ->
ok;
_ ->
OldRoles = maps:get(<<"old_roles">>, MemberUpdate, []),
NewRoles = maps:get(<<"roles">>, MemberUpdate, []),
case OldRoles =/= NewRoles of
true ->
sync_user_voice_permissions(UserId, State);
false ->
ok
end
end.
-spec sync_voice_state_permissions(integer(), integer(), voice_state(), guild_state()) -> ok.
sync_voice_state_permissions(GuildId, UserId, VoiceState, State) ->
ChannelId = voice_state_utils:voice_state_channel_id(VoiceState),
ConnectionId = maps:get(<<"connection_id">>, VoiceState, undefined),
case {ChannelId, ConnectionId} of
{undefined, _} ->
ok;
{_, undefined} ->
ok;
{ChId, ConnId} when is_integer(ChId), is_binary(ConnId) ->
VoicePermissions = voice_utils:compute_voice_permissions(UserId, ChId, State),
dispatch_permission_update(GuildId, ChId, UserId, ConnId, VoicePermissions, State)
end.
-spec dispatch_permission_update(integer(), integer(), integer(), binary(), map(), guild_state()) ->
ok.
dispatch_permission_update(GuildId, ChannelId, UserId, ConnectionId, VoicePermissions, State) ->
case maps:get(test_permission_sync_fun, State, undefined) of
Fun when is_function(Fun, 5) ->
Fun(GuildId, ChannelId, UserId, ConnectionId, VoicePermissions);
_ ->
spawn(fun() ->
enforce_voice_permissions_in_livekit(
GuildId, ChannelId, UserId, ConnectionId, VoicePermissions
)
end)
end.
-spec enforce_voice_permissions_in_livekit(
integer(), integer(), integer(), binary(), map()
) -> ok.
enforce_voice_permissions_in_livekit(GuildId, ChannelId, UserId, ConnectionId, VoicePermissions) ->
Req = voice_utils:build_update_participant_permissions_rpc_request(
GuildId, ChannelId, UserId, ConnectionId, VoicePermissions
),
case rpc_client:call(Req) of
{ok, _Data} ->
logger:debug(
"[guild_voice_permission_sync] Synced voice permissions ~p",
[
[
{guildId, GuildId},
{channelId, ChannelId},
{userId, UserId},
{connectionId, ConnectionId},
{permissions, VoicePermissions}
]
]
),
ok;
{error, Reason} ->
logger:warning(
"[guild_voice_permission_sync] Failed to sync voice permissions ~p",
[
[
{guildId, GuildId},
{channelId, ChannelId},
{userId, UserId},
{connectionId, ConnectionId},
{permissions, VoicePermissions},
{error, Reason}
]
]
),
ok
end.
-spec sync_users_with_role(binary() | integer(), guild_state()) -> ok.
sync_users_with_role(RoleId, State) ->
RoleIdBin = ensure_binary(RoleId),
VoiceStates = voice_state_utils:voice_states(State),
GuildId = map_utils:get_integer(State, id, 0),
maps:foreach(
fun(_ConnId, VoiceState) ->
UserId = voice_state_utils:voice_state_user_id(VoiceState),
case UserId of
undefined ->
ok;
_ ->
case user_has_role(UserId, RoleIdBin, State) of
true ->
sync_voice_state_permissions(GuildId, UserId, VoiceState, State);
false ->
ok
end
end
end,
VoiceStates
),
ok.
-spec user_has_role(integer(), binary(), guild_state()) -> boolean().
user_has_role(UserId, RoleIdBin, State) ->
case guild_voice_member:find_member_by_user_id(UserId, State) of
undefined ->
false;
Member ->
Roles = maps:get(<<"roles">>, Member, []),
lists:member(RoleIdBin, Roles)
end.
-spec get_member_user_id(map()) -> integer() | undefined.
get_member_user_id(MemberUpdate) ->
User = maps:get(<<"user">>, MemberUpdate, #{}),
map_utils:get_integer(User, <<"id">>, undefined).
-spec ensure_binary(binary() | integer()) -> binary().
ensure_binary(Value) when is_binary(Value) -> Value;
ensure_binary(Value) when is_integer(Value) -> integer_to_binary(Value).
-ifdef(TEST).
sync_user_voice_permissions_syncs_connected_user_test() ->
Self = self(),
TestFun = fun(GuildId, ChannelId, UserId, ConnectionId, Permissions) ->
Self ! {synced, GuildId, ChannelId, UserId, ConnectionId, Permissions}
end,
UserId = 10,
ChannelId = 500,
GuildId = 42,
RoleId = 999,
VoiceState = #{
<<"user_id">> => integer_to_binary(UserId),
<<"channel_id">> => integer_to_binary(ChannelId),
<<"connection_id">> => <<"test-conn">>
},
Permissions =
constants:view_channel_permission() bor
constants:connect_permission() bor
constants:speak_permission() bor
constants:stream_permission(),
State = #{
id => GuildId,
voice_states => #{<<"conn">> => VoiceState},
test_permission_sync_fun => TestFun,
data => #{
<<"guild">> => #{<<"owner_id">> => <<"1">>},
<<"roles">> => [
#{
<<"id">> => integer_to_binary(RoleId),
<<"permissions">> => integer_to_binary(Permissions)
},
#{
<<"id">> => integer_to_binary(GuildId),
<<"permissions">> => <<"0">>
}
],
<<"members">> => [
#{
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
<<"roles">> => [integer_to_binary(RoleId)]
}
],
<<"channels">> => [
#{
<<"id">> => integer_to_binary(ChannelId),
<<"permission_overwrites">> => []
}
]
}
},
ok = sync_user_voice_permissions(UserId, State),
receive
{synced, GuildId, ChannelId, UserId, <<"test-conn">>, Perms} ->
?assertEqual(true, maps:get(can_speak, Perms)),
?assertEqual(true, maps:get(can_stream, Perms))
after 100 ->
?assert(false)
end.
sync_user_voice_permissions_no_voice_state_test() ->
State = #{
id => 42,
voice_states => #{}
},
ok = sync_user_voice_permissions(10, State).
-endif.

View File

@@ -0,0 +1,170 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_voice_permissions).
-export([check_voice_permissions_and_limits/6]).
-type guild_state() :: map().
-type voice_state_map() :: #{binary() => map()}.
-type channel() :: map().
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-import(utils, [parse_iso8601_to_unix_ms/1]).
-spec check_voice_permissions_and_limits(
integer(), integer(), channel(), voice_state_map(), guild_state(), boolean()
) ->
{ok, allowed} | {error, atom(), atom()}.
check_voice_permissions_and_limits(UserId, ChannelIdValue, Channel, VoiceStates, State, IsUpdate) ->
case is_member_timed_out(UserId, State) of
true ->
gateway_errors:error(voice_member_timed_out);
false ->
case has_view_and_connect_perms(UserId, ChannelIdValue, State) of
false ->
gateway_errors:error(voice_permission_denied);
true ->
case
channel_has_capacity(UserId, ChannelIdValue, Channel, VoiceStates, IsUpdate)
of
true -> {ok, allowed};
false -> gateway_errors:error(voice_channel_full)
end
end
end.
-spec has_view_and_connect_perms(integer(), integer(), guild_state()) -> boolean().
has_view_and_connect_perms(UserId, ChannelIdValue, State) ->
case guild_virtual_channel_access:has_virtual_access(UserId, ChannelIdValue, State) of
true ->
true;
false ->
Permissions = resolve_permissions(UserId, ChannelIdValue, State),
ViewPerm = constants:view_channel_permission(),
ConnectPerm = constants:connect_permission(),
(Permissions band ViewPerm) =:= ViewPerm andalso
(Permissions band ConnectPerm) =:= ConnectPerm
end.
-spec channel_has_capacity(integer(), integer(), channel(), voice_state_map(), boolean()) ->
boolean().
channel_has_capacity(UserId, ChannelIdValue, Channel, VoiceStates, IsUpdate) ->
UserLimit = maps:get(<<"user_limit">>, Channel, 0),
case UserLimit of
0 ->
true;
Limit when Limit > 0 ->
UsersInChannel = users_in_channel(ChannelIdValue, VoiceStates),
CurrentCount = sets:size(UsersInChannel),
AlreadyPresent = sets:is_element(UserId, UsersInChannel),
AdjustedCount =
case AlreadyPresent orelse IsUpdate of
true -> CurrentCount - 1;
false -> CurrentCount
end,
AdjustedCount < Limit;
_ ->
true
end.
-spec is_member_timed_out(integer(), guild_state()) -> boolean().
is_member_timed_out(UserId, State) ->
case guild_permissions:find_member_by_user_id(UserId, State) of
undefined ->
false;
Member ->
TimeoutMs = parse_iso8601_to_unix_ms(
maps:get(<<"communication_disabled_until">>, Member, undefined)
),
case TimeoutMs of
undefined ->
false;
Value when is_integer(Value) ->
Value > erlang:system_time(millisecond);
_ ->
false
end
end.
-spec users_in_channel(integer(), voice_state_map()) -> sets:set().
users_in_channel(ChannelIdValue, VoiceStates0) ->
VoiceStates = voice_state_utils:ensure_voice_states(VoiceStates0),
maps:fold(
fun(_ConnId, VState, Acc) ->
case voice_state_utils:voice_state_channel_id(VState) of
ChannelIdValue ->
case voice_state_utils:voice_state_user_id(VState) of
undefined -> Acc;
UserId -> sets:add_element(UserId, Acc)
end;
_ ->
Acc
end
end,
sets:new(),
VoiceStates
).
-spec resolve_permissions(integer(), integer(), guild_state()) -> integer().
resolve_permissions(UserId, ChannelIdValue, State) ->
case State of
#{test_perm_fun := Fun} when is_function(Fun, 1) ->
Fun(UserId);
_ ->
guild_permissions:get_member_permissions(UserId, ChannelIdValue, State)
end.
-ifdef(TEST).
voice_permissions_missing_view_test() ->
State = permission_test_state(0, fun(_) -> constants:view_channel_permission() end),
Result = check_voice_permissions_and_limits(1, 10, #{<<"user_limit">> => 0}, #{}, State, false),
?assertMatch({error, permission_denied, voice_permission_denied}, Result).
voice_permissions_full_channel_test() ->
State = permission_test_state(2, fun(_) -> required_voice_perms() end),
VoiceStates = #{
<<"conn1">> => #{<<"channel_id">> => <<"10">>, <<"user_id">> => <<"1">>},
<<"conn2">> => #{<<"channel_id">> => <<"10">>, <<"user_id">> => <<"2">>}
},
Result = check_voice_permissions_and_limits(
3, 10, #{<<"user_limit">> => 2}, VoiceStates, State, false
),
?assertMatch({error, permission_denied, voice_channel_full}, Result).
voice_permissions_existing_user_update_test() ->
State = permission_test_state(2, fun(_) -> required_voice_perms() end),
VoiceStates = #{
<<"conn1">> => #{<<"channel_id">> => <<"10">>, <<"user_id">> => <<"1">>},
<<"conn2">> => #{<<"channel_id">> => <<"10">>, <<"user_id">> => <<"2">>}
},
Result = check_voice_permissions_and_limits(
1, 10, #{<<"user_limit">> => 2}, VoiceStates, State, true
),
?assertEqual({ok, allowed}, Result).
required_voice_perms() ->
constants:view_channel_permission() bor constants:connect_permission().
permission_test_state(GuildId, PermFun) ->
#{id => GuildId, test_perm_fun => PermFun}.
-endif.

View File

@@ -0,0 +1,127 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_voice_region).
-export([switch_voice_region_handler/2]).
-export([switch_voice_region/3]).
switch_voice_region_handler(Request, State) ->
#{channel_id := ChannelId} = Request,
Channel = guild_voice_member:find_channel_by_id(ChannelId, State),
case Channel of
undefined ->
{reply, gateway_errors:error(voice_channel_not_found), State};
_ ->
ChannelType = maps:get(<<"type">>, Channel, 0),
case ChannelType of
2 ->
{reply, #{success => true}, State};
_ ->
{reply, gateway_errors:error(voice_channel_not_voice), State}
end
end.
switch_voice_region(GuildId, ChannelId, GuildPid) ->
case gen_server:call(GuildPid, {get_sessions}, 10000) of
State when is_map(State) ->
VoiceStates = voice_state_utils:voice_states(State),
UsersInChannel = maps:fold(
fun(ConnectionId, VoiceState, Acc) ->
case voice_state_utils:voice_state_channel_id(VoiceState) of
ChannelId ->
case voice_state_utils:voice_state_user_id(VoiceState) of
undefined ->
logger:warning(
"[guild_voice_region] Missing user_id for connection ~p",
[ConnectionId]
),
Acc;
UserId ->
SessionId = maps:get(<<"session_id">>, VoiceState, undefined),
[{UserId, SessionId, VoiceState} | Acc]
end;
_ ->
Acc
end
end,
[],
VoiceStates
),
lists:foreach(
fun({UserId, SessionId, VoiceState}) ->
case SessionId of
undefined ->
ok;
_ ->
send_voice_server_update_for_region_switch(
GuildId, ChannelId, UserId, SessionId, VoiceState, GuildPid
)
end
end,
UsersInChannel
);
_ ->
ok
end.
send_voice_server_update_for_region_switch(
GuildId, ChannelId, UserId, SessionId, ExistingVoiceState, GuildPid
) ->
case gen_server:call(GuildPid, {get_sessions}, 10000) of
State when is_map(State) ->
VoicePermissions = voice_utils:compute_voice_permissions(UserId, ChannelId, State),
case
guild_voice_connection:request_voice_token(
GuildId, ChannelId, UserId, VoicePermissions
)
of
{ok, TokenData} ->
Token = maps:get(token, TokenData),
Endpoint = maps:get(endpoint, TokenData),
ConnectionId = maps:get(connection_id, TokenData),
PendingMetadata = #{
user_id => UserId,
guild_id => GuildId,
channel_id => ChannelId,
session_id => SessionId,
self_mute => maps:get(<<"self_mute">>, ExistingVoiceState, false),
self_deaf => maps:get(<<"self_deaf">>, ExistingVoiceState, false),
self_video => maps:get(<<"self_video">>, ExistingVoiceState, false),
self_stream => maps:get(<<"self_stream">>, ExistingVoiceState, false),
is_mobile => maps:get(<<"is_mobile">>, ExistingVoiceState, false),
server_mute => maps:get(<<"mute">>, ExistingVoiceState, false),
server_deaf => maps:get(<<"deaf">>, ExistingVoiceState, false),
member => maps:get(<<"member">>, ExistingVoiceState, #{})
},
gen_server:cast(
GuildPid, {store_pending_connection, ConnectionId, PendingMetadata}
),
guild_voice_broadcast:broadcast_voice_server_update_to_session(
GuildId, SessionId, Token, Endpoint, ConnectionId, State
);
{error, _Reason} ->
ok
end;
_ ->
ok
end.

View File

@@ -0,0 +1,205 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_voice_state).
-include_lib("fluxer_gateway/include/voice_state.hrl").
-export([get_voice_state/2]).
-export([get_voice_states_list/1]).
-export([update_voice_state_data/9]).
-export([user_matches_voice_state/2]).
-export([create_voice_state/8]).
-export([extract_session_info_from_voice_state/2]).
-type guild_state() :: map().
-type voice_state() :: map().
-type voice_state_map() :: #{binary() => voice_state()}.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec get_voice_state(map(), guild_state()) -> {reply, map(), guild_state()}.
get_voice_state(Request, State) ->
case maps:get(connection_id, Request, null) of
null ->
{reply, #{voice_state => null}, State};
ConnectionId ->
VoiceStates = voice_state_utils:voice_states(State),
VoiceState = maps:get(ConnectionId, VoiceStates, null),
{reply, #{voice_state => VoiceState}, State}
end.
-spec get_voice_states_list(guild_state()) -> [voice_state()].
get_voice_states_list(State) ->
maps:values(voice_state_utils:voice_states(State)).
-spec update_voice_state_data(
binary(),
binary(),
voice_flags(),
map(),
voice_state(),
voice_state_map(),
guild_state(),
boolean(),
term()
) -> {reply, map(), guild_state()}.
update_voice_state_data(
ConnectionId,
ChannelIdBin,
Flags,
Member,
ExistingVoiceState,
VoiceStates,
State,
NeedsToken,
ViewerStreamKey
) ->
#voice_flags{
self_mute = SelfMute,
self_deaf = SelfDeaf,
self_video = SelfVideo,
self_stream = SelfStream,
is_mobile = IsMobile
} = Flags,
ServerMute = maps:get(<<"mute">>, Member, false),
ServerDeaf = maps:get(<<"deaf">>, Member, false),
OldVersion = maps:get(<<"version">>, ExistingVoiceState, 0),
UpdatedVoiceState = ExistingVoiceState#{
<<"channel_id">> => ChannelIdBin,
<<"mute">> => ServerMute,
<<"deaf">> => ServerDeaf,
<<"self_mute">> => SelfMute,
<<"self_deaf">> => SelfDeaf,
<<"self_video">> => SelfVideo,
<<"self_stream">> => SelfStream,
<<"is_mobile">> => IsMobile,
<<"viewer_stream_key">> => ViewerStreamKey,
<<"version">> => OldVersion + 1
},
NewVoiceStates = maps:put(ConnectionId, UpdatedVoiceState, VoiceStates),
NewState = maps:put(voice_states, NewVoiceStates, State),
guild_voice_broadcast:broadcast_voice_state_update(UpdatedVoiceState, NewState, ChannelIdBin),
Reply =
case NeedsToken of
true -> #{success => true, voice_state => UpdatedVoiceState, needs_token => true};
false -> #{success => true, voice_state => UpdatedVoiceState}
end,
{reply, Reply, NewState}.
-spec user_matches_voice_state(voice_state(), integer() | binary()) -> boolean().
user_matches_voice_state(VoiceState, UserId) when is_integer(UserId) ->
case map_utils:get_integer(VoiceState, <<"user_id">>, undefined) of
undefined -> false;
VoiceUserId -> VoiceUserId =:= UserId
end;
user_matches_voice_state(VoiceState, UserId) when is_binary(UserId) ->
type_conv:to_binary(map_utils:get_binary(VoiceState, <<"user_id">>, undefined)) =:= UserId;
user_matches_voice_state(_VoiceState, _UserId) ->
false.
-spec create_voice_state(
binary(),
binary(),
binary(),
binary(),
boolean(),
boolean(),
voice_flags(),
term()
) -> voice_state().
create_voice_state(
GuildIdBin,
ChannelIdBin,
UserIdBin,
ConnectionId,
ServerMute,
ServerDeaf,
Flags,
ViewerStreamKey
) ->
#voice_flags{
self_mute = SelfMute,
self_deaf = SelfDeaf,
self_video = SelfVideo,
self_stream = SelfStream,
is_mobile = IsMobile
} = Flags,
#{
<<"guild_id">> => GuildIdBin,
<<"channel_id">> => ChannelIdBin,
<<"user_id">> => UserIdBin,
<<"connection_id">> => ConnectionId,
<<"mute">> => ServerMute,
<<"deaf">> => ServerDeaf,
<<"self_mute">> => SelfMute,
<<"self_deaf">> => SelfDeaf,
<<"self_video">> => SelfVideo,
<<"self_stream">> => SelfStream,
<<"is_mobile">> => IsMobile,
<<"viewer_stream_key">> => ViewerStreamKey,
<<"version">> => 0
}.
-spec extract_session_info_from_voice_state(binary(), voice_state()) -> map().
extract_session_info_from_voice_state(ConnId, VoiceState) ->
#{
connection_id => ConnId,
session_id => maps:get(<<"session_id">>, VoiceState, undefined),
self_mute => maps:get(<<"self_mute">>, VoiceState, false),
self_deaf => maps:get(<<"self_deaf">>, VoiceState, false),
self_video => maps:get(<<"self_video">>, VoiceState, false),
self_stream => maps:get(<<"self_stream">>, VoiceState, false),
is_mobile => maps:get(<<"is_mobile">>, VoiceState, false),
member => maps:get(<<"member">>, VoiceState, #{})
}.
-ifdef(TEST).
user_matches_voice_state_integer_test() ->
VoiceState = #{<<"user_id">> => <<"10">>},
?assert(user_matches_voice_state(VoiceState, 10)),
?assertNot(user_matches_voice_state(VoiceState, 11)).
update_voice_state_data_updates_version_test() ->
VoiceState = #{<<"version">> => 1, <<"channel_id">> => <<"1">>},
Member = #{<<"mute">> => true, <<"deaf">> => false},
Flags = #voice_flags{
self_mute = true,
self_deaf = false,
self_video = false,
self_stream = false,
is_mobile = false
},
{reply, #{voice_state := Updated}, _} =
update_voice_state_data(
<<"conn">>,
<<"2">>,
Flags,
Member,
VoiceState,
#{<<"conn">> => VoiceState},
#{voice_states => #{}},
false,
null
),
?assertEqual(2, maps:get(<<"version">>, Updated)),
?assertEqual(<<"2">>, maps:get(<<"channel_id">>, Updated)).
-endif.

View File

@@ -0,0 +1,105 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(voice_disconnect_common).
-export([
find_session_by_user_id/2,
disconnect_user/4,
disconnect_user_if_in_channel/5,
channel_has_capacity/3
]).
-type user_id() :: integer().
-type session_id() :: binary().
-type session_pid() :: pid().
-type monitor_ref() :: reference().
-type session_tuple() :: {user_id(), session_pid(), monitor_ref()}.
-type sessions_map() :: #{session_id() => session_tuple()}.
-type voice_states_map() :: #{user_id() => map()}.
-type cleanup_fun() :: fun((user_id(), session_id()) -> ok).
-spec find_session_by_user_id(user_id(), sessions_map()) ->
{ok, session_id(), session_pid(), monitor_ref()} | not_found.
find_session_by_user_id(UserId, Sessions) ->
maps:fold(
fun
(SessionId, {U, Pid, Ref}, _) when U =:= UserId ->
{ok, SessionId, Pid, Ref};
(_, _, Acc) ->
Acc
end,
not_found,
Sessions
).
-spec disconnect_user(user_id(), voice_states_map(), sessions_map(), cleanup_fun()) ->
{ok, voice_states_map(), sessions_map()} | {not_found, voice_states_map(), sessions_map()}.
disconnect_user(UserId, VoiceStates, Sessions, CleanupFun) ->
case find_session_by_user_id(UserId, Sessions) of
not_found ->
{not_found, VoiceStates, Sessions};
{ok, SessionId, _Pid, Ref} ->
demonitor(Ref, [flush]),
CleanupFun(UserId, SessionId),
NewVoiceStates = maps:remove(UserId, VoiceStates),
NewSessions = maps:remove(SessionId, Sessions),
{ok, NewVoiceStates, NewSessions}
end.
-spec disconnect_user_if_in_channel(
user_id(), integer(), voice_states_map(), sessions_map(), cleanup_fun()
) ->
{ok, voice_states_map(), sessions_map()}
| {not_found, voice_states_map(), sessions_map()}
| {channel_mismatch, voice_states_map(), sessions_map()}.
disconnect_user_if_in_channel(UserId, ExpectedChannelId, VoiceStates, Sessions, CleanupFun) ->
case maps:get(UserId, VoiceStates, undefined) of
undefined ->
{not_found, VoiceStates, Sessions};
VoiceState ->
ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, undefined),
ExpectedBin = integer_to_binary(ExpectedChannelId),
case ChannelIdBin =:= ExpectedBin of
false ->
{channel_mismatch, VoiceStates, Sessions};
true ->
disconnect_user(UserId, VoiceStates, Sessions, CleanupFun)
end
end.
-spec channel_has_capacity(binary() | integer(), non_neg_integer(), voice_states_map()) ->
boolean().
channel_has_capacity(_ChannelId, 0, _VoiceStates) ->
true;
channel_has_capacity(ChannelId, UserLimit, VoiceStates) ->
ChannelIdBin = ensure_binary(ChannelId),
UsersInChannel = maps:fold(
fun(_UserId, VoiceState, Count) ->
case maps:get(<<"channel_id">>, VoiceState, undefined) of
ChannelIdBin -> Count + 1;
_ -> Count
end
end,
0,
VoiceStates
),
UsersInChannel < UserLimit.
-spec ensure_binary(binary() | integer()) -> binary().
ensure_binary(Value) when is_binary(Value) -> Value;
ensure_binary(Value) when is_integer(Value) -> integer_to_binary(Value).

View File

@@ -0,0 +1,58 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(voice_pending_common).
-export([
add_pending_connection/3,
remove_pending_connection/2,
get_pending_connection/2,
confirm_pending_connection/2
]).
-type connection_id() :: binary().
-type pending_metadata() :: map().
-type pending_map() :: #{connection_id() => pending_metadata()}.
-spec add_pending_connection(connection_id(), pending_metadata(), pending_map()) -> pending_map().
add_pending_connection(ConnectionId, Metadata, PendingMap) ->
maps:put(ConnectionId, Metadata#{joined_at => erlang:system_time(millisecond)}, PendingMap).
-spec remove_pending_connection(connection_id() | undefined, pending_map()) -> pending_map().
remove_pending_connection(undefined, PendingMap) ->
PendingMap;
remove_pending_connection(ConnectionId, PendingMap) ->
maps:remove(ConnectionId, PendingMap).
-spec get_pending_connection(connection_id() | undefined, pending_map()) ->
pending_metadata() | undefined.
get_pending_connection(undefined, _PendingMap) ->
undefined;
get_pending_connection(ConnectionId, PendingMap) ->
maps:get(ConnectionId, PendingMap, undefined).
-spec confirm_pending_connection(connection_id() | undefined, pending_map()) ->
{confirmed, pending_map()} | {not_found, pending_map()}.
confirm_pending_connection(undefined, PendingMap) ->
{not_found, PendingMap};
confirm_pending_connection(ConnectionId, PendingMap) ->
case maps:get(ConnectionId, PendingMap, undefined) of
undefined ->
{not_found, PendingMap};
_Metadata ->
{confirmed, maps:remove(ConnectionId, PendingMap)}
end.

View File

@@ -0,0 +1,164 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(voice_state_utils).
-include_lib("fluxer_gateway/include/voice_state.hrl").
-export([
voice_states/1,
ensure_voice_states/1,
voice_state_user_id/1,
voice_state_channel_id/1,
voice_state_guild_id/1,
filter_voice_states/2,
drop_voice_states/2,
broadcast_disconnects/2,
voice_flags_from_context/1,
parse_stream_key/1,
build_stream_key/3
]).
voice_states(State) when is_map(State) ->
case maps:get(voice_states, State, undefined) of
Map when is_map(Map) -> Map;
_ -> #{}
end.
ensure_voice_states(Map) when is_map(Map) ->
Map;
ensure_voice_states(_) ->
#{}.
voice_state_user_id(VoiceState) ->
map_utils:get_integer(VoiceState, <<"user_id">>, undefined).
voice_state_channel_id(VoiceState) ->
map_utils:get_integer(VoiceState, <<"channel_id">>, undefined).
voice_state_guild_id(VoiceState) ->
map_utils:get_integer(VoiceState, <<"guild_id">>, undefined).
filter_voice_states(VoiceStates, Predicate) when is_map(VoiceStates) ->
maps:filter(Predicate, VoiceStates);
filter_voice_states(_, _) ->
#{}.
drop_voice_states(ToDrop, VoiceStates) ->
maps:fold(fun(ConnId, _VoiceState, Acc) -> maps:remove(ConnId, Acc) end, VoiceStates, ToDrop).
broadcast_disconnects(VoiceStates, State) ->
maps:foreach(
fun(ConnId, VoiceState) ->
OldChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
DisconnectVoiceState = VoiceState#{
<<"channel_id">> => null,
<<"connection_id">> => ConnId
},
guild_voice_broadcast:broadcast_voice_state_update(
DisconnectVoiceState, State, OldChannelIdBin
)
end,
VoiceStates
).
voice_flags_from_context(Context) ->
#voice_flags{
self_mute = maps:get(self_mute, Context, false),
self_deaf = maps:get(self_deaf, Context, false),
self_video = maps:get(self_video, Context, false),
self_stream = maps:get(self_stream, Context, false),
is_mobile = maps:get(is_mobile, Context, false)
}.
-spec parse_stream_key(term()) ->
{ok, #{
scope := guild | dm,
guild_id := integer() | undefined,
channel_id := integer(),
connection_id := binary()
}}
| {error, invalid_stream_key}.
parse_stream_key(StreamKey) when is_binary(StreamKey) ->
Parts = binary:split(StreamKey, <<":">>, [global]),
case Parts of
[ScopeBin, ChannelBin, ConnId] when byte_size(ChannelBin) > 0, byte_size(ConnId) > 0 ->
try
Scope = parse_scope_bin(ScopeBin),
ChannelId = parse_channel_bin(ChannelBin),
build_stream_key_result(Scope, ChannelId, ConnId)
catch
_:_ ->
{error, invalid_stream_key}
end;
_ ->
{error, invalid_stream_key}
end;
parse_stream_key(_) ->
{error, invalid_stream_key}.
-spec parse_scope_bin(binary()) -> {dm, undefined} | {guild, integer()}.
parse_scope_bin(<<"dm">>) ->
{dm, undefined};
parse_scope_bin(ScopeBin) ->
GuildId = type_conv:to_integer(ScopeBin),
true = is_integer(GuildId),
{guild, GuildId}.
-spec parse_channel_bin(binary()) -> integer().
parse_channel_bin(ChannelBin) ->
Chan = type_conv:to_integer(ChannelBin),
true = is_integer(Chan),
Chan.
-spec build_stream_key_result({dm, undefined} | {guild, integer()}, integer(), binary()) ->
{ok, #{
scope := guild | dm,
guild_id := integer() | undefined,
channel_id := integer(),
connection_id := binary()
}}.
build_stream_key_result({dm, _}, ChannelId, ConnId) ->
{ok, #{
scope => dm,
guild_id => undefined,
channel_id => ChannelId,
connection_id => ConnId
}};
build_stream_key_result({guild, GuildId}, ChannelId, ConnId) ->
{ok, #{
scope => guild,
guild_id => GuildId,
channel_id => ChannelId,
connection_id => ConnId
}}.
-spec build_stream_key(integer() | undefined, integer(), binary()) -> binary().
build_stream_key(undefined, ChannelId, ConnectionId) when
is_integer(ChannelId), is_binary(ConnectionId)
->
<<"dm:", (integer_to_binary(ChannelId))/binary, ":", ConnectionId/binary>>;
build_stream_key(GuildId, ChannelId, ConnectionId) when
is_integer(GuildId), is_integer(ChannelId), is_binary(ConnectionId)
->
<<
(integer_to_binary(GuildId))/binary,
":",
(integer_to_binary(ChannelId))/binary,
":",
ConnectionId/binary
>>.

View File

@@ -0,0 +1,150 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(voice_utils).
-export([
build_voice_token_rpc_request/6,
build_voice_token_rpc_request/7,
build_force_disconnect_rpc_request/4,
build_update_participant_rpc_request/5,
build_update_participant_permissions_rpc_request/5,
add_geolocation_to_request/3,
compute_voice_permissions/3
]).
build_voice_token_rpc_request(GuildId, ChannelId, UserId, ConnectionId, Latitude, Longitude) ->
BaseReq =
case GuildId of
null ->
#{
<<"type">> => <<"voice_get_token">>,
<<"channel_id">> => integer_to_binary(ChannelId),
<<"user_id">> => integer_to_binary(UserId)
};
_ ->
BaseMap = #{
<<"type">> => <<"voice_get_token">>,
<<"guild_id">> => integer_to_binary(GuildId),
<<"channel_id">> => integer_to_binary(ChannelId),
<<"user_id">> => integer_to_binary(UserId)
},
case ConnectionId of
null ->
BaseMap;
ConnectionId when is_binary(ConnectionId) ->
maps:put(<<"connection_id">>, ConnectionId, BaseMap);
ConnectionId when is_integer(ConnectionId) ->
maps:put(<<"connection_id">>, integer_to_binary(ConnectionId), BaseMap);
_ ->
BaseMap
end
end,
add_geolocation_to_request(BaseReq, Latitude, Longitude).
add_geolocation_to_request(RequestMap, Latitude, Longitude) ->
case {Latitude, Longitude} of
{Lat, Long} when is_binary(Lat) andalso is_binary(Long) ->
maps:merge(RequestMap, #{
<<"latitude">> => Lat,
<<"longitude">> => Long
});
_ ->
RequestMap
end.
build_force_disconnect_rpc_request(GuildId, ChannelId, UserId, ConnectionId) ->
BaseReq = #{
<<"type">> => <<"voice_force_disconnect_participant">>,
<<"channel_id">> => integer_to_binary(ChannelId),
<<"user_id">> => integer_to_binary(UserId),
<<"connection_id">> => ConnectionId
},
case GuildId of
null ->
BaseReq;
_ ->
maps:put(<<"guild_id">>, integer_to_binary(GuildId), BaseReq)
end.
build_update_participant_rpc_request(GuildId, ChannelId, UserId, Mute, Deaf) ->
BaseReq = #{
<<"type">> => <<"voice_update_participant">>,
<<"channel_id">> => integer_to_binary(ChannelId),
<<"user_id">> => integer_to_binary(UserId),
<<"mute">> => Mute,
<<"deaf">> => Deaf
},
case GuildId of
null ->
BaseReq;
_ ->
maps:put(<<"guild_id">>, integer_to_binary(GuildId), BaseReq)
end.
build_update_participant_permissions_rpc_request(
GuildId, ChannelId, UserId, ConnectionId, VoicePermissions
) ->
BaseReq = #{
<<"type">> => <<"voice_update_participant_permissions">>,
<<"channel_id">> => integer_to_binary(ChannelId),
<<"user_id">> => integer_to_binary(UserId),
<<"connection_id">> => ConnectionId,
<<"can_speak">> => maps:get(can_speak, VoicePermissions, true),
<<"can_stream">> => maps:get(can_stream, VoicePermissions, true),
<<"can_video">> => maps:get(can_video, VoicePermissions, true)
},
case GuildId of
null ->
BaseReq;
_ ->
maps:put(<<"guild_id">>, integer_to_binary(GuildId), BaseReq)
end.
-spec compute_voice_permissions(integer(), integer(), map()) -> map().
compute_voice_permissions(UserId, ChannelId, State) ->
Permissions = guild_permissions:get_member_permissions(UserId, ChannelId, State),
SpeakPerm = constants:speak_permission(),
StreamPerm = constants:stream_permission(),
AdminPerm = constants:administrator_permission(),
IsAdmin = (Permissions band AdminPerm) =:= AdminPerm,
CanSpeak = IsAdmin orelse ((Permissions band SpeakPerm) =:= SpeakPerm),
CanStream = IsAdmin orelse ((Permissions band StreamPerm) =:= StreamPerm),
HasVirtualAccess = guild_virtual_channel_access:has_virtual_access(UserId, ChannelId, State),
FinalCanSpeak = CanSpeak orelse HasVirtualAccess,
FinalCanStream = CanStream orelse HasVirtualAccess,
#{
can_speak => FinalCanSpeak,
can_stream => FinalCanStream,
can_video => FinalCanStream
}.
build_voice_token_rpc_request(
GuildId, ChannelId, UserId, ConnectionId, Latitude, Longitude, VoicePermissions
) ->
BaseReq = build_voice_token_rpc_request(
GuildId, ChannelId, UserId, ConnectionId, Latitude, Longitude
),
maps:merge(BaseReq, #{
<<"can_speak">> => maps:get(can_speak, VoicePermissions, true),
<<"can_stream">> => maps:get(can_stream, VoicePermissions, true),
<<"can_video">> => maps:get(can_video, VoicePermissions, true)
}).