mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
392 lines
15 KiB
Erlang
392 lines
15 KiB
Erlang
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
-module(presence).
|
|
-typing([eqwalizer]).
|
|
-behaviour(gen_server).
|
|
|
|
-export([start_link/1]).
|
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
|
|
|
|
-type user_id() :: integer().
|
|
-type session_id() :: binary().
|
|
-type status() :: online | offline | idle | dnd | invisible.
|
|
-type custom_status() :: map() | null.
|
|
-type session_entry() :: #{
|
|
session_id := session_id(),
|
|
status := status(),
|
|
afk := boolean(),
|
|
mobile := boolean(),
|
|
pid := pid(),
|
|
mref := reference(),
|
|
socket_pid := pid() | undefined
|
|
}.
|
|
-type sessions() :: #{session_id() => session_entry()}.
|
|
-type push_buffer_entry() :: #{
|
|
channel_id := integer(), message_id := integer(), params := map()
|
|
}.
|
|
-type state() :: #{
|
|
user_id := user_id(),
|
|
user_data := map(),
|
|
sessions := sessions(),
|
|
push_buffer := [push_buffer_entry()],
|
|
custom_status := custom_status(),
|
|
status := status(),
|
|
guild_ids := #{integer() => true},
|
|
temporary_guild_ids := #{integer() => true},
|
|
friends := #{user_id() => true},
|
|
group_dm_recipients := #{integer() => #{user_id() => true}},
|
|
subscriptions := map(),
|
|
is_bot := boolean(),
|
|
initial_presences_sent := boolean(),
|
|
last_published_presence := map() | undefined
|
|
}.
|
|
-type presence_data() :: #{
|
|
user_id := user_id(),
|
|
user_data := map(),
|
|
guild_ids => [integer()],
|
|
friend_ids => [user_id()],
|
|
group_dm_recipients => #{integer() => [user_id()] | #{user_id() => true}},
|
|
status := status(),
|
|
custom_status => custom_status()
|
|
}.
|
|
|
|
-spec start_link(presence_data()) -> {ok, pid()} | {error, term()}.
|
|
start_link(PresenceData) ->
|
|
normalize_start_link(gen_server:start_link(?MODULE, PresenceData, [])).
|
|
|
|
-spec init(presence_data()) -> {ok, state()}.
|
|
init(PresenceData) ->
|
|
process_flag(trap_exit, true),
|
|
erlang:process_flag(fullsweep_after, 20),
|
|
State = build_initial_state(PresenceData),
|
|
StateWithSubs = presence_broadcast:ensure_initial_global_subscriptions(State),
|
|
{ok, StateWithSubs}.
|
|
|
|
-spec terminate(term(), state() | term()) -> ok.
|
|
terminate(_Reason, State) when not is_map(State) ->
|
|
ok;
|
|
terminate(_Reason, State) ->
|
|
_ = presence_update:flush_push_buffer(State),
|
|
case maps:get(user_id, State, undefined) of
|
|
UserId when is_integer(UserId) ->
|
|
presence_cache:delete(UserId),
|
|
presence_connect:publish_offline_on_terminate(UserId, State),
|
|
presence_connect:kick_temporary_members_on_terminate(UserId, State);
|
|
_ ->
|
|
ok
|
|
end.
|
|
|
|
-spec code_change(term(), state(), term()) -> {ok, state()}.
|
|
code_change(_OldVsn, State, _Extra) ->
|
|
erlang:garbage_collect(),
|
|
{ok, State}.
|
|
|
|
-spec handle_call(term(), gen_server:from(), state()) ->
|
|
{reply, term(), state()} | {stop, normal, ok, state()}.
|
|
handle_call({session_connect, Request}, {Pid, _}, State) when is_map(Request), is_pid(Pid) ->
|
|
SessionPid = session_connect_pid(Request, Pid),
|
|
{reply, Reply, NewState} = presence_session:handle_session_connect(
|
|
Request, SessionPid, State
|
|
),
|
|
FinalState = presence_broadcast:publish_global_presence(
|
|
maps:get(sessions, NewState), NewState
|
|
),
|
|
presence_broadcast:send_cached_presences_to_session(SessionPid, FinalState),
|
|
{reply, Reply, FinalState};
|
|
handle_call(get_current_visible_presence, _From, State) ->
|
|
{reply, presence_broadcast:current_visible_presence(State), State};
|
|
handle_call({terminate_session, SessionIdHashes}, _From, State) when is_list(SessionIdHashes) ->
|
|
presence_connect:handle_terminate_session_call(binary_list(SessionIdHashes), State);
|
|
handle_call({dispatch, EventAtom, Data}, _From, State) when is_atom(EventAtom), is_map(Data) ->
|
|
handle_dispatch_call(EventAtom, Data, State);
|
|
handle_call({join_guild, GuildId}, _From, State) when is_integer(GuildId) ->
|
|
presence_connect:handle_join_guild(GuildId, State);
|
|
handle_call({leave_guild, GuildId}, _From, State) when is_integer(GuildId) ->
|
|
presence_connect:handle_leave_guild(GuildId, State);
|
|
handle_call({add_temporary_guild, GuildId}, _From, State) when is_integer(GuildId) ->
|
|
presence_connect:handle_add_temporary_guild(GuildId, State);
|
|
handle_call({remove_temporary_guild, GuildId}, _From, State) when is_integer(GuildId) ->
|
|
{reply, ok, presence_connect:remove_temporary_guild_id(GuildId, State)};
|
|
handle_call({terminate, SessionIdHashes}, _From, State) when is_list(SessionIdHashes) ->
|
|
presence_connect:terminate_all_session_pids(binary_list(SessionIdHashes), State),
|
|
{stop, normal, ok, State};
|
|
handle_call(_, _From, State) ->
|
|
{reply, ok, State}.
|
|
|
|
-spec handle_cast(term(), state()) -> {noreply, state()}.
|
|
handle_cast({dispatch, Event, Data}, State) when is_atom(Event), is_map(Data) ->
|
|
handle_dispatch_cast(Event, Data, State);
|
|
handle_cast(presence_rejoin, State) ->
|
|
handle_presence_rejoin(State);
|
|
handle_cast(reconcile_flattened_presence, State) ->
|
|
{noreply,
|
|
presence_broadcast:publish_global_presence(
|
|
maps:get(sessions, State), State
|
|
)};
|
|
handle_cast({presence_update, Request}, State) when is_map(Request) ->
|
|
handle_presence_update_cast(Request, State);
|
|
handle_cast({terminate_session, SessionIdHashes}, State) when is_list(SessionIdHashes) ->
|
|
presence_connect:terminate_all_session_pids(binary_list(SessionIdHashes), State),
|
|
{noreply, State};
|
|
handle_cast({terminate_all_sessions}, State) ->
|
|
presence_connect:force_terminate_all_sessions(State),
|
|
{noreply, State};
|
|
handle_cast({sync_friends, FriendIds}, State) when is_list(FriendIds) ->
|
|
handle_sync_friends_cast(FriendIds, [], State);
|
|
handle_cast({sync_friends, FriendIds, FlushedIds}, State) when
|
|
is_list(FriendIds), is_list(FlushedIds)
|
|
->
|
|
handle_sync_friends_cast(FriendIds, FlushedIds, State);
|
|
handle_cast({sync_group_dm_recipients, RecipientsByChannel}, State) when
|
|
is_map(RecipientsByChannel)
|
|
->
|
|
{noreply, presence_broadcast:sync_group_dm_subscriptions(RecipientsByChannel, State)};
|
|
handle_cast(Msg, State) ->
|
|
handle_cast_guild(Msg, State).
|
|
|
|
-spec session_connect_pid(map(), pid()) -> pid().
|
|
session_connect_pid(#{session_pid := Pid}, _CallerPid) when is_pid(Pid) ->
|
|
Pid;
|
|
session_connect_pid(_Request, CallerPid) ->
|
|
CallerPid.
|
|
|
|
-spec handle_sync_friends_cast([term()], [term()], state()) -> {noreply, state()}.
|
|
handle_sync_friends_cast(FriendIds, FlushedIds, State) ->
|
|
{noreply,
|
|
presence_broadcast:sync_friend_subscriptions(
|
|
user_ids(FriendIds), user_ids(FlushedIds), State
|
|
)}.
|
|
|
|
-spec handle_cast_guild(term(), state()) -> {noreply, state()}.
|
|
handle_cast_guild({join_guild, GuildId}, State) when is_integer(GuildId) ->
|
|
{noreply, cast_guild_op(fun presence_connect:handle_join_guild/2, GuildId, State)};
|
|
handle_cast_guild({leave_guild, GuildId}, State) when is_integer(GuildId) ->
|
|
{noreply, cast_guild_op(fun presence_connect:handle_leave_guild/2, GuildId, State)};
|
|
handle_cast_guild({add_temporary_guild, GuildId}, State) when is_integer(GuildId) ->
|
|
{noreply, cast_guild_op(fun presence_connect:handle_add_temporary_guild/2, GuildId, State)};
|
|
handle_cast_guild({remove_temporary_guild, GuildId}, State) when is_integer(GuildId) ->
|
|
{noreply, presence_connect:remove_temporary_guild_id(GuildId, State)};
|
|
handle_cast_guild(_, State) ->
|
|
{noreply, State}.
|
|
|
|
-spec handle_info(term(), state()) -> {noreply, state()} | {stop, normal, state()}.
|
|
handle_info({presence, TargetId, Payload}, State) when is_integer(TargetId), is_map(Payload) ->
|
|
presence_broadcast:dispatch_global_presence(TargetId, Payload, State);
|
|
handle_info({initial_presences, Presences}, State) when is_list(Presences) ->
|
|
presence_broadcast:dispatch_initial_presences(map_list(Presences), State),
|
|
{noreply, State};
|
|
handle_info({'DOWN', Ref, process, _Pid, Reason}, State) when is_reference(Ref) ->
|
|
presence_connect:handle_process_down(Ref, Reason, State);
|
|
handle_info(_, State) ->
|
|
{noreply, State}.
|
|
|
|
-spec build_initial_state(presence_data()) -> state().
|
|
build_initial_state(PresenceData) ->
|
|
UserId = maps:get(user_id, PresenceData),
|
|
UserData = maps:get(user_data, PresenceData),
|
|
Status = maps:get(status, PresenceData),
|
|
IsBot = maps:get(<<"bot">>, UserData, false),
|
|
GuildIds = maps:get(guild_ids, PresenceData, []),
|
|
FriendIds = select_friend_ids(IsBot, maps:get(friend_ids, PresenceData, [])),
|
|
GroupDmRecipients0 = maps:get(group_dm_recipients, PresenceData, #{}),
|
|
#{
|
|
user_id => UserId,
|
|
user_data => UserData,
|
|
sessions => #{},
|
|
push_buffer => [],
|
|
custom_status => maps:get(custom_status, PresenceData, null),
|
|
status => Status,
|
|
guild_ids => presence_targets:map_from_ids(GuildIds),
|
|
temporary_guild_ids => #{},
|
|
friends => presence_targets:map_from_ids(FriendIds),
|
|
group_dm_recipients =>
|
|
presence_broadcast:normalize_group_dm_recipients(GroupDmRecipients0, UserId, IsBot),
|
|
subscriptions => #{},
|
|
is_bot => IsBot,
|
|
initial_presences_sent => false,
|
|
last_published_presence => undefined
|
|
}.
|
|
|
|
-spec select_friend_ids(boolean(), [user_id()]) -> [user_id()].
|
|
select_friend_ids(true, _FriendIds) ->
|
|
[];
|
|
select_friend_ids(false, FriendIds) ->
|
|
FriendIds.
|
|
|
|
-spec handle_presence_update_cast(map(), state()) -> {noreply, state()}.
|
|
handle_presence_update_cast(Request, State) ->
|
|
{UpdatedRequest, StateWithCustomStatus} = presence_update:maybe_handle_custom_status(
|
|
Request, State
|
|
),
|
|
{noreply, NewState} = presence_session:handle_presence_update(
|
|
UpdatedRequest, StateWithCustomStatus
|
|
),
|
|
FinalState = presence_broadcast:publish_global_presence(
|
|
maps:get(sessions, NewState), NewState
|
|
),
|
|
{noreply, FinalState}.
|
|
|
|
-spec cast_guild_op(fun((integer(), state()) -> {reply, term(), state()}), integer(), state()) ->
|
|
state().
|
|
cast_guild_op(Fun, GuildId, State) ->
|
|
{reply, _Reply, NewState} = Fun(GuildId, State),
|
|
NewState.
|
|
|
|
-spec handle_dispatch_call(atom(), map(), state()) -> {reply, ok, state()}.
|
|
handle_dispatch_call(EventAtom, Data, State) ->
|
|
presence_broadcast:dispatch_to_all_sessions(EventAtom, Data, State),
|
|
{reply, ok, process_dispatch_event(EventAtom, Data, State)}.
|
|
|
|
-spec handle_dispatch_cast(atom(), map(), state()) -> {noreply, state()}.
|
|
handle_dispatch_cast(Event, Data, State) ->
|
|
presence_broadcast:dispatch_to_all_sessions(Event, Data, State),
|
|
{noreply, process_dispatch_event(Event, Data, State)}.
|
|
|
|
-spec handle_presence_rejoin(state()) -> {noreply, state()}.
|
|
handle_presence_rejoin(State) ->
|
|
SessionPids = presence_connect:collect_session_pids(State),
|
|
lists:foreach(
|
|
fun(Pid) ->
|
|
Pid ! {presence_rejoin_check}
|
|
end,
|
|
SessionPids
|
|
),
|
|
{noreply, State}.
|
|
|
|
-spec process_dispatch_event(atom(), map(), state()) -> state().
|
|
process_dispatch_event(user_update, Data, State) ->
|
|
presence_update:handle_user_update_event(Data, State);
|
|
process_dispatch_event(user_settings_update, Data, State) ->
|
|
NewState = presence_update:handle_user_settings_update(Data, State),
|
|
presence_broadcast:force_publish_global_presence(NewState);
|
|
process_dispatch_event(message_create, Data, State) ->
|
|
presence_update:handle_message_create_event(Data, State);
|
|
process_dispatch_event(message_ack, Data, State) ->
|
|
presence_update:handle_message_ack_event(Data, State);
|
|
process_dispatch_event(_, _Data, State) ->
|
|
State.
|
|
|
|
-spec binary_list([term()]) -> [binary()].
|
|
binary_list(Items) ->
|
|
[Item || Item <- Items, is_binary(Item)].
|
|
|
|
-spec user_ids([term()]) -> [user_id()].
|
|
user_ids(Items) ->
|
|
[Item || Item <- Items, is_integer(Item)].
|
|
|
|
-spec map_list([term()]) -> [map()].
|
|
map_list(Items) ->
|
|
[Item || Item <- Items, is_map(Item)].
|
|
|
|
-spec normalize_start_link(gen_server:start_ret()) -> {ok, pid()} | {error, term()}.
|
|
normalize_start_link({ok, Pid}) ->
|
|
{ok, Pid};
|
|
normalize_start_link({error, Reason}) ->
|
|
{error, Reason};
|
|
normalize_start_link(ignore) ->
|
|
{error, ignore}.
|
|
|
|
-ifdef(TEST).
|
|
-include_lib("eunit/include/eunit.hrl").
|
|
|
|
session_connect_pid_prefers_request_session_pid_test() ->
|
|
Caller = self(),
|
|
Other = spawn(fun idle_session_proc/0),
|
|
?assertEqual(Other, session_connect_pid(#{session_pid => Other}, Caller)),
|
|
?assertEqual(Caller, session_connect_pid(#{}, Caller)),
|
|
?assertEqual(Caller, session_connect_pid(#{session_pid => undefined}, Caller)),
|
|
Other ! stop.
|
|
|
|
presence_rejoin_notifies_all_sessions_test() ->
|
|
Parent = self(),
|
|
Session1 = spawn(fun() -> rejoin_check_receiver(Parent, one) end),
|
|
Session2 = spawn(fun() -> rejoin_check_receiver(Parent, two) end),
|
|
State = test_state(#{
|
|
<<"s1">> => test_session_entry(Session1),
|
|
<<"s2">> => test_session_entry(Session2)
|
|
}),
|
|
?assertEqual({noreply, State}, handle_cast(presence_rejoin, State)),
|
|
?assert(received_rejoin_ack(one)),
|
|
?assert(received_rejoin_ack(two)).
|
|
|
|
presence_rejoin_with_no_sessions_is_noop_test() ->
|
|
State = test_state(#{}),
|
|
?assertEqual({noreply, State}, handle_cast(presence_rejoin, State)).
|
|
|
|
reconcile_flattened_presence_uses_current_session_state_test() ->
|
|
State = test_state(#{}),
|
|
PublishedState = State#{last_published_presence => #{status => <<"offline">>}},
|
|
meck:new(presence_broadcast, [passthrough]),
|
|
meck:expect(
|
|
presence_broadcast,
|
|
publish_global_presence,
|
|
fun(Sessions, ReceivedState) ->
|
|
?assertEqual(#{}, Sessions),
|
|
?assertEqual(State, ReceivedState),
|
|
PublishedState
|
|
end
|
|
),
|
|
try
|
|
?assertEqual(
|
|
{noreply, PublishedState},
|
|
handle_cast(reconcile_flattened_presence, State)
|
|
)
|
|
after
|
|
meck:unload(presence_broadcast)
|
|
end.
|
|
|
|
-spec test_state(sessions()) -> state().
|
|
test_state(Sessions) ->
|
|
#{
|
|
user_id => 1,
|
|
user_data => #{},
|
|
sessions => Sessions,
|
|
push_buffer => [],
|
|
custom_status => null,
|
|
status => online,
|
|
guild_ids => #{},
|
|
temporary_guild_ids => #{},
|
|
friends => #{},
|
|
group_dm_recipients => #{},
|
|
subscriptions => #{},
|
|
is_bot => false,
|
|
initial_presences_sent => false,
|
|
last_published_presence => undefined
|
|
}.
|
|
|
|
-spec test_session_entry(pid()) -> session_entry().
|
|
test_session_entry(Pid) ->
|
|
#{
|
|
session_id => <<"s">>,
|
|
status => online,
|
|
afk => false,
|
|
mobile => false,
|
|
pid => Pid,
|
|
mref => make_ref(),
|
|
socket_pid => undefined
|
|
}.
|
|
|
|
-spec idle_session_proc() -> ok.
|
|
idle_session_proc() ->
|
|
receive
|
|
stop -> ok
|
|
after 1000 -> ok
|
|
end.
|
|
|
|
-spec rejoin_check_receiver(pid(), atom()) -> term().
|
|
rejoin_check_receiver(Parent, Tag) ->
|
|
receive
|
|
{presence_rejoin_check} -> Parent ! {rejoin_ack, Tag}
|
|
after 1000 -> Parent ! {rejoin_timeout, Tag}
|
|
end.
|
|
|
|
-spec received_rejoin_ack(atom()) -> boolean().
|
|
received_rejoin_ack(Tag) ->
|
|
receive
|
|
{rejoin_ack, Tag} -> true;
|
|
{rejoin_timeout, Tag} -> false
|
|
after 1500 -> false
|
|
end.
|
|
|
|
-endif.
|