initial commit
This commit is contained in:
572
fluxer_gateway/src/guild/guild.erl
Normal file
572
fluxer_gateway/src/guild/guild.erl
Normal 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)).
|
||||
143
fluxer_gateway/src/guild/guild_availability.erl
Normal file
143
fluxer_gateway/src/guild/guild_availability.erl
Normal 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.
|
||||
75
fluxer_gateway/src/guild/guild_client.erl
Normal file
75
fluxer_gateway/src/guild/guild_client.erl
Normal 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.
|
||||
313
fluxer_gateway/src/guild/guild_data.erl
Normal file
313
fluxer_gateway/src/guild/guild_data.erl
Normal 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.
|
||||
510
fluxer_gateway/src/guild/guild_dispatch.erl
Normal file
510
fluxer_gateway/src/guild/guild_dispatch.erl
Normal 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
|
||||
).
|
||||
368
fluxer_gateway/src/guild/guild_manager.erl
Normal file
368
fluxer_gateway/src/guild/guild_manager.erl
Normal 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.
|
||||
530
fluxer_gateway/src/guild/guild_manager_shard.erl
Normal file
530
fluxer_gateway/src/guild/guild_manager_shard.erl
Normal 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.
|
||||
1027
fluxer_gateway/src/guild/guild_member_list.erl
Normal file
1027
fluxer_gateway/src/guild/guild_member_list.erl
Normal file
File diff suppressed because it is too large
Load Diff
328
fluxer_gateway/src/guild/guild_member_storage.erl
Normal file
328
fluxer_gateway/src/guild/guild_member_storage.erl
Normal 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.
|
||||
535
fluxer_gateway/src/guild/guild_members.erl
Normal file
535
fluxer_gateway/src/guild/guild_members.erl
Normal 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.
|
||||
175
fluxer_gateway/src/guild/guild_passive_sync.erl
Normal file
175
fluxer_gateway/src/guild/guild_passive_sync.erl
Normal 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.
|
||||
560
fluxer_gateway/src/guild/guild_permissions.erl
Normal file
560
fluxer_gateway/src/guild/guild_permissions.erl
Normal 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.
|
||||
302
fluxer_gateway/src/guild/guild_presence.erl
Normal file
302
fluxer_gateway/src/guild/guild_presence.erl
Normal 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).
|
||||
505
fluxer_gateway/src/guild/guild_request_members.erl
Normal file
505
fluxer_gateway/src/guild/guild_request_members.erl
Normal 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.
|
||||
348
fluxer_gateway/src/guild/guild_sessions.erl
Normal file
348
fluxer_gateway/src/guild/guild_sessions.erl
Normal 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.
|
||||
466
fluxer_gateway/src/guild/guild_state.erl
Normal file
466
fluxer_gateway/src/guild/guild_state.erl
Normal 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.
|
||||
185
fluxer_gateway/src/guild/guild_subscriptions.erl
Normal file
185
fluxer_gateway/src/guild/guild_subscriptions.erl
Normal 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.
|
||||
23
fluxer_gateway/src/guild/guild_sync.erl
Normal file
23
fluxer_gateway/src/guild/guild_sync.erl
Normal 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}).
|
||||
236
fluxer_gateway/src/guild/guild_unified_subscriptions.erl
Normal file
236
fluxer_gateway/src/guild/guild_unified_subscriptions.erl
Normal 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.
|
||||
152
fluxer_gateway/src/guild/guild_user_data.erl
Normal file
152
fluxer_gateway/src/guild/guild_user_data.erl
Normal 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.
|
||||
127
fluxer_gateway/src/guild/guild_virtual_channel_access.erl
Normal file
127
fluxer_gateway/src/guild/guild_virtual_channel_access.erl
Normal 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.
|
||||
170
fluxer_gateway/src/guild/guild_visibility.erl
Normal file
170
fluxer_gateway/src/guild/guild_visibility.erl
Normal 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).
|
||||
626
fluxer_gateway/src/guild/voice/dm_voice.erl
Normal file
626
fluxer_gateway/src/guild/voice/dm_voice.erl
Normal 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}.
|
||||
125
fluxer_gateway/src/guild/voice/guild_voice.erl
Normal file
125
fluxer_gateway/src/guild/voice/guild_voice.erl
Normal 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}).
|
||||
117
fluxer_gateway/src/guild/voice/guild_voice_broadcast.erl
Normal file
117
fluxer_gateway/src/guild/voice/guild_voice_broadcast.erl
Normal 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.
|
||||
699
fluxer_gateway/src/guild/voice/guild_voice_connection.erl
Normal file
699
fluxer_gateway/src/guild/voice/guild_voice_connection.erl
Normal 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.
|
||||
319
fluxer_gateway/src/guild/voice/guild_voice_disconnect.erl
Normal file
319
fluxer_gateway/src/guild/voice/guild_voice_disconnect.erl
Normal 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.
|
||||
278
fluxer_gateway/src/guild/voice/guild_voice_member.erl
Normal file
278
fluxer_gateway/src/guild/voice/guild_voice_member.erl
Normal 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.
|
||||
378
fluxer_gateway/src/guild/voice/guild_voice_move.erl
Normal file
378
fluxer_gateway/src/guild/voice/guild_voice_move.erl
Normal 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.
|
||||
304
fluxer_gateway/src/guild/voice/guild_voice_permission_sync.erl
Normal file
304
fluxer_gateway/src/guild/voice/guild_voice_permission_sync.erl
Normal 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.
|
||||
170
fluxer_gateway/src/guild/voice/guild_voice_permissions.erl
Normal file
170
fluxer_gateway/src/guild/voice/guild_voice_permissions.erl
Normal 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.
|
||||
127
fluxer_gateway/src/guild/voice/guild_voice_region.erl
Normal file
127
fluxer_gateway/src/guild/voice/guild_voice_region.erl
Normal 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.
|
||||
205
fluxer_gateway/src/guild/voice/guild_voice_state.erl
Normal file
205
fluxer_gateway/src/guild/voice/guild_voice_state.erl
Normal 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.
|
||||
105
fluxer_gateway/src/guild/voice/voice_disconnect_common.erl
Normal file
105
fluxer_gateway/src/guild/voice/voice_disconnect_common.erl
Normal 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).
|
||||
58
fluxer_gateway/src/guild/voice/voice_pending_common.erl
Normal file
58
fluxer_gateway/src/guild/voice/voice_pending_common.erl
Normal 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.
|
||||
164
fluxer_gateway/src/guild/voice/voice_state_utils.erl
Normal file
164
fluxer_gateway/src/guild/voice/voice_state_utils.erl
Normal 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
|
||||
>>.
|
||||
150
fluxer_gateway/src/guild/voice/voice_utils.erl
Normal file
150
fluxer_gateway/src/guild/voice/voice_utils.erl
Normal 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)
|
||||
}).
|
||||
Reference in New Issue
Block a user