initial commit

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

View File

@@ -0,0 +1,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">> => []}.

View 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().

View 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.

View 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.

View 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
).

View 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.

View 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">>.

View 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.