Files
fluxer/fluxer_gateway/src/guild/guild_presence_sync.erl
T

443 lines
18 KiB
Erlang

%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild_presence_sync).
-typing([eqwalizer]).
-export([
sync_online_status/2,
sync_member_data/2,
partition_subscribed_sessions/5,
get_user_viewable_channel_map/3,
remove_invalid_subscriptions/3,
dispatch_to_valid_sessions/4
]).
-export_type([guild_state/0, user_id/0]).
-type guild_state() :: map().
-type user_id() :: integer().
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec sync_online_status(user_id(), guild_state()) -> ok.
sync_online_status(UserId, State) ->
IsOnline = compute_is_online(UserId, State),
case maps:find(member_list_engine, State) of
error ->
ok;
{ok, Ref} ->
guild_member_list_store:set_online(Ref, UserId, IsOnline)
end,
guild_member_list_channel_engine:sync_online(UserId, IsOnline, State).
-spec sync_member_data(user_id(), guild_state()) -> ok.
sync_member_data(UserId, State) ->
Data = maps:get(data, State, #{}),
case guild_data_index:get_member(UserId, Data) of
undefined -> ok;
Member -> sync_local_nif_member_data(UserId, Member, State)
end.
-spec sync_local_nif_member_data(user_id(), map(), guild_state()) -> ok.
sync_local_nif_member_data(UserId, Member, State) ->
ok = sync_default_member_list_data(UserId, Member, State),
guild_member_list_channel_engine:update_user_all(UserId, State).
-spec sync_default_member_list_data(user_id(), map(), guild_state()) -> ok.
sync_default_member_list_data(UserId, Member, State) ->
case maps:find(member_list_engine, State) of
error ->
ok;
{ok, Ref} ->
DisplayName = guild_member_list_common:get_member_display_name(Member),
SortKey = guild_member_list_common:casefold_binary(DisplayName),
RoleIds = guild_member_list_store:extract_role_ids(Member),
IsOnline = compute_is_online(UserId, State),
guild_member_list_store:update_member(Ref, UserId, SortKey, RoleIds, IsOnline)
end.
-spec compute_is_online(user_id(), guild_state()) -> boolean().
compute_is_online(UserId, State) ->
ConnectedUserIds = guild_member_list_common:connected_session_user_ids(State),
Presence = guild_state_member:lookup_presence(maps:get(member_presence, State), UserId),
Status = maps:get(<<"status">>, Presence, <<"offline">>),
IsConnected = sets:is_element(UserId, ConnectedUserIds),
IsConnected andalso Status =/= <<"offline">> andalso Status =/= <<"invisible">>.
-spec partition_subscribed_sessions([binary()], map(), map(), user_id(), guild_state()) ->
{[binary()], [binary()]}.
partition_subscribed_sessions(SessionIds, Sessions, TargetChannelMap, TargetUserId, State) ->
lists:foldl(
fun(SessionId, {Valids, Invalids}) ->
partition_session(SessionId, Sessions, TargetChannelMap, TargetUserId, State, {
Valids, Invalids
})
end,
{[], []},
SessionIds
).
-spec partition_session(
binary(), map(), map(), user_id(), guild_state(), {[binary()], [binary()]}
) -> {[binary()], [binary()]}.
partition_session(
SessionId, Sessions, TargetChannelMap, TargetUserId, State, {Valids, Invalids}
) ->
case classify_session(SessionId, Sessions, TargetChannelMap, TargetUserId, State) of
valid -> {[SessionId | Valids], Invalids};
invalid -> {Valids, [SessionId | Invalids]}
end.
-spec classify_session(binary(), map(), map(), user_id(), guild_state()) -> valid | invalid.
classify_session(SessionId, Sessions, TargetChannelMap, TargetUserId, State) ->
case maps:find(SessionId, Sessions) of
error ->
invalid;
{ok, SessionData} ->
classify_session_data(SessionData, TargetChannelMap, TargetUserId, State)
end.
-spec classify_session_data(map(), map(), user_id(), guild_state()) -> valid | invalid.
classify_session_data(SessionData, TargetChannelMap, TargetUserId, State) ->
SessionUserId = maps:get(user_id, SessionData, undefined),
case SessionUserId of
undefined -> invalid;
TargetUserId -> invalid;
_ -> classify_non_target_session(SessionData, SessionUserId, TargetChannelMap, State)
end.
-spec classify_non_target_session(map(), user_id(), map(), guild_state()) -> valid | invalid.
classify_non_target_session(SessionData, SessionUserId, TargetChannelMap, State) ->
case session_shares_channels(SessionData, SessionUserId, TargetChannelMap, State) of
true -> valid;
false -> invalid
end.
-spec session_shares_channels(map(), user_id(), map(), guild_state()) -> boolean().
session_shares_channels(SessionData, SessionUserId, TargetChannelMap, State) ->
case maps:find(viewable_channels, SessionData) of
{ok, ViewableMap} when is_map(ViewableMap) ->
maps_share_any_key(ViewableMap, TargetChannelMap);
_ ->
session_channel_list_shares(SessionUserId, TargetChannelMap, State)
end.
-spec session_channel_list_shares(user_id(), map(), guild_state()) -> boolean().
session_channel_list_shares(SessionUserId, TargetChannelMap, State) ->
SessionChannelList = guild_visibility:get_user_viewable_channels(SessionUserId, State),
lists:any(fun(Ch) -> maps:is_key(Ch, TargetChannelMap) end, SessionChannelList).
-spec maps_share_any_key(map(), map()) -> boolean().
maps_share_any_key(MapA, MapB) ->
{Smaller, Larger} =
case map_size(MapA) =< map_size(MapB) of
true -> {MapA, MapB};
false -> {MapB, MapA}
end,
maps_share_any_key_iter(maps:iterator(Smaller), Larger).
-spec maps_share_any_key_iter(maps:iterator(), map()) -> boolean().
maps_share_any_key_iter(Iterator, LargerMap) ->
case maps:next(Iterator) of
none -> false;
{Key, _, NextIterator} -> key_matches_or_continue(Key, NextIterator, LargerMap)
end.
-spec key_matches_or_continue(term(), maps:iterator(), map()) -> boolean().
key_matches_or_continue(Key, NextIterator, LargerMap) ->
case maps:is_key(Key, LargerMap) of
true -> true;
false -> maps_share_any_key_iter(NextIterator, LargerMap)
end.
-spec get_user_viewable_channel_map(user_id(), map(), guild_state()) -> map().
get_user_viewable_channel_map(UserId, Sessions, State) ->
case find_session_viewable_channels_for_user(UserId, Sessions) of
undefined ->
ChannelList = guild_visibility:get_user_viewable_channels(UserId, State),
maps:from_list([{Ch, true} || Ch <- ChannelList]);
ViewableMap ->
ViewableMap
end.
-spec find_session_viewable_channels_for_user(user_id(), map()) -> map() | undefined.
find_session_viewable_channels_for_user(UserId, Sessions) ->
find_session_viewable_channels_iter(UserId, maps:iterator(Sessions)).
-spec find_session_viewable_channels_iter(user_id(), maps:iterator()) -> map() | undefined.
find_session_viewable_channels_iter(UserId, Iterator) ->
case maps:next(Iterator) of
none ->
undefined;
{_, SessionData, NextIterator} when is_map(SessionData) ->
viewable_channels_or_continue(UserId, SessionData, NextIterator);
{_, _, NextIterator} ->
find_session_viewable_channels_iter(UserId, NextIterator)
end.
-spec viewable_channels_or_continue(user_id(), map(), maps:iterator()) -> map() | undefined.
viewable_channels_or_continue(UserId, SessionData, NextIterator) ->
ViewableChannels = maps:get(viewable_channels, SessionData, undefined),
case {maps:get(user_id, SessionData, undefined), ViewableChannels} of
{UserId, Map} when is_map(Map) ->
Map;
_ ->
find_session_viewable_channels_iter(UserId, NextIterator)
end.
-spec remove_invalid_subscriptions([binary()], user_id(), guild_state()) -> guild_state().
remove_invalid_subscriptions([], _UserId, State) ->
State;
remove_invalid_subscriptions(InvalidSessionIds, UserId, State) ->
MemberSubs = maps:get(member_subscriptions, State, guild_subscriptions:init_state()),
{NewMemberSubs, RemovedCount} = unsubscribe_sessions_for_user(
InvalidSessionIds, UserId, MemberSubs
),
State1 = State#{member_subscriptions => NewMemberSubs},
guild_sessions_presence:unsubscribe_many_from_user_presence(UserId, RemovedCount, State1).
-spec unsubscribe_sessions_for_user(
[binary()], user_id(), guild_subscriptions:subscription_state()
) ->
{guild_subscriptions:subscription_state(), non_neg_integer()}.
unsubscribe_sessions_for_user(SessionIds, UserId, MemberSubs) ->
case maps:get(UserId, MemberSubs, undefined) of
undefined ->
{MemberSubs, 0};
Subscribers ->
remove_sessions_from_subscribers(SessionIds, UserId, Subscribers, MemberSubs)
end.
-spec remove_sessions_from_subscribers(
[binary()],
user_id(),
sets:set(binary()),
guild_subscriptions:subscription_state()
) -> {guild_subscriptions:subscription_state(), non_neg_integer()}.
remove_sessions_from_subscribers(SessionIds, UserId, Subscribers, MemberSubs) ->
{NewSubscribers, RemovedCount} = lists:foldl(
fun remove_session_from_subscriber_set/2,
{Subscribers, 0},
SessionIds
),
NewMemberSubs = put_or_remove_subscribers(UserId, NewSubscribers, MemberSubs),
{NewMemberSubs, RemovedCount}.
-spec remove_session_from_subscriber_set(binary(), {sets:set(binary()), non_neg_integer()}) ->
{sets:set(binary()), non_neg_integer()}.
remove_session_from_subscriber_set(SessionId, {Subscribers, Count}) ->
case sets:is_element(SessionId, Subscribers) of
true -> {sets:del_element(SessionId, Subscribers), Count + 1};
false -> {Subscribers, Count}
end.
-spec put_or_remove_subscribers(
user_id(), sets:set(binary()), guild_subscriptions:subscription_state()
) -> guild_subscriptions:subscription_state().
put_or_remove_subscribers(UserId, Subscribers, MemberSubs) ->
case sets:size(Subscribers) of
0 -> maps:remove(UserId, MemberSubs);
_ -> MemberSubs#{UserId => Subscribers}
end.
-spec dispatch_to_valid_sessions([binary()], map(), map(), integer()) -> ok.
dispatch_to_valid_sessions(ValidSessionIds, Sessions, PresenceUpdate, GuildId) ->
Pids = lists:filtermap(
fun(SessionId) ->
session_pid(SessionId, Sessions)
end,
ValidSessionIds
),
gateway_dispatch_relay:dispatch_many(Pids, presence_update, PresenceUpdate, GuildId),
ok.
-spec session_pid(binary(), map()) -> {true, pid()} | false.
session_pid(SessionId, Sessions) ->
case maps:find(SessionId, Sessions) of
{ok, SessionData} -> session_data_pid(SessionData);
error -> false
end.
-spec session_data_pid(map()) -> {true, pid()} | false.
session_data_pid(SessionData) ->
case maps:get(pid, SessionData, undefined) of
Pid when is_pid(Pid) -> {true, Pid};
_ -> false
end.
-ifdef(TEST).
maps_share_any_key_empty_test() ->
?assertEqual(false, maps_share_any_key(#{}, #{})),
?assertEqual(false, maps_share_any_key(#{1 => true}, #{})),
?assertEqual(false, maps_share_any_key(#{}, #{1 => true})).
maps_share_any_key_overlap_test() ->
?assertEqual(true, maps_share_any_key(#{1 => true, 2 => true}, #{2 => true, 3 => true})),
?assertEqual(true, maps_share_any_key(#{5 => true}, #{5 => true})).
maps_share_any_key_no_overlap_test() ->
?assertEqual(false, maps_share_any_key(#{1 => true, 2 => true}, #{3 => true, 4 => true})).
get_user_viewable_channel_map_uses_session_cache_test() ->
Sessions = #{
<<"s1">> => #{user_id => 10, viewable_channels => #{100 => true, 200 => true}},
<<"s2">> => #{user_id => 20, viewable_channels => #{300 => true}}
},
State = #{sessions => Sessions, data => #{<<"members">> => #{}}},
Result = get_user_viewable_channel_map(10, Sessions, State),
?assertEqual(#{100 => true, 200 => true}, Result).
get_user_viewable_channel_map_skips_session_without_cache_test() ->
Sessions = #{
<<"s1">> => #{user_id => 10},
<<"s2">> => #{user_id => 10, viewable_channels => #{100 => true}}
},
State = #{sessions => Sessions, data => #{<<"members">> => #{}}},
Result = get_user_viewable_channel_map(10, Sessions, State),
?assertEqual(#{100 => true}, Result).
session_shares_channels_uses_cached_viewable_test() ->
SessionData = #{user_id => 20, viewable_channels => #{100 => true, 200 => true}},
TargetChannelMap = #{200 => true, 300 => true},
State = #{sessions => #{}, data => #{<<"members">> => #{}}},
?assertEqual(true, session_shares_channels(SessionData, 20, TargetChannelMap, State)).
session_shares_channels_no_overlap_test() ->
SessionData = #{user_id => 20, viewable_channels => #{100 => true}},
TargetChannelMap = #{200 => true, 300 => true},
State = #{sessions => #{}, data => #{<<"members">> => #{}}},
?assertEqual(false, session_shares_channels(SessionData, 20, TargetChannelMap, State)).
partition_subscribed_sessions_uses_cached_channels_test() ->
Sessions = #{
<<"s1">> => #{user_id => 20, pid => self(), viewable_channels => #{100 => true}},
<<"s2">> => #{user_id => 30, pid => self(), viewable_channels => #{200 => true}}
},
TargetChannelMap = #{100 => true, 300 => true},
State = #{sessions => Sessions, data => #{<<"members">> => #{}}},
{Valid, Invalid} = partition_subscribed_sessions(
[<<"s1">>, <<"s2">>], Sessions, TargetChannelMap, 10, State
),
?assertEqual([<<"s1">>], Valid),
?assertEqual([<<"s2">>], Invalid).
partition_subscribed_sessions_excludes_target_user_test() ->
Sessions = #{
<<"s1">> => #{user_id => 10, pid => self(), viewable_channels => #{100 => true}}
},
assert_partition_result(Sessions, #{100 => true}, [], [<<"s1">>]).
partition_subscribed_sessions_missing_session_test() ->
assert_partition_result(#{}, #{100 => true}, [], [<<"s1">>]).
remove_invalid_subscriptions_batches_by_user_test() ->
MemberSubs0 = guild_subscriptions:init_state(),
MemberSubs1 = guild_subscriptions:subscribe(<<"s1">>, 10, MemberSubs0),
MemberSubs2 = guild_subscriptions:subscribe(<<"s2">>, 10, MemberSubs1),
MemberSubs3 = guild_subscriptions:subscribe(<<"s3">>, 10, MemberSubs2),
State = #{
member_subscriptions => MemberSubs3,
presence_subscriptions => #{10 => 5}
},
Result = remove_invalid_subscriptions([<<"s1">>, <<"s3">>, <<"missing">>], 10, State),
Remaining = guild_subscriptions:get_subscribed_sessions(
10, maps:get(member_subscriptions, Result)
),
?assertEqual([<<"s2">>], lists:sort(Remaining)),
?assertEqual(#{10 => 3}, maps:get(presence_subscriptions, Result)).
sync_member_data_updates_loaded_channel_engines_test() ->
GuildId = 100,
UserId = 10,
ChannelId = 500,
BotsRoleId = 200,
CountingRoleId = 300,
DefaultRef = guild_member_list_engine:new(),
ChannelRef = guild_member_list_engine:new(),
try
ok = load_sync_member_test_ref(DefaultRef, UserId, CountingRoleId, BotsRoleId),
ok = load_sync_member_test_ref(ChannelRef, UserId, CountingRoleId, BotsRoleId),
State = sync_member_data_test_state(
GuildId, UserId, ChannelId, BotsRoleId, CountingRoleId, DefaultRef, ChannelRef
),
?assertEqual(stale_groups(BotsRoleId), guild_member_list_engine:get_groups(ChannelRef)),
ok = sync_member_data(UserId, State),
?assertEqual(
hoisted_groups(BotsRoleId), guild_member_list_engine:get_groups(ChannelRef)
)
after
guild_member_list_engine:destroy(DefaultRef),
guild_member_list_engine:destroy(ChannelRef)
end.
assert_partition_result(Sessions, TargetChannelMap, ExpectedValid, ExpectedInvalid) ->
State = #{sessions => Sessions, data => #{<<"members">> => #{}}},
{Valid, Invalid} = partition_subscribed_sessions(
[<<"s1">>], Sessions, TargetChannelMap, 10, State
),
?assertEqual(ExpectedValid, Valid),
?assertEqual(ExpectedInvalid, Invalid).
-spec load_sync_member_test_ref(
guild_member_list_store:store_ref(), integer(), integer(), integer()
) -> ok.
load_sync_member_test_ref(Ref, UserId, MemberRoleId, HoistedRoleId) ->
guild_member_list_engine:bulk_load(
Ref, [{UserId, <<"counting">>, [MemberRoleId], true}], [HoistedRoleId]
).
sync_member_data_test_state(
GuildId, UserId, ChannelId, BotsRoleId, CountingRoleId, DefaultRef, ChannelRef
) ->
Data = guild_data_index:normalize_data(#{
<<"guild">> => #{<<"owner_id">> => integer_to_binary(UserId)},
<<"roles">> => [
role(GuildId, <<"everyone">>, false, 0),
role(BotsRoleId, <<"bots">>, true, 1),
role(CountingRoleId, <<"Counting">>, false, 1)
],
<<"members">> => [
#{
<<"user">> => #{
<<"id">> => integer_to_binary(UserId),
<<"username">> => <<"Counting">>
},
<<"roles">> => [
integer_to_binary(BotsRoleId), integer_to_binary(CountingRoleId)
]
}
],
<<"channels">> => [
#{<<"id">> => integer_to_binary(ChannelId), <<"permission_overwrites">> => []}
]
}),
#{
id => GuildId,
data => Data,
sessions => #{},
member_presence => #{UserId => #{<<"status">> => <<"online">>}},
connected_user_ids => sets:from_list([UserId]),
member_list_engine => DefaultRef,
channel_member_list_engines => #{integer_to_binary(ChannelId) => ChannelRef}
}.
role(RoleId, Name, Hoist, Position) ->
#{
<<"id">> => integer_to_binary(RoleId),
<<"name">> => Name,
<<"hoist">> => Hoist,
<<"position">> => Position,
<<"permissions">> => <<"0">>
}.
stale_groups(RoleId) ->
[{integer_to_binary(RoleId), 0}, {<<"online">>, 1}, {<<"offline">>, 0}].
hoisted_groups(RoleId) ->
[{integer_to_binary(RoleId), 1}, {<<"online">>, 0}, {<<"offline">>, 0}].
-endif.