initial commit
This commit is contained in:
370
fluxer_gateway/src/session/session.erl
Normal file
370
fluxer_gateway/src/session/session.erl
Normal file
@@ -0,0 +1,370 @@
|
||||
%% 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(session).
|
||||
-behaviour(gen_server).
|
||||
|
||||
-export([start_link/1]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
|
||||
|
||||
start_link(SessionData) ->
|
||||
gen_server:start_link(?MODULE, SessionData, []).
|
||||
|
||||
init(SessionData) ->
|
||||
process_flag(trap_exit, true),
|
||||
|
||||
Id = maps:get(id, SessionData),
|
||||
UserId = maps:get(user_id, SessionData),
|
||||
UserData = maps:get(user_data, SessionData),
|
||||
Version = maps:get(version, SessionData),
|
||||
TokenHash = maps:get(token_hash, SessionData),
|
||||
AuthSessionIdHash = maps:get(auth_session_id_hash, SessionData),
|
||||
Properties = maps:get(properties, SessionData),
|
||||
Status = maps:get(status, SessionData),
|
||||
Afk = maps:get(afk, SessionData, false),
|
||||
Mobile = maps:get(mobile, SessionData, false),
|
||||
SocketPid = maps:get(socket_pid, SessionData),
|
||||
GuildIds = maps:get(guilds, SessionData),
|
||||
Ready0 = maps:get(ready, SessionData),
|
||||
Bot = maps:get(bot, SessionData, false),
|
||||
InitialGuildId = maps:get(initial_guild_id, SessionData, undefined),
|
||||
Ready =
|
||||
case Bot of
|
||||
true -> ensure_bot_ready_map(Ready0);
|
||||
false -> Ready0
|
||||
end,
|
||||
IgnoredEvents = build_ignored_events_map(maps:get(ignored_events, SessionData, [])),
|
||||
|
||||
Channels = load_private_channels(Ready),
|
||||
logger:debug("[session] Loaded ~p private channels into session state for user ~p", [
|
||||
maps:size(Channels),
|
||||
UserId
|
||||
]),
|
||||
|
||||
State = #{
|
||||
id => Id,
|
||||
user_id => UserId,
|
||||
user_data => UserData,
|
||||
custom_status => maps:get(custom_status, SessionData, null),
|
||||
version => Version,
|
||||
token_hash => TokenHash,
|
||||
auth_session_id_hash => AuthSessionIdHash,
|
||||
buffer => [],
|
||||
seq => 0,
|
||||
ack_seq => 0,
|
||||
properties => Properties,
|
||||
status => Status,
|
||||
afk => Afk,
|
||||
mobile => Mobile,
|
||||
presence_pid => undefined,
|
||||
presence_mref => undefined,
|
||||
socket_pid => SocketPid,
|
||||
socket_mref => monitor(process, SocketPid),
|
||||
guilds => maps:from_list([{Gid, undefined} || Gid <- GuildIds]),
|
||||
calls => #{},
|
||||
channels => Channels,
|
||||
ready => Ready,
|
||||
bot => Bot,
|
||||
ignored_events => IgnoredEvents,
|
||||
initial_guild_id => InitialGuildId,
|
||||
collected_guild_states => [],
|
||||
collected_sessions => [],
|
||||
collected_presences => [],
|
||||
relationships => load_relationships(Ready),
|
||||
suppress_presence_updates => true,
|
||||
pending_presences => [],
|
||||
guild_connect_inflight => #{}
|
||||
},
|
||||
|
||||
self() ! {presence_connect, 0},
|
||||
case Bot of
|
||||
true -> self() ! bot_initial_ready;
|
||||
false -> ok
|
||||
end,
|
||||
lists:foreach(fun(Gid) -> self() ! {guild_connect, Gid, 0} end, GuildIds),
|
||||
erlang:send_after(3000, self(), premature_readiness),
|
||||
erlang:send_after(200, self(), enable_presence_updates),
|
||||
|
||||
{ok, State}.
|
||||
|
||||
handle_call({token_verify, Token}, _From, State) ->
|
||||
TokenHash = maps:get(token_hash, State),
|
||||
HashedInput = utils:hash_token(Token),
|
||||
IsValid = HashedInput =:= TokenHash,
|
||||
{reply, IsValid, State};
|
||||
handle_call({heartbeat_ack, Seq}, _From, State) ->
|
||||
AckSeq = maps:get(ack_seq, State),
|
||||
Buffer = maps:get(buffer, State),
|
||||
|
||||
if
|
||||
Seq < AckSeq ->
|
||||
{reply, false, State};
|
||||
true ->
|
||||
NewBuffer = [Event || Event <- Buffer, maps:get(seq, Event) > Seq],
|
||||
{reply, true, maps:merge(State, #{ack_seq => Seq, buffer => NewBuffer})}
|
||||
end;
|
||||
handle_call({resume, Seq, SocketPid}, _From, State) ->
|
||||
CurrentSeq = maps:get(seq, State),
|
||||
Buffer = maps:get(buffer, State),
|
||||
PresencePid = maps:get(presence_pid, State, undefined),
|
||||
SessionId = maps:get(id, State),
|
||||
Status = maps:get(status, State),
|
||||
Afk = maps:get(afk, State),
|
||||
Mobile = maps:get(mobile, State),
|
||||
|
||||
if
|
||||
Seq > CurrentSeq ->
|
||||
{reply, invalid_seq, State};
|
||||
true ->
|
||||
MissedEvents = [Event || Event <- Buffer, maps:get(seq, Event) > Seq],
|
||||
NewState = maps:merge(State, #{
|
||||
socket_pid => SocketPid,
|
||||
socket_mref => monitor(process, SocketPid)
|
||||
}),
|
||||
|
||||
case PresencePid of
|
||||
undefined ->
|
||||
ok;
|
||||
Pid when is_pid(Pid) ->
|
||||
gen_server:call(
|
||||
Pid,
|
||||
{session_connect, #{
|
||||
session_id => SessionId,
|
||||
status => Status,
|
||||
afk => Afk,
|
||||
mobile => Mobile
|
||||
}},
|
||||
10000
|
||||
)
|
||||
end,
|
||||
|
||||
{reply, {ok, MissedEvents}, NewState}
|
||||
end;
|
||||
handle_call({get_state}, _From, State) ->
|
||||
SerializedState = serialize_state(State),
|
||||
{reply, SerializedState, State};
|
||||
handle_call({voice_state_update, Data}, _From, State) ->
|
||||
session_voice:handle_voice_state_update(Data, State);
|
||||
handle_call(_, _From, State) ->
|
||||
{reply, ok, State}.
|
||||
|
||||
handle_cast({presence_update, Update}, State) ->
|
||||
PresencePid = maps:get(presence_pid, State, undefined),
|
||||
SessionId = maps:get(id, State),
|
||||
Status = maps:get(status, State),
|
||||
Afk = maps:get(afk, State),
|
||||
Mobile = maps:get(mobile, State),
|
||||
|
||||
NewStatus = maps:get(status, Update, Status),
|
||||
NewAfk = maps:get(afk, Update, Afk),
|
||||
NewMobile = maps:get(mobile, Update, Mobile),
|
||||
|
||||
NewState = maps:merge(State, #{status => NewStatus, afk => NewAfk, mobile => NewMobile}),
|
||||
case PresencePid of
|
||||
undefined ->
|
||||
ok;
|
||||
Pid when is_pid(Pid) ->
|
||||
gen_server:cast(
|
||||
Pid,
|
||||
{presence_update, #{
|
||||
session_id => SessionId, status => NewStatus, afk => NewAfk, mobile => NewMobile
|
||||
}}
|
||||
)
|
||||
end,
|
||||
{noreply, NewState};
|
||||
handle_cast({dispatch, Event, Data}, State) ->
|
||||
session_dispatch:handle_dispatch(Event, Data, State);
|
||||
handle_cast({initial_global_presences, Presences}, State) ->
|
||||
NewState =
|
||||
lists:foldl(
|
||||
fun(Presence, AccState) ->
|
||||
{noreply, UpdatedState} = session_dispatch:handle_dispatch(
|
||||
presence_update, Presence, AccState
|
||||
),
|
||||
UpdatedState
|
||||
end,
|
||||
State,
|
||||
Presences
|
||||
),
|
||||
{noreply, NewState};
|
||||
handle_cast({guild_join, GuildId}, State) ->
|
||||
self() ! {guild_connect, GuildId, 0},
|
||||
{noreply, State};
|
||||
handle_cast({guild_leave, GuildId}, State) ->
|
||||
Guilds = maps:get(guilds, State),
|
||||
case maps:get(GuildId, Guilds, undefined) of
|
||||
{Pid, Ref} when is_pid(Pid) ->
|
||||
demonitor(Ref),
|
||||
NewGuilds = maps:put(GuildId, undefined, Guilds),
|
||||
session_dispatch:handle_dispatch(
|
||||
guild_delete, #{<<"id">> => integer_to_binary(GuildId)}, State
|
||||
),
|
||||
{noreply, maps:put(guilds, NewGuilds, State)};
|
||||
_ ->
|
||||
{noreply, State}
|
||||
end;
|
||||
handle_cast({terminate, SessionIdHashes}, State) ->
|
||||
AuthHash = maps:get(auth_session_id_hash, State),
|
||||
DecodedHashes = [base64url:decode(Hash) || Hash <- SessionIdHashes],
|
||||
case lists:member(AuthHash, DecodedHashes) of
|
||||
true -> {stop, normal, State};
|
||||
false -> {noreply, State}
|
||||
end;
|
||||
handle_cast({terminate_force}, State) ->
|
||||
{stop, normal, State};
|
||||
handle_cast({call_connect, ChannelIdBin}, State) ->
|
||||
case validation:validate_snowflake(<<"channel_id">>, ChannelIdBin) of
|
||||
{ok, ChannelId} ->
|
||||
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
|
||||
{ok, CallPid} ->
|
||||
case gen_server:call(CallPid, {get_state}, 5000) of
|
||||
{ok, CallData} ->
|
||||
session_dispatch:handle_dispatch(call_create, CallData, State);
|
||||
_ ->
|
||||
{noreply, State}
|
||||
end;
|
||||
not_found ->
|
||||
{noreply, State}
|
||||
end;
|
||||
{error, _, Reason} ->
|
||||
logger:warning("[session] Invalid channel_id for call_connect: ~p", [Reason]),
|
||||
{noreply, State}
|
||||
end;
|
||||
handle_cast({call_monitor, ChannelId, CallPid}, State) ->
|
||||
Calls = maps:get(calls, State, #{}),
|
||||
case maps:get(ChannelId, Calls, undefined) of
|
||||
undefined ->
|
||||
Ref = monitor(process, CallPid),
|
||||
NewCalls = maps:put(ChannelId, {CallPid, Ref}, Calls),
|
||||
{noreply, maps:put(calls, NewCalls, State)};
|
||||
{OldPid, OldRef} when OldPid =/= CallPid ->
|
||||
demonitor(OldRef, [flush]),
|
||||
Ref = monitor(process, CallPid),
|
||||
NewCalls = maps:put(ChannelId, {CallPid, Ref}, Calls),
|
||||
{noreply, maps:put(calls, NewCalls, State)};
|
||||
_ ->
|
||||
{noreply, State}
|
||||
end;
|
||||
handle_cast({call_unmonitor, ChannelId}, State) ->
|
||||
Calls = maps:get(calls, State, #{}),
|
||||
case maps:get(ChannelId, Calls, undefined) of
|
||||
{_Pid, Ref} ->
|
||||
demonitor(Ref, [flush]),
|
||||
NewCalls = maps:remove(ChannelId, Calls),
|
||||
{noreply, maps:put(calls, NewCalls, State)};
|
||||
undefined ->
|
||||
{noreply, State}
|
||||
end;
|
||||
handle_cast(_, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
handle_info({presence_connect, Attempt}, State) ->
|
||||
PresencePid = maps:get(presence_pid, State, undefined),
|
||||
case PresencePid of
|
||||
undefined -> session_connection:handle_presence_connect(Attempt, State);
|
||||
_ -> {noreply, State}
|
||||
end;
|
||||
handle_info({guild_connect, GuildId, Attempt}, State) ->
|
||||
session_connection:handle_guild_connect(GuildId, Attempt, State);
|
||||
handle_info({guild_connect_result, GuildId, Attempt, Result}, State) ->
|
||||
session_connection:handle_guild_connect_result(GuildId, Attempt, Result, State);
|
||||
handle_info({call_reconnect, ChannelId, Attempt}, State) ->
|
||||
session_connection:handle_call_reconnect(ChannelId, Attempt, State);
|
||||
handle_info(enable_presence_updates, State) ->
|
||||
FlushedState = session_dispatch:flush_all_pending_presences(State),
|
||||
{noreply, maps:put(suppress_presence_updates, false, FlushedState)};
|
||||
handle_info(premature_readiness, State) ->
|
||||
Ready = maps:get(ready, State),
|
||||
case Ready of
|
||||
undefined -> {noreply, State};
|
||||
_ -> session_ready:dispatch_ready_data(State)
|
||||
end;
|
||||
handle_info(bot_initial_ready, State) ->
|
||||
Ready = maps:get(ready, State, undefined),
|
||||
case Ready of
|
||||
undefined -> {noreply, State};
|
||||
_ -> session_ready:dispatch_ready_data(State)
|
||||
end;
|
||||
handle_info(resume_timeout, State) ->
|
||||
SocketPid = maps:get(socket_pid, State, undefined),
|
||||
case SocketPid of
|
||||
undefined -> {stop, normal, State};
|
||||
_ -> {noreply, State}
|
||||
end;
|
||||
handle_info({'DOWN', Ref, process, _Pid, Reason}, State) ->
|
||||
session_monitor:handle_process_down(Ref, Reason, State);
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
terminate(_Reason, _State) ->
|
||||
ok.
|
||||
|
||||
code_change(_OldVsn, State, _Extra) ->
|
||||
{ok, State}.
|
||||
|
||||
serialize_state(State) ->
|
||||
#{
|
||||
id => maps:get(id, State),
|
||||
session_id => maps:get(id, State),
|
||||
user_id => integer_to_binary(maps:get(user_id, State)),
|
||||
user_data => maps:get(user_data, State),
|
||||
version => maps:get(version, State),
|
||||
seq => maps:get(seq, State),
|
||||
ack_seq => maps:get(ack_seq, State),
|
||||
properties => maps:get(properties, State),
|
||||
status => maps:get(status, State),
|
||||
afk => maps:get(afk, State),
|
||||
mobile => maps:get(mobile, State),
|
||||
buffer => maps:get(buffer, State),
|
||||
ready => maps:get(ready, State),
|
||||
guilds => maps:get(guilds, State, #{}),
|
||||
collected_guild_states => maps:get(collected_guild_states, State),
|
||||
collected_sessions => maps:get(collected_sessions, State),
|
||||
collected_presences => maps:get(collected_presences, State, [])
|
||||
}.
|
||||
|
||||
build_ignored_events_map(Events) when is_list(Events) ->
|
||||
maps:from_list([{Event, true} || Event <- Events]);
|
||||
build_ignored_events_map(_) ->
|
||||
#{}.
|
||||
|
||||
load_private_channels(Ready) when is_map(Ready) ->
|
||||
PrivateChannels = maps:get(<<"private_channels">>, Ready, []),
|
||||
maps:from_list([
|
||||
{type_conv:extract_id(Channel, <<"id">>), Channel}
|
||||
|| Channel <- PrivateChannels
|
||||
]);
|
||||
load_private_channels(_) ->
|
||||
#{}.
|
||||
|
||||
load_relationships(Ready) when is_map(Ready) ->
|
||||
Relationships = maps:get(<<"relationships">>, Ready, []),
|
||||
maps:from_list(
|
||||
[
|
||||
{type_conv:extract_id(Rel, <<"id">>), maps:get(<<"type">>, Rel, 0)}
|
||||
|| Rel <- Relationships, type_conv:extract_id(Rel, <<"id">>) =/= undefined
|
||||
]
|
||||
);
|
||||
load_relationships(_) ->
|
||||
#{}.
|
||||
|
||||
ensure_bot_ready_map(undefined) ->
|
||||
#{<<"guilds">> => []};
|
||||
ensure_bot_ready_map(Ready) when is_map(Ready) ->
|
||||
maps:merge(Ready, #{<<"guilds">> => []});
|
||||
ensure_bot_ready_map(_) ->
|
||||
#{<<"guilds">> => []}.
|
||||
301
fluxer_gateway/src/session/session_connection.erl
Normal file
301
fluxer_gateway/src/session/session_connection.erl
Normal file
@@ -0,0 +1,301 @@
|
||||
%% 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(session_connection).
|
||||
|
||||
-export([
|
||||
handle_presence_connect/2,
|
||||
handle_guild_connect/3,
|
||||
handle_guild_connect_result/4,
|
||||
handle_call_reconnect/3
|
||||
]).
|
||||
|
||||
-define(GUILD_CONNECT_MAX_INFLIGHT, 8).
|
||||
|
||||
handle_presence_connect(Attempt, State) ->
|
||||
UserId = maps:get(user_id, State),
|
||||
UserData = maps:get(user_data, State),
|
||||
Guilds = maps:get(guilds, State),
|
||||
Status = maps:get(status, State),
|
||||
SessionId = maps:get(id, State),
|
||||
Afk = maps:get(afk, State),
|
||||
Mobile = maps:get(mobile, State),
|
||||
SocketPid = maps:get(socket_pid, State, undefined),
|
||||
FriendIds = presence_targets:friend_ids_from_state(State),
|
||||
GroupDmRecipients = presence_targets:group_dm_recipients_from_state(State),
|
||||
|
||||
Message =
|
||||
{start_or_lookup, #{
|
||||
user_id => UserId,
|
||||
user_data => UserData,
|
||||
guild_ids => maps:keys(Guilds),
|
||||
status => Status,
|
||||
friend_ids => FriendIds,
|
||||
group_dm_recipients => GroupDmRecipients,
|
||||
custom_status => maps:get(custom_status, State, null)
|
||||
}},
|
||||
|
||||
case gen_server:call(presence_manager, Message, 5000) of
|
||||
{ok, Pid} ->
|
||||
try
|
||||
case
|
||||
gen_server:call(
|
||||
Pid,
|
||||
{session_connect, #{
|
||||
session_id => SessionId,
|
||||
status => Status,
|
||||
afk => Afk,
|
||||
mobile => Mobile,
|
||||
socket_pid => SocketPid
|
||||
}},
|
||||
10000
|
||||
)
|
||||
of
|
||||
{ok, Sessions} ->
|
||||
gen_server:cast(Pid, {sync_friends, FriendIds}),
|
||||
gen_server:cast(Pid, {sync_group_dm_recipients, GroupDmRecipients}),
|
||||
NewState = maps:merge(State, #{
|
||||
presence_pid => Pid,
|
||||
presence_mref => monitor(process, Pid),
|
||||
collected_sessions => Sessions
|
||||
}),
|
||||
session_ready:check_readiness(NewState);
|
||||
_ ->
|
||||
case Attempt < 25 of
|
||||
true ->
|
||||
erlang:send_after(
|
||||
backoff_utils:calculate(Attempt),
|
||||
self(),
|
||||
{presence_connect, Attempt + 1}
|
||||
),
|
||||
{noreply, State};
|
||||
false ->
|
||||
{noreply, State}
|
||||
end
|
||||
end
|
||||
catch
|
||||
exit:{noproc, _} when Attempt < 25 ->
|
||||
erlang:send_after(
|
||||
backoff_utils:calculate(Attempt), self(), {presence_connect, Attempt + 1}
|
||||
),
|
||||
{noreply, State};
|
||||
exit:{normal, _} when Attempt < 25 ->
|
||||
erlang:send_after(
|
||||
backoff_utils:calculate(Attempt), self(), {presence_connect, Attempt + 1}
|
||||
),
|
||||
{noreply, State};
|
||||
_:_ ->
|
||||
{noreply, State}
|
||||
end;
|
||||
_ ->
|
||||
case Attempt < 25 of
|
||||
true ->
|
||||
erlang:send_after(
|
||||
backoff_utils:calculate(Attempt), self(), {presence_connect, Attempt + 1}
|
||||
),
|
||||
{noreply, State};
|
||||
false ->
|
||||
{noreply, State}
|
||||
end
|
||||
end.
|
||||
|
||||
handle_guild_connect(GuildId, Attempt, State) ->
|
||||
Guilds = maps:get(guilds, State),
|
||||
SessionId = maps:get(id, State),
|
||||
UserId = maps:get(user_id, State),
|
||||
|
||||
case maps:get(GuildId, Guilds, undefined) of
|
||||
{_Pid, _Ref} ->
|
||||
{noreply, State};
|
||||
_ ->
|
||||
maybe_spawn_guild_connect(GuildId, Attempt, SessionId, UserId, State)
|
||||
end.
|
||||
|
||||
handle_guild_connect_result(GuildId, Attempt, Result, State) ->
|
||||
Inflight = maps:get(guild_connect_inflight, State, #{}),
|
||||
case maps:get(GuildId, Inflight, undefined) of
|
||||
Attempt ->
|
||||
NewInflight = maps:remove(GuildId, Inflight),
|
||||
State1 = maps:put(guild_connect_inflight, NewInflight, State),
|
||||
handle_guild_connect_result_internal(GuildId, Attempt, Result, State1);
|
||||
_ ->
|
||||
{noreply, State}
|
||||
end.
|
||||
|
||||
handle_call_reconnect(ChannelId, Attempt, State) ->
|
||||
Calls = maps:get(calls, State, #{}),
|
||||
SessionId = maps:get(id, State),
|
||||
|
||||
case maps:get(ChannelId, Calls, undefined) of
|
||||
{_Pid, _Ref} ->
|
||||
{noreply, State};
|
||||
_ ->
|
||||
attempt_call_reconnect(ChannelId, Attempt, SessionId, State)
|
||||
end.
|
||||
|
||||
maybe_spawn_guild_connect(GuildId, Attempt, SessionId, UserId, State) ->
|
||||
Inflight0 = maps:get(guild_connect_inflight, State, #{}),
|
||||
AlreadyInflight = maps:is_key(GuildId, Inflight0),
|
||||
TooManyInflight = map_size(Inflight0) >= ?GUILD_CONNECT_MAX_INFLIGHT,
|
||||
Bot = maps:get(bot, State, false),
|
||||
case {AlreadyInflight, TooManyInflight} of
|
||||
{true, _} ->
|
||||
{noreply, State};
|
||||
{false, true} ->
|
||||
erlang:send_after(50, self(), {guild_connect, GuildId, Attempt}),
|
||||
{noreply, State};
|
||||
{false, false} ->
|
||||
Inflight = maps:put(GuildId, Attempt, Inflight0),
|
||||
State1 = maps:put(guild_connect_inflight, Inflight, State),
|
||||
SessionPid = self(),
|
||||
InitialGuildId = maps:get(initial_guild_id, State, undefined),
|
||||
spawn(fun() ->
|
||||
do_guild_connect(SessionPid, GuildId, Attempt, SessionId, UserId, Bot, InitialGuildId)
|
||||
end),
|
||||
{noreply, State1}
|
||||
end.
|
||||
|
||||
do_guild_connect(SessionPid, GuildId, Attempt, SessionId, UserId, Bot, InitialGuildId) ->
|
||||
Result =
|
||||
try
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 5000) of
|
||||
{ok, GuildPid} ->
|
||||
ActiveGuilds = build_initial_active_guilds(InitialGuildId, GuildId),
|
||||
Request = #{
|
||||
session_id => SessionId,
|
||||
user_id => UserId,
|
||||
session_pid => SessionPid,
|
||||
bot => Bot,
|
||||
initial_guild_id => InitialGuildId,
|
||||
active_guilds => ActiveGuilds
|
||||
},
|
||||
case gen_server:call(GuildPid, {session_connect, Request}, 10000) of
|
||||
{ok, unavailable, UnavailableResponse} ->
|
||||
{ok_unavailable, GuildPid, UnavailableResponse};
|
||||
{ok, GuildState} ->
|
||||
{ok, GuildPid, GuildState};
|
||||
Error ->
|
||||
{error, {session_connect_failed, Error}}
|
||||
end;
|
||||
Error ->
|
||||
{error, {guild_manager_failed, Error}}
|
||||
end
|
||||
catch
|
||||
exit:{noproc, _} ->
|
||||
{error, {guild_died, noproc}};
|
||||
exit:{normal, _} ->
|
||||
{error, {guild_died, normal}};
|
||||
_:Reason ->
|
||||
{error, {exception, Reason}}
|
||||
end,
|
||||
SessionPid ! {guild_connect_result, GuildId, Attempt, Result},
|
||||
ok.
|
||||
|
||||
handle_guild_connect_result_internal(
|
||||
GuildId, _Attempt, {ok_unavailable, GuildPid, UnavailableResponse}, State
|
||||
) ->
|
||||
finalize_guild_connection(GuildId, GuildPid, State, fun(St) ->
|
||||
session_ready:process_guild_state(UnavailableResponse, St)
|
||||
end);
|
||||
handle_guild_connect_result_internal(GuildId, _Attempt, {ok, GuildPid, GuildState}, State) ->
|
||||
finalize_guild_connection(GuildId, GuildPid, State, fun(St) ->
|
||||
session_ready:process_guild_state(GuildState, St)
|
||||
end);
|
||||
handle_guild_connect_result_internal(GuildId, Attempt, {error, {session_connect_failed, _}}, State) ->
|
||||
retry_or_fail(GuildId, Attempt, State, fun(_GId, St) -> {noreply, St} end);
|
||||
handle_guild_connect_result_internal(GuildId, Attempt, {error, _Reason}, State) ->
|
||||
retry_or_fail(GuildId, Attempt, State, fun(GId, St) ->
|
||||
session_ready:mark_guild_unavailable(GId, St)
|
||||
end).
|
||||
|
||||
finalize_guild_connection(GuildId, GuildPid, State, ReadyFun) ->
|
||||
Guilds0 = maps:get(guilds, State),
|
||||
case maps:get(GuildId, Guilds0, undefined) of
|
||||
{Pid, _Ref} when is_pid(Pid) ->
|
||||
{noreply, State};
|
||||
_ ->
|
||||
MonitorRef = monitor(process, GuildPid),
|
||||
Guilds = maps:put(GuildId, {GuildPid, MonitorRef}, Guilds0),
|
||||
State1 = maps:put(guilds, Guilds, State),
|
||||
ReadyFun(State1)
|
||||
end.
|
||||
|
||||
retry_or_fail(GuildId, Attempt, State, FailureFun) ->
|
||||
case Attempt < 25 of
|
||||
true ->
|
||||
BackoffMs = backoff_utils:calculate(Attempt),
|
||||
erlang:send_after(BackoffMs, self(), {guild_connect, GuildId, Attempt + 1}),
|
||||
{noreply, State};
|
||||
false ->
|
||||
logger:error(
|
||||
"[session_connection] Guild ~p connect failed after ~p attempts",
|
||||
[GuildId, Attempt]
|
||||
),
|
||||
FailureFun(GuildId, State)
|
||||
end.
|
||||
|
||||
attempt_call_reconnect(ChannelId, Attempt, _SessionId, State) ->
|
||||
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
|
||||
{ok, CallPid} ->
|
||||
connect_to_call_process(CallPid, ChannelId, State);
|
||||
not_found ->
|
||||
handle_call_not_found(ChannelId, Attempt, State);
|
||||
_Error ->
|
||||
handle_call_lookup_error(ChannelId, Attempt, State)
|
||||
end.
|
||||
|
||||
connect_to_call_process(CallPid, ChannelId, State) ->
|
||||
Calls = maps:get(calls, State, #{}),
|
||||
MonitorRef = monitor(process, CallPid),
|
||||
NewCalls = maps:put(ChannelId, {CallPid, MonitorRef}, Calls),
|
||||
StateWithCall = maps:put(calls, NewCalls, State),
|
||||
|
||||
case gen_server:call(CallPid, {get_state}, 5000) of
|
||||
{ok, CallData} ->
|
||||
session_dispatch:handle_dispatch(call_create, CallData, StateWithCall);
|
||||
_Error ->
|
||||
demonitor(MonitorRef, [flush]),
|
||||
{noreply, State}
|
||||
end.
|
||||
|
||||
handle_call_not_found(ChannelId, Attempt, State) ->
|
||||
retry_call_or_remove(ChannelId, Attempt, State).
|
||||
|
||||
handle_call_lookup_error(ChannelId, Attempt, State) ->
|
||||
retry_call_or_remove(ChannelId, Attempt, State).
|
||||
|
||||
retry_call_or_remove(ChannelId, Attempt, State) ->
|
||||
case Attempt < 15 of
|
||||
true ->
|
||||
erlang:send_after(
|
||||
backoff_utils:calculate(Attempt),
|
||||
self(),
|
||||
{call_reconnect, ChannelId, Attempt + 1}
|
||||
),
|
||||
{noreply, State};
|
||||
false ->
|
||||
Calls = maps:get(calls, State, #{}),
|
||||
NewCalls = maps:remove(ChannelId, Calls),
|
||||
{noreply, maps:put(calls, NewCalls, State)}
|
||||
end.
|
||||
|
||||
build_initial_active_guilds(undefined, _GuildId) ->
|
||||
sets:new();
|
||||
build_initial_active_guilds(GuildId, GuildId) ->
|
||||
sets:from_list([GuildId]);
|
||||
build_initial_active_guilds(_, _) ->
|
||||
sets:new().
|
||||
486
fluxer_gateway/src/session/session_dispatch.erl
Normal file
486
fluxer_gateway/src/session/session_dispatch.erl
Normal file
@@ -0,0 +1,486 @@
|
||||
%% 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(session_dispatch).
|
||||
|
||||
-export([
|
||||
handle_dispatch/3,
|
||||
flush_all_pending_presences/1
|
||||
]).
|
||||
|
||||
handle_dispatch(Event, Data, State) ->
|
||||
case should_ignore_event(Event, State) of
|
||||
true ->
|
||||
{noreply, State};
|
||||
false ->
|
||||
case should_buffer_presence(Event, Data, State) of
|
||||
true ->
|
||||
{noreply, buffer_presence(Event, Data, State)};
|
||||
false ->
|
||||
Seq = maps:get(seq, State),
|
||||
Buffer = maps:get(buffer, State),
|
||||
SocketPid = maps:get(socket_pid, State, undefined),
|
||||
|
||||
NewSeq = Seq + 1,
|
||||
Request = #{event => Event, data => Data, seq => NewSeq},
|
||||
|
||||
NewBuffer =
|
||||
case Event of
|
||||
message_reaction_add ->
|
||||
Buffer;
|
||||
message_reaction_remove ->
|
||||
Buffer;
|
||||
_ ->
|
||||
Buffer ++ [Request]
|
||||
end,
|
||||
|
||||
case SocketPid of
|
||||
undefined ->
|
||||
ok;
|
||||
Pid when is_pid(Pid) ->
|
||||
case erlang:is_process_alive(Pid) of
|
||||
true ->
|
||||
Pid ! {dispatch, Event, Data, NewSeq},
|
||||
ok;
|
||||
false ->
|
||||
ok
|
||||
end
|
||||
end,
|
||||
|
||||
StateWithChannels = update_channels_map(Event, Data, State),
|
||||
StateWithRelationships0 = update_relationships_map(
|
||||
Event, Data, StateWithChannels
|
||||
),
|
||||
StateAfterMain = maps:merge(StateWithRelationships0, #{
|
||||
seq => NewSeq, buffer => NewBuffer
|
||||
}),
|
||||
StateWithPending = maybe_flush_pending_presences(Event, Data, StateAfterMain),
|
||||
FinalState = sync_presence_targets(StateWithPending),
|
||||
{noreply, FinalState}
|
||||
end
|
||||
end.
|
||||
|
||||
should_buffer_presence(presence_update, Data, State) ->
|
||||
case maps:get(suppress_presence_updates, State, true) of
|
||||
true ->
|
||||
true;
|
||||
false ->
|
||||
HasGuildId =
|
||||
is_map(Data) andalso (maps:get(<<"guild_id">>, Data, undefined) =/= undefined),
|
||||
case HasGuildId of
|
||||
true ->
|
||||
false;
|
||||
false ->
|
||||
UserId = presence_user_id(Data),
|
||||
Relationships = maps:get(relationships, State, #{}),
|
||||
case UserId of
|
||||
undefined ->
|
||||
false;
|
||||
_ ->
|
||||
IsRelationship = relationship_allows_presence(UserId, Relationships),
|
||||
IsGroupDmRecipient = is_group_dm_recipient(UserId, State),
|
||||
not (IsRelationship orelse IsGroupDmRecipient)
|
||||
end
|
||||
end
|
||||
end;
|
||||
should_buffer_presence(_, _, _) ->
|
||||
false.
|
||||
|
||||
relationship_allows_presence(UserId, Relationships) when
|
||||
is_integer(UserId), is_map(Relationships)
|
||||
->
|
||||
case maps:get(UserId, Relationships, 0) of
|
||||
1 -> true;
|
||||
3 -> true;
|
||||
_ -> false
|
||||
end;
|
||||
relationship_allows_presence(_, _) ->
|
||||
false.
|
||||
|
||||
is_group_dm_recipient(UserId, State) ->
|
||||
GroupDmRecipients = presence_targets:group_dm_recipients_from_state(State),
|
||||
lists:any(
|
||||
fun({_ChannelId, Recipients}) ->
|
||||
maps:is_key(UserId, Recipients)
|
||||
end,
|
||||
maps:to_list(GroupDmRecipients)
|
||||
).
|
||||
|
||||
buffer_presence(Event, Data, State) ->
|
||||
Pending = maps:get(pending_presences, State, []),
|
||||
UserId = presence_user_id(Data),
|
||||
maps:put(
|
||||
pending_presences, Pending ++ [#{event => Event, data => Data, user_id => UserId}], State
|
||||
).
|
||||
|
||||
maybe_flush_pending_presences(relationship_add, Data, State) ->
|
||||
maybe_flush_relationship_pending_presences(Data, State);
|
||||
maybe_flush_pending_presences(relationship_update, Data, State) ->
|
||||
maybe_flush_relationship_pending_presences(Data, State);
|
||||
maybe_flush_pending_presences(_, _, State) ->
|
||||
State.
|
||||
|
||||
maybe_flush_relationship_pending_presences(Data, State) when is_map(Data) ->
|
||||
case maps:get(<<"type">>, Data, 0) of
|
||||
1 ->
|
||||
flush_pending_presences(relationship_target_id(Data), State);
|
||||
3 ->
|
||||
flush_pending_presences(relationship_target_id(Data), State);
|
||||
_ ->
|
||||
State
|
||||
end;
|
||||
maybe_flush_relationship_pending_presences(_Data, State) ->
|
||||
State.
|
||||
|
||||
flush_pending_presences(undefined, State) ->
|
||||
State;
|
||||
flush_pending_presences(UserId, State) ->
|
||||
Pending = maps:get(pending_presences, State, []),
|
||||
{ToSend, Remaining} =
|
||||
lists:partition(fun(P) -> maps:get(user_id, P, undefined) =:= UserId end, Pending),
|
||||
FlushedState =
|
||||
lists:foldl(
|
||||
fun(P, AccState) ->
|
||||
dispatch_presence_now(P, AccState)
|
||||
end,
|
||||
State,
|
||||
ToSend
|
||||
),
|
||||
maps:put(pending_presences, Remaining, FlushedState).
|
||||
|
||||
dispatch_presence_now(P, State) ->
|
||||
Event = maps:get(event, P),
|
||||
Data = maps:get(data, P),
|
||||
Seq = maps:get(seq, State),
|
||||
Buffer = maps:get(buffer, State),
|
||||
SocketPid = maps:get(socket_pid, State, undefined),
|
||||
|
||||
NewSeq = Seq + 1,
|
||||
Request = #{event => Event, data => Data, seq => NewSeq},
|
||||
NewBuffer = Buffer ++ [Request],
|
||||
|
||||
case SocketPid of
|
||||
undefined ->
|
||||
ok;
|
||||
Pid when is_pid(Pid) ->
|
||||
case erlang:is_process_alive(Pid) of
|
||||
true ->
|
||||
Pid ! {dispatch, Event, Data, NewSeq},
|
||||
ok;
|
||||
false ->
|
||||
ok
|
||||
end
|
||||
end,
|
||||
|
||||
maps:merge(State, #{seq => NewSeq, buffer => NewBuffer}).
|
||||
|
||||
presence_user_id(Data) when is_map(Data) ->
|
||||
User = maps:get(<<"user">>, Data, #{}),
|
||||
map_utils:get_integer(User, <<"id">>, undefined);
|
||||
presence_user_id(_) ->
|
||||
undefined.
|
||||
|
||||
relationship_target_id(Data) when is_map(Data) ->
|
||||
type_conv:extract_id(Data, <<"id">>).
|
||||
|
||||
flush_all_pending_presences(State) ->
|
||||
Pending = maps:get(pending_presences, State, []),
|
||||
FlushedState =
|
||||
lists:foldl(
|
||||
fun(P, AccState) ->
|
||||
dispatch_presence_now(P, AccState)
|
||||
end,
|
||||
State,
|
||||
Pending
|
||||
),
|
||||
maps:put(pending_presences, [], FlushedState).
|
||||
|
||||
should_ignore_event(Event, State) ->
|
||||
IgnoredEvents = maps:get(ignored_events, State, #{}),
|
||||
case event_name(Event) of
|
||||
undefined ->
|
||||
false;
|
||||
EventName ->
|
||||
maps:is_key(EventName, IgnoredEvents)
|
||||
end.
|
||||
|
||||
event_name(Event) when is_binary(Event) ->
|
||||
Event;
|
||||
event_name(Event) when is_atom(Event) ->
|
||||
try constants:dispatch_event_atom(Event) of
|
||||
Name when is_binary(Name) ->
|
||||
Name
|
||||
catch
|
||||
_:_ ->
|
||||
undefined
|
||||
end;
|
||||
event_name(_) ->
|
||||
undefined.
|
||||
|
||||
update_channels_map(channel_create, Data, State) when is_map(Data) ->
|
||||
case maps:get(<<"type">>, Data, undefined) of
|
||||
1 ->
|
||||
add_channel_to_state(Data, State);
|
||||
3 ->
|
||||
add_channel_to_state(Data, State);
|
||||
_ ->
|
||||
State
|
||||
end;
|
||||
update_channels_map(channel_update, Data, State) when is_map(Data) ->
|
||||
case maps:get(<<"type">>, Data, undefined) of
|
||||
1 ->
|
||||
add_channel_to_state(Data, State);
|
||||
3 ->
|
||||
add_channel_to_state(Data, State);
|
||||
_ ->
|
||||
State
|
||||
end;
|
||||
update_channels_map(channel_delete, Data, State) when is_map(Data) ->
|
||||
case maps:get(<<"id">>, Data, undefined) of
|
||||
undefined ->
|
||||
State;
|
||||
ChannelIdBin ->
|
||||
case validation:validate_snowflake(<<"id">>, ChannelIdBin) of
|
||||
{ok, ChannelId} ->
|
||||
Channels = maps:get(channels, State, #{}),
|
||||
NewChannels = maps:remove(ChannelId, Channels),
|
||||
maps:put(channels, NewChannels, State);
|
||||
{error, _, _} ->
|
||||
State
|
||||
end
|
||||
end;
|
||||
update_channels_map(channel_recipient_add, Data, State) when is_map(Data) ->
|
||||
update_recipient_membership(add, Data, State);
|
||||
update_channels_map(channel_recipient_remove, Data, State) when is_map(Data) ->
|
||||
update_recipient_membership(remove, Data, State);
|
||||
update_channels_map(_Event, _Data, State) ->
|
||||
State.
|
||||
|
||||
add_channel_to_state(Data, State) ->
|
||||
case maps:get(<<"id">>, Data, undefined) of
|
||||
undefined ->
|
||||
State;
|
||||
ChannelIdBin ->
|
||||
case validation:validate_snowflake(<<"id">>, ChannelIdBin) of
|
||||
{ok, ChannelId} ->
|
||||
Channels = maps:get(channels, State, #{}),
|
||||
NewChannels = maps:put(ChannelId, Data, Channels),
|
||||
UserId = maps:get(user_id, State),
|
||||
logger:info(
|
||||
"[session_dispatch] Added/updated channel ~p for user ~p, type: ~p",
|
||||
[ChannelId, UserId, maps:get(<<"type">>, Data, 0)]
|
||||
),
|
||||
maps:put(channels, NewChannels, State);
|
||||
{error, _, _} ->
|
||||
State
|
||||
end
|
||||
end.
|
||||
|
||||
update_recipient_membership(Action, Data, State) ->
|
||||
ChannelIdBin = maps:get(<<"channel_id">>, Data, undefined),
|
||||
case validation:validate_snowflake(<<"channel_id">>, ChannelIdBin) of
|
||||
{ok, ChannelId} ->
|
||||
Channels = maps:get(channels, State, #{}),
|
||||
case maps:get(ChannelId, Channels, undefined) of
|
||||
undefined ->
|
||||
State;
|
||||
Channel ->
|
||||
case maps:get(<<"type">>, Channel, 0) of
|
||||
3 ->
|
||||
UserMap = maps:get(<<"user">>, Data, #{}),
|
||||
RecipientId = type_conv:extract_id(UserMap, <<"id">>),
|
||||
case RecipientId of
|
||||
undefined ->
|
||||
State;
|
||||
_ ->
|
||||
UpdatedChannel = update_channel_recipient(
|
||||
Channel, RecipientId, UserMap, Action
|
||||
),
|
||||
NewChannels = maps:put(ChannelId, UpdatedChannel, Channels),
|
||||
maps:put(channels, NewChannels, State)
|
||||
end;
|
||||
_ ->
|
||||
State
|
||||
end
|
||||
end;
|
||||
_ ->
|
||||
State
|
||||
end.
|
||||
|
||||
update_channel_recipient(Channel, RecipientId, UserMap, add) ->
|
||||
RecipientIds = maps:get(<<"recipient_ids">>, Channel, []),
|
||||
Recipients = maps:get(<<"recipients">>, Channel, []),
|
||||
NewRecipientIds = add_unique_id(RecipientId, RecipientIds),
|
||||
NewRecipients = add_unique_user(UserMap, Recipients),
|
||||
Channel#{<<"recipient_ids">> => NewRecipientIds, <<"recipients">> => NewRecipients};
|
||||
update_channel_recipient(Channel, RecipientId, _UserMap, remove) ->
|
||||
RecipientIds = maps:get(<<"recipient_ids">>, Channel, []),
|
||||
Recipients = maps:get(<<"recipients">>, Channel, []),
|
||||
NewRecipientIds = lists:filter(
|
||||
fun(Id) -> Id =/= integer_to_binary(RecipientId) andalso Id =/= RecipientId end,
|
||||
RecipientIds
|
||||
),
|
||||
NewRecipients = lists:filter(
|
||||
fun(R) ->
|
||||
case type_conv:extract_id(R, <<"id">>) of
|
||||
RecipientId -> false;
|
||||
_ -> true
|
||||
end
|
||||
end,
|
||||
Recipients
|
||||
),
|
||||
Channel#{<<"recipient_ids">> => NewRecipientIds, <<"recipients">> => NewRecipients}.
|
||||
|
||||
add_unique_id(Id, List) ->
|
||||
case lists:member(Id, List) orelse lists:member(integer_to_binary(Id), List) of
|
||||
true -> List;
|
||||
false -> [Id | List]
|
||||
end.
|
||||
|
||||
add_unique_user(UserMap, List) when is_map(UserMap) ->
|
||||
case type_conv:extract_id(UserMap, <<"id">>) of
|
||||
undefined ->
|
||||
List;
|
||||
Id ->
|
||||
case
|
||||
lists:any(
|
||||
fun(R) -> type_conv:extract_id(R, <<"id">>) =:= Id end,
|
||||
List
|
||||
)
|
||||
of
|
||||
true -> List;
|
||||
false -> [UserMap | List]
|
||||
end
|
||||
end.
|
||||
|
||||
update_relationships_map(relationship_add, Data, State) ->
|
||||
upsert_relationship(Data, State);
|
||||
update_relationships_map(relationship_update, Data, State) ->
|
||||
upsert_relationship(Data, State);
|
||||
update_relationships_map(relationship_remove, Data, State) ->
|
||||
case type_conv:extract_id(Data, <<"id">>) of
|
||||
undefined ->
|
||||
State;
|
||||
UserId ->
|
||||
Relationships = maps:get(relationships, State, #{}),
|
||||
NewRelationships = maps:remove(UserId, Relationships),
|
||||
maps:put(relationships, NewRelationships, State)
|
||||
end;
|
||||
update_relationships_map(_, _, State) ->
|
||||
State.
|
||||
|
||||
upsert_relationship(Data, State) ->
|
||||
case type_conv:extract_id(Data, <<"id">>) of
|
||||
undefined ->
|
||||
State;
|
||||
UserId ->
|
||||
Type = maps:get(<<"type">>, Data, 0),
|
||||
Relationships = maps:get(relationships, State, #{}),
|
||||
NewRelationships = maps:put(UserId, Type, Relationships),
|
||||
maps:put(relationships, NewRelationships, State)
|
||||
end.
|
||||
|
||||
sync_presence_targets(State) ->
|
||||
PresencePid = maps:get(presence_pid, State, undefined),
|
||||
case PresencePid of
|
||||
undefined ->
|
||||
State;
|
||||
Pid when is_pid(Pid) ->
|
||||
FriendIds = presence_targets:friend_ids_from_state(State),
|
||||
GroupRecipients = presence_targets:group_dm_recipients_from_state(State),
|
||||
gen_server:cast(Pid, {sync_friends, FriendIds}),
|
||||
gen_server:cast(Pid, {sync_group_dm_recipients, GroupRecipients}),
|
||||
State
|
||||
end.
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
base_state_for_presence_buffer_test(Opts) ->
|
||||
maps:merge(
|
||||
#{
|
||||
seq => 0,
|
||||
user_id => 1,
|
||||
buffer => [],
|
||||
socket_pid => undefined,
|
||||
channels => #{},
|
||||
relationships => #{},
|
||||
suppress_presence_updates => false,
|
||||
pending_presences => [],
|
||||
presence_pid => undefined,
|
||||
ignored_events => #{}
|
||||
},
|
||||
Opts
|
||||
).
|
||||
|
||||
presence_update_with_guild_id_not_buffered_test() ->
|
||||
State0 = base_state_for_presence_buffer_test(#{}),
|
||||
Presence = #{
|
||||
<<"guild_id">> => <<"123">>,
|
||||
<<"user">> => #{<<"id">> => <<"2">>},
|
||||
<<"status">> => <<"idle">>
|
||||
},
|
||||
{noreply, State1} = handle_dispatch(presence_update, Presence, State0),
|
||||
?assertEqual([], maps:get(pending_presences, State1, [])),
|
||||
?assertEqual(1, length(maps:get(buffer, State1, []))),
|
||||
ok.
|
||||
|
||||
presence_update_without_guild_id_buffered_for_non_relationship_test() ->
|
||||
State0 = base_state_for_presence_buffer_test(#{}),
|
||||
Presence = #{
|
||||
<<"user">> => #{<<"id">> => <<"2">>},
|
||||
<<"status">> => <<"online">>
|
||||
},
|
||||
{noreply, State1} = handle_dispatch(presence_update, Presence, State0),
|
||||
?assertEqual(1, length(maps:get(pending_presences, State1, []))),
|
||||
?assertEqual([], maps:get(buffer, State1, [])),
|
||||
ok.
|
||||
|
||||
presence_update_without_guild_id_not_buffered_for_relationship_test() ->
|
||||
State0 = base_state_for_presence_buffer_test(#{relationships => #{2 => 1}}),
|
||||
Presence = #{
|
||||
<<"user">> => #{<<"id">> => <<"2">>},
|
||||
<<"status">> => <<"online">>
|
||||
},
|
||||
{noreply, State1} = handle_dispatch(presence_update, Presence, State0),
|
||||
?assertEqual([], maps:get(pending_presences, State1, [])),
|
||||
?assertEqual(1, length(maps:get(buffer, State1, []))),
|
||||
ok.
|
||||
|
||||
presence_update_without_guild_id_buffered_for_outgoing_request_relationship_test() ->
|
||||
State0 = base_state_for_presence_buffer_test(#{relationships => #{2 => 4}}),
|
||||
Presence = #{
|
||||
<<"user">> => #{<<"id">> => <<"2">>},
|
||||
<<"status">> => <<"online">>
|
||||
},
|
||||
{noreply, State1} = handle_dispatch(presence_update, Presence, State0),
|
||||
?assertEqual(1, length(maps:get(pending_presences, State1, []))),
|
||||
?assertEqual([], maps:get(buffer, State1, [])),
|
||||
ok.
|
||||
|
||||
presence_update_without_guild_id_not_buffered_for_incoming_request_relationship_test() ->
|
||||
State0 = base_state_for_presence_buffer_test(#{relationships => #{2 => 3}}),
|
||||
Presence = #{
|
||||
<<"user">> => #{<<"id">> => <<"2">>},
|
||||
<<"status">> => <<"online">>
|
||||
},
|
||||
{noreply, State1} = handle_dispatch(presence_update, Presence, State0),
|
||||
?assertEqual([], maps:get(pending_presences, State1, [])),
|
||||
?assertEqual(1, length(maps:get(buffer, State1, []))),
|
||||
ok.
|
||||
|
||||
-endif.
|
||||
559
fluxer_gateway/src/session/session_manager.erl
Normal file
559
fluxer_gateway/src/session/session_manager.erl
Normal file
@@ -0,0 +1,559 @@
|
||||
%% 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(session_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]).
|
||||
-export_type([session_data/0, user_id/0]).
|
||||
|
||||
-type session_id() :: binary().
|
||||
-type user_id() :: integer().
|
||||
-type session_ref() :: {pid(), reference()}.
|
||||
-type status() :: online | offline | idle | dnd.
|
||||
-type identify_timestamp() :: integer().
|
||||
-define(IDENTIFY_FLAG_USE_CANARY_API, 16#1).
|
||||
|
||||
-type identify_request() :: #{
|
||||
session_id := session_id(),
|
||||
identify_data := map(),
|
||||
version := non_neg_integer(),
|
||||
peer_ip := term(),
|
||||
token := binary()
|
||||
}.
|
||||
|
||||
-type session_data() :: #{
|
||||
id := session_id(),
|
||||
user_id := user_id(),
|
||||
user_data := map(),
|
||||
version := non_neg_integer(),
|
||||
token_hash := binary(),
|
||||
auth_session_id_hash := binary(),
|
||||
properties := map(),
|
||||
status := status(),
|
||||
afk := boolean(),
|
||||
mobile := boolean(),
|
||||
socket_pid := pid(),
|
||||
guilds := [integer()],
|
||||
ready := map(),
|
||||
ignored_events := [binary()]
|
||||
}.
|
||||
|
||||
-type state() :: #{
|
||||
sessions := #{session_id() => session_ref()},
|
||||
api_host := string(),
|
||||
api_canary_host := undefined | string(),
|
||||
identify_attempts := [identify_timestamp()]
|
||||
}.
|
||||
|
||||
-spec start_link() -> {ok, pid()} | {error, term()}.
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||
|
||||
-spec init([]) -> {ok, state()}.
|
||||
init([]) ->
|
||||
fluxer_gateway_env:load(),
|
||||
process_flag(trap_exit, true),
|
||||
ApiHost = fluxer_gateway_env:get(api_host),
|
||||
ApiCanaryHost = fluxer_gateway_env:get(api_canary_host),
|
||||
{ok, #{
|
||||
sessions => #{},
|
||||
api_host => ApiHost,
|
||||
api_canary_host => ApiCanaryHost,
|
||||
identify_attempts => []
|
||||
}}.
|
||||
|
||||
-spec handle_call(Request, From, State) -> Result when
|
||||
Request ::
|
||||
{start, identify_request(), pid()}
|
||||
| {lookup, session_id()}
|
||||
| get_local_count
|
||||
| get_global_count
|
||||
| term(),
|
||||
From :: gen_server:from(),
|
||||
State :: state(),
|
||||
Result :: {reply, Reply, state()},
|
||||
Reply ::
|
||||
{success, pid()}
|
||||
| {ok, pid()}
|
||||
| {error, not_found}
|
||||
| {error, identify_rate_limited}
|
||||
| {error, invalid_token}
|
||||
| {error, rate_limited}
|
||||
| {error, {server_error, non_neg_integer()}}
|
||||
| {error, {http_error, non_neg_integer()}}
|
||||
| {error, {network_error, term()}}
|
||||
| {error, registration_failed}
|
||||
| {error, term()}
|
||||
| {ok, non_neg_integer()}
|
||||
| ok.
|
||||
handle_call(
|
||||
{start, Request, SocketPid},
|
||||
_From,
|
||||
State
|
||||
) ->
|
||||
Sessions = maps:get(sessions, State),
|
||||
Attempts = maps:get(identify_attempts, State),
|
||||
SessionId = maps:get(session_id, Request),
|
||||
case maps:get(SessionId, Sessions, undefined) of
|
||||
{Pid, _Ref} ->
|
||||
{reply, {success, Pid}, State};
|
||||
undefined ->
|
||||
SessionName = process_registry:build_process_name(session, SessionId),
|
||||
case whereis(SessionName) of
|
||||
undefined ->
|
||||
case check_identify_rate_limit(Attempts) of
|
||||
{ok, NewAttempts} ->
|
||||
handle_identify_request(
|
||||
Request,
|
||||
SocketPid,
|
||||
SessionId,
|
||||
Sessions,
|
||||
maps:put(identify_attempts, NewAttempts, State)
|
||||
);
|
||||
{error, rate_limited} ->
|
||||
{reply, {error, identify_rate_limited}, State}
|
||||
end;
|
||||
Pid ->
|
||||
Ref = monitor(process, Pid),
|
||||
NewSessions = maps:put(SessionId, {Pid, Ref}, Sessions),
|
||||
{reply, {success, Pid}, maps:put(sessions, NewSessions, State)}
|
||||
end
|
||||
end;
|
||||
handle_call({lookup, SessionId}, _From, State) ->
|
||||
Sessions = maps:get(sessions, State),
|
||||
case maps:get(SessionId, Sessions, undefined) of
|
||||
{Pid, _Ref} ->
|
||||
{reply, {ok, Pid}, State};
|
||||
undefined ->
|
||||
SessionName = process_registry:build_process_name(session, SessionId),
|
||||
case whereis(SessionName) of
|
||||
undefined ->
|
||||
{reply, {error, not_found}, State};
|
||||
Pid ->
|
||||
Ref = monitor(process, Pid),
|
||||
NewSessions = maps:put(SessionId, {Pid, Ref}, Sessions),
|
||||
{reply, {ok, Pid}, maps:put(sessions, NewSessions, State)}
|
||||
end
|
||||
end;
|
||||
handle_call(get_local_count, _From, State) ->
|
||||
Sessions = maps:get(sessions, State),
|
||||
{reply, {ok, maps:size(Sessions)}, State};
|
||||
handle_call(get_global_count, _From, State) ->
|
||||
Sessions = maps:get(sessions, State),
|
||||
{reply, {ok, maps:size(Sessions)}, State};
|
||||
handle_call(_, _From, State) ->
|
||||
{reply, ok, State}.
|
||||
|
||||
-spec handle_identify_request(
|
||||
identify_request(),
|
||||
pid(),
|
||||
session_id(),
|
||||
#{session_id() => session_ref()},
|
||||
state()
|
||||
) ->
|
||||
{reply,
|
||||
{success, pid()}
|
||||
| {error, invalid_token}
|
||||
| {error, rate_limited}
|
||||
| {error, {server_error, non_neg_integer()}}
|
||||
| {error, {http_error, non_neg_integer()}}
|
||||
| {error, {network_error, term()}}
|
||||
| {error, registration_failed}
|
||||
| {error, term()},
|
||||
state()}.
|
||||
handle_identify_request(
|
||||
Request, SocketPid, SessionId, Sessions, State
|
||||
) ->
|
||||
IdentifyData = maps:get(identify_data, Request),
|
||||
Version = maps:get(version, Request),
|
||||
PeerIP = maps:get(peer_ip, Request),
|
||||
UseCanary = should_use_canary_api(IdentifyData),
|
||||
{_UsedCanary, RpcClient} = select_rpc_client(State, UseCanary),
|
||||
case fetch_rpc_data(Request, PeerIP, RpcClient) of
|
||||
{ok, Data} ->
|
||||
UserDataMap = maps:get(<<"user">>, Data),
|
||||
UserId = type_conv:extract_id(UserDataMap, <<"id">>),
|
||||
AuthSessionIdHashEncoded = maps:get(<<"auth_session_id_hash">>, Data, undefined),
|
||||
AuthSessionIdHash =
|
||||
case AuthSessionIdHashEncoded of
|
||||
undefined -> <<>>;
|
||||
null -> <<>>;
|
||||
_ -> base64url:decode(AuthSessionIdHashEncoded)
|
||||
end,
|
||||
Status = parse_presence(Data, IdentifyData),
|
||||
GuildIds = parse_guild_ids(Data),
|
||||
Properties = maps:get(properties, IdentifyData),
|
||||
Presence = map_utils:get_safe(IdentifyData, presence, null),
|
||||
IgnoredEvents = map_utils:get_safe(IdentifyData, ignored_events, []),
|
||||
InitialGuildId = map_utils:get_safe(IdentifyData, initial_guild_id, undefined),
|
||||
Bot = map_utils:get_safe(UserDataMap, <<"bot">>, false),
|
||||
ReadyData =
|
||||
case Bot of
|
||||
true -> maps:merge(Data, #{<<"guilds">> => []});
|
||||
false -> Data
|
||||
end,
|
||||
UserSettingsMap = map_utils:get_safe(Data, <<"user_settings">>, #{}),
|
||||
CustomStatusFromSettings = map_utils:get_safe(
|
||||
UserSettingsMap, <<"custom_status">>, null
|
||||
),
|
||||
PresenceCustomStatus = get_presence_custom_status(Presence),
|
||||
CustomStatus =
|
||||
case CustomStatusFromSettings of
|
||||
null -> PresenceCustomStatus;
|
||||
_ -> CustomStatusFromSettings
|
||||
end,
|
||||
Mobile =
|
||||
case Presence of
|
||||
null -> map_utils:get_safe(Properties, <<"mobile">>, false);
|
||||
P when is_map(P) -> map_utils:get_safe(P, <<"mobile">>, false);
|
||||
_ -> false
|
||||
end,
|
||||
Afk =
|
||||
case Presence of
|
||||
null -> false;
|
||||
P2 when is_map(P2) -> map_utils:get_safe(P2, <<"afk">>, false);
|
||||
_ -> false
|
||||
end,
|
||||
UserData0 = #{
|
||||
<<"id">> => maps:get(<<"id">>, UserDataMap),
|
||||
<<"username">> => maps:get(<<"username">>, UserDataMap),
|
||||
<<"discriminator">> => maps:get(<<"discriminator">>, UserDataMap),
|
||||
<<"avatar">> => maps:get(<<"avatar">>, UserDataMap),
|
||||
<<"avatar_color">> => map_utils:get_safe(
|
||||
UserDataMap, <<"avatar_color">>, undefined
|
||||
),
|
||||
<<"bot">> => map_utils:get_safe(UserDataMap, <<"bot">>, undefined),
|
||||
<<"system">> => map_utils:get_safe(UserDataMap, <<"system">>, undefined),
|
||||
<<"flags">> => maps:get(<<"flags">>, UserDataMap)
|
||||
},
|
||||
UserData = user_utils:normalize_user(UserData0),
|
||||
SessionData = #{
|
||||
id => SessionId,
|
||||
user_id => UserId,
|
||||
user_data => UserData,
|
||||
custom_status => CustomStatus,
|
||||
version => Version,
|
||||
token_hash => utils:hash_token(maps:get(token, IdentifyData)),
|
||||
auth_session_id_hash => AuthSessionIdHash,
|
||||
properties => Properties,
|
||||
status => Status,
|
||||
afk => Afk,
|
||||
mobile => Mobile,
|
||||
socket_pid => SocketPid,
|
||||
guilds => GuildIds,
|
||||
ready => ReadyData,
|
||||
bot => Bot,
|
||||
ignored_events => IgnoredEvents,
|
||||
initial_guild_id => InitialGuildId
|
||||
},
|
||||
SessionName = process_registry:build_process_name(session, SessionId),
|
||||
case whereis(SessionName) of
|
||||
undefined ->
|
||||
case session:start_link(SessionData) of
|
||||
{ok, Pid} ->
|
||||
case
|
||||
process_registry:register_and_monitor(SessionName, Pid, Sessions)
|
||||
of
|
||||
{ok, RegisteredPid, Ref, NewSessions0} ->
|
||||
CleanSessions = maps:remove(SessionName, NewSessions0),
|
||||
NewSessions = maps:put(
|
||||
SessionId, {RegisteredPid, Ref}, CleanSessions
|
||||
),
|
||||
{reply, {success, RegisteredPid}, maps:put(
|
||||
sessions, NewSessions, State
|
||||
)};
|
||||
{error, registration_race_condition} ->
|
||||
{reply, {error, registration_failed}, State};
|
||||
{error, _Reason} ->
|
||||
{reply, {error, registration_failed}, State}
|
||||
end;
|
||||
Error ->
|
||||
{reply, Error, State}
|
||||
end;
|
||||
ExistingPid ->
|
||||
Ref = monitor(process, ExistingPid),
|
||||
CleanSessions = maps:remove(SessionName, Sessions),
|
||||
NewSessions = maps:put(SessionId, {ExistingPid, Ref}, CleanSessions),
|
||||
{reply, {success, ExistingPid}, maps:put(sessions, NewSessions, State)}
|
||||
end;
|
||||
{error, invalid_token} ->
|
||||
{reply, {error, invalid_token}, State};
|
||||
{error, rate_limited} ->
|
||||
{reply, {error, rate_limited}, State};
|
||||
{error, Reason} ->
|
||||
{reply, {error, Reason}, State}
|
||||
end.
|
||||
|
||||
-spec handle_cast(term(), state()) -> {noreply, state()}.
|
||||
handle_cast(_, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
select_rpc_client(State, true) ->
|
||||
case maps:get(api_canary_host, State) of
|
||||
undefined ->
|
||||
logger:warning(
|
||||
"[session_manager] Canary API requested but not configured, falling back to stable API"
|
||||
),
|
||||
{false, maps:get(api_host, State)};
|
||||
CanaryHost ->
|
||||
{true, CanaryHost}
|
||||
end;
|
||||
select_rpc_client(State, false) ->
|
||||
{false, maps:get(api_host, State)}.
|
||||
|
||||
should_use_canary_api(IdentifyData) ->
|
||||
case map_utils:get_safe(IdentifyData, flags, 0) of
|
||||
Flags when is_integer(Flags), Flags >= 0 ->
|
||||
(Flags band ?IDENTIFY_FLAG_USE_CANARY_API) =/= 0;
|
||||
_ ->
|
||||
false
|
||||
end.
|
||||
|
||||
-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) ->
|
||||
Sessions = maps:get(sessions, State),
|
||||
NewSessions = process_registry:cleanup_on_down(Pid, Sessions),
|
||||
{noreply, maps:put(sessions, NewSessions, State)};
|
||||
handle_info(_, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec terminate(Reason, State) -> ok when
|
||||
Reason :: term(),
|
||||
State :: state().
|
||||
terminate(_Reason, _State) ->
|
||||
ok.
|
||||
|
||||
-spec code_change(OldVsn, State, Extra) -> {ok, state()} when
|
||||
OldVsn :: term(),
|
||||
State :: state() | tuple(),
|
||||
Extra :: term().
|
||||
code_change(_OldVsn, State, _Extra) when is_map(State) ->
|
||||
{ok, State};
|
||||
code_change(_OldVsn, State, _Extra) when is_tuple(State), element(1, State) =:= state ->
|
||||
Sessions = element(2, State),
|
||||
ApiHost = element(3, State),
|
||||
ApiCanaryHost = element(4, State),
|
||||
IdentifyAttempts = element(5, State),
|
||||
{ok, #{
|
||||
sessions => Sessions,
|
||||
api_host => ApiHost,
|
||||
api_canary_host => ApiCanaryHost,
|
||||
identify_attempts => IdentifyAttempts
|
||||
}};
|
||||
code_change(_OldVsn, State, _Extra) ->
|
||||
{ok, State}.
|
||||
|
||||
-spec fetch_rpc_data(map(), term(), string()) ->
|
||||
{ok, map()}
|
||||
| {error, invalid_token}
|
||||
| {error, rate_limited}
|
||||
| {error, {server_error, non_neg_integer()}}
|
||||
| {error, {http_error, non_neg_integer()}}
|
||||
| {error, {network_error, term()}}.
|
||||
fetch_rpc_data(Request, PeerIP, ApiHost) ->
|
||||
StartTime = erlang:system_time(millisecond),
|
||||
Result = do_fetch_rpc_data(Request, PeerIP, ApiHost),
|
||||
EndTime = erlang:system_time(millisecond),
|
||||
LatencyMs = EndTime - StartTime,
|
||||
gateway_metrics_collector:record_rpc_latency(LatencyMs),
|
||||
Result.
|
||||
|
||||
-spec do_fetch_rpc_data(map(), term(), string()) ->
|
||||
{ok, map()}
|
||||
| {error, invalid_token}
|
||||
| {error, rate_limited}
|
||||
| {error, {server_error, non_neg_integer()}}
|
||||
| {error, {http_error, non_neg_integer()}}
|
||||
| {error, {network_error, term()}}.
|
||||
do_fetch_rpc_data(Request, PeerIP, ApiHost) ->
|
||||
Url = rpc_client:get_rpc_url(ApiHost),
|
||||
Headers = rpc_client:get_rpc_headers() ++ [{<<"content-type">>, <<"application/json">>}],
|
||||
IdentifyData = maps:get(identify_data, Request),
|
||||
Properties = map_utils:get_safe(IdentifyData, properties, #{}),
|
||||
LatitudeRaw = map_utils:get_safe(Properties, <<"latitude">>, undefined),
|
||||
LongitudeRaw = map_utils:get_safe(Properties, <<"longitude">>, undefined),
|
||||
Latitude =
|
||||
case LatitudeRaw of
|
||||
undefined -> undefined;
|
||||
null -> undefined;
|
||||
SafeLatitude -> SafeLatitude
|
||||
end,
|
||||
Longitude =
|
||||
case LongitudeRaw of
|
||||
undefined -> undefined;
|
||||
null -> undefined;
|
||||
SafeLongitude -> SafeLongitude
|
||||
end,
|
||||
RpcRequest = #{
|
||||
<<"type">> => <<"session">>,
|
||||
<<"token">> => maps:get(token, IdentifyData),
|
||||
<<"version">> => maps:get(version, Request),
|
||||
<<"ip">> => PeerIP
|
||||
},
|
||||
RpcRequestWithLatitude =
|
||||
case Latitude of
|
||||
undefined -> RpcRequest;
|
||||
LatitudeValue -> maps:put(<<"latitude">>, LatitudeValue, RpcRequest)
|
||||
end,
|
||||
RpcRequestWithLongitude =
|
||||
case Longitude of
|
||||
undefined -> RpcRequestWithLatitude;
|
||||
LongitudeValue -> maps:put(<<"longitude">>, LongitudeValue, RpcRequestWithLatitude)
|
||||
end,
|
||||
Body = jsx:encode(RpcRequestWithLongitude),
|
||||
case hackney:request(post, Url, Headers, Body, []) of
|
||||
{ok, 200, _RespHeaders, ClientRef} ->
|
||||
case hackney:body(ClientRef) of
|
||||
{ok, ResponseBody} ->
|
||||
hackney:close(ClientRef),
|
||||
ResponseData = jsx:decode(ResponseBody, [{return_maps, true}]),
|
||||
{ok, maps:get(<<"data">>, ResponseData)};
|
||||
{error, BodyError} ->
|
||||
hackney:close(ClientRef),
|
||||
logger:error("[session_manager] Failed to read response body: ~p", [BodyError]),
|
||||
{error, {network_error, BodyError}}
|
||||
end;
|
||||
{ok, 401, _, ClientRef} ->
|
||||
hackney:close(ClientRef),
|
||||
logger:info("[session_manager] RPC authentication failed (401)"),
|
||||
{error, invalid_token};
|
||||
{ok, 429, _, ClientRef} ->
|
||||
hackney:close(ClientRef),
|
||||
logger:warning("[session_manager] RPC rate limited (429)"),
|
||||
{error, rate_limited};
|
||||
{ok, StatusCode, _, ClientRef} when StatusCode >= 500 ->
|
||||
ErrorBody =
|
||||
case hackney:body(ClientRef) of
|
||||
{ok, Body2} -> Body2;
|
||||
{error, _} -> <<"<unable to read error body>">>
|
||||
end,
|
||||
hackney:close(ClientRef),
|
||||
logger:error("[session_manager] RPC server error ~p: ~s", [StatusCode, ErrorBody]),
|
||||
{error, {server_error, StatusCode}};
|
||||
{ok, StatusCode, _, ClientRef} when StatusCode >= 400 ->
|
||||
ErrorBody =
|
||||
case hackney:body(ClientRef) of
|
||||
{ok, Body2} -> Body2;
|
||||
{error, _} -> <<"<unable to read error body>">>
|
||||
end,
|
||||
hackney:close(ClientRef),
|
||||
logger:warning("[session_manager] RPC client error ~p: ~s", [StatusCode, ErrorBody]),
|
||||
{error, {http_error, StatusCode}};
|
||||
{ok, StatusCode, _, ClientRef} ->
|
||||
hackney:close(ClientRef),
|
||||
logger:warning("[session_manager] RPC unexpected status: ~p", [StatusCode]),
|
||||
{error, {http_error, StatusCode}};
|
||||
{error, Reason} ->
|
||||
logger:error("[session_manager] RPC request failed: ~p", [Reason]),
|
||||
{error, {network_error, Reason}}
|
||||
end.
|
||||
|
||||
-spec parse_presence(map(), map()) -> status().
|
||||
parse_presence(Data, IdentifyData) ->
|
||||
StoredStatus = get_stored_status(Data),
|
||||
PresenceStatus =
|
||||
case map_utils:get_safe(IdentifyData, presence, null) of
|
||||
null ->
|
||||
undefined;
|
||||
Presence when is_map(Presence) ->
|
||||
map_utils:get_safe(Presence, status, <<"online">>);
|
||||
_ ->
|
||||
undefined
|
||||
end,
|
||||
SelectedStatus = select_initial_status(PresenceStatus, StoredStatus),
|
||||
utils:parse_status(SelectedStatus).
|
||||
|
||||
-spec parse_guild_ids(map()) -> [integer()].
|
||||
parse_guild_ids(Data) ->
|
||||
GuildIds = map_utils:get_safe(Data, <<"guild_ids">>, []),
|
||||
[utils:binary_to_integer_safe(Id) || Id <- GuildIds, Id =/= undefined].
|
||||
|
||||
-spec check_identify_rate_limit(list()) -> {ok, list()} | {error, rate_limited}.
|
||||
check_identify_rate_limit(Attempts) ->
|
||||
case fluxer_gateway_env:get(identify_rate_limit_enabled) of
|
||||
true ->
|
||||
Now = erlang:system_time(millisecond),
|
||||
WindowDuration = 5000,
|
||||
AttemptsInWindow = [T || T <- Attempts, (Now - T) < WindowDuration],
|
||||
AttemptsCount = length(AttemptsInWindow),
|
||||
MaxIdentifiesPerWindow = 1,
|
||||
case AttemptsCount >= MaxIdentifiesPerWindow of
|
||||
true ->
|
||||
{error, rate_limited};
|
||||
false ->
|
||||
NewAttempts = [Now | AttemptsInWindow],
|
||||
{ok, NewAttempts}
|
||||
end;
|
||||
_ ->
|
||||
{ok, Attempts}
|
||||
end.
|
||||
|
||||
-spec get_presence_custom_status(term()) -> map() | null.
|
||||
get_presence_custom_status(Presence) ->
|
||||
case Presence of
|
||||
null -> null;
|
||||
Map when is_map(Map) -> map_utils:get_safe(Map, <<"custom_status">>, null);
|
||||
_ -> null
|
||||
end.
|
||||
|
||||
-spec get_stored_status(map()) -> binary().
|
||||
get_stored_status(Data) ->
|
||||
case map_utils:get_safe(Data, <<"user_settings">>, null) of
|
||||
null ->
|
||||
<<"online">>;
|
||||
UserSettings ->
|
||||
case normalize_status(map_utils:get_safe(UserSettings, <<"status">>, <<"online">>)) of
|
||||
undefined -> <<"online">>;
|
||||
Value -> Value
|
||||
end
|
||||
end.
|
||||
|
||||
-spec select_initial_status(binary() | undefined, binary()) -> binary().
|
||||
select_initial_status(PresenceStatus, StoredStatus) ->
|
||||
NormalizedPresence = normalize_status(PresenceStatus),
|
||||
case {NormalizedPresence, StoredStatus} of
|
||||
{undefined, Stored} ->
|
||||
Stored;
|
||||
{<<"unknown">>, Stored} ->
|
||||
Stored;
|
||||
{<<"online">>, Stored} when Stored =/= <<"online">> ->
|
||||
Stored;
|
||||
{Presence, _} ->
|
||||
Presence
|
||||
end.
|
||||
|
||||
-spec normalize_status(term()) -> binary() | undefined.
|
||||
normalize_status(undefined) ->
|
||||
undefined;
|
||||
normalize_status(null) ->
|
||||
undefined;
|
||||
normalize_status(Status) when is_binary(Status) ->
|
||||
Status;
|
||||
normalize_status(Status) when is_atom(Status) ->
|
||||
try constants:status_type_atom(Status) of
|
||||
Value when is_binary(Value) -> Value
|
||||
catch
|
||||
_:_ -> undefined
|
||||
end;
|
||||
normalize_status(_) ->
|
||||
undefined.
|
||||
106
fluxer_gateway/src/session/session_monitor.erl
Normal file
106
fluxer_gateway/src/session/session_monitor.erl
Normal file
@@ -0,0 +1,106 @@
|
||||
%% 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(session_monitor).
|
||||
|
||||
-export([
|
||||
handle_process_down/3,
|
||||
find_guild_by_ref/2,
|
||||
find_call_by_ref/2
|
||||
]).
|
||||
|
||||
handle_process_down(Ref, _Reason, State) ->
|
||||
SocketRef = maps:get(socket_mref, State, undefined),
|
||||
PresenceRef = maps:get(presence_mref, State, undefined),
|
||||
Guilds = maps:get(guilds, State),
|
||||
Calls = maps:get(calls, State, #{}),
|
||||
|
||||
case Ref of
|
||||
SocketRef when Ref =:= SocketRef ->
|
||||
self() ! {presence_update, #{status => offline}},
|
||||
erlang:send_after(10000, self(), resume_timeout),
|
||||
{noreply, maps:merge(State, #{socket_pid => undefined, socket_mref => undefined})};
|
||||
PresenceRef when Ref =:= PresenceRef ->
|
||||
self() ! {presence_connect, 0},
|
||||
{noreply, maps:put(presence_pid, undefined, State)};
|
||||
_ ->
|
||||
case find_guild_by_ref(Ref, Guilds) of
|
||||
{ok, GuildId} ->
|
||||
handle_guild_down(GuildId, _Reason, State, Guilds);
|
||||
not_found ->
|
||||
case find_call_by_ref(Ref, Calls) of
|
||||
{ok, ChannelId} ->
|
||||
handle_call_down(ChannelId, _Reason, State, Calls);
|
||||
not_found ->
|
||||
{noreply, State}
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
handle_guild_down(GuildId, Reason, State, Guilds) ->
|
||||
case Reason of
|
||||
killed ->
|
||||
gen_server:cast(self(), {guild_leave, GuildId}),
|
||||
{noreply, State};
|
||||
_ ->
|
||||
GuildDeleteData = #{
|
||||
<<"id">> => integer_to_binary(GuildId),
|
||||
<<"unavailable">> => true
|
||||
},
|
||||
{noreply, UpdatedState} = session_dispatch:handle_dispatch(
|
||||
guild_delete, GuildDeleteData, State
|
||||
),
|
||||
|
||||
NewGuilds = maps:put(GuildId, undefined, Guilds),
|
||||
erlang:send_after(1000, self(), {guild_connect, GuildId, 0}),
|
||||
{noreply, maps:put(guilds, NewGuilds, UpdatedState)}
|
||||
end.
|
||||
|
||||
handle_call_down(ChannelId, Reason, State, Calls) ->
|
||||
case Reason of
|
||||
killed ->
|
||||
NewCalls = maps:remove(ChannelId, Calls),
|
||||
{noreply, maps:put(calls, NewCalls, State)};
|
||||
_ ->
|
||||
CallDeleteData = #{
|
||||
<<"channel_id">> => integer_to_binary(ChannelId),
|
||||
<<"unavailable">> => true
|
||||
},
|
||||
{noreply, UpdatedState} = session_dispatch:handle_dispatch(
|
||||
call_delete, CallDeleteData, State
|
||||
),
|
||||
|
||||
NewCalls = maps:put(ChannelId, undefined, Calls),
|
||||
erlang:send_after(1000, self(), {call_reconnect, ChannelId, 0}),
|
||||
{noreply, maps:put(calls, NewCalls, UpdatedState)}
|
||||
end.
|
||||
|
||||
find_guild_by_ref(Ref, Guilds) ->
|
||||
find_by_ref(Ref, Guilds).
|
||||
|
||||
find_call_by_ref(Ref, Calls) ->
|
||||
find_by_ref(Ref, Calls).
|
||||
|
||||
find_by_ref(Ref, Map) ->
|
||||
maps:fold(
|
||||
fun
|
||||
(Id, {_Pid, R}, _) when R =:= Ref -> {ok, Id};
|
||||
(_, _, Acc) -> Acc
|
||||
end,
|
||||
not_found,
|
||||
Map
|
||||
).
|
||||
345
fluxer_gateway/src/session/session_passive.erl
Normal file
345
fluxer_gateway/src/session/session_passive.erl
Normal file
@@ -0,0 +1,345 @@
|
||||
%% 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(session_passive).
|
||||
|
||||
-export([
|
||||
is_passive/2,
|
||||
set_active/2,
|
||||
set_passive/2,
|
||||
should_receive_event/5,
|
||||
get_user_roles_for_guild/2,
|
||||
should_receive_typing/2,
|
||||
set_typing_override/3,
|
||||
get_typing_override/2,
|
||||
is_guild_synced/2,
|
||||
mark_guild_synced/2,
|
||||
clear_guild_synced/2
|
||||
]).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-endif.
|
||||
|
||||
is_passive(GuildId, SessionData) ->
|
||||
case maps:get(bot, SessionData, false) of
|
||||
true ->
|
||||
false;
|
||||
false ->
|
||||
ActiveGuilds = maps:get(active_guilds, SessionData, sets:new()),
|
||||
not sets:is_element(GuildId, ActiveGuilds)
|
||||
end.
|
||||
|
||||
set_active(GuildId, SessionData) ->
|
||||
ActiveGuilds = maps:get(active_guilds, SessionData, sets:new()),
|
||||
NewActiveGuilds = sets:add_element(GuildId, ActiveGuilds),
|
||||
maps:put(active_guilds, NewActiveGuilds, SessionData).
|
||||
|
||||
set_passive(GuildId, SessionData) ->
|
||||
ActiveGuilds = maps:get(active_guilds, SessionData, sets:new()),
|
||||
NewActiveGuilds = sets:del_element(GuildId, ActiveGuilds),
|
||||
maps:put(active_guilds, NewActiveGuilds, SessionData).
|
||||
|
||||
should_receive_event(Event, EventData, GuildId, SessionData, State) ->
|
||||
case Event of
|
||||
typing_start ->
|
||||
should_receive_typing(GuildId, SessionData);
|
||||
_ ->
|
||||
case maps:get(bot, SessionData, false) of
|
||||
true ->
|
||||
true;
|
||||
false ->
|
||||
case is_message_event(Event) of
|
||||
true ->
|
||||
case is_small_guild(State) of
|
||||
true ->
|
||||
true;
|
||||
false ->
|
||||
case is_passive(GuildId, SessionData) of
|
||||
false -> true;
|
||||
true -> should_passive_receive(Event, EventData, SessionData)
|
||||
end
|
||||
end;
|
||||
false ->
|
||||
case is_passive(GuildId, SessionData) of
|
||||
false -> true;
|
||||
true -> should_passive_receive(Event, EventData, SessionData)
|
||||
end
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
is_small_guild(State) ->
|
||||
MemberCount = maps:get(member_count, State, undefined),
|
||||
case MemberCount of
|
||||
undefined -> false; %% Conservative: treat as large
|
||||
Count when is_integer(Count) -> Count =< 250
|
||||
end.
|
||||
|
||||
is_message_event(message_create) -> true;
|
||||
is_message_event(message_update) -> true;
|
||||
is_message_event(message_delete) -> true;
|
||||
is_message_event(message_delete_bulk) -> true;
|
||||
is_message_event(_) -> false.
|
||||
|
||||
should_passive_receive(message_create, EventData, SessionData) ->
|
||||
is_user_mentioned(EventData, SessionData);
|
||||
should_passive_receive(guild_delete, _EventData, _SessionData) ->
|
||||
true;
|
||||
should_passive_receive(channel_create, _EventData, _SessionData) ->
|
||||
true;
|
||||
should_passive_receive(channel_delete, _EventData, _SessionData) ->
|
||||
true;
|
||||
should_passive_receive(passive_updates, _EventData, _SessionData) ->
|
||||
true;
|
||||
should_passive_receive(guild_update, _EventData, _SessionData) ->
|
||||
true;
|
||||
should_passive_receive(guild_member_update, EventData, SessionData) ->
|
||||
UserId = maps:get(user_id, SessionData),
|
||||
MemberUser = maps:get(<<"user">>, EventData, #{}),
|
||||
MemberUserId = map_utils:get_integer(MemberUser, <<"id">>, undefined),
|
||||
UserId =:= MemberUserId;
|
||||
should_passive_receive(guild_member_remove, EventData, SessionData) ->
|
||||
UserId = maps:get(user_id, SessionData),
|
||||
MemberUser = maps:get(<<"user">>, EventData, #{}),
|
||||
MemberUserId = map_utils:get_integer(MemberUser, <<"id">>, undefined),
|
||||
UserId =:= MemberUserId;
|
||||
should_passive_receive(voice_state_update, EventData, SessionData) ->
|
||||
UserId = maps:get(user_id, SessionData),
|
||||
EventUserId = map_utils:get_integer(EventData, <<"user_id">>, undefined),
|
||||
UserId =:= EventUserId;
|
||||
should_passive_receive(voice_server_update, _EventData, _SessionData) ->
|
||||
true;
|
||||
should_passive_receive(_, _, _) ->
|
||||
false.
|
||||
|
||||
is_user_mentioned(EventData, SessionData) ->
|
||||
UserId = maps:get(user_id, SessionData),
|
||||
MentionEveryone = maps:get(<<"mention_everyone">>, EventData, false),
|
||||
Mentions = maps:get(<<"mentions">>, EventData, []),
|
||||
MentionRoles = maps:get(<<"mention_roles">>, EventData, []),
|
||||
UserRoles = maps:get(user_roles, SessionData, []),
|
||||
|
||||
MentionEveryone orelse
|
||||
is_user_in_mentions(UserId, Mentions) orelse
|
||||
has_mentioned_role(UserRoles, MentionRoles).
|
||||
|
||||
is_user_in_mentions(_UserId, []) ->
|
||||
false;
|
||||
is_user_in_mentions(UserId, [#{<<"id">> := Id} | Rest]) when is_binary(Id) ->
|
||||
case validation:validate_snowflake(<<"id">>, Id) of
|
||||
{ok, ParsedId} ->
|
||||
UserId =:= ParsedId orelse is_user_in_mentions(UserId, Rest);
|
||||
{error, _, _} ->
|
||||
is_user_in_mentions(UserId, Rest)
|
||||
end;
|
||||
is_user_in_mentions(UserId, [_ | Rest]) ->
|
||||
is_user_in_mentions(UserId, Rest).
|
||||
|
||||
has_mentioned_role([], _MentionRoles) ->
|
||||
false;
|
||||
has_mentioned_role([RoleId | Rest], MentionRoles) ->
|
||||
RoleIdBin = integer_to_binary(RoleId),
|
||||
lists:member(RoleIdBin, MentionRoles) orelse
|
||||
lists:member(RoleId, MentionRoles) orelse
|
||||
has_mentioned_role(Rest, MentionRoles).
|
||||
|
||||
get_user_roles_for_guild(UserId, GuildState) ->
|
||||
Data = maps:get(data, GuildState, #{}),
|
||||
Members = maps:get(<<"members">>, Data, []),
|
||||
case find_member_by_user_id(UserId, Members) of
|
||||
undefined -> [];
|
||||
Member -> extract_role_ids(maps:get(<<"roles">>, Member, []))
|
||||
end.
|
||||
|
||||
find_member_by_user_id(_UserId, []) ->
|
||||
undefined;
|
||||
find_member_by_user_id(UserId, [Member | Rest]) ->
|
||||
User = maps:get(<<"user">>, Member, #{}),
|
||||
MemberUserId = map_utils:get_integer(User, <<"id">>, undefined),
|
||||
case UserId =:= MemberUserId of
|
||||
true -> Member;
|
||||
false -> find_member_by_user_id(UserId, Rest)
|
||||
end.
|
||||
|
||||
extract_role_ids(Roles) ->
|
||||
lists:filtermap(
|
||||
fun(Role) when is_binary(Role) ->
|
||||
case validation:validate_snowflake(<<"role">>, Role) of
|
||||
{ok, RoleId} -> {true, RoleId};
|
||||
{error, _, _} -> false
|
||||
end;
|
||||
(Role) when is_integer(Role) ->
|
||||
{true, Role};
|
||||
(_) ->
|
||||
false
|
||||
end,
|
||||
Roles
|
||||
).
|
||||
|
||||
should_receive_typing(GuildId, SessionData) ->
|
||||
case get_typing_override(GuildId, SessionData) of
|
||||
undefined ->
|
||||
not is_passive(GuildId, SessionData);
|
||||
TypingFlag ->
|
||||
TypingFlag
|
||||
end.
|
||||
|
||||
set_typing_override(GuildId, TypingFlag, SessionData) ->
|
||||
TypingOverrides = maps:get(typing_overrides, SessionData, #{}),
|
||||
NewTypingOverrides = maps:put(GuildId, TypingFlag, TypingOverrides),
|
||||
maps:put(typing_overrides, NewTypingOverrides, SessionData).
|
||||
|
||||
get_typing_override(GuildId, SessionData) ->
|
||||
TypingOverrides = maps:get(typing_overrides, SessionData, #{}),
|
||||
maps:get(GuildId, TypingOverrides, undefined).
|
||||
|
||||
is_guild_synced(GuildId, SessionData) ->
|
||||
SyncedGuilds = maps:get(synced_guilds, SessionData, sets:new()),
|
||||
sets:is_element(GuildId, SyncedGuilds).
|
||||
|
||||
mark_guild_synced(GuildId, SessionData) ->
|
||||
SyncedGuilds = maps:get(synced_guilds, SessionData, sets:new()),
|
||||
NewSyncedGuilds = sets:add_element(GuildId, SyncedGuilds),
|
||||
maps:put(synced_guilds, NewSyncedGuilds, SessionData).
|
||||
|
||||
clear_guild_synced(GuildId, SessionData) ->
|
||||
SyncedGuilds = maps:get(synced_guilds, SessionData, sets:new()),
|
||||
NewSyncedGuilds = sets:del_element(GuildId, SyncedGuilds),
|
||||
maps:put(synced_guilds, NewSyncedGuilds, SessionData).
|
||||
|
||||
-ifdef(TEST).
|
||||
|
||||
is_passive_test() ->
|
||||
SessionData = #{active_guilds => sets:from_list([123, 456])},
|
||||
?assertEqual(false, is_passive(123, SessionData)),
|
||||
?assertEqual(false, is_passive(456, SessionData)),
|
||||
?assertEqual(true, is_passive(789, SessionData)),
|
||||
?assertEqual(true, is_passive(123, #{})),
|
||||
ok.
|
||||
|
||||
set_active_test() ->
|
||||
SessionData = #{active_guilds => sets:from_list([123])},
|
||||
NewSessionData = set_active(456, SessionData),
|
||||
?assertEqual(false, is_passive(456, NewSessionData)),
|
||||
?assertEqual(false, is_passive(123, NewSessionData)),
|
||||
ok.
|
||||
|
||||
set_passive_test() ->
|
||||
SessionData = #{active_guilds => sets:from_list([123, 456])},
|
||||
NewSessionData = set_passive(123, SessionData),
|
||||
?assertEqual(true, is_passive(123, NewSessionData)),
|
||||
?assertEqual(false, is_passive(456, NewSessionData)),
|
||||
ok.
|
||||
|
||||
should_receive_event_active_session_test() ->
|
||||
SessionData = #{user_id => 1, active_guilds => sets:from_list([123])},
|
||||
State = #{member_count => 100},
|
||||
?assertEqual(true, should_receive_event(message_create, #{}, 123, SessionData, State)),
|
||||
?assertEqual(true, should_receive_event(typing_start, #{}, 123, SessionData, State)),
|
||||
ok.
|
||||
|
||||
should_receive_event_passive_guild_delete_test() ->
|
||||
SessionData = #{user_id => 1, active_guilds => sets:new()},
|
||||
State = #{member_count => 100},
|
||||
?assertEqual(true, should_receive_event(guild_delete, #{}, 123, SessionData, State)),
|
||||
ok.
|
||||
|
||||
should_receive_event_passive_channel_create_test() ->
|
||||
SessionData = #{user_id => 1, active_guilds => sets:new()},
|
||||
State = #{member_count => 100},
|
||||
?assertEqual(true, should_receive_event(channel_create, #{}, 123, SessionData, State)),
|
||||
ok.
|
||||
|
||||
should_receive_event_passive_channel_delete_test() ->
|
||||
SessionData = #{user_id => 1, active_guilds => sets:new()},
|
||||
State = #{member_count => 100},
|
||||
?assertEqual(true, should_receive_event(channel_delete, #{}, 123, SessionData, State)),
|
||||
ok.
|
||||
|
||||
should_receive_event_passive_passive_updates_test() ->
|
||||
SessionData = #{user_id => 1, active_guilds => sets:new()},
|
||||
State = #{member_count => 100},
|
||||
?assertEqual(true, should_receive_event(passive_updates, #{}, 123, SessionData, State)),
|
||||
ok.
|
||||
|
||||
should_receive_event_passive_message_not_mentioned_test() ->
|
||||
SessionData = #{user_id => 1, active_guilds => sets:new(), user_roles => []},
|
||||
EventData = #{<<"mentions">> => [], <<"mention_roles">> => [], <<"mention_everyone">> => false},
|
||||
State = #{member_count => 300}, %% Large guild
|
||||
?assertEqual(false, should_receive_event(message_create, EventData, 123, SessionData, State)),
|
||||
ok.
|
||||
|
||||
should_receive_event_passive_message_user_mentioned_test() ->
|
||||
SessionData = #{user_id => 1, active_guilds => sets:new(), user_roles => []},
|
||||
EventData = #{
|
||||
<<"mentions">> => [#{<<"id">> => <<"1">>}],
|
||||
<<"mention_roles">> => [],
|
||||
<<"mention_everyone">> => false
|
||||
},
|
||||
State = #{member_count => 300}, %% Large guild
|
||||
?assertEqual(true, should_receive_event(message_create, EventData, 123, SessionData, State)),
|
||||
ok.
|
||||
|
||||
should_receive_event_passive_message_mention_everyone_test() ->
|
||||
SessionData = #{user_id => 1, active_guilds => sets:new(), user_roles => []},
|
||||
EventData = #{<<"mentions">> => [], <<"mention_roles">> => [], <<"mention_everyone">> => true},
|
||||
State = #{member_count => 300}, %% Large guild
|
||||
?assertEqual(true, should_receive_event(message_create, EventData, 123, SessionData, State)),
|
||||
ok.
|
||||
|
||||
should_receive_event_passive_message_role_mentioned_test() ->
|
||||
SessionData = #{user_id => 1, active_guilds => sets:new(), user_roles => [100]},
|
||||
EventData = #{
|
||||
<<"mentions">> => [], <<"mention_roles">> => [<<"100">>], <<"mention_everyone">> => false
|
||||
},
|
||||
State = #{member_count => 300}, %% Large guild
|
||||
?assertEqual(true, should_receive_event(message_create, EventData, 123, SessionData, State)),
|
||||
ok.
|
||||
|
||||
should_receive_event_passive_other_event_test() ->
|
||||
SessionData = #{user_id => 1, active_guilds => sets:new()},
|
||||
State = #{member_count => 300}, %% Large guild
|
||||
?assertEqual(false, should_receive_event(typing_start, #{}, 123, SessionData, State)),
|
||||
?assertEqual(false, should_receive_event(message_update, #{}, 123, SessionData, State)),
|
||||
ok.
|
||||
|
||||
should_receive_event_small_guild_all_sessions_receive_messages_test() ->
|
||||
SessionData = #{user_id => 1, active_guilds => sets:new()},
|
||||
State = #{member_count => 100}, %% Small guild
|
||||
?assertEqual(true, should_receive_event(message_create, #{}, 123, SessionData, State)),
|
||||
?assertEqual(true, should_receive_event(message_update, #{}, 123, SessionData, State)),
|
||||
?assertEqual(true, should_receive_event(message_delete, #{}, 123, SessionData, State)),
|
||||
ok.
|
||||
|
||||
is_passive_bot_always_active_test() ->
|
||||
BotSessionData = #{user_id => 1, active_guilds => sets:new(), bot => true},
|
||||
?assertEqual(false, is_passive(123, BotSessionData)),
|
||||
?assertEqual(false, is_passive(456, BotSessionData)),
|
||||
?assertEqual(false, is_passive(789, BotSessionData)),
|
||||
ok.
|
||||
|
||||
should_receive_event_bot_always_receives_test() ->
|
||||
BotSessionData = #{user_id => 1, active_guilds => sets:new(), bot => true},
|
||||
State = #{member_count => 300},
|
||||
?assertEqual(true, should_receive_event(message_create, #{}, 123, BotSessionData, State)),
|
||||
?assertEqual(true, should_receive_event(typing_start, #{}, 123, BotSessionData, State)),
|
||||
?assertEqual(true, should_receive_event(message_update, #{}, 123, BotSessionData, State)),
|
||||
?assertEqual(true, should_receive_event(guild_delete, #{}, 123, BotSessionData, State)),
|
||||
ok.
|
||||
|
||||
-endif.
|
||||
443
fluxer_gateway/src/session/session_ready.erl
Normal file
443
fluxer_gateway/src/session/session_ready.erl
Normal file
@@ -0,0 +1,443 @@
|
||||
%% 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(session_ready).
|
||||
|
||||
-export([
|
||||
process_guild_state/2,
|
||||
mark_guild_unavailable/2,
|
||||
check_readiness/1,
|
||||
dispatch_ready_data/1,
|
||||
update_ready_guilds/2
|
||||
]).
|
||||
|
||||
process_guild_state(GuildState, State) ->
|
||||
Ready = maps:get(ready, State),
|
||||
CollectedGuilds = maps:get(collected_guild_states, State),
|
||||
|
||||
case Ready of
|
||||
undefined ->
|
||||
{noreply, StateAfterCreate} = session_dispatch:handle_dispatch(
|
||||
guild_create, GuildState, State
|
||||
),
|
||||
dispatch_guild_initial_presences(GuildState, StateAfterCreate);
|
||||
_ ->
|
||||
NewCollectedGuilds = [GuildState | CollectedGuilds],
|
||||
NewState = maps:put(collected_guild_states, NewCollectedGuilds, State),
|
||||
check_readiness(update_ready_guilds(GuildState, NewState))
|
||||
end.
|
||||
|
||||
dispatch_guild_initial_presences(_GuildState, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
mark_guild_unavailable(GuildId, State) ->
|
||||
CollectedGuilds = maps:get(collected_guild_states, State),
|
||||
Ready = maps:get(ready, State),
|
||||
|
||||
UnavailableState = #{<<"id">> => integer_to_binary(GuildId), <<"unavailable">> => true},
|
||||
NewCollectedGuilds = [UnavailableState | CollectedGuilds],
|
||||
NewState = maps:put(collected_guild_states, NewCollectedGuilds, State),
|
||||
case Ready of
|
||||
undefined -> {noreply, NewState};
|
||||
_ -> {noreply, update_ready_guilds(UnavailableState, NewState)}
|
||||
end.
|
||||
|
||||
check_readiness(State) ->
|
||||
Ready = maps:get(ready, State),
|
||||
PresencePid = maps:get(presence_pid, State, undefined),
|
||||
Guilds = maps:get(guilds, State),
|
||||
|
||||
case Ready of
|
||||
undefined ->
|
||||
{noreply, State};
|
||||
_ when PresencePid =/= undefined ->
|
||||
AllGuildsReady = lists:all(fun({_, V}) -> V =/= undefined end, maps:to_list(Guilds)),
|
||||
if
|
||||
AllGuildsReady -> dispatch_ready_data(State);
|
||||
true -> {noreply, State}
|
||||
end;
|
||||
_ ->
|
||||
{noreply, State}
|
||||
end.
|
||||
|
||||
dispatch_ready_data(State) ->
|
||||
Ready = maps:get(ready, State),
|
||||
CollectedGuilds = maps:get(collected_guild_states, State),
|
||||
CollectedSessions = maps:get(collected_sessions, State),
|
||||
CollectedPresences = collect_ready_presences(State, CollectedGuilds),
|
||||
Users = collect_ready_users(State, CollectedGuilds),
|
||||
Version = maps:get(version, State),
|
||||
UserId = maps:get(user_id, State),
|
||||
SessionId = maps:get(id, State),
|
||||
SocketPid = maps:get(socket_pid, State, undefined),
|
||||
Guilds = maps:get(guilds, State),
|
||||
IsBot = maps:get(bot, State, false),
|
||||
|
||||
ReadyData =
|
||||
case Ready of
|
||||
undefined -> #{<<"guilds">> => []};
|
||||
R -> R
|
||||
end,
|
||||
|
||||
ReadyDataWithStrippedRelationships = strip_user_from_relationships(ReadyData),
|
||||
|
||||
ReadyDataBotStripped =
|
||||
case IsBot of
|
||||
true -> maps:put(<<"guilds">>, [], ReadyDataWithStrippedRelationships);
|
||||
false -> ReadyDataWithStrippedRelationships
|
||||
end,
|
||||
|
||||
UnavailableGuilds = [
|
||||
#{<<"id">> => integer_to_binary(GuildId), <<"unavailable">> => true}
|
||||
|| {GuildId, undefined} <- maps:to_list(Guilds)
|
||||
],
|
||||
StrippedGuilds = [strip_users_from_guild_members(G) || G <- lists:reverse(CollectedGuilds)],
|
||||
AllGuildStates = StrippedGuilds ++ UnavailableGuilds,
|
||||
|
||||
ReadyDataWithoutGuildIds = maps:remove(<<"guild_ids">>, ReadyDataBotStripped),
|
||||
|
||||
GuildsForReady =
|
||||
case IsBot of
|
||||
true -> [];
|
||||
false -> AllGuildStates
|
||||
end,
|
||||
|
||||
logger:debug(
|
||||
"[session_ready] dispatching READY for user ~p session ~p",
|
||||
[UserId, SessionId]
|
||||
),
|
||||
|
||||
FinalReadyData = maps:merge(ReadyDataWithoutGuildIds, #{
|
||||
<<"guilds">> => GuildsForReady,
|
||||
<<"sessions">> => CollectedSessions,
|
||||
<<"presences">> => CollectedPresences,
|
||||
<<"users">> => Users,
|
||||
<<"version">> => Version,
|
||||
<<"session_id">> => SessionId
|
||||
}),
|
||||
|
||||
case SocketPid of
|
||||
undefined ->
|
||||
{stop, normal, State};
|
||||
Pid when is_pid(Pid) ->
|
||||
metrics_client:counter(<<"gateway.ready">>),
|
||||
StateAfterReady = dispatch_event(ready, FinalReadyData, State),
|
||||
SessionCount = length(CollectedSessions),
|
||||
GuildCount = length(GuildsForReady),
|
||||
PresenceCount = length(CollectedPresences),
|
||||
Dimensions = #{
|
||||
<<"session_id">> => SessionId,
|
||||
<<"user_id">> => integer_to_binary(UserId),
|
||||
<<"bot">> => bool_to_binary(IsBot)
|
||||
},
|
||||
metrics_client:gauge(<<"gateway.sessions.active">>, Dimensions, SessionCount),
|
||||
metrics_client:gauge(<<"gateway.guilds.active">>, Dimensions, GuildCount),
|
||||
metrics_client:gauge(<<"gateway.presences.active">>, Dimensions, PresenceCount),
|
||||
|
||||
StateAfterGuildCreates =
|
||||
case IsBot of
|
||||
true ->
|
||||
lists:foldl(
|
||||
fun(GuildState, AccState) ->
|
||||
dispatch_event(guild_create, GuildState, AccState)
|
||||
end,
|
||||
StateAfterReady,
|
||||
AllGuildStates
|
||||
);
|
||||
false ->
|
||||
StateAfterReady
|
||||
end,
|
||||
|
||||
PrivateChannels = get_private_channels(StateAfterGuildCreates),
|
||||
spawn(fun() ->
|
||||
dispatch_call_creates_for_channels(
|
||||
PrivateChannels, SessionId, StateAfterGuildCreates
|
||||
)
|
||||
end),
|
||||
|
||||
FinalState = maps:merge(StateAfterGuildCreates, #{
|
||||
ready => undefined,
|
||||
collected_guild_states => [],
|
||||
collected_sessions => []
|
||||
}),
|
||||
{noreply, FinalState}
|
||||
end.
|
||||
|
||||
dispatch_event(Event, Data, State) ->
|
||||
Seq = maps:get(seq, State),
|
||||
SocketPid = maps:get(socket_pid, State, undefined),
|
||||
NewSeq = Seq + 1,
|
||||
case SocketPid of
|
||||
undefined -> ok;
|
||||
Pid when is_pid(Pid) -> Pid ! {dispatch, Event, Data, NewSeq}
|
||||
end,
|
||||
maps:put(seq, NewSeq, State).
|
||||
|
||||
update_ready_guilds(GuildState, State) ->
|
||||
case maps:get(bot, State, false) of
|
||||
true ->
|
||||
State;
|
||||
false ->
|
||||
Ready = maps:get(ready, State),
|
||||
case is_map(Ready) of
|
||||
true ->
|
||||
Guilds = maps:get(<<"guilds">>, Ready, []),
|
||||
NewGuilds = Guilds ++ [GuildState],
|
||||
NewReady = maps:put(<<"guilds">>, NewGuilds, Ready),
|
||||
maps:put(ready, NewReady, State);
|
||||
false ->
|
||||
State
|
||||
end
|
||||
end.
|
||||
|
||||
collect_ready_users(State, CollectedGuilds) ->
|
||||
case maps:get(bot, State, false) of
|
||||
true ->
|
||||
[];
|
||||
false ->
|
||||
collect_ready_users_nonbot(State, CollectedGuilds)
|
||||
end.
|
||||
|
||||
collect_ready_users_nonbot(State, CollectedGuilds) ->
|
||||
Ready = maps:get(ready, State, #{}),
|
||||
Relationships = map_utils:ensure_list(map_utils:get_safe(Ready, <<"relationships">>, [])),
|
||||
RelUsers = [
|
||||
user_utils:normalize_user(maps:get(<<"user">>, Rel, undefined))
|
||||
|| Rel <- Relationships
|
||||
],
|
||||
Channels = maps:get(channels, State, #{}),
|
||||
ChannelUsers = collect_channel_users(maps:values(Channels)),
|
||||
GuildUsers = collect_guild_users(CollectedGuilds),
|
||||
Users0 = [U || U <- RelUsers ++ ChannelUsers ++ GuildUsers, is_map(U)],
|
||||
dedup_users(Users0).
|
||||
|
||||
collect_ready_presences(State, _CollectedGuilds) ->
|
||||
CurrentUserId = maps:get(user_id, State),
|
||||
IsBot = maps:get(bot, State, false),
|
||||
|
||||
{FriendIds, GdmIds} =
|
||||
case IsBot of
|
||||
true ->
|
||||
{[], []};
|
||||
false ->
|
||||
FIds = presence_targets:friend_ids_from_state(State),
|
||||
GdmMap = presence_targets:group_dm_recipients_from_state(State),
|
||||
GIds = lists:append([
|
||||
maps:keys(Recipients)
|
||||
|| {_Cid, Recipients} <- maps:to_list(GdmMap)
|
||||
]),
|
||||
{FIds, GIds}
|
||||
end,
|
||||
|
||||
Targets = lists:usort(FriendIds ++ GdmIds) -- [CurrentUserId],
|
||||
case Targets of
|
||||
[] ->
|
||||
[];
|
||||
_ ->
|
||||
Cached = presence_cache:bulk_get(Targets),
|
||||
Visible = [P || P <- Cached, presence_visible(P)],
|
||||
dedup_presences(Visible)
|
||||
end.
|
||||
|
||||
presence_user_id(P) when is_map(P) ->
|
||||
User = maps:get(<<"user">>, P, #{}),
|
||||
map_utils:get_integer(User, <<"id">>, undefined);
|
||||
presence_user_id(_) ->
|
||||
undefined.
|
||||
|
||||
presence_visible(P) ->
|
||||
Status = maps:get(<<"status">>, P, <<"offline">>),
|
||||
Status =/= <<"offline">> andalso Status =/= <<"invisible">>.
|
||||
|
||||
dedup_presences(Presences) ->
|
||||
Map =
|
||||
lists:foldl(
|
||||
fun(P, Acc) ->
|
||||
case presence_user_id(P) of
|
||||
undefined -> Acc;
|
||||
Id -> maps:put(Id, P, Acc)
|
||||
end
|
||||
end,
|
||||
#{},
|
||||
Presences
|
||||
),
|
||||
maps:values(Map).
|
||||
|
||||
collect_channel_users(Channels) ->
|
||||
lists:foldl(
|
||||
fun(Channel, Acc) ->
|
||||
Type = maps:get(<<"type">>, Channel, 0),
|
||||
case Type =:= 1 orelse Type =:= 3 of
|
||||
true ->
|
||||
RecipientsRaw = map_utils:ensure_list(maps:get(<<"recipients">>, Channel, [])),
|
||||
Recipients = [user_utils:normalize_user(R) || R <- RecipientsRaw],
|
||||
Recipients ++ Acc;
|
||||
false ->
|
||||
Acc
|
||||
end
|
||||
end,
|
||||
[],
|
||||
Channels
|
||||
).
|
||||
|
||||
collect_guild_users(GuildStates) ->
|
||||
lists:foldl(
|
||||
fun(GuildState, Acc) ->
|
||||
Members = map_utils:ensure_list(maps:get(<<"members">>, GuildState, [])),
|
||||
MemberUsers = [
|
||||
user_utils:normalize_user(maps:get(<<"user">>, M, undefined))
|
||||
|| M <- Members
|
||||
],
|
||||
MemberUsers ++ Acc
|
||||
end,
|
||||
[],
|
||||
ensure_list(GuildStates)
|
||||
).
|
||||
|
||||
dedup_users(Users) ->
|
||||
Map =
|
||||
lists:foldl(
|
||||
fun(U, Acc) ->
|
||||
Id = maps:get(<<"id">>, U, undefined),
|
||||
case Id of
|
||||
undefined -> Acc;
|
||||
_ -> maps:put(Id, U, Acc)
|
||||
end
|
||||
end,
|
||||
#{},
|
||||
Users
|
||||
),
|
||||
maps:values(Map).
|
||||
|
||||
ensure_list(List) when is_list(List) -> List;
|
||||
ensure_list(_) -> [].
|
||||
|
||||
strip_users_from_guild_members(GuildState) when is_map(GuildState) ->
|
||||
case maps:get(<<"unavailable">>, GuildState, false) of
|
||||
true ->
|
||||
GuildState;
|
||||
false ->
|
||||
Members = map_utils:ensure_list(maps:get(<<"members">>, GuildState, [])),
|
||||
StrippedMembers = [strip_user_from_member(M) || M <- Members],
|
||||
maps:put(<<"members">>, StrippedMembers, GuildState)
|
||||
end;
|
||||
strip_users_from_guild_members(GuildState) ->
|
||||
GuildState.
|
||||
|
||||
strip_user_from_member(Member) when is_map(Member) ->
|
||||
case maps:get(<<"user">>, Member, undefined) of
|
||||
undefined ->
|
||||
Member;
|
||||
User when is_map(User) ->
|
||||
UserId = maps:get(<<"id">>, User, undefined),
|
||||
maps:put(<<"user">>, #{<<"id">> => UserId}, Member);
|
||||
_ ->
|
||||
Member
|
||||
end;
|
||||
strip_user_from_member(Member) ->
|
||||
Member.
|
||||
|
||||
strip_user_from_relationships(ReadyData) when is_map(ReadyData) ->
|
||||
Relationships = map_utils:ensure_list(maps:get(<<"relationships">>, ReadyData, [])),
|
||||
StrippedRelationships = [strip_user_from_relationship(R) || R <- Relationships],
|
||||
maps:put(<<"relationships">>, StrippedRelationships, ReadyData);
|
||||
strip_user_from_relationships(ReadyData) ->
|
||||
ReadyData.
|
||||
|
||||
strip_user_from_relationship(Relationship) when is_map(Relationship) ->
|
||||
case maps:get(<<"user">>, Relationship, undefined) of
|
||||
undefined ->
|
||||
Relationship;
|
||||
User when is_map(User) ->
|
||||
UserId = maps:get(<<"id">>, User, undefined),
|
||||
RelWithoutUser = maps:remove(<<"user">>, Relationship),
|
||||
case maps:get(<<"id">>, RelWithoutUser, undefined) of
|
||||
undefined -> maps:put(<<"id">>, UserId, RelWithoutUser);
|
||||
_ -> RelWithoutUser
|
||||
end;
|
||||
_ ->
|
||||
Relationship
|
||||
end;
|
||||
strip_user_from_relationship(Relationship) ->
|
||||
Relationship.
|
||||
|
||||
get_private_channels(State) ->
|
||||
Channels = maps:get(channels, State, #{}),
|
||||
maps:filter(
|
||||
fun(_ChannelId, Channel) ->
|
||||
ChannelType = maps:get(<<"type">>, Channel, 0),
|
||||
ChannelType =:= 1 orelse ChannelType =:= 3
|
||||
end,
|
||||
Channels
|
||||
).
|
||||
|
||||
dispatch_call_creates_for_channels(PrivateChannels, SessionId, State) ->
|
||||
lists:foreach(
|
||||
fun({ChannelId, _Channel}) ->
|
||||
dispatch_call_create_for_channel(ChannelId, SessionId, State)
|
||||
end,
|
||||
maps:to_list(PrivateChannels)
|
||||
).
|
||||
|
||||
dispatch_call_create_for_channel(ChannelId, _SessionId, State) ->
|
||||
try
|
||||
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
|
||||
{ok, CallPid} ->
|
||||
dispatch_call_create_from_pid(CallPid, State);
|
||||
_ ->
|
||||
ok
|
||||
end
|
||||
catch
|
||||
_:_ -> ok
|
||||
end.
|
||||
|
||||
dispatch_call_create_from_pid(CallPid, State) ->
|
||||
case gen_server:call(CallPid, {get_state}, 5000) of
|
||||
{ok, CallData} ->
|
||||
CreatedAt = maps:get(created_at, CallData, 0),
|
||||
Now = erlang:system_time(millisecond),
|
||||
CallAge = Now - CreatedAt,
|
||||
case CallAge < 5000 of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
ChannelIdBin = maps:get(channel_id, CallData),
|
||||
case validation:validate_snowflake(<<"channel_id">>, ChannelIdBin) of
|
||||
{ok, ChannelId} ->
|
||||
SessionPid = self(),
|
||||
gen_server:cast(SessionPid, {call_monitor, ChannelId, CallPid}),
|
||||
dispatch_event(call_create, CallData, State),
|
||||
SessionId = maps:get(id, State),
|
||||
metrics_client:counter(<<"gateway.calls.total">>, #{
|
||||
<<"channel_id">> => integer_to_binary(ChannelId),
|
||||
<<"session_id">> => SessionId,
|
||||
<<"status">> => <<"create">>
|
||||
});
|
||||
{error, _, Reason} ->
|
||||
logger:warning("[session_ready] Invalid channel_id in call data: ~p", [
|
||||
Reason
|
||||
]),
|
||||
ok
|
||||
end
|
||||
end;
|
||||
_ ->
|
||||
ok
|
||||
end.
|
||||
|
||||
-spec bool_to_binary(term()) -> binary().
|
||||
bool_to_binary(true) -> <<"true">>;
|
||||
bool_to_binary(false) -> <<"false">>.
|
||||
333
fluxer_gateway/src/session/session_voice.erl
Normal file
333
fluxer_gateway/src/session/session_voice.erl
Normal file
@@ -0,0 +1,333 @@
|
||||
%% 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(session_voice).
|
||||
|
||||
-export([
|
||||
handle_voice_state_update/2,
|
||||
handle_voice_disconnect/1,
|
||||
handle_voice_token_request/8
|
||||
]).
|
||||
|
||||
handle_voice_state_update(Data, State) ->
|
||||
GuildIdRaw = maps:get(<<"guild_id">>, Data, null),
|
||||
ChannelIdRaw = maps:get(<<"channel_id">>, Data, null),
|
||||
ConnectionId = maps:get(<<"connection_id">>, Data, null),
|
||||
SelfMute = maps:get(<<"self_mute">>, Data, false),
|
||||
SelfDeaf = maps:get(<<"self_deaf">>, Data, false),
|
||||
SelfVideo = maps:get(<<"self_video">>, Data, false),
|
||||
SelfStream = maps:get(<<"self_stream">>, Data, false),
|
||||
ViewerStreamKey = maps:get(<<"viewer_stream_key">>, Data, undefined),
|
||||
IsMobile = maps:get(<<"is_mobile">>, Data, false),
|
||||
Latitude = maps:get(<<"latitude">>, Data, null),
|
||||
Longitude = maps:get(<<"longitude">>, Data, null),
|
||||
|
||||
SessionId = maps:get(id, State),
|
||||
UserId = maps:get(user_id, State),
|
||||
Guilds = maps:get(guilds, State),
|
||||
|
||||
GuildIdResult = validation:validate_optional_snowflake(GuildIdRaw),
|
||||
ChannelIdResult = validation:validate_optional_snowflake(ChannelIdRaw),
|
||||
|
||||
case {GuildIdResult, ChannelIdResult} of
|
||||
{{ok, GuildId}, {ok, ChannelId}} ->
|
||||
handle_validated_voice_state_update(
|
||||
GuildId,
|
||||
ChannelId,
|
||||
ConnectionId,
|
||||
SelfMute,
|
||||
SelfDeaf,
|
||||
SelfVideo,
|
||||
SelfStream,
|
||||
ViewerStreamKey,
|
||||
IsMobile,
|
||||
Latitude,
|
||||
Longitude,
|
||||
SessionId,
|
||||
UserId,
|
||||
Guilds,
|
||||
State
|
||||
);
|
||||
{Error = {error, _, _}, _} ->
|
||||
{reply, Error, State};
|
||||
{_, Error = {error, _, _}} ->
|
||||
{reply, Error, State}
|
||||
end.
|
||||
|
||||
handle_validated_voice_state_update(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
_SelfMute,
|
||||
_SelfDeaf,
|
||||
_SelfVideo,
|
||||
_SelfStream,
|
||||
_ViewerStreamKey,
|
||||
_IsMobile,
|
||||
_Latitude,
|
||||
_Longitude,
|
||||
_SessionId,
|
||||
_UserId,
|
||||
_Guilds,
|
||||
State
|
||||
) ->
|
||||
handle_voice_disconnect(State);
|
||||
handle_validated_voice_state_update(
|
||||
null,
|
||||
null,
|
||||
ConnectionId,
|
||||
_SelfMute,
|
||||
_SelfDeaf,
|
||||
_SelfVideo,
|
||||
_SelfStream,
|
||||
_ViewerStreamKey,
|
||||
_IsMobile,
|
||||
_Latitude,
|
||||
_Longitude,
|
||||
SessionId,
|
||||
UserId,
|
||||
_Guilds,
|
||||
State
|
||||
) when is_binary(ConnectionId) ->
|
||||
Request = #{
|
||||
user_id => UserId,
|
||||
channel_id => null,
|
||||
session_id => SessionId,
|
||||
connection_id => ConnectionId,
|
||||
self_mute => false,
|
||||
self_deaf => false,
|
||||
self_video => false,
|
||||
self_stream => false,
|
||||
viewer_stream_key => null,
|
||||
is_mobile => false,
|
||||
latitude => null,
|
||||
longitude => null
|
||||
},
|
||||
|
||||
StateWithSessionPid = maps:put(session_pid, self(), State),
|
||||
case dm_voice:voice_state_update(Request, StateWithSessionPid) of
|
||||
{reply, #{success := true}, NewState} ->
|
||||
CleanState = maps:remove(session_pid, NewState),
|
||||
{reply, ok, CleanState};
|
||||
{reply, {error, Category, ErrorAtom}, _StateWithPid} ->
|
||||
{reply, {error, Category, ErrorAtom}, State}
|
||||
end;
|
||||
handle_validated_voice_state_update(
|
||||
null,
|
||||
ChannelId,
|
||||
ConnectionId,
|
||||
SelfMute,
|
||||
SelfDeaf,
|
||||
SelfVideo,
|
||||
SelfStream,
|
||||
ViewerStreamKey,
|
||||
IsMobile,
|
||||
Latitude,
|
||||
Longitude,
|
||||
SessionId,
|
||||
UserId,
|
||||
_Guilds,
|
||||
State
|
||||
) when is_integer(ChannelId), (is_binary(ConnectionId) orelse ConnectionId =:= null) ->
|
||||
Request = #{
|
||||
user_id => UserId,
|
||||
channel_id => ChannelId,
|
||||
session_id => SessionId,
|
||||
connection_id => ConnectionId,
|
||||
self_mute => SelfMute,
|
||||
self_deaf => SelfDeaf,
|
||||
self_video => SelfVideo,
|
||||
self_stream => SelfStream,
|
||||
viewer_stream_key => ViewerStreamKey,
|
||||
is_mobile => IsMobile,
|
||||
latitude => Latitude,
|
||||
longitude => Longitude
|
||||
},
|
||||
|
||||
StateWithSessionPid = maps:put(session_pid, self(), State),
|
||||
case dm_voice:voice_state_update(Request, StateWithSessionPid) of
|
||||
{reply, #{success := true, needs_token := true}, NewState} ->
|
||||
SessionPid = self(),
|
||||
spawn(fun() ->
|
||||
dm_voice:get_voice_token(
|
||||
ChannelId, UserId, SessionId, SessionPid, Latitude, Longitude
|
||||
)
|
||||
end),
|
||||
CleanState = maps:remove(session_pid, NewState),
|
||||
{reply, ok, CleanState};
|
||||
{reply, #{success := true}, NewState} ->
|
||||
CleanState = maps:remove(session_pid, NewState),
|
||||
{reply, ok, CleanState};
|
||||
{reply, {error, Category, ErrorAtom}, _StateWithPid} ->
|
||||
{reply, {error, Category, ErrorAtom}, State}
|
||||
end;
|
||||
handle_validated_voice_state_update(
|
||||
GuildId,
|
||||
ChannelId,
|
||||
ConnectionId,
|
||||
SelfMute,
|
||||
SelfDeaf,
|
||||
SelfVideo,
|
||||
SelfStream,
|
||||
ViewerStreamKey,
|
||||
IsMobile,
|
||||
Latitude,
|
||||
Longitude,
|
||||
SessionId,
|
||||
UserId,
|
||||
Guilds,
|
||||
State
|
||||
) when is_integer(GuildId) ->
|
||||
case maps:get(GuildId, Guilds, undefined) of
|
||||
undefined ->
|
||||
logger:warning("[session_voice] Guild not found in session: ~p", [GuildId]),
|
||||
{reply, gateway_errors:error(voice_guild_not_found), State};
|
||||
{GuildPid, _Ref} when is_pid(GuildPid) ->
|
||||
Request = #{
|
||||
user_id => UserId,
|
||||
channel_id => ChannelId,
|
||||
session_id => SessionId,
|
||||
connection_id => ConnectionId,
|
||||
self_mute => SelfMute,
|
||||
self_deaf => SelfDeaf,
|
||||
self_video => SelfVideo,
|
||||
self_stream => SelfStream,
|
||||
viewer_stream_key => ViewerStreamKey,
|
||||
is_mobile => IsMobile,
|
||||
latitude => Latitude,
|
||||
longitude => Longitude
|
||||
},
|
||||
logger:debug(
|
||||
"[session_voice] Calling guild process for voice state update: GuildId=~p, ChannelId=~p, ConnectionId=~p",
|
||||
[GuildId, ChannelId, ConnectionId]
|
||||
),
|
||||
case guild_client:voice_state_update(GuildPid, Request, 12000) of
|
||||
{ok, #{needs_token := true}} ->
|
||||
logger:debug("[session_voice] Voice state update succeeded, needs token"),
|
||||
SessionPid = self(),
|
||||
spawn(fun() ->
|
||||
handle_voice_token_request(
|
||||
GuildId,
|
||||
ChannelId,
|
||||
UserId,
|
||||
ConnectionId,
|
||||
SessionId,
|
||||
SessionPid,
|
||||
Latitude,
|
||||
Longitude
|
||||
)
|
||||
end),
|
||||
{reply, ok, State};
|
||||
{ok, _} ->
|
||||
logger:debug("[session_voice] Voice state update succeeded"),
|
||||
{reply, ok, State};
|
||||
{error, timeout} ->
|
||||
logger:error(
|
||||
"[session_voice] Voice state update timed out (>12s) for GuildId=~p, ChannelId=~p",
|
||||
[GuildId, ChannelId]
|
||||
),
|
||||
{reply, gateway_errors:error(timeout), State};
|
||||
{error, noproc} ->
|
||||
logger:error(
|
||||
"[session_voice] Guild process not running for GuildId=~p",
|
||||
[GuildId]
|
||||
),
|
||||
{reply, gateway_errors:error(internal_error), State};
|
||||
{error, Category, ErrorAtom} ->
|
||||
logger:warning("[session_voice] Voice state update failed: ~p", [ErrorAtom]),
|
||||
{reply, {error, Category, ErrorAtom}, State}
|
||||
end;
|
||||
_ ->
|
||||
logger:warning("[session_voice] Invalid guild pid in session"),
|
||||
{reply, gateway_errors:error(internal_error), State}
|
||||
end;
|
||||
handle_validated_voice_state_update(
|
||||
GuildId,
|
||||
ChannelId,
|
||||
ConnectionId,
|
||||
_SelfMute,
|
||||
_SelfDeaf,
|
||||
_SelfVideo,
|
||||
_SelfStream,
|
||||
_ViewerStreamKey,
|
||||
_IsMobile,
|
||||
_Latitude,
|
||||
_Longitude,
|
||||
_SessionId,
|
||||
_UserId,
|
||||
_Guilds,
|
||||
State
|
||||
) ->
|
||||
logger:warning(
|
||||
"[session_voice] Invalid voice state update parameters: GuildId=~p, ChannelId=~p, ConnectionId=~p",
|
||||
[GuildId, ChannelId, ConnectionId]
|
||||
),
|
||||
{reply, gateway_errors:error(validation_invalid_params), State}.
|
||||
|
||||
handle_voice_disconnect(State) ->
|
||||
Guilds = maps:get(guilds, State),
|
||||
UserId = maps:get(user_id, State),
|
||||
SessionId = maps:get(id, State),
|
||||
ConnectionId = maps:get(connection_id, State),
|
||||
|
||||
lists:foreach(
|
||||
fun
|
||||
({_GuildId, {GuildPid, _Ref}}) when is_pid(GuildPid) ->
|
||||
Request = #{
|
||||
user_id => UserId,
|
||||
channel_id => null,
|
||||
session_id => SessionId,
|
||||
connection_id => ConnectionId,
|
||||
self_mute => false,
|
||||
self_deaf => false,
|
||||
self_video => false,
|
||||
self_stream => false,
|
||||
viewer_stream_key => null
|
||||
},
|
||||
_ = guild_client:voice_state_update(GuildPid, Request, 10000);
|
||||
(_) ->
|
||||
ok
|
||||
end,
|
||||
maps:to_list(Guilds)
|
||||
),
|
||||
|
||||
{reply, #{success := true}, NewState} = dm_voice:disconnect_voice_user(UserId, State),
|
||||
{reply, ok, NewState}.
|
||||
|
||||
handle_voice_token_request(
|
||||
GuildId, ChannelId, UserId, ConnectionId, _SessionId, SessionPid, Latitude, Longitude
|
||||
) ->
|
||||
Req = voice_utils:build_voice_token_rpc_request(
|
||||
GuildId, ChannelId, UserId, ConnectionId, Latitude, Longitude
|
||||
),
|
||||
|
||||
case rpc_client:call(Req) of
|
||||
{ok, Data} ->
|
||||
Token = maps:get(<<"token">>, Data),
|
||||
Endpoint = maps:get(<<"endpoint">>, Data),
|
||||
|
||||
VoiceServerUpdate = #{
|
||||
<<"token">> => Token,
|
||||
<<"endpoint">> => Endpoint,
|
||||
<<"guild_id">> => integer_to_binary(GuildId),
|
||||
<<"connection_id">> => ConnectionId
|
||||
},
|
||||
|
||||
gen_server:cast(SessionPid, {dispatch, voice_server_update, VoiceServerUpdate});
|
||||
{error, _Reason} ->
|
||||
ok
|
||||
end.
|
||||
Reference in New Issue
Block a user