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