fix: various fixes to sentry-reported errors and more
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -227,16 +227,7 @@ maybe_disconnect_voice_for_user(UserId, ProcessedUsers, State) ->
|
||||
|
||||
-spec ensure_unavailability_cache_table() -> ok.
|
||||
ensure_unavailability_cache_table() ->
|
||||
case ets:whereis(?GUILD_UNAVAILABILITY_CACHE) of
|
||||
undefined ->
|
||||
try ets:new(?GUILD_UNAVAILABILITY_CACHE, [named_table, public, set, {read_concurrency, true}]) of
|
||||
_ -> ok
|
||||
catch
|
||||
error:badarg -> ok
|
||||
end;
|
||||
_ ->
|
||||
ok
|
||||
end.
|
||||
guild_ets_utils:ensure_table(?GUILD_UNAVAILABILITY_CACHE, [named_table, public, set, {read_concurrency, true}]).
|
||||
|
||||
-spec set_cached_unavailability_mode(guild_id(), unavailability_mode()) -> ok.
|
||||
set_cached_unavailability_mode(GuildId, available) ->
|
||||
@@ -449,10 +440,7 @@ state_for_unavailability_transition_test(GuildId, NonStaffPid, StaffPid) ->
|
||||
active_guilds => sets:new(),
|
||||
user_roles => [],
|
||||
bot => false,
|
||||
is_staff => false,
|
||||
previous_passive_updates => #{},
|
||||
previous_passive_channel_versions => #{},
|
||||
previous_passive_voice_states => #{}
|
||||
is_staff => false
|
||||
},
|
||||
<<"staff">> => #{
|
||||
session_id => <<"staff">>,
|
||||
@@ -462,10 +450,7 @@ state_for_unavailability_transition_test(GuildId, NonStaffPid, StaffPid) ->
|
||||
active_guilds => sets:new(),
|
||||
user_roles => [],
|
||||
bot => false,
|
||||
is_staff => true,
|
||||
previous_passive_updates => #{},
|
||||
previous_passive_channel_versions => #{},
|
||||
previous_passive_voice_states => #{}
|
||||
is_staff => true
|
||||
}
|
||||
},
|
||||
presence_subscriptions => #{},
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
-module(guild_client).
|
||||
|
||||
-export([voice_state_update/3]).
|
||||
-export([voice_state_update/4]).
|
||||
|
||||
-export_type([
|
||||
voice_state_update_success/0,
|
||||
@@ -54,17 +55,40 @@
|
||||
-spec voice_state_update(pid(), map(), timeout()) -> voice_state_update_result().
|
||||
voice_state_update(GuildPid, Request, Timeout) ->
|
||||
ensure_table(),
|
||||
case acquire_slot(GuildPid) of
|
||||
TargetPid = GuildPid,
|
||||
case acquire_slot(TargetPid) of
|
||||
ok ->
|
||||
try
|
||||
execute_with_circuit_breaker(GuildPid, Request, Timeout)
|
||||
execute_with_circuit_breaker(TargetPid, Request, Timeout)
|
||||
after
|
||||
release_slot(GuildPid)
|
||||
release_slot(TargetPid)
|
||||
end;
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec voice_state_update(pid(), integer(), map(), timeout()) -> voice_state_update_result().
|
||||
voice_state_update(GuildPid, GuildId, Request, Timeout) ->
|
||||
ensure_table(),
|
||||
TargetPid = resolve_voice_pid(GuildId, GuildPid),
|
||||
case acquire_slot(TargetPid) of
|
||||
ok ->
|
||||
try
|
||||
execute_with_circuit_breaker(TargetPid, Request, Timeout)
|
||||
after
|
||||
release_slot(TargetPid)
|
||||
end;
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec resolve_voice_pid(integer(), pid()) -> pid().
|
||||
resolve_voice_pid(GuildId, FallbackGuildPid) ->
|
||||
case guild_voice_server:lookup(GuildId) of
|
||||
{ok, VoicePid} -> VoicePid;
|
||||
{error, not_found} -> FallbackGuildPid
|
||||
end.
|
||||
|
||||
-spec execute_with_circuit_breaker(pid(), map(), timeout()) -> voice_state_update_result().
|
||||
execute_with_circuit_breaker(GuildPid, Request, Timeout) ->
|
||||
case get_circuit_state(GuildPid) of
|
||||
@@ -203,23 +227,13 @@ safe_lookup(GuildPid) ->
|
||||
|
||||
-spec ensure_table() -> ok.
|
||||
ensure_table() ->
|
||||
case ets:whereis(?CIRCUIT_BREAKER_TABLE) of
|
||||
undefined ->
|
||||
try
|
||||
ets:new(?CIRCUIT_BREAKER_TABLE, [
|
||||
named_table,
|
||||
public,
|
||||
set,
|
||||
{read_concurrency, true},
|
||||
{write_concurrency, true}
|
||||
]),
|
||||
ok
|
||||
catch
|
||||
error:badarg -> ok
|
||||
end;
|
||||
_ ->
|
||||
ok
|
||||
end.
|
||||
guild_ets_utils:ensure_table(?CIRCUIT_BREAKER_TABLE, [
|
||||
named_table,
|
||||
public,
|
||||
set,
|
||||
{read_concurrency, true},
|
||||
{write_concurrency, true}
|
||||
]).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
278
fluxer_gateway/src/guild/guild_common.erl
Normal file
278
fluxer_gateway/src/guild/guild_common.erl
Normal file
@@ -0,0 +1,278 @@
|
||||
%% 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_common).
|
||||
|
||||
-export([
|
||||
safe_call/3,
|
||||
parse_event_data/1,
|
||||
relay_upsert_voice_state/2,
|
||||
strip_members/1,
|
||||
build_shard_state/4,
|
||||
merge_cluster_state/2,
|
||||
merge_user_set_maps/2
|
||||
]).
|
||||
|
||||
-type guild_id() :: integer().
|
||||
-type shard_index() :: non_neg_integer().
|
||||
|
||||
-spec safe_call(pid(), term(), timeout()) -> term().
|
||||
safe_call(Pid, Msg, Timeout) when is_pid(Pid) ->
|
||||
try gen_server:call(Pid, Msg, Timeout) of
|
||||
Reply -> Reply
|
||||
catch
|
||||
exit:{timeout, _} -> {error, timeout};
|
||||
exit:{noproc, _} -> {error, noproc};
|
||||
exit:{normal, _} -> {error, noproc};
|
||||
_:Reason -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec parse_event_data(binary() | map()) -> map().
|
||||
parse_event_data(EventData) when is_binary(EventData) ->
|
||||
json:decode(EventData);
|
||||
parse_event_data(EventData) when is_map(EventData) ->
|
||||
EventData.
|
||||
|
||||
-spec relay_upsert_voice_state(map(), map()) -> map().
|
||||
relay_upsert_voice_state(VoiceState, State) when is_map(VoiceState) ->
|
||||
ConnectionId = maps:get(<<"connection_id">>, VoiceState, undefined),
|
||||
case ConnectionId of
|
||||
undefined ->
|
||||
State;
|
||||
_ ->
|
||||
VoiceStates0 = maps:get(voice_states, State, #{}),
|
||||
ChannelId = maps:get(<<"channel_id">>, VoiceState, null),
|
||||
VoiceStates =
|
||||
case ChannelId of
|
||||
null -> maps:remove(ConnectionId, VoiceStates0);
|
||||
_ -> maps:put(ConnectionId, VoiceState, VoiceStates0)
|
||||
end,
|
||||
maps:put(voice_states, VoiceStates, State)
|
||||
end;
|
||||
relay_upsert_voice_state(_, State) ->
|
||||
State.
|
||||
|
||||
-spec strip_members(map()) -> map().
|
||||
strip_members(Data) when is_map(Data) ->
|
||||
Data1 = maps:remove(<<"members">>, Data),
|
||||
maps:remove(<<"member_role_index">>, Data1);
|
||||
strip_members(Data) ->
|
||||
Data.
|
||||
|
||||
-spec build_shard_state(guild_id(), map(), pos_integer(), shard_index()) -> map().
|
||||
build_shard_state(GuildId, Data, ShardCount, ShardIndex) ->
|
||||
DisableCache = ShardIndex =/= 0,
|
||||
MemberCount = guild_data_index:member_count(Data),
|
||||
ShardData =
|
||||
case DisableCache of
|
||||
true -> strip_members(Data);
|
||||
false -> Data
|
||||
end,
|
||||
ShardState0 = #{
|
||||
id => GuildId,
|
||||
data => ShardData,
|
||||
sessions => #{},
|
||||
member_count => MemberCount,
|
||||
disable_push_notifications => true,
|
||||
disable_member_list_updates => DisableCache,
|
||||
disable_auto_stop_on_empty => true,
|
||||
very_large_guild_coordinator_pid => self(),
|
||||
very_large_guild_shard_count => ShardCount,
|
||||
very_large_guild_shard_index => ShardIndex
|
||||
},
|
||||
case DisableCache of
|
||||
true -> maps:put(disable_permission_cache_updates, true, ShardState0);
|
||||
false -> ShardState0
|
||||
end.
|
||||
|
||||
-spec merge_cluster_state(map(), map()) -> map().
|
||||
merge_cluster_state(Acc, Frag) ->
|
||||
SessionsAcc = maps:get(sessions, Acc, #{}),
|
||||
SessionsFrag = maps:get(sessions, Frag, #{}),
|
||||
VoiceAcc = maps:get(voice_states, Acc, #{}),
|
||||
VoiceFrag = maps:get(voice_states, Frag, #{}),
|
||||
VAAcc = maps:get(virtual_channel_access, Acc, #{}),
|
||||
VAFrag = maps:get(virtual_channel_access, Frag, #{}),
|
||||
PendingAcc = maps:get(virtual_channel_access_pending, Acc, #{}),
|
||||
PendingFrag = maps:get(virtual_channel_access_pending, Frag, #{}),
|
||||
PreserveAcc = maps:get(virtual_channel_access_preserve, Acc, #{}),
|
||||
PreserveFrag = maps:get(virtual_channel_access_preserve, Frag, #{}),
|
||||
MoveAcc = maps:get(virtual_channel_access_move_pending, Acc, #{}),
|
||||
MoveFrag = maps:get(virtual_channel_access_move_pending, Frag, #{}),
|
||||
Acc#{
|
||||
sessions => maps:merge(SessionsAcc, SessionsFrag),
|
||||
voice_states => maps:merge(VoiceAcc, VoiceFrag),
|
||||
virtual_channel_access => merge_user_set_maps(VAAcc, VAFrag),
|
||||
virtual_channel_access_pending => merge_user_set_maps(PendingAcc, PendingFrag),
|
||||
virtual_channel_access_preserve => merge_user_set_maps(PreserveAcc, PreserveFrag),
|
||||
virtual_channel_access_move_pending => merge_user_set_maps(MoveAcc, MoveFrag)
|
||||
}.
|
||||
|
||||
-spec merge_user_set_maps(map(), map()) -> map().
|
||||
merge_user_set_maps(A, B) ->
|
||||
maps:fold(
|
||||
fun(UserId, SetB, Acc) ->
|
||||
case maps:get(UserId, Acc, undefined) of
|
||||
undefined ->
|
||||
maps:put(UserId, SetB, Acc);
|
||||
SetA ->
|
||||
maps:put(UserId, sets:union(SetA, SetB), Acc)
|
||||
end
|
||||
end,
|
||||
A,
|
||||
B
|
||||
).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
safe_call_timeout_test() ->
|
||||
Pid = spawn(fun() ->
|
||||
receive
|
||||
{'$gen_call', _From, _Msg} ->
|
||||
timer:sleep(5000)
|
||||
end
|
||||
end),
|
||||
Result = safe_call(Pid, ping, 50),
|
||||
?assertEqual({error, timeout}, Result),
|
||||
exit(Pid, kill),
|
||||
ok.
|
||||
|
||||
safe_call_noproc_test() ->
|
||||
Pid = spawn(fun() -> ok end),
|
||||
timer:sleep(50),
|
||||
Result = safe_call(Pid, ping, 100),
|
||||
?assertMatch({error, _}, Result),
|
||||
ok.
|
||||
|
||||
parse_event_data_binary_test() ->
|
||||
Binary = <<"{\"key\":\"value\"}">>,
|
||||
Result = parse_event_data(Binary),
|
||||
?assertEqual(#{<<"key">> => <<"value">>}, Result).
|
||||
|
||||
parse_event_data_map_test() ->
|
||||
Map = #{<<"key">> => <<"value">>},
|
||||
Result = parse_event_data(Map),
|
||||
?assertEqual(Map, Result).
|
||||
|
||||
relay_upsert_voice_state_adds_state_test() ->
|
||||
VoiceState = #{
|
||||
<<"connection_id">> => <<"conn-1">>,
|
||||
<<"channel_id">> => <<"100">>,
|
||||
<<"user_id">> => <<"42">>
|
||||
},
|
||||
State0 = #{voice_states => #{}},
|
||||
State1 = relay_upsert_voice_state(VoiceState, State0),
|
||||
VoiceStates = maps:get(voice_states, State1),
|
||||
?assertEqual(VoiceState, maps:get(<<"conn-1">>, VoiceStates)).
|
||||
|
||||
relay_upsert_voice_state_removes_on_null_channel_test() ->
|
||||
Existing = #{<<"connection_id">> => <<"conn-1">>, <<"channel_id">> => <<"100">>},
|
||||
State0 = #{voice_states => #{<<"conn-1">> => Existing}},
|
||||
RemoveState = #{<<"connection_id">> => <<"conn-1">>, <<"channel_id">> => null},
|
||||
State1 = relay_upsert_voice_state(RemoveState, State0),
|
||||
VoiceStates = maps:get(voice_states, State1),
|
||||
?assertEqual(false, maps:is_key(<<"conn-1">>, VoiceStates)).
|
||||
|
||||
relay_upsert_voice_state_no_connection_id_test() ->
|
||||
State0 = #{voice_states => #{}},
|
||||
State1 = relay_upsert_voice_state(#{<<"channel_id">> => <<"100">>}, State0),
|
||||
?assertEqual(State0, State1).
|
||||
|
||||
relay_upsert_voice_state_non_map_test() ->
|
||||
State0 = #{voice_states => #{}},
|
||||
State1 = relay_upsert_voice_state(not_a_map, State0),
|
||||
?assertEqual(State0, State1).
|
||||
|
||||
strip_members_test() ->
|
||||
Data = #{
|
||||
<<"members">> => [#{<<"user">> => #{<<"id">> => <<"1">>}}],
|
||||
<<"member_role_index">> => #{1 => [<<"role1">>]},
|
||||
<<"channels">> => [#{<<"id">> => <<"10">>}],
|
||||
<<"roles">> => [#{<<"id">> => <<"role1">>}]
|
||||
},
|
||||
Stripped = strip_members(Data),
|
||||
?assertEqual(false, maps:is_key(<<"members">>, Stripped)),
|
||||
?assertEqual(false, maps:is_key(<<"member_role_index">>, Stripped)),
|
||||
?assertEqual([#{<<"id">> => <<"10">>}], maps:get(<<"channels">>, Stripped)),
|
||||
?assertEqual([#{<<"id">> => <<"role1">>}], maps:get(<<"roles">>, Stripped)).
|
||||
|
||||
strip_members_empty_test() ->
|
||||
?assertEqual(#{}, strip_members(#{})).
|
||||
|
||||
strip_members_non_map_test() ->
|
||||
?assertEqual(not_a_map, strip_members(not_a_map)).
|
||||
|
||||
merge_user_set_maps_test() ->
|
||||
SetA = sets:from_list([1, 2]),
|
||||
SetB = sets:from_list([2, 3]),
|
||||
MapA = #{10 => SetA},
|
||||
MapB = #{10 => SetB, 20 => SetB},
|
||||
Merged = merge_user_set_maps(MapA, MapB),
|
||||
?assert(maps:is_key(10, Merged)),
|
||||
?assert(maps:is_key(20, Merged)),
|
||||
MergedSet10 = maps:get(10, Merged),
|
||||
?assert(sets:is_element(1, MergedSet10)),
|
||||
?assert(sets:is_element(2, MergedSet10)),
|
||||
?assert(sets:is_element(3, MergedSet10)),
|
||||
?assertEqual(3, sets:size(MergedSet10)),
|
||||
?assertEqual(SetB, maps:get(20, Merged)).
|
||||
|
||||
merge_user_set_maps_empty_test() ->
|
||||
?assertEqual(#{}, merge_user_set_maps(#{}, #{})).
|
||||
|
||||
merge_cluster_state_test() ->
|
||||
Acc = #{
|
||||
sessions => #{<<"s1">> => #{user_id => 1}},
|
||||
voice_states => #{<<"c1">> => #{channel_id => 10}},
|
||||
virtual_channel_access => #{},
|
||||
virtual_channel_access_pending => #{},
|
||||
virtual_channel_access_preserve => #{},
|
||||
virtual_channel_access_move_pending => #{}
|
||||
},
|
||||
Frag = #{
|
||||
sessions => #{<<"s2">> => #{user_id => 2}},
|
||||
voice_states => #{<<"c2">> => #{channel_id => 20}},
|
||||
virtual_channel_access => #{},
|
||||
virtual_channel_access_pending => #{},
|
||||
virtual_channel_access_preserve => #{},
|
||||
virtual_channel_access_move_pending => #{}
|
||||
},
|
||||
Merged = merge_cluster_state(Acc, Frag),
|
||||
?assert(maps:is_key(<<"s1">>, maps:get(sessions, Merged))),
|
||||
?assert(maps:is_key(<<"s2">>, maps:get(sessions, Merged))),
|
||||
?assert(maps:is_key(<<"c1">>, maps:get(voice_states, Merged))),
|
||||
?assert(maps:is_key(<<"c2">>, maps:get(voice_states, Merged))).
|
||||
|
||||
build_shard_state_primary_test() ->
|
||||
Data = #{<<"members">> => [#{<<"user">> => #{<<"id">> => <<"1">>}}]},
|
||||
ShardState = build_shard_state(100, Data, 4, 0),
|
||||
?assertEqual(100, maps:get(id, ShardState)),
|
||||
?assertEqual(Data, maps:get(data, ShardState)),
|
||||
?assertEqual(false, maps:get(disable_member_list_updates, ShardState)),
|
||||
?assertEqual(false, maps:is_key(disable_permission_cache_updates, ShardState)).
|
||||
|
||||
build_shard_state_secondary_test() ->
|
||||
Data = #{<<"members">> => [#{<<"user">> => #{<<"id">> => <<"1">>}}]},
|
||||
ShardState = build_shard_state(100, Data, 4, 2),
|
||||
?assertEqual(100, maps:get(id, ShardState)),
|
||||
ShardData = maps:get(data, ShardState),
|
||||
?assertEqual(false, maps:is_key(<<"members">>, ShardData)),
|
||||
?assertEqual(true, maps:get(disable_member_list_updates, ShardState)),
|
||||
?assertEqual(true, maps:get(disable_permission_cache_updates, ShardState)).
|
||||
|
||||
-endif.
|
||||
128
fluxer_gateway/src/guild/guild_counts_cache.erl
Normal file
128
fluxer_gateway/src/guild/guild_counts_cache.erl
Normal file
@@ -0,0 +1,128 @@
|
||||
%% 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_counts_cache).
|
||||
|
||||
-export([
|
||||
init/0,
|
||||
update/3,
|
||||
get/1,
|
||||
delete/1
|
||||
]).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-endif.
|
||||
|
||||
-define(TABLE, guild_counts_cache).
|
||||
|
||||
-type guild_id() :: integer().
|
||||
|
||||
-spec init() -> ok.
|
||||
init() ->
|
||||
case ets:whereis(?TABLE) of
|
||||
undefined ->
|
||||
_ = ets:new(?TABLE, [
|
||||
named_table,
|
||||
public,
|
||||
set,
|
||||
{read_concurrency, true},
|
||||
{write_concurrency, true}
|
||||
]),
|
||||
ok;
|
||||
_ ->
|
||||
ok
|
||||
end.
|
||||
|
||||
-spec update(guild_id(), non_neg_integer(), non_neg_integer()) -> ok.
|
||||
update(GuildId, MemberCount, OnlineCount) ->
|
||||
ensure_table(),
|
||||
ets:insert(?TABLE, {GuildId, MemberCount, OnlineCount}),
|
||||
ok.
|
||||
|
||||
-spec get(guild_id()) -> {ok, non_neg_integer(), non_neg_integer()} | miss.
|
||||
get(GuildId) ->
|
||||
case catch ets:lookup(?TABLE, GuildId) of
|
||||
[{GuildId, MemberCount, OnlineCount}] ->
|
||||
{ok, MemberCount, OnlineCount};
|
||||
_ ->
|
||||
miss
|
||||
end.
|
||||
|
||||
-spec delete(guild_id()) -> ok.
|
||||
delete(GuildId) ->
|
||||
catch ets:delete(?TABLE, GuildId),
|
||||
ok.
|
||||
|
||||
-spec ensure_table() -> ok.
|
||||
ensure_table() ->
|
||||
case ets:whereis(?TABLE) of
|
||||
undefined -> init();
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
-ifdef(TEST).
|
||||
|
||||
init_creates_table_test() ->
|
||||
catch ets:delete(?TABLE),
|
||||
ok = init(),
|
||||
?assertNotEqual(undefined, ets:whereis(?TABLE)),
|
||||
catch ets:delete(?TABLE).
|
||||
|
||||
init_idempotent_test() ->
|
||||
catch ets:delete(?TABLE),
|
||||
ok = init(),
|
||||
ok = init(),
|
||||
?assertNotEqual(undefined, ets:whereis(?TABLE)),
|
||||
catch ets:delete(?TABLE).
|
||||
|
||||
update_and_get_test() ->
|
||||
catch ets:delete(?TABLE),
|
||||
ok = init(),
|
||||
ok = update(100, 50, 25),
|
||||
?assertEqual({ok, 50, 25}, guild_counts_cache:get(100)),
|
||||
catch ets:delete(?TABLE).
|
||||
|
||||
get_miss_test() ->
|
||||
catch ets:delete(?TABLE),
|
||||
ok = init(),
|
||||
?assertEqual(miss, guild_counts_cache:get(999)),
|
||||
catch ets:delete(?TABLE).
|
||||
|
||||
update_overwrites_test() ->
|
||||
catch ets:delete(?TABLE),
|
||||
ok = init(),
|
||||
ok = update(100, 50, 25),
|
||||
ok = update(100, 60, 30),
|
||||
?assertEqual({ok, 60, 30}, guild_counts_cache:get(100)),
|
||||
catch ets:delete(?TABLE).
|
||||
|
||||
delete_removes_entry_test() ->
|
||||
catch ets:delete(?TABLE),
|
||||
ok = init(),
|
||||
ok = update(100, 50, 25),
|
||||
ok = delete(100),
|
||||
?assertEqual(miss, guild_counts_cache:get(100)),
|
||||
catch ets:delete(?TABLE).
|
||||
|
||||
delete_nonexistent_test() ->
|
||||
catch ets:delete(?TABLE),
|
||||
ok = init(),
|
||||
ok = delete(999),
|
||||
catch ets:delete(?TABLE).
|
||||
|
||||
-endif.
|
||||
@@ -477,4 +477,296 @@ put_member_and_remove_member_keep_member_role_index_in_sync_test() ->
|
||||
Index2 = member_role_index(Data2),
|
||||
?assertEqual(undefined, maps:get(30, Index2, undefined)).
|
||||
|
||||
normalize_data_empty_lists_test() ->
|
||||
Data = #{
|
||||
<<"members">> => [],
|
||||
<<"roles">> => [],
|
||||
<<"channels">> => []
|
||||
},
|
||||
Normalized = normalize_data(Data),
|
||||
?assertEqual(#{}, maps:get(<<"members">>, Normalized)),
|
||||
?assertEqual([], maps:get(<<"roles">>, Normalized)),
|
||||
?assertEqual([], maps:get(<<"channels">>, Normalized)),
|
||||
?assertEqual(#{}, maps:get(<<"role_index">>, Normalized)),
|
||||
?assertEqual(#{}, maps:get(<<"channel_index">>, Normalized)),
|
||||
?assertEqual(#{}, maps:get(<<"member_role_index">>, Normalized)).
|
||||
|
||||
normalize_data_non_map_input_test() ->
|
||||
?assertEqual(not_a_map, normalize_data(not_a_map)),
|
||||
?assertEqual(42, normalize_data(42)).
|
||||
|
||||
normalize_data_missing_keys_defaults_test() ->
|
||||
Data = #{},
|
||||
Normalized = normalize_data(Data),
|
||||
?assertEqual(#{}, maps:get(<<"members">>, Normalized)),
|
||||
?assertEqual([], maps:get(<<"roles">>, Normalized)),
|
||||
?assertEqual([], maps:get(<<"channels">>, Normalized)).
|
||||
|
||||
normalize_data_already_map_members_test() ->
|
||||
Data = #{
|
||||
<<"members">> => #{
|
||||
1 => #{<<"user">> => #{<<"id">> => <<"1">>}, <<"nick">> => <<"a">>}
|
||||
},
|
||||
<<"roles">> => [],
|
||||
<<"channels">> => []
|
||||
},
|
||||
Normalized = normalize_data(Data),
|
||||
Members = maps:get(<<"members">>, Normalized),
|
||||
?assertEqual(1, map_size(Members)),
|
||||
?assertMatch(#{1 := _}, Members).
|
||||
|
||||
member_map_non_map_input_test() ->
|
||||
?assertEqual(#{}, member_map(not_a_map)),
|
||||
?assertEqual(#{}, member_map(42)).
|
||||
|
||||
member_map_invalid_members_value_test() ->
|
||||
Data = #{<<"members">> => <<"invalid">>},
|
||||
?assertEqual(#{}, member_map(Data)).
|
||||
|
||||
member_map_members_without_user_test() ->
|
||||
Data = #{<<"members">> => [#{<<"nick">> => <<"orphan">>}]},
|
||||
?assertEqual(#{}, member_map(Data)).
|
||||
|
||||
member_map_duplicate_user_ids_last_wins_test() ->
|
||||
Data = #{
|
||||
<<"members">> => [
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>}, <<"nick">> => <<"first">>},
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>}, <<"nick">> => <<"second">>}
|
||||
]
|
||||
},
|
||||
MemberMap = member_map(Data),
|
||||
?assertEqual(1, map_size(MemberMap)),
|
||||
?assertEqual(<<"second">>, maps:get(<<"nick">>, maps:get(1, MemberMap))).
|
||||
|
||||
member_list_empty_test() ->
|
||||
Data = #{<<"members">> => #{}},
|
||||
?assertEqual([], member_list(Data)).
|
||||
|
||||
member_ids_returns_all_user_ids_test() ->
|
||||
Data = #{
|
||||
<<"members">> => #{
|
||||
5 => #{<<"user">> => #{<<"id">> => <<"5">>}},
|
||||
3 => #{<<"user">> => #{<<"id">> => <<"3">>}},
|
||||
8 => #{<<"user">> => #{<<"id">> => <<"8">>}}
|
||||
}
|
||||
},
|
||||
Ids = lists:sort(member_ids(Data)),
|
||||
?assertEqual([3, 5, 8], Ids).
|
||||
|
||||
get_member_non_integer_key_test() ->
|
||||
Data = #{<<"members">> => #{1 => #{<<"user">> => #{<<"id">> => <<"1">>}}}},
|
||||
?assertEqual(undefined, get_member(not_an_integer, Data)).
|
||||
|
||||
get_member_missing_user_test() ->
|
||||
Data = #{<<"members">> => #{}},
|
||||
?assertEqual(undefined, get_member(999, Data)).
|
||||
|
||||
put_member_no_user_id_returns_unchanged_test() ->
|
||||
Data = #{<<"members">> => #{}},
|
||||
?assertEqual(Data, put_member(#{<<"nick">> => <<"orphan">>}, Data)).
|
||||
|
||||
put_member_non_map_member_returns_unchanged_test() ->
|
||||
Data = #{<<"members">> => #{}},
|
||||
?assertEqual(Data, put_member(not_a_map, Data)).
|
||||
|
||||
put_member_non_map_data_returns_unchanged_test() ->
|
||||
?assertEqual(not_a_map, put_member(#{<<"user">> => #{<<"id">> => <<"1">>}}, not_a_map)).
|
||||
|
||||
put_member_adds_new_member_test() ->
|
||||
Data = #{<<"members">> => #{}},
|
||||
UpdatedData = put_member(
|
||||
#{<<"user">> => #{<<"id">> => <<"42">>}, <<"nick">> => <<"new">>},
|
||||
Data
|
||||
),
|
||||
?assertMatch(#{42 := _}, maps:get(<<"members">>, UpdatedData)),
|
||||
?assertEqual(<<"new">>, maps:get(<<"nick">>, get_member(42, UpdatedData))).
|
||||
|
||||
put_member_map_replaces_all_members_test() ->
|
||||
Data = #{
|
||||
<<"members">> => #{1 => #{<<"user">> => #{<<"id">> => <<"1">>}}}
|
||||
},
|
||||
NewMap = #{2 => #{<<"user">> => #{<<"id">> => <<"2">>}, <<"roles">> => [<<"10">>]}},
|
||||
Updated = put_member_map(NewMap, Data),
|
||||
?assertEqual(undefined, get_member(1, Updated)),
|
||||
?assertMatch(#{2 := _}, maps:get(<<"members">>, Updated)).
|
||||
|
||||
put_member_map_non_map_returns_unchanged_test() ->
|
||||
Data = #{<<"members">> => #{}},
|
||||
?assertEqual(Data, put_member_map(not_a_map, Data)).
|
||||
|
||||
put_member_list_converts_and_stores_test() ->
|
||||
Data = #{<<"members">> => #{}},
|
||||
Members = [
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>}},
|
||||
#{<<"user">> => #{<<"id">> => <<"2">>}}
|
||||
],
|
||||
Updated = put_member_list(Members, Data),
|
||||
?assertEqual(2, map_size(maps:get(<<"members">>, Updated))).
|
||||
|
||||
put_member_list_non_list_returns_unchanged_test() ->
|
||||
Data = #{<<"members">> => #{}},
|
||||
?assertEqual(Data, put_member_list(not_a_list, Data)).
|
||||
|
||||
remove_member_non_integer_returns_unchanged_test() ->
|
||||
Data = #{<<"members">> => #{1 => #{<<"user">> => #{<<"id">> => <<"1">>}}}},
|
||||
?assertEqual(Data, remove_member(not_an_int, Data)).
|
||||
|
||||
remove_member_non_existent_test() ->
|
||||
Data = #{<<"members">> => #{1 => #{<<"user">> => #{<<"id">> => <<"1">>}}}},
|
||||
Updated = remove_member(999, Data),
|
||||
?assertEqual(1, map_size(maps:get(<<"members">>, Updated))).
|
||||
|
||||
role_list_non_map_input_test() ->
|
||||
?assertEqual([], role_list(not_a_map)).
|
||||
|
||||
role_list_non_list_roles_value_test() ->
|
||||
Data = #{<<"roles">> => <<"invalid">>},
|
||||
?assertEqual([], role_list(Data)).
|
||||
|
||||
role_index_non_map_input_test() ->
|
||||
?assertEqual(#{}, role_index(not_a_map)).
|
||||
|
||||
role_index_from_list_test() ->
|
||||
Data = #{
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => <<"100">>, <<"name">> => <<"Admin">>},
|
||||
#{<<"id">> => <<"200">>, <<"name">> => <<"Member">>}
|
||||
]
|
||||
},
|
||||
Index = role_index(Data),
|
||||
?assertEqual(2, map_size(Index)),
|
||||
?assertEqual(<<"Admin">>, maps:get(<<"name">>, maps:get(100, Index))).
|
||||
|
||||
put_roles_updates_list_and_index_test() ->
|
||||
Data = #{
|
||||
<<"roles">> => [#{<<"id">> => <<"1">>, <<"name">> => <<"old">>}]
|
||||
},
|
||||
NewRoles = [
|
||||
#{<<"id">> => <<"10">>, <<"name">> => <<"new1">>},
|
||||
#{<<"id">> => <<"20">>, <<"name">> => <<"new2">>}
|
||||
],
|
||||
Updated = put_roles(NewRoles, Data),
|
||||
?assertEqual(NewRoles, role_list(Updated)),
|
||||
?assertEqual(2, map_size(role_index(Updated))).
|
||||
|
||||
put_roles_non_map_data_returns_unchanged_test() ->
|
||||
?assertEqual(not_a_map, put_roles([], not_a_map)).
|
||||
|
||||
channel_list_non_map_input_test() ->
|
||||
?assertEqual([], channel_list(not_a_map)).
|
||||
|
||||
channel_list_non_list_channels_value_test() ->
|
||||
Data = #{<<"channels">> => <<"invalid">>},
|
||||
?assertEqual([], channel_list(Data)).
|
||||
|
||||
channel_index_non_map_input_test() ->
|
||||
?assertEqual(#{}, channel_index(not_a_map)).
|
||||
|
||||
channel_index_from_list_test() ->
|
||||
Data = #{
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"300">>, <<"name">> => <<"general">>},
|
||||
#{<<"id">> => <<"301">>, <<"name">> => <<"random">>}
|
||||
]
|
||||
},
|
||||
Index = channel_index(Data),
|
||||
?assertEqual(2, map_size(Index)),
|
||||
?assertEqual(<<"general">>, maps:get(<<"name">>, maps:get(300, Index))).
|
||||
|
||||
put_channels_updates_list_and_index_test() ->
|
||||
Data = #{<<"channels">> => []},
|
||||
NewChannels = [
|
||||
#{<<"id">> => <<"50">>, <<"name">> => <<"ch1">>},
|
||||
#{<<"id">> => <<"51">>, <<"name">> => <<"ch2">>}
|
||||
],
|
||||
Updated = put_channels(NewChannels, Data),
|
||||
?assertEqual(NewChannels, channel_list(Updated)),
|
||||
?assertEqual(2, map_size(channel_index(Updated))).
|
||||
|
||||
put_channels_non_map_data_returns_unchanged_test() ->
|
||||
?assertEqual(not_a_map, put_channels([], not_a_map)).
|
||||
|
||||
member_role_index_non_map_input_test() ->
|
||||
?assertEqual(#{}, member_role_index(not_a_map)).
|
||||
|
||||
member_role_index_members_without_roles_test() ->
|
||||
Data = #{
|
||||
<<"members">> => #{
|
||||
1 => #{<<"user">> => #{<<"id">> => <<"1">>}}
|
||||
}
|
||||
},
|
||||
Index = member_role_index(Data),
|
||||
?assertEqual(#{}, Index).
|
||||
|
||||
member_role_index_shared_roles_test() ->
|
||||
Data = #{
|
||||
<<"members">> => #{
|
||||
1 => #{<<"user">> => #{<<"id">> => <<"1">>}, <<"roles">> => [<<"10">>]},
|
||||
2 => #{<<"user">> => #{<<"id">> => <<"2">>}, <<"roles">> => [<<"10">>]},
|
||||
3 => #{<<"user">> => #{<<"id">> => <<"3">>}, <<"roles">> => [<<"10">>, <<"20">>]}
|
||||
}
|
||||
},
|
||||
Index = member_role_index(Data),
|
||||
?assertEqual(#{1 => true, 2 => true, 3 => true}, maps:get(10, Index)),
|
||||
?assertEqual(#{3 => true}, maps:get(20, Index)).
|
||||
|
||||
build_id_index_skips_items_without_id_test() ->
|
||||
Items = [
|
||||
#{<<"id">> => <<"1">>, <<"name">> => <<"first">>},
|
||||
#{<<"name">> => <<"no_id">>},
|
||||
#{<<"id">> => <<"2">>, <<"name">> => <<"second">>}
|
||||
],
|
||||
Index = build_id_index(Items),
|
||||
?assertEqual(2, map_size(Index)),
|
||||
?assertEqual(<<"first">>, maps:get(<<"name">>, maps:get(1, Index))).
|
||||
|
||||
build_id_index_empty_list_test() ->
|
||||
?assertEqual(#{}, build_id_index([])).
|
||||
|
||||
extract_integer_list_mixed_types_test() ->
|
||||
?assertEqual([1, 2, 3], extract_integer_list([<<"1">>, 2, <<"3">>])),
|
||||
?assertEqual([1, 3], extract_integer_list([<<"1">>, <<"invalid">>, <<"3">>])),
|
||||
?assertEqual([], extract_integer_list(not_a_list)).
|
||||
|
||||
ensure_list_test() ->
|
||||
?assertEqual([1, 2], ensure_list([1, 2])),
|
||||
?assertEqual([], ensure_list(not_a_list)),
|
||||
?assertEqual([], ensure_list(#{})).
|
||||
|
||||
normalize_member_map_with_binary_keys_test() ->
|
||||
MemberMap = #{
|
||||
<<"42">> => #{<<"user">> => #{<<"id">> => <<"42">>}, <<"nick">> => <<"test">>}
|
||||
},
|
||||
Normalized = normalize_member_map(MemberMap),
|
||||
?assertMatch(#{42 := _}, Normalized),
|
||||
?assertEqual(<<"test">>, maps:get(<<"nick">>, maps:get(42, Normalized))).
|
||||
|
||||
put_member_multiple_roles_index_test() ->
|
||||
Data = #{<<"members">> => #{}},
|
||||
Member = #{<<"user">> => #{<<"id">> => <<"7">>}, <<"roles">> => [<<"10">>, <<"20">>, <<"30">>]},
|
||||
Updated = put_member(Member, Data),
|
||||
Index = member_role_index(Updated),
|
||||
?assertEqual(#{7 => true}, maps:get(10, Index)),
|
||||
?assertEqual(#{7 => true}, maps:get(20, Index)),
|
||||
?assertEqual(#{7 => true}, maps:get(30, Index)).
|
||||
|
||||
remove_member_cleans_empty_role_entries_test() ->
|
||||
Data0 = #{<<"members">> => #{}},
|
||||
Data1 = put_member(
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>}, <<"roles">> => [<<"10">>]},
|
||||
Data0
|
||||
),
|
||||
Data2 = put_member(
|
||||
#{<<"user">> => #{<<"id">> => <<"2">>}, <<"roles">> => [<<"10">>, <<"20">>]},
|
||||
Data1
|
||||
),
|
||||
Data3 = remove_member(1, Data2),
|
||||
Index = member_role_index(Data3),
|
||||
?assertEqual(#{2 => true}, maps:get(10, Index)),
|
||||
?assertEqual(#{2 => true}, maps:get(20, Index)),
|
||||
Data4 = remove_member(2, Data3),
|
||||
Index2 = member_role_index(Data4),
|
||||
?assertEqual(undefined, maps:get(10, Index2, undefined)),
|
||||
?assertEqual(undefined, maps:get(20, Index2, undefined)).
|
||||
|
||||
-endif.
|
||||
|
||||
@@ -74,6 +74,8 @@ process_dispatch(Event, EventData, State) ->
|
||||
FilteredSessions = filter_sessions_for_event(
|
||||
Event, FinalData, SessionIdOpt, Sessions, FilterState
|
||||
),
|
||||
logger:info("process_dispatch: event=~p guild_id=~p total_sessions=~p filtered_sessions=~p",
|
||||
[Event, GuildId, map_size(Sessions), length(FilteredSessions)]),
|
||||
DispatchSuccess = dispatch_to_sessions(FilteredSessions, Event, FinalData, UpdatedState),
|
||||
track_dispatch_metrics(Event, DispatchSuccess),
|
||||
maybe_send_push_notifications(Event, FinalData, GuildId, UpdatedState),
|
||||
@@ -260,8 +262,10 @@ dispatch_bulk_to_session(_, _, _, _, Acc) ->
|
||||
-spec dispatch_standard([session_pair()], event(), event_data(), guild_id(), guild_state()) ->
|
||||
non_neg_integer().
|
||||
dispatch_standard(FilteredSessions, Event, FinalData, GuildId, State) ->
|
||||
logger:info("dispatch_standard: event=~p guild_id=~p filtered_sessions=~p member_count=~p",
|
||||
[Event, GuildId, length(FilteredSessions), maps:get(member_count, State, undefined)]),
|
||||
SuccessCount = lists:foldl(
|
||||
fun({_Sid, SessionData}, Acc) ->
|
||||
fun({Sid, SessionData}, Acc) ->
|
||||
Pid = maps:get(pid, SessionData),
|
||||
case
|
||||
is_pid(Pid) andalso
|
||||
@@ -277,6 +281,11 @@ dispatch_standard(FilteredSessions, Event, FinalData, GuildId, State) ->
|
||||
_:_ -> Acc
|
||||
end;
|
||||
false ->
|
||||
logger:info("dispatch_standard skip: sid=~p is_pid=~p passive=~p small=~p",
|
||||
[Sid,
|
||||
is_pid(Pid),
|
||||
session_passive:is_passive(GuildId, SessionData),
|
||||
session_passive:is_small_guild(State)]),
|
||||
Acc
|
||||
end
|
||||
end,
|
||||
@@ -1098,4 +1107,188 @@ build_channel_delete_dispatch_state(VisiblePid, HiddenPid) ->
|
||||
}
|
||||
}.
|
||||
|
||||
should_skip_dispatch_guild_update_never_skipped_test() ->
|
||||
State = #{
|
||||
data => #{
|
||||
<<"guild">> => #{<<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE">>]}
|
||||
}
|
||||
},
|
||||
?assertEqual(false, should_skip_dispatch(guild_update, State)).
|
||||
|
||||
should_skip_dispatch_unavailable_for_everyone_test() ->
|
||||
State = #{
|
||||
data => #{
|
||||
<<"guild">> => #{<<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE">>]}
|
||||
}
|
||||
},
|
||||
?assertEqual(true, should_skip_dispatch(message_create, State)).
|
||||
|
||||
should_skip_dispatch_unavailable_for_everyone_but_staff_test() ->
|
||||
State = #{
|
||||
data => #{
|
||||
<<"guild">> => #{<<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF">>]}
|
||||
}
|
||||
},
|
||||
?assertEqual(true, should_skip_dispatch(message_create, State)).
|
||||
|
||||
should_skip_dispatch_normal_guild_test() ->
|
||||
State = #{
|
||||
data => #{
|
||||
<<"guild">> => #{<<"features">> => []}
|
||||
}
|
||||
},
|
||||
?assertEqual(false, should_skip_dispatch(message_create, State)).
|
||||
|
||||
should_skip_dispatch_no_features_test() ->
|
||||
State = #{data => #{<<"guild">> => #{}}},
|
||||
?assertEqual(false, should_skip_dispatch(message_create, State)).
|
||||
|
||||
filter_sessions_for_event_guild_wide_goes_to_all_sessions_test() ->
|
||||
S1 = #{session_id => <<"s1">>, user_id => 10, pid => self()},
|
||||
S2 = #{session_id => <<"s2">>, user_id => 11, pid => self()},
|
||||
Sessions = #{<<"s1">> => S1, <<"s2">> => S2},
|
||||
State = #{sessions => Sessions, data => #{<<"members">> => #{}}},
|
||||
Result = filter_sessions_for_event(guild_member_add, #{}, undefined, Sessions, State),
|
||||
?assertEqual(2, length(Result)).
|
||||
|
||||
extract_channel_id_message_create_uses_channel_id_field_test() ->
|
||||
Data = #{<<"channel_id">> => <<"42">>},
|
||||
?assertEqual(42, extract_channel_id(message_create, Data)).
|
||||
|
||||
extract_channel_id_channel_create_uses_id_field_test() ->
|
||||
Data = #{<<"id">> => <<"42">>},
|
||||
?assertEqual(42, extract_channel_id(channel_create, Data)).
|
||||
|
||||
extract_channel_id_channel_update_uses_id_field_test() ->
|
||||
Data = #{<<"id">> => <<"42">>},
|
||||
?assertEqual(42, extract_channel_id(channel_update, Data)).
|
||||
|
||||
parse_integer_undefined_returns_default_test() ->
|
||||
?assertEqual(42, parse_integer(undefined, 42)).
|
||||
|
||||
parse_integer_integer_test() ->
|
||||
?assertEqual(7, parse_integer(7, 0)).
|
||||
|
||||
parse_integer_valid_binary_test() ->
|
||||
?assertEqual(123, parse_integer(<<"123">>, 0)).
|
||||
|
||||
parse_integer_invalid_binary_test() ->
|
||||
?assertEqual(0, parse_integer(<<"abc">>, 0)).
|
||||
|
||||
parse_integer_other_type_test() ->
|
||||
?assertEqual(5, parse_integer(3.14, 5)).
|
||||
|
||||
is_guild_operation_disabled_test() ->
|
||||
State = disabled_operations_state(3),
|
||||
?assertEqual(true, is_guild_operation_disabled(State, 1)),
|
||||
?assertEqual(true, is_guild_operation_disabled(State, 2)),
|
||||
?assertEqual(true, is_guild_operation_disabled(State, 3)),
|
||||
?assertEqual(false, is_guild_operation_disabled(State, 4)).
|
||||
|
||||
is_guild_operation_disabled_binary_test() ->
|
||||
State = disabled_operations_state(<<"5">>),
|
||||
?assertEqual(true, is_guild_operation_disabled(State, 1)),
|
||||
?assertEqual(true, is_guild_operation_disabled(State, 4)),
|
||||
?assertEqual(false, is_guild_operation_disabled(State, 2)).
|
||||
|
||||
extract_session_id_if_needed_reaction_remove_test() ->
|
||||
Data = #{<<"session_id">> => <<"sid">>, <<"emoji">> => #{}},
|
||||
{SessionId, CleanData} = extract_session_id_if_needed(message_reaction_remove, Data),
|
||||
?assertEqual(<<"sid">>, SessionId),
|
||||
?assertNot(maps:is_key(<<"session_id">>, CleanData)).
|
||||
|
||||
decorate_member_data_typing_start_test() ->
|
||||
Member = #{<<"user">> => #{<<"id">> => <<"456">>}, <<"roles">> => []},
|
||||
State = #{data => #{<<"members">> => [Member]}},
|
||||
Data = #{<<"user_id">> => <<"456">>},
|
||||
Decorated = decorate_member_data(typing_start, Data, State),
|
||||
?assert(maps:is_key(<<"member">>, Decorated)),
|
||||
?assert(maps:is_key(<<"user">>, maps:get(<<"member">>, Decorated))).
|
||||
|
||||
decorate_member_data_guild_event_no_decoration_test() ->
|
||||
State = #{data => #{<<"members">> => []}},
|
||||
Data = #{<<"name">> => <<"test">>},
|
||||
Decorated = decorate_member_data(guild_update, Data, State),
|
||||
?assertEqual(false, maps:is_key(<<"member">>, Decorated)).
|
||||
|
||||
filter_visible_channels_test() ->
|
||||
GuildId = 42,
|
||||
UserId = 10,
|
||||
ViewPerm = constants:view_channel_permission(),
|
||||
Member = #{<<"user">> => #{<<"id">> => integer_to_binary(UserId)}, <<"roles">> => []},
|
||||
State = #{
|
||||
id => GuildId,
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => integer_to_binary(ViewPerm)}
|
||||
],
|
||||
<<"members">> => [Member],
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"100">>, <<"permission_overwrites">> => []},
|
||||
#{
|
||||
<<"id">> => <<"101">>,
|
||||
<<"permission_overwrites">> => [
|
||||
#{
|
||||
<<"id">> => integer_to_binary(GuildId),
|
||||
<<"type">> => 0,
|
||||
<<"allow">> => <<"0">>,
|
||||
<<"deny">> => integer_to_binary(ViewPerm)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
Channels = [
|
||||
#{<<"id">> => <<"100">>},
|
||||
#{<<"id">> => <<"101">>}
|
||||
],
|
||||
Result = filter_visible_channels(Channels, UserId, Member, State),
|
||||
?assertEqual(1, length(Result)),
|
||||
?assertEqual(<<"100">>, maps:get(<<"id">>, hd(Result))).
|
||||
|
||||
filter_visible_channels_undefined_member_test() ->
|
||||
State = #{data => #{<<"members">> => []}},
|
||||
Channels = [#{<<"id">> => <<"100">>}],
|
||||
Result = filter_visible_channels(Channels, 10, undefined, State),
|
||||
?assertEqual([], Result).
|
||||
|
||||
extract_user_id_from_event_test() ->
|
||||
EventData = #{<<"user">> => #{<<"id">> => <<"42">>}},
|
||||
?assertEqual(42, extract_user_id_from_event(EventData)).
|
||||
|
||||
extract_user_id_from_event_missing_test() ->
|
||||
?assertEqual(undefined, extract_user_id_from_event(#{})).
|
||||
|
||||
extract_user_id_from_event_invalid_test() ->
|
||||
EventData = #{<<"user">> => #{<<"id">> => <<"invalid">>}},
|
||||
?assertEqual(undefined, extract_user_id_from_event(EventData)).
|
||||
|
||||
find_channel_name_uses_index_test() ->
|
||||
Data = #{
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"100">>, <<"name">> => <<"general">>}
|
||||
],
|
||||
<<"channel_index">> => #{100 => #{<<"id">> => <<"100">>, <<"name">> => <<"general">>}}
|
||||
},
|
||||
?assertEqual(<<"general">>, find_channel_name(<<"100">>, Data)).
|
||||
|
||||
find_channel_name_invalid_id_test() ->
|
||||
Data = #{<<"channels">> => []},
|
||||
?assertEqual(<<"unknown">>, find_channel_name(<<"invalid">>, Data)).
|
||||
|
||||
extract_role_ids_test() ->
|
||||
Member = #{<<"roles">> => [<<"10">>, <<"20">>, <<"invalid">>]},
|
||||
Result = lists:sort(extract_role_ids(Member)),
|
||||
?assertEqual([10, 20], Result).
|
||||
|
||||
extract_role_ids_empty_test() ->
|
||||
Member = #{<<"roles">> => []},
|
||||
?assertEqual([], extract_role_ids(Member)).
|
||||
|
||||
extract_role_ids_missing_key_test() ->
|
||||
Member = #{},
|
||||
?assertEqual([], extract_role_ids(Member)).
|
||||
|
||||
-endif.
|
||||
|
||||
53
fluxer_gateway/src/guild/guild_ets_utils.erl
Normal file
53
fluxer_gateway/src/guild/guild_ets_utils.erl
Normal file
@@ -0,0 +1,53 @@
|
||||
%% 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_ets_utils).
|
||||
|
||||
-export([ensure_table/2]).
|
||||
|
||||
-spec ensure_table(atom(), list()) -> ok.
|
||||
ensure_table(TableName, Options) ->
|
||||
case ets:whereis(TableName) of
|
||||
undefined ->
|
||||
try ets:new(TableName, Options) of
|
||||
_ -> ok
|
||||
catch
|
||||
error:badarg -> ok
|
||||
end;
|
||||
_ ->
|
||||
ok
|
||||
end.
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
ensure_table_creates_new_table_test() ->
|
||||
TableName = guild_ets_utils_test_table,
|
||||
catch ets:delete(TableName),
|
||||
ok = ensure_table(TableName, [named_table, public, set]),
|
||||
?assertNotEqual(undefined, ets:whereis(TableName)),
|
||||
ets:delete(TableName).
|
||||
|
||||
ensure_table_idempotent_test() ->
|
||||
TableName = guild_ets_utils_test_idempotent,
|
||||
catch ets:delete(TableName),
|
||||
ok = ensure_table(TableName, [named_table, public, set]),
|
||||
ok = ensure_table(TableName, [named_table, public, set]),
|
||||
?assertNotEqual(undefined, ets:whereis(TableName)),
|
||||
ets:delete(TableName).
|
||||
|
||||
-endif.
|
||||
@@ -193,8 +193,13 @@ forward_call_to_shard(GuildId, Request, State) ->
|
||||
Pid = maps:get(pid, ShardMap),
|
||||
case catch gen_server:call(Pid, Request, ?DEFAULT_GEN_SERVER_TIMEOUT) of
|
||||
{'EXIT', _} ->
|
||||
{_Shard, State2} = restart_shard(Index, State1),
|
||||
forward_call_to_shard(GuildId, Request, State2);
|
||||
case erlang:is_process_alive(Pid) of
|
||||
true ->
|
||||
{{error, timeout}, State1};
|
||||
false ->
|
||||
{_Shard, State2} = restart_shard(Index, State1),
|
||||
forward_call_to_shard(GuildId, Request, State2)
|
||||
end;
|
||||
{ok, GuildPid} = Reply ->
|
||||
ets:insert(?GUILD_PID_CACHE, {GuildId, GuildPid}),
|
||||
erlang:monitor(process, GuildPid),
|
||||
@@ -367,4 +372,54 @@ find_shard_by_pid_found_test() ->
|
||||
Shards = #{0 => #{pid => Pid, ref => make_ref()}},
|
||||
?assertMatch({ok, 0}, find_shard_by_pid(Pid, Shards)).
|
||||
|
||||
forward_call_to_shard_timeout_does_not_restart_shard_test_() ->
|
||||
{timeout, 15, fun() ->
|
||||
catch ets:delete(guild_pid_cache),
|
||||
SlowShardPid = spawn(fun() -> slow_shard_loop() end),
|
||||
ShardRef = erlang:monitor(process, SlowShardPid),
|
||||
State = #{
|
||||
shards => #{0 => #{pid => SlowShardPid, ref => ShardRef}},
|
||||
shard_count => 1
|
||||
},
|
||||
ets:new(guild_pid_cache, [named_table, public, set, {read_concurrency, true}]),
|
||||
try
|
||||
GuildId = 99999,
|
||||
{Reply, NewState} = forward_call_to_shard(GuildId, {start_or_lookup, GuildId}, State),
|
||||
?assertMatch({error, timeout}, Reply),
|
||||
?assert(is_process_alive(SlowShardPid)),
|
||||
NewShards = maps:get(shards, NewState),
|
||||
#{pid := ShardPidAfter} = maps:get(0, NewShards),
|
||||
?assertEqual(SlowShardPid, ShardPidAfter)
|
||||
after
|
||||
SlowShardPid ! stop,
|
||||
catch ets:delete(guild_pid_cache)
|
||||
end
|
||||
end}.
|
||||
|
||||
slow_shard_loop() ->
|
||||
receive
|
||||
{'$gen_call', _From, _Msg} ->
|
||||
timer:sleep(10000),
|
||||
slow_shard_loop();
|
||||
stop ->
|
||||
ok;
|
||||
_ ->
|
||||
slow_shard_loop()
|
||||
end.
|
||||
|
||||
cleanup_guild_from_cache_does_not_remove_new_pid_test() ->
|
||||
catch ets:delete(guild_pid_cache),
|
||||
ets:new(guild_pid_cache, [named_table, public, set, {read_concurrency, true}]),
|
||||
try
|
||||
OldPid = spawn(fun() -> ok end),
|
||||
timer:sleep(10),
|
||||
NewPid = spawn(fun() -> timer:sleep(1000) end),
|
||||
ets:insert(guild_pid_cache, {42, NewPid}),
|
||||
cleanup_guild_from_cache(OldPid),
|
||||
[{42, FoundPid}] = ets:lookup(guild_pid_cache, 42),
|
||||
?assertEqual(NewPid, FoundPid)
|
||||
after
|
||||
catch ets:delete(guild_pid_cache)
|
||||
end.
|
||||
|
||||
-endif.
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
-include_lib("fluxer_gateway/include/timeout_config.hrl").
|
||||
|
||||
-define(GUILD_API_CANARY_PERCENTAGE, 5).
|
||||
-define(BATCH_SIZE, 10).
|
||||
-define(BATCH_DELAY_MS, 100).
|
||||
|
||||
@@ -33,8 +32,6 @@
|
||||
-type fetch_result() :: {ok, guild_data()} | {error, term()}.
|
||||
-type state() :: #{
|
||||
guilds := #{guild_id() => guild_ref() | loading},
|
||||
api_host := string(),
|
||||
api_canary_host := undefined | string(),
|
||||
pending_requests := #{guild_id() => [gen_server:from()]},
|
||||
shard_index := non_neg_integer()
|
||||
}.
|
||||
@@ -47,13 +44,9 @@ start_link(ShardIndex) ->
|
||||
init(Args) ->
|
||||
process_flag(trap_exit, true),
|
||||
fluxer_gateway_env:load(),
|
||||
ApiHost = fluxer_gateway_env:get(api_host),
|
||||
ApiCanaryHost = fluxer_gateway_env:get(api_canary_host),
|
||||
ShardIndex = maps:get(shard_index, Args, 0),
|
||||
{ok, #{
|
||||
guilds => #{},
|
||||
api_host => ApiHost,
|
||||
api_canary_host => ApiCanaryHost,
|
||||
pending_requests => #{},
|
||||
shard_index => ShardIndex
|
||||
}}.
|
||||
@@ -149,12 +142,11 @@ start_fetch(GuildId, From, State) ->
|
||||
{noreply, NewState}.
|
||||
|
||||
-spec spawn_fetch(guild_id(), state()) -> pid().
|
||||
spawn_fetch(GuildId, State) ->
|
||||
spawn_fetch(GuildId, _State) ->
|
||||
Manager = self(),
|
||||
ApiHostInfo = select_api_host(State),
|
||||
spawn(fun() ->
|
||||
try
|
||||
Result = fetch_guild_data_with_fallback(GuildId, ApiHostInfo, State),
|
||||
Result = fetch_guild_data(GuildId),
|
||||
gen_server:cast(Manager, {guild_data_fetched, GuildId, Result})
|
||||
catch
|
||||
_:_:_ ->
|
||||
@@ -258,12 +250,11 @@ do_reload_guild(GuildId, From, State) ->
|
||||
end.
|
||||
|
||||
-spec spawn_reload(guild_id(), pid(), gen_server:from(), state()) -> pid().
|
||||
spawn_reload(GuildId, Pid, From, State) ->
|
||||
spawn_reload(GuildId, Pid, From, _State) ->
|
||||
Manager = self(),
|
||||
ApiHostInfo = select_api_host(State),
|
||||
spawn(fun() ->
|
||||
try
|
||||
Result = fetch_guild_data_with_fallback(GuildId, ApiHostInfo, State),
|
||||
Result = fetch_guild_data(GuildId),
|
||||
gen_server:cast(Manager, {guild_data_reloaded, GuildId, Pid, From, Result})
|
||||
catch
|
||||
_:_:_ ->
|
||||
@@ -319,13 +310,12 @@ reload_guilds_in_batches(Guilds, State) ->
|
||||
end.
|
||||
|
||||
-spec reload_batch([{guild_id(), pid()}], state()) -> ok.
|
||||
reload_batch(Batch, State) ->
|
||||
ApiHostInfo = select_api_host(State),
|
||||
reload_batch(Batch, _State) ->
|
||||
lists:foreach(
|
||||
fun({GuildId, Pid}) ->
|
||||
spawn(fun() ->
|
||||
try
|
||||
case fetch_guild_data_with_fallback(GuildId, ApiHostInfo, State) of
|
||||
case fetch_guild_data(GuildId) of
|
||||
{ok, Data} ->
|
||||
catch gen_server:call(Pid, {reload, Data}, ?GUILD_CALL_TIMEOUT);
|
||||
{error, _Reason} ->
|
||||
@@ -385,18 +375,23 @@ start_new_guild(GuildId, Data, GuildName, State) ->
|
||||
true -> very_large_guild;
|
||||
false -> guild
|
||||
end,
|
||||
case GuildModule:start_link(GuildState) of
|
||||
{ok, Pid} ->
|
||||
case process_registry:register_and_monitor(GuildName, Pid, Guilds) of
|
||||
{ok, RegisteredPid, Ref, NewGuilds0} ->
|
||||
CleanGuilds = maps:remove(GuildName, NewGuilds0),
|
||||
NewGuilds = maps:put(GuildId, {RegisteredPid, Ref}, CleanGuilds),
|
||||
{ok, RegisteredPid, State#{guilds => NewGuilds}};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
case whereis(GuildName) of
|
||||
undefined ->
|
||||
case GuildModule:start_link(GuildState) of
|
||||
{ok, Pid} ->
|
||||
case process_registry:register_and_monitor(GuildName, Pid, Guilds) of
|
||||
{ok, RegisteredPid, Ref, NewGuilds0} ->
|
||||
CleanGuilds = maps:remove(GuildName, NewGuilds0),
|
||||
NewGuilds = maps:put(GuildId, {RegisteredPid, Ref}, CleanGuilds),
|
||||
{ok, RegisteredPid, State#{guilds => NewGuilds}};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
Error ->
|
||||
Error
|
||||
end;
|
||||
Error ->
|
||||
Error
|
||||
_AlreadyRegistered ->
|
||||
lookup_existing_guild(GuildId, GuildName, State)
|
||||
end.
|
||||
|
||||
-spec is_very_large_guild(guild_data()) -> boolean().
|
||||
@@ -417,81 +412,18 @@ lookup_existing_guild(GuildId, GuildName, State) ->
|
||||
{error, process_died}
|
||||
end.
|
||||
|
||||
-spec fetch_guild_data(guild_id(), string()) -> fetch_result().
|
||||
fetch_guild_data(GuildId, ApiHost) ->
|
||||
-spec fetch_guild_data(guild_id()) -> fetch_result().
|
||||
fetch_guild_data(GuildId) ->
|
||||
RpcRequest = #{
|
||||
<<"type">> => <<"guild">>,
|
||||
<<"guild_id">> => type_conv:to_binary(GuildId),
|
||||
<<"version">> => 1
|
||||
},
|
||||
Url = rpc_client:get_rpc_url(ApiHost),
|
||||
Headers = rpc_client:get_rpc_headers() ++ [{<<"content-type">>, <<"application/json">>}],
|
||||
Body = json:encode(RpcRequest),
|
||||
case gateway_http_client:request(rpc, post, Url, Headers, Body) of
|
||||
{ok, 200, _RespHeaders, RespBody} ->
|
||||
handle_fetch_response(RespBody);
|
||||
{ok, StatusCode, _RespHeaders, _RespBody} ->
|
||||
handle_fetch_error(StatusCode);
|
||||
{error, Reason} ->
|
||||
{error, {request_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec handle_fetch_response(binary()) -> fetch_result().
|
||||
handle_fetch_response(RespBody) ->
|
||||
Response = json:decode(RespBody),
|
||||
Data = maps:get(<<"data">>, Response, #{}),
|
||||
{ok, Data}.
|
||||
|
||||
-spec handle_fetch_error(integer()) -> {error, {http_status, integer()}}.
|
||||
handle_fetch_error(StatusCode) ->
|
||||
{error, {http_status, StatusCode}}.
|
||||
|
||||
-spec select_api_host(state()) -> {string(), boolean()}.
|
||||
select_api_host(State) ->
|
||||
case maps:get(api_canary_host, State) of
|
||||
undefined ->
|
||||
{maps:get(api_host, State), false};
|
||||
_ ->
|
||||
case should_use_canary_api() of
|
||||
true -> {maps:get(api_canary_host, State), true};
|
||||
false -> {maps:get(api_host, State), false}
|
||||
end
|
||||
end.
|
||||
|
||||
-spec should_use_canary_api() -> boolean().
|
||||
should_use_canary_api() ->
|
||||
erlang:unique_integer([positive]) rem 100 < ?GUILD_API_CANARY_PERCENTAGE.
|
||||
|
||||
-spec fetch_guild_data_with_fallback(guild_id(), {string(), boolean()}, state()) -> fetch_result().
|
||||
fetch_guild_data_with_fallback(GuildId, {ApiHost, false}, _State) ->
|
||||
fetch_guild_data(GuildId, ApiHost);
|
||||
fetch_guild_data_with_fallback(GuildId, {ApiHost, true}, State) ->
|
||||
case fetch_guild_data(GuildId, ApiHost) of
|
||||
{ok, Data} ->
|
||||
{ok, Data};
|
||||
Error ->
|
||||
StableHost = maps:get(api_host, State),
|
||||
case StableHost == ApiHost of
|
||||
true ->
|
||||
Error;
|
||||
false ->
|
||||
fetch_guild_data(GuildId, StableHost)
|
||||
end
|
||||
end.
|
||||
rpc_client:call(RpcRequest).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
select_api_host_no_canary_test() ->
|
||||
State = #{api_host => "http://api.local", api_canary_host => undefined},
|
||||
{Host, IsCanary} = select_api_host(State),
|
||||
?assertEqual("http://api.local", Host),
|
||||
?assertEqual(false, IsCanary).
|
||||
|
||||
should_use_canary_api_returns_boolean_test() ->
|
||||
Result = should_use_canary_api(),
|
||||
?assert(is_boolean(Result)).
|
||||
|
||||
select_guilds_to_reload_empty_ids_test() ->
|
||||
Guilds = #{1 => {self(), make_ref()}, 2 => {self(), make_ref()}},
|
||||
Result = select_guilds_to_reload([], Guilds),
|
||||
@@ -513,8 +445,6 @@ do_start_or_lookup_loading_deduplicates_requests_test() ->
|
||||
From2 = {self(), make_ref()},
|
||||
State0 = #{
|
||||
guilds => #{GuildId => loading},
|
||||
api_host => "http://api.local",
|
||||
api_canary_host => undefined,
|
||||
pending_requests => #{},
|
||||
shard_index => 0
|
||||
},
|
||||
@@ -528,4 +458,76 @@ do_start_or_lookup_loading_deduplicates_requests_test() ->
|
||||
?assert(lists:member(From1, Requests)),
|
||||
?assert(lists:member(From2, Requests)).
|
||||
|
||||
start_new_guild_skips_start_when_already_registered_test() ->
|
||||
GuildId = 77777,
|
||||
GuildName = process_registry:build_process_name(guild, GuildId),
|
||||
ExistingPid = spawn(fun() -> mock_guild_loop() end),
|
||||
register(GuildName, ExistingPid),
|
||||
try
|
||||
State0 = #{
|
||||
guilds => #{},
|
||||
pending_requests => #{},
|
||||
shard_index => 0
|
||||
},
|
||||
Data = #{<<"guild">> => #{<<"id">> => <<"77777">>, <<"features">> => []}},
|
||||
Result = start_new_guild(GuildId, Data, GuildName, State0),
|
||||
?assertMatch({ok, ExistingPid, _}, Result),
|
||||
{ok, RetPid, _NewState} = Result,
|
||||
?assertEqual(ExistingPid, RetPid)
|
||||
after
|
||||
catch unregister(GuildName),
|
||||
ExistingPid ! stop
|
||||
end.
|
||||
|
||||
start_guild_returns_existing_when_registered_test() ->
|
||||
GuildId = 88888,
|
||||
GuildName = process_registry:build_process_name(guild, GuildId),
|
||||
ExistingPid = spawn(fun() -> mock_guild_loop() end),
|
||||
register(GuildName, ExistingPid),
|
||||
try
|
||||
State0 = #{
|
||||
guilds => #{},
|
||||
pending_requests => #{},
|
||||
shard_index => 0
|
||||
},
|
||||
Data = #{<<"guild">> => #{<<"id">> => <<"88888">>, <<"features">> => []}},
|
||||
Result = start_guild(GuildId, Data, State0),
|
||||
?assertMatch({ok, ExistingPid, _}, Result)
|
||||
after
|
||||
catch unregister(GuildName),
|
||||
ExistingPid ! stop
|
||||
end.
|
||||
|
||||
register_and_monitor_race_kills_duplicate_test_() ->
|
||||
{timeout, 15, fun() ->
|
||||
GuildId = 66666,
|
||||
GuildName = process_registry:build_process_name(guild, GuildId),
|
||||
WinnerPid = spawn(fun() -> mock_guild_loop() end),
|
||||
register(GuildName, WinnerPid),
|
||||
LoserPid = spawn(fun() -> mock_guild_loop() end),
|
||||
try
|
||||
Guilds = #{},
|
||||
Result = process_registry:register_and_monitor(GuildName, LoserPid, Guilds),
|
||||
?assertMatch({ok, WinnerPid, _, _}, Result),
|
||||
timer:sleep(200),
|
||||
?assertEqual(false, is_process_alive(LoserPid)),
|
||||
?assert(is_process_alive(WinnerPid))
|
||||
after
|
||||
catch unregister(GuildName),
|
||||
catch (WinnerPid ! stop),
|
||||
catch (LoserPid ! stop)
|
||||
end
|
||||
end}.
|
||||
|
||||
mock_guild_loop() ->
|
||||
receive
|
||||
{'$gen_call', From, _Msg} ->
|
||||
gen_server:reply(From, ok),
|
||||
mock_guild_loop();
|
||||
stop ->
|
||||
ok;
|
||||
_ ->
|
||||
mock_guild_loop()
|
||||
end.
|
||||
|
||||
-endif.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1522
fluxer_gateway/src/guild/guild_member_list_common.erl
Normal file
1522
fluxer_gateway/src/guild/guild_member_list_common.erl
Normal file
File diff suppressed because it is too large
Load Diff
@@ -769,6 +769,449 @@ role_ids_from_roles_test() ->
|
||||
],
|
||||
?assertEqual([100, 200], role_ids_from_roles(Roles)).
|
||||
|
||||
check_target_member_owner_can_manage_anyone_test() ->
|
||||
State = test_state(),
|
||||
{reply, #{can_manage := CanManage}, _} =
|
||||
check_target_member(#{user_id => 1, target_user_id => 2}, State),
|
||||
?assertEqual(true, CanManage).
|
||||
|
||||
check_target_member_cannot_manage_owner_test() ->
|
||||
State = test_state(),
|
||||
{reply, #{can_manage := CanManage}, _} =
|
||||
check_target_member(#{user_id => 2, target_user_id => 1}, State),
|
||||
?assertEqual(false, CanManage).
|
||||
|
||||
check_target_member_higher_role_can_manage_lower_test() ->
|
||||
State = test_state(),
|
||||
{reply, #{can_manage := CanManage}, _} =
|
||||
check_target_member(#{user_id => 3, target_user_id => 2}, State),
|
||||
?assertEqual(true, CanManage).
|
||||
|
||||
check_target_member_lower_role_cannot_manage_higher_test() ->
|
||||
State = test_state(),
|
||||
{reply, #{can_manage := CanManage}, _} =
|
||||
check_target_member(#{user_id => 2, target_user_id => 3}, State),
|
||||
?assertEqual(false, CanManage).
|
||||
|
||||
can_manage_roles_owner_always_true_test() ->
|
||||
State = test_state(),
|
||||
{reply, #{can_manage := CanManage}, _} =
|
||||
can_manage_roles(#{user_id => 1, role_id => 201}, State),
|
||||
?assertEqual(true, CanManage).
|
||||
|
||||
can_manage_roles_member_lower_role_test() ->
|
||||
State = test_state(),
|
||||
{reply, #{can_manage := CanManage}, _} =
|
||||
can_manage_roles(#{user_id => 2, role_id => 201}, State),
|
||||
?assertEqual(false, CanManage).
|
||||
|
||||
can_manage_roles_unknown_role_test() ->
|
||||
State = test_state(),
|
||||
{reply, #{can_manage := CanManage}, _} =
|
||||
can_manage_roles(#{user_id => 2, role_id => 999}, State),
|
||||
?assertEqual(false, CanManage).
|
||||
|
||||
get_viewable_channels_unknown_user_test() ->
|
||||
State = test_state(),
|
||||
{reply, #{channel_ids := ChannelIds}, _} =
|
||||
get_viewable_channels(#{user_id => 999}, State),
|
||||
?assertEqual([], ChannelIds).
|
||||
|
||||
get_viewable_channels_owner_sees_all_test() ->
|
||||
State = test_state(),
|
||||
{reply, #{channel_ids := ChannelIds}, _} =
|
||||
get_viewable_channels(#{user_id => 1}, State),
|
||||
?assert(length(ChannelIds) >= 2).
|
||||
|
||||
get_viewable_channels_with_deny_overwrite_test() ->
|
||||
ViewPerm = constants:view_channel_permission(),
|
||||
ManageRoles = constants:manage_roles_permission(),
|
||||
State = #{
|
||||
id => 100,
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"1">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => <<"100">>, <<"permissions">> => integer_to_binary(ViewPerm bor ManageRoles), <<"position">> => 0}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{
|
||||
<<"id">> => <<"500">>,
|
||||
<<"type">> => 0,
|
||||
<<"permission_overwrites">> => [
|
||||
#{<<"id">> => <<"100">>, <<"type">> => 0, <<"allow">> => <<"0">>, <<"deny">> => integer_to_binary(ViewPerm)}
|
||||
]
|
||||
},
|
||||
#{<<"id">> => <<"501">>, <<"type">> => 0, <<"permission_overwrites">> => []}
|
||||
],
|
||||
<<"members">> => [
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>}, <<"roles">> => [<<"100">>]},
|
||||
#{<<"user">> => #{<<"id">> => <<"2">>}, <<"roles">> => [<<"100">>]}
|
||||
]
|
||||
}
|
||||
},
|
||||
{reply, #{channel_ids := OwnerChannels}, _} =
|
||||
get_viewable_channels(#{user_id => 1}, State),
|
||||
?assert(lists:member(500, OwnerChannels)),
|
||||
{reply, #{channel_ids := MemberChannels}, _} =
|
||||
get_viewable_channels(#{user_id => 2}, State),
|
||||
?assertNot(lists:member(500, MemberChannels)),
|
||||
?assert(lists:member(501, MemberChannels)).
|
||||
|
||||
resolve_all_mentions_mention_everyone_test() ->
|
||||
State = test_state(),
|
||||
Request = #{
|
||||
channel_id => 500,
|
||||
author_id => 1,
|
||||
mention_everyone => true,
|
||||
mention_here => false,
|
||||
role_ids => [],
|
||||
user_ids => []
|
||||
},
|
||||
{reply, #{user_ids := UserIds}, _} = resolve_all_mentions(Request, State),
|
||||
?assert(lists:member(2, UserIds)),
|
||||
?assert(lists:member(3, UserIds)),
|
||||
?assertNot(lists:member(1, UserIds)).
|
||||
|
||||
resolve_all_mentions_mention_here_only_connected_test() ->
|
||||
State0 = test_state(),
|
||||
State = State0#{
|
||||
sessions => #{
|
||||
<<"sess_2">> => #{user_id => 2}
|
||||
}
|
||||
},
|
||||
Request = #{
|
||||
channel_id => 500,
|
||||
author_id => 1,
|
||||
mention_everyone => false,
|
||||
mention_here => true,
|
||||
role_ids => [],
|
||||
user_ids => []
|
||||
},
|
||||
{reply, #{user_ids := UserIds}, _} = resolve_all_mentions(Request, State),
|
||||
?assert(lists:member(2, UserIds)),
|
||||
?assertNot(lists:member(3, UserIds)).
|
||||
|
||||
resolve_all_mentions_excludes_bots_test() ->
|
||||
ViewPerm = constants:view_channel_permission(),
|
||||
ManageRoles = constants:manage_roles_permission(),
|
||||
State = #{
|
||||
id => 100,
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"1">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => <<"100">>, <<"permissions">> => integer_to_binary(ViewPerm bor ManageRoles), <<"position">> => 0}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"500">>, <<"type">> => 0, <<"permission_overwrites">> => []}
|
||||
],
|
||||
<<"members">> => [
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>}, <<"roles">> => [<<"100">>]},
|
||||
#{<<"user">> => #{<<"id">> => <<"2">>, <<"bot">> => true}, <<"roles">> => [<<"100">>]},
|
||||
#{<<"user">> => #{<<"id">> => <<"3">>}, <<"roles">> => [<<"100">>]}
|
||||
]
|
||||
},
|
||||
sessions => #{}
|
||||
},
|
||||
Request = #{
|
||||
channel_id => 500,
|
||||
author_id => 1,
|
||||
mention_everyone => true,
|
||||
mention_here => false,
|
||||
role_ids => [],
|
||||
user_ids => []
|
||||
},
|
||||
{reply, #{user_ids := UserIds}, _} = resolve_all_mentions(Request, State),
|
||||
?assertNot(lists:member(2, UserIds)),
|
||||
?assert(lists:member(3, UserIds)).
|
||||
|
||||
resolve_all_mentions_author_always_excluded_test() ->
|
||||
State = test_state(),
|
||||
Request = #{
|
||||
channel_id => 500,
|
||||
author_id => 2,
|
||||
mention_everyone => false,
|
||||
mention_here => false,
|
||||
role_ids => [],
|
||||
user_ids => [2]
|
||||
},
|
||||
{reply, #{user_ids := UserIds}, _} = resolve_all_mentions(Request, State),
|
||||
?assertNot(lists:member(2, UserIds)).
|
||||
|
||||
get_all_users_to_mention_excludes_author_test() ->
|
||||
State = test_state(),
|
||||
{reply, #{user_ids := UserIds}, _} =
|
||||
get_all_users_to_mention(#{channel_id => 500, author_id => 1}, State),
|
||||
?assertNot(lists:member(1, UserIds)),
|
||||
?assert(lists:member(2, UserIds)),
|
||||
?assert(lists:member(3, UserIds)).
|
||||
|
||||
get_members_with_role_undefined_role_id_test() ->
|
||||
State = test_state(),
|
||||
{reply, #{user_ids := UserIds}, _} =
|
||||
get_members_with_role(#{role_id => undefined}, State),
|
||||
?assertEqual([], UserIds).
|
||||
|
||||
get_members_with_role_nonexistent_role_test() ->
|
||||
State = test_state(),
|
||||
{reply, #{user_ids := UserIds}, _} =
|
||||
get_members_with_role(#{role_id => 999}, State),
|
||||
?assertEqual([], UserIds).
|
||||
|
||||
get_assignable_roles_non_member_test() ->
|
||||
State = test_state(),
|
||||
{reply, #{role_ids := RoleIds}, _} =
|
||||
get_assignable_roles(#{user_id => 999}, State),
|
||||
?assertEqual([], RoleIds).
|
||||
|
||||
member_user_id_missing_user_test() ->
|
||||
?assertEqual(undefined, member_user_id(#{})).
|
||||
|
||||
member_user_id_missing_id_test() ->
|
||||
?assertEqual(undefined, member_user_id(#{<<"user">> => #{}})).
|
||||
|
||||
member_user_id_valid_test() ->
|
||||
?assertEqual(42, member_user_id(#{<<"user">> => #{<<"id">> => <<"42">>}})).
|
||||
|
||||
member_roles_empty_test() ->
|
||||
?assertEqual([], member_roles(#{})).
|
||||
|
||||
member_roles_binary_ids_test() ->
|
||||
Member = #{<<"roles">> => [<<"100">>, <<"200">>]},
|
||||
?assertEqual([100, 200], member_roles(Member)).
|
||||
|
||||
is_member_bot_missing_user_test() ->
|
||||
?assertEqual(false, is_member_bot(#{})).
|
||||
|
||||
is_member_bot_missing_bot_field_test() ->
|
||||
?assertEqual(false, is_member_bot(#{<<"user">> => #{}})).
|
||||
|
||||
role_position_default_test() ->
|
||||
?assertEqual(0, role_position(#{})).
|
||||
|
||||
role_position_explicit_test() ->
|
||||
?assertEqual(5, role_position(#{<<"position">> => 5})).
|
||||
|
||||
role_ids_from_roles_empty_test() ->
|
||||
?assertEqual([], role_ids_from_roles([])).
|
||||
|
||||
role_ids_from_roles_skips_undefined_ids_test() ->
|
||||
Roles = [#{}, #{<<"id">> => <<"100">>}],
|
||||
?assertEqual([100], role_ids_from_roles(Roles)).
|
||||
|
||||
normalize_int_list_mixed_types_test() ->
|
||||
?assertEqual([1, 2], normalize_int_list([<<"1">>, <<"invalid">>, 2])).
|
||||
|
||||
normalize_int_list_non_list_input_test() ->
|
||||
?assertEqual([], normalize_int_list(not_a_list)).
|
||||
|
||||
member_has_any_role_set_empty_roles_test() ->
|
||||
Member = #{<<"roles">> => []},
|
||||
RoleSet = gb_sets:from_list([100]),
|
||||
?assertEqual(false, member_has_any_role_set(Member, RoleSet)).
|
||||
|
||||
member_has_any_role_set_empty_set_test() ->
|
||||
Member = #{<<"roles">> => [<<"100">>]},
|
||||
RoleSet = gb_sets:empty(),
|
||||
?assertEqual(false, member_has_any_role_set(Member, RoleSet)).
|
||||
|
||||
compare_roles_first_undefined_test() ->
|
||||
Role = #{<<"position">> => 5, <<"id">> => <<"10">>},
|
||||
?assertEqual(Role, compare_roles(Role, undefined)).
|
||||
|
||||
compare_roles_higher_position_wins_test() ->
|
||||
RoleA = #{<<"position">> => 5, <<"id">> => <<"10">>},
|
||||
RoleB = #{<<"position">> => 10, <<"id">> => <<"20">>},
|
||||
?assertEqual(RoleB, compare_roles(RoleB, RoleA)).
|
||||
|
||||
compare_roles_same_position_lower_id_wins_test() ->
|
||||
RoleA = #{<<"position">> => 5, <<"id">> => <<"20">>},
|
||||
RoleB = #{<<"position">> => 5, <<"id">> => <<"10">>},
|
||||
?assertEqual(RoleB, compare_roles(RoleB, RoleA)).
|
||||
|
||||
compare_roles_same_position_higher_id_loses_test() ->
|
||||
RoleA = #{<<"position">> => 5, <<"id">> => <<"10">>},
|
||||
RoleB = #{<<"position">> => 5, <<"id">> => <<"20">>},
|
||||
?assertEqual(RoleA, compare_roles(RoleB, RoleA)).
|
||||
|
||||
get_highest_role_empty_roles_test() ->
|
||||
?assertEqual(undefined, get_highest_role([], #{})).
|
||||
|
||||
get_highest_role_no_matching_roles_test() ->
|
||||
Roles = #{999 => #{<<"id">> => <<"999">>, <<"position">> => 5}},
|
||||
?assertEqual(undefined, get_highest_role([100], Roles)).
|
||||
|
||||
get_highest_role_picks_highest_position_test() ->
|
||||
RoleLow = #{<<"id">> => <<"100">>, <<"position">> => 5},
|
||||
RoleHigh = #{<<"id">> => <<"200">>, <<"position">> => 10},
|
||||
Roles = #{100 => RoleLow, 200 => RoleHigh},
|
||||
?assertEqual(RoleHigh, get_highest_role([100, 200], Roles)).
|
||||
|
||||
build_connected_user_ids_false_returns_empty_test() ->
|
||||
Sessions = #{<<"s1">> => #{user_id => 1}},
|
||||
?assertEqual(gb_sets:empty(), build_connected_user_ids(false, Sessions)).
|
||||
|
||||
build_connected_user_ids_true_collects_user_ids_test() ->
|
||||
Sessions = #{
|
||||
<<"s1">> => #{user_id => 1},
|
||||
<<"s2">> => #{user_id => 2},
|
||||
<<"s3">> => #{other => data}
|
||||
},
|
||||
Result = build_connected_user_ids(true, Sessions),
|
||||
?assert(gb_sets:is_member(1, Result)),
|
||||
?assert(gb_sets:is_member(2, Result)),
|
||||
?assertEqual(2, gb_sets:size(Result)).
|
||||
|
||||
build_connected_user_ids_empty_sessions_test() ->
|
||||
Result = build_connected_user_ids(true, #{}),
|
||||
?assertEqual(gb_sets:empty(), Result).
|
||||
|
||||
check_should_mention_everyone_true_test() ->
|
||||
Member = #{<<"roles">> => []},
|
||||
?assertEqual(true, check_should_mention(
|
||||
1, Member, true, false, false, false,
|
||||
gb_sets:empty(), gb_sets:empty(), gb_sets:empty()
|
||||
)).
|
||||
|
||||
check_should_mention_here_connected_test() ->
|
||||
Member = #{<<"roles">> => []},
|
||||
Connected = gb_sets:from_list([1]),
|
||||
?assertEqual(true, check_should_mention(
|
||||
1, Member, false, true, false, false,
|
||||
gb_sets:empty(), gb_sets:empty(), Connected
|
||||
)).
|
||||
|
||||
check_should_mention_here_not_connected_test() ->
|
||||
Member = #{<<"roles">> => []},
|
||||
Connected = gb_sets:from_list([2]),
|
||||
?assertEqual(false, check_should_mention(
|
||||
1, Member, false, true, false, false,
|
||||
gb_sets:empty(), gb_sets:empty(), Connected
|
||||
)).
|
||||
|
||||
check_should_mention_role_match_test() ->
|
||||
Member = #{<<"roles">> => [<<"100">>]},
|
||||
RoleSet = gb_sets:from_list([100]),
|
||||
?assertEqual(true, check_should_mention(
|
||||
1, Member, false, false, true, false,
|
||||
RoleSet, gb_sets:empty(), gb_sets:empty()
|
||||
)).
|
||||
|
||||
check_should_mention_direct_id_match_test() ->
|
||||
Member = #{<<"roles">> => []},
|
||||
DirectSet = gb_sets:from_list([1]),
|
||||
?assertEqual(true, check_should_mention(
|
||||
1, Member, false, false, false, true,
|
||||
gb_sets:empty(), DirectSet, gb_sets:empty()
|
||||
)).
|
||||
|
||||
check_should_mention_nothing_matches_test() ->
|
||||
Member = #{<<"roles">> => []},
|
||||
?assertEqual(false, check_should_mention(
|
||||
1, Member, false, false, false, false,
|
||||
gb_sets:empty(), gb_sets:empty(), gb_sets:empty()
|
||||
)).
|
||||
|
||||
member_can_view_channel_non_integer_channel_id_test() ->
|
||||
?assertEqual(false, member_can_view_channel(1, undefined, #{}, #{})).
|
||||
|
||||
collect_mentions_excludes_author_test() ->
|
||||
ViewPerm = constants:view_channel_permission(),
|
||||
State = #{
|
||||
id => 100,
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"1">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => <<"100">>, <<"permissions">> => integer_to_binary(ViewPerm), <<"position">> => 0}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"500">>, <<"type">> => 0, <<"permission_overwrites">> => []}
|
||||
],
|
||||
<<"members">> => [
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>}, <<"roles">> => [<<"100">>]},
|
||||
#{<<"user">> => #{<<"id">> => <<"2">>}, <<"roles">> => [<<"100">>]}
|
||||
]
|
||||
}
|
||||
},
|
||||
Members = [
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>}, <<"roles">> => [<<"100">>]},
|
||||
#{<<"user">> => #{<<"id">> => <<"2">>}, <<"roles">> => [<<"100">>]}
|
||||
],
|
||||
UserIds = collect_mentions(Members, 1, 500, State, fun(_) -> true end),
|
||||
?assertNot(lists:member(1, UserIds)),
|
||||
?assert(lists:member(2, UserIds)).
|
||||
|
||||
collect_mentions_skips_members_without_user_id_test() ->
|
||||
ViewPerm = constants:view_channel_permission(),
|
||||
State = #{
|
||||
id => 100,
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"1">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => <<"100">>, <<"permissions">> => integer_to_binary(ViewPerm), <<"position">> => 0}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"500">>, <<"type">> => 0, <<"permission_overwrites">> => []}
|
||||
],
|
||||
<<"members">> => []
|
||||
}
|
||||
},
|
||||
Members = [#{}, #{<<"user">> => #{}}],
|
||||
UserIds = collect_mentions(Members, 1, 500, State, fun(_) -> true end),
|
||||
?assertEqual([], UserIds).
|
||||
|
||||
filter_assignable_role_below_position_test() ->
|
||||
Role = #{<<"id">> => <<"100">>, <<"position">> => 5},
|
||||
?assertEqual({true, 100}, filter_assignable_role(Role, 10)).
|
||||
|
||||
filter_assignable_role_at_position_test() ->
|
||||
Role = #{<<"id">> => <<"100">>, <<"position">> => 10},
|
||||
?assertEqual(false, filter_assignable_role(Role, 10)).
|
||||
|
||||
filter_assignable_role_above_position_test() ->
|
||||
Role = #{<<"id">> => <<"100">>, <<"position">> => 15},
|
||||
?assertEqual(false, filter_assignable_role(Role, 10)).
|
||||
|
||||
filter_assignable_role_no_id_test() ->
|
||||
Role = #{<<"position">> => 5},
|
||||
?assertEqual(false, filter_assignable_role(Role, 10)).
|
||||
|
||||
user_ids_for_any_role_empty_roles_test() ->
|
||||
State = test_state(),
|
||||
?assertEqual([], user_ids_for_any_role([], State)).
|
||||
|
||||
user_ids_for_any_role_nonexistent_role_test() ->
|
||||
State = test_state(),
|
||||
?assertEqual([], user_ids_for_any_role([999], State)).
|
||||
|
||||
user_ids_for_any_role_multiple_roles_test() ->
|
||||
State = test_state(),
|
||||
UserIds = lists:sort(user_ids_for_any_role([200, 201], State)),
|
||||
?assertEqual([2, 3], UserIds).
|
||||
|
||||
owner_id_valid_test() ->
|
||||
State = test_state(),
|
||||
?assertEqual(1, owner_id(State)).
|
||||
|
||||
owner_id_missing_guild_test() ->
|
||||
State = #{data => #{}},
|
||||
?assertEqual(0, owner_id(State)).
|
||||
|
||||
guild_data_missing_data_test() ->
|
||||
State = #{},
|
||||
?assertEqual(#{}, guild_data(State)).
|
||||
|
||||
guild_members_empty_test() ->
|
||||
State = #{data => #{<<"members">> => []}},
|
||||
?assertEqual([], guild_members(State)).
|
||||
|
||||
guild_roles_empty_test() ->
|
||||
State = #{data => #{<<"roles">> => []}},
|
||||
?assertEqual([], guild_roles(State)).
|
||||
|
||||
guild_channels_empty_test() ->
|
||||
State = #{data => #{<<"channels">> => []}},
|
||||
?assertEqual([], guild_channels(State)).
|
||||
|
||||
test_state() ->
|
||||
GuildId = 100,
|
||||
OwnerId = 1,
|
||||
|
||||
@@ -70,35 +70,35 @@ send_passive_updates_to_sessions(State) ->
|
||||
0 ->
|
||||
State;
|
||||
_ ->
|
||||
UpdatedSessions = process_passive_sessions(
|
||||
maps:to_list(PassiveSessions), GuildId, Sessions, Channels, State
|
||||
process_passive_sessions(
|
||||
maps:to_list(PassiveSessions), GuildId, Channels, State
|
||||
),
|
||||
maps:put(sessions, UpdatedSessions, State)
|
||||
State
|
||||
end.
|
||||
|
||||
-spec process_passive_sessions([{binary(), map()}], integer(), map(), [map()], guild_state()) ->
|
||||
map().
|
||||
process_passive_sessions(PassiveSessionList, GuildId, Sessions, Channels, State) ->
|
||||
lists:foldl(
|
||||
fun({SessionId, SessionData}, AccSessions) ->
|
||||
-spec process_passive_sessions([{binary(), map()}], integer(), [map()], guild_state()) ->
|
||||
ok.
|
||||
process_passive_sessions(PassiveSessionList, GuildId, Channels, State) ->
|
||||
lists:foreach(
|
||||
fun({SessionId, SessionData}) ->
|
||||
process_single_passive_session(
|
||||
SessionId, SessionData, GuildId, Channels, State, AccSessions
|
||||
SessionId, SessionData, GuildId, Channels, State
|
||||
)
|
||||
end,
|
||||
Sessions,
|
||||
PassiveSessionList
|
||||
).
|
||||
|
||||
-spec process_single_passive_session(binary(), map(), integer(), [map()], guild_state(), map()) ->
|
||||
map().
|
||||
process_single_passive_session(SessionId, SessionData, GuildId, Channels, State, AccSessions) ->
|
||||
-spec process_single_passive_session(binary(), map(), integer(), [map()], guild_state()) ->
|
||||
ok.
|
||||
process_single_passive_session(SessionId, SessionData, GuildId, Channels, State) ->
|
||||
Pid = maps:get(pid, SessionData),
|
||||
UserId = maps:get(user_id, SessionData),
|
||||
Member = guild_permissions:find_member_by_user_id(UserId, State),
|
||||
CurrentLastMessageIds = build_last_message_ids(Channels, UserId, Member, State),
|
||||
PreviousLastMessageIds = maps:get(previous_passive_updates, SessionData, #{}),
|
||||
RegState = passive_sync_registry:lookup(SessionId, GuildId),
|
||||
PreviousLastMessageIds = maps:get(previous_passive_updates, RegState, #{}),
|
||||
Delta = compute_delta(CurrentLastMessageIds, PreviousLastMessageIds),
|
||||
PreviousChannelVersions = maps:get(previous_passive_channel_versions, SessionData, #{}),
|
||||
PreviousChannelVersions = maps:get(previous_passive_channel_versions, RegState, #{}),
|
||||
{CurrentChannelVersions, CurrentChannelsById} =
|
||||
build_viewable_channel_snapshots(Channels, UserId, Member, State),
|
||||
{CreatedChannelIds, UpdatedChannelIds, DeletedChannelIds} =
|
||||
@@ -107,12 +107,10 @@ process_single_passive_session(SessionId, SessionData, GuildId, Channels, State,
|
||||
UpdatedChannels = [maps:get(Id, CurrentChannelsById) || Id <- UpdatedChannelIds],
|
||||
ViewableChannels = guild_visibility:viewable_channel_set(UserId, State),
|
||||
CurrentVoiceStates = build_current_voice_state_map(ViewableChannels, State),
|
||||
PreviousVoiceStates = maps:get(previous_passive_voice_states, SessionData, #{}),
|
||||
PreviousVoiceStates = maps:get(previous_passive_voice_states, RegState, #{}),
|
||||
VoiceStateUpdates = compute_voice_state_updates(
|
||||
CurrentVoiceStates, PreviousVoiceStates, GuildId
|
||||
),
|
||||
UpdatedSessionDataBase =
|
||||
maps:put(previous_passive_voice_states, CurrentVoiceStates, SessionData),
|
||||
HasChannelDelta = map_size(Delta) > 0,
|
||||
HasVoiceUpdates = VoiceStateUpdates =/= [],
|
||||
HasCreatedChannels = CreatedChannels =/= [],
|
||||
@@ -134,21 +132,21 @@ process_single_passive_session(SessionId, SessionData, GuildId, Channels, State,
|
||||
gen_server:cast(Pid, {dispatch, passive_updates, EventData}),
|
||||
PreviousLastMessageIds1 = maps:without(DeletedChannelIds, PreviousLastMessageIds),
|
||||
MergedLastMessageIds = maps:merge(PreviousLastMessageIds1, Delta),
|
||||
UpdatedSessionData0 =
|
||||
maps:put(previous_passive_updates, MergedLastMessageIds, UpdatedSessionDataBase),
|
||||
UpdatedSessionData =
|
||||
maps:put(
|
||||
previous_passive_channel_versions, CurrentChannelVersions, UpdatedSessionData0
|
||||
),
|
||||
maps:put(SessionId, UpdatedSessionData, AccSessions);
|
||||
NewRegState = #{
|
||||
previous_passive_updates => MergedLastMessageIds,
|
||||
previous_passive_channel_versions => CurrentChannelVersions,
|
||||
previous_passive_voice_states => CurrentVoiceStates
|
||||
},
|
||||
passive_sync_registry:store(SessionId, GuildId, NewRegState),
|
||||
ok;
|
||||
_ ->
|
||||
UpdatedSessionData =
|
||||
maps:put(
|
||||
previous_passive_channel_versions,
|
||||
CurrentChannelVersions,
|
||||
UpdatedSessionDataBase
|
||||
),
|
||||
maps:put(SessionId, UpdatedSessionData, AccSessions)
|
||||
NewRegState = #{
|
||||
previous_passive_updates => PreviousLastMessageIds,
|
||||
previous_passive_channel_versions => CurrentChannelVersions,
|
||||
previous_passive_voice_states => CurrentVoiceStates
|
||||
},
|
||||
passive_sync_registry:store(SessionId, GuildId, NewRegState),
|
||||
ok
|
||||
end.
|
||||
|
||||
-spec build_passive_event_data(integer(), map(), [map()], [map()], [binary()], [map()]) -> map().
|
||||
|
||||
@@ -20,9 +20,12 @@
|
||||
-export([
|
||||
put_state/1,
|
||||
put_data/2,
|
||||
put_normalized_data/2,
|
||||
delete/1,
|
||||
get_permissions/3,
|
||||
get_snapshot/1
|
||||
get_snapshot/1,
|
||||
has_member/2,
|
||||
get_member/2
|
||||
]).
|
||||
|
||||
-type guild_id() :: integer().
|
||||
@@ -39,7 +42,7 @@ put_state(State) when is_map(State) ->
|
||||
Data = maps:get(data, State, #{}),
|
||||
case is_integer(GuildId) of
|
||||
true ->
|
||||
put_data(GuildId, Data);
|
||||
put_normalized_data(GuildId, Data);
|
||||
false ->
|
||||
ok
|
||||
end;
|
||||
@@ -48,12 +51,18 @@ put_state(_) ->
|
||||
|
||||
-spec put_data(guild_id(), guild_data()) -> ok.
|
||||
put_data(GuildId, Data) when is_integer(GuildId), is_map(Data) ->
|
||||
ensure_table(),
|
||||
NormalizedData = guild_data_index:normalize_data(Data),
|
||||
put_normalized_data(GuildId, NormalizedData);
|
||||
put_data(_, _) ->
|
||||
ok.
|
||||
|
||||
-spec put_normalized_data(guild_id(), guild_data()) -> ok.
|
||||
put_normalized_data(GuildId, NormalizedData) when is_integer(GuildId), is_map(NormalizedData) ->
|
||||
ensure_table(),
|
||||
Snapshot = #{id => GuildId, data => NormalizedData},
|
||||
true = ets:insert(?TABLE, {GuildId, Snapshot}),
|
||||
ok;
|
||||
put_data(_, _) ->
|
||||
put_normalized_data(_, _) ->
|
||||
ok.
|
||||
|
||||
-spec delete(guild_id()) -> ok.
|
||||
@@ -77,6 +86,29 @@ get_permissions(GuildId, UserId, ChannelId) when is_integer(GuildId), is_integer
|
||||
get_permissions(_, _, _) ->
|
||||
{error, not_found}.
|
||||
|
||||
-spec has_member(guild_id(), user_id()) -> {ok, boolean()} | {error, not_found}.
|
||||
has_member(GuildId, UserId) when is_integer(GuildId), is_integer(UserId) ->
|
||||
case get_snapshot(GuildId) of
|
||||
{ok, Snapshot} ->
|
||||
Member = guild_permissions:find_member_by_user_id(UserId, Snapshot),
|
||||
{ok, Member =/= undefined};
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end;
|
||||
has_member(_, _) ->
|
||||
{error, not_found}.
|
||||
|
||||
-spec get_member(guild_id(), user_id()) -> {ok, map() | undefined} | {error, not_found}.
|
||||
get_member(GuildId, UserId) when is_integer(GuildId), is_integer(UserId) ->
|
||||
case get_snapshot(GuildId) of
|
||||
{ok, Snapshot} ->
|
||||
{ok, guild_permissions:find_member_by_user_id(UserId, Snapshot)};
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end;
|
||||
get_member(_, _) ->
|
||||
{error, not_found}.
|
||||
|
||||
-spec get_snapshot(guild_id()) -> {ok, guild_state()} | {error, not_found}.
|
||||
get_snapshot(GuildId) when is_integer(GuildId) ->
|
||||
ensure_table(),
|
||||
@@ -91,16 +123,7 @@ get_snapshot(_) ->
|
||||
|
||||
-spec ensure_table() -> ok.
|
||||
ensure_table() ->
|
||||
case ets:whereis(?TABLE) of
|
||||
undefined ->
|
||||
try ets:new(?TABLE, [named_table, public, set, {read_concurrency, true}]) of
|
||||
_ -> ok
|
||||
catch
|
||||
error:badarg -> ok
|
||||
end;
|
||||
_ ->
|
||||
ok
|
||||
end.
|
||||
guild_ets_utils:ensure_table(?TABLE, [named_table, public, set, {read_concurrency, true}]).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
@@ -129,4 +152,89 @@ put_and_get_permissions_test() ->
|
||||
missing_guild_returns_not_found_test() ->
|
||||
?assertEqual({error, not_found}, get_permissions(999999, 1, undefined)).
|
||||
|
||||
put_normalized_data_skips_renormalization_test() ->
|
||||
GuildId = 102,
|
||||
UserId = 45,
|
||||
ViewPermission = constants:view_channel_permission(),
|
||||
Data = guild_data_index:normalize_data(#{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => integer_to_binary(ViewPermission)}
|
||||
],
|
||||
<<"members">> => [
|
||||
#{<<"user">> => #{<<"id">> => integer_to_binary(UserId)}, <<"roles">> => []}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"600">>, <<"permission_overwrites">> => []}
|
||||
]
|
||||
}),
|
||||
ok = put_normalized_data(GuildId, Data),
|
||||
{ok, Permissions} = get_permissions(GuildId, UserId, 600),
|
||||
?assert((Permissions band ViewPermission) =/= 0),
|
||||
ok = delete(GuildId).
|
||||
|
||||
put_state_uses_fast_path_test() ->
|
||||
GuildId = 103,
|
||||
UserId = 46,
|
||||
ViewPermission = constants:view_channel_permission(),
|
||||
NormalizedData = guild_data_index:normalize_data(#{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => integer_to_binary(ViewPermission)}
|
||||
],
|
||||
<<"members">> => [
|
||||
#{<<"user">> => #{<<"id">> => integer_to_binary(UserId)}, <<"roles">> => []}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"700">>, <<"permission_overwrites">> => []}
|
||||
]
|
||||
}),
|
||||
State = #{id => GuildId, data => NormalizedData},
|
||||
ok = put_state(State),
|
||||
{ok, Permissions} = get_permissions(GuildId, UserId, 700),
|
||||
?assert((Permissions band ViewPermission) =/= 0),
|
||||
ok = delete(GuildId).
|
||||
|
||||
has_member_returns_true_when_member_exists_test() ->
|
||||
GuildId = 104,
|
||||
UserId = 47,
|
||||
Data = guild_data_index:normalize_data(#{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"roles">> => [],
|
||||
<<"members">> => [
|
||||
#{<<"user">> => #{<<"id">> => integer_to_binary(UserId)}, <<"roles">> => []}
|
||||
],
|
||||
<<"channels">> => []
|
||||
}),
|
||||
ok = put_normalized_data(GuildId, Data),
|
||||
?assertEqual({ok, true}, has_member(GuildId, UserId)),
|
||||
?assertEqual({ok, false}, has_member(GuildId, 99999)),
|
||||
ok = delete(GuildId).
|
||||
|
||||
has_member_returns_not_found_when_no_snapshot_test() ->
|
||||
?assertEqual({error, not_found}, has_member(999998, 1)).
|
||||
|
||||
get_member_returns_member_data_test() ->
|
||||
GuildId = 105,
|
||||
UserId = 48,
|
||||
MemberData = #{
|
||||
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
|
||||
<<"roles">> => [],
|
||||
<<"nick">> => <<"TestNick">>
|
||||
},
|
||||
Data = guild_data_index:normalize_data(#{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"roles">> => [],
|
||||
<<"members">> => [MemberData],
|
||||
<<"channels">> => []
|
||||
}),
|
||||
ok = put_normalized_data(GuildId, Data),
|
||||
{ok, Result} = get_member(GuildId, UserId),
|
||||
?assertEqual(<<"TestNick">>, maps:get(<<"nick">>, Result)),
|
||||
{ok, undefined} = get_member(GuildId, 99999),
|
||||
ok = delete(GuildId).
|
||||
|
||||
get_member_returns_not_found_when_no_snapshot_test() ->
|
||||
?assertEqual({error, not_found}, get_member(999997, 1)).
|
||||
|
||||
-endif.
|
||||
|
||||
@@ -110,10 +110,10 @@ broadcast_presence_update(UserId, Payload, State) ->
|
||||
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),
|
||||
TargetChannelMap = get_user_viewable_channel_map(UserId, Sessions, State),
|
||||
{ValidSessionIds, InvalidSessionIds} =
|
||||
partition_subscribed_sessions(
|
||||
SubscribedSessionIds, Sessions, TargetChannels, UserId, State
|
||||
SubscribedSessionIds, Sessions, TargetChannelMap, UserId, State
|
||||
),
|
||||
StateAfterInvalidRemovals =
|
||||
lists:foldl(
|
||||
@@ -245,9 +245,9 @@ member_id(Member) ->
|
||||
User = map_utils:ensure_map(maps:get(<<"user">>, Member, #{})),
|
||||
map_utils:get_integer(User, <<"id">>, undefined).
|
||||
|
||||
-spec partition_subscribed_sessions([binary()], map(), sets:set(), user_id(), guild_state()) ->
|
||||
-spec partition_subscribed_sessions([binary()], map(), map(), user_id(), guild_state()) ->
|
||||
{[binary()], [binary()]}.
|
||||
partition_subscribed_sessions(SessionIds, Sessions, TargetChannels, TargetUserId, State) ->
|
||||
partition_subscribed_sessions(SessionIds, Sessions, TargetChannelMap, TargetUserId, State) ->
|
||||
lists:foldl(
|
||||
fun(SessionId, {Valids, Invalids}) ->
|
||||
case maps:get(SessionId, Sessions, undefined) of
|
||||
@@ -262,11 +262,8 @@ partition_subscribed_sessions(SessionIds, Sessions, TargetChannels, TargetUserId
|
||||
UserId when UserId =:= TargetUserId ->
|
||||
false;
|
||||
_ ->
|
||||
SessionChannels = guild_visibility:viewable_channel_set(
|
||||
SessionUserId, State
|
||||
),
|
||||
not sets:is_empty(
|
||||
sets:intersection(SessionChannels, TargetChannels)
|
||||
session_shares_channels(
|
||||
SessionData, SessionUserId, TargetChannelMap, State
|
||||
)
|
||||
end,
|
||||
case Shared of
|
||||
@@ -279,6 +276,71 @@ partition_subscribed_sessions(SessionIds, Sessions, TargetChannels, TargetUserId
|
||||
SessionIds
|
||||
).
|
||||
|
||||
-spec session_shares_channels(map(), user_id(), map(), guild_state()) -> boolean().
|
||||
session_shares_channels(SessionData, SessionUserId, TargetChannelMap, State) ->
|
||||
case maps:get(viewable_channels, SessionData, undefined) of
|
||||
ViewableMap when is_map(ViewableMap) ->
|
||||
maps_share_any_key(ViewableMap, TargetChannelMap);
|
||||
_ ->
|
||||
SessionChannels = guild_visibility:viewable_channel_set(SessionUserId, State),
|
||||
TargetChannels = sets:from_list(maps:keys(TargetChannelMap)),
|
||||
not sets:is_empty(sets:intersection(SessionChannels, TargetChannels))
|
||||
end.
|
||||
|
||||
-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} ->
|
||||
case maps:is_key(Key, LargerMap) of
|
||||
true -> true;
|
||||
false -> maps_share_any_key_iter(NextIterator, LargerMap)
|
||||
end
|
||||
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} ->
|
||||
case maps:get(user_id, SessionData, undefined) of
|
||||
UserId ->
|
||||
case maps:get(viewable_channels, SessionData, undefined) of
|
||||
ViewableChannels when is_map(ViewableChannels) ->
|
||||
ViewableChannels;
|
||||
_ ->
|
||||
find_session_viewable_channels_iter(UserId, NextIterator)
|
||||
end;
|
||||
_ ->
|
||||
find_session_viewable_channels_iter(UserId, NextIterator)
|
||||
end
|
||||
end.
|
||||
|
||||
-spec remove_session_member_subscription(binary(), user_id(), guild_state()) -> guild_state().
|
||||
remove_session_member_subscription(SessionId, UserId, State) ->
|
||||
MemberSubs = maps:get(member_subscriptions, State, guild_subscriptions:init_state()),
|
||||
@@ -334,6 +396,96 @@ normalize_presence_status_test() ->
|
||||
?assertEqual(<<"idle">>, normalize_presence_status(<<"idle">>)),
|
||||
?assertEqual(<<"offline">>, normalize_presence_status(undefined)).
|
||||
|
||||
handle_bus_presence_invisible_normalized_test() ->
|
||||
State = presence_test_state(),
|
||||
Payload = #{
|
||||
<<"status">> => <<"invisible">>,
|
||||
<<"mobile">> => false,
|
||||
<<"afk">> => false,
|
||||
<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"Alpha">>}
|
||||
},
|
||||
{noreply, NewState} = handle_bus_presence(1, Payload, State),
|
||||
MemberPresence = maps:get(member_presence, NewState, #{}),
|
||||
UserPresence = maps:get(1, MemberPresence),
|
||||
?assertEqual(<<"offline">>, maps:get(<<"status">>, UserPresence)).
|
||||
|
||||
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}}
|
||||
},
|
||||
TargetChannelMap = #{100 => true},
|
||||
State = #{sessions => Sessions, data => #{<<"members">> => #{}}},
|
||||
{Valid, Invalid} = partition_subscribed_sessions(
|
||||
[<<"s1">>], Sessions, TargetChannelMap, 10, State
|
||||
),
|
||||
?assertEqual([], Valid),
|
||||
?assertEqual([<<"s1">>], Invalid).
|
||||
|
||||
partition_subscribed_sessions_missing_session_test() ->
|
||||
Sessions = #{},
|
||||
TargetChannelMap = #{100 => true},
|
||||
State = #{sessions => Sessions, data => #{<<"members">> => #{}}},
|
||||
{Valid, Invalid} = partition_subscribed_sessions(
|
||||
[<<"s1">>], Sessions, TargetChannelMap, 10, State
|
||||
),
|
||||
?assertEqual([], Valid),
|
||||
?assertEqual([<<"s1">>], Invalid).
|
||||
|
||||
presence_test_state() ->
|
||||
#{
|
||||
id => 42,
|
||||
|
||||
251
fluxer_gateway/src/guild/guild_query_handler.erl
Normal file
251
fluxer_gateway/src/guild/guild_query_handler.erl
Normal file
@@ -0,0 +1,251 @@
|
||||
%% 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_query_handler).
|
||||
|
||||
-export([handle_call/3]).
|
||||
|
||||
-type guild_state() :: map().
|
||||
-type user_id() :: integer().
|
||||
|
||||
-spec handle_call(term(), gen_server:from(), guild_state()) ->
|
||||
{reply, term(), guild_state()}
|
||||
| {noreply, guild_state()}.
|
||||
handle_call({very_large_guild_prime_member, Member}, _From, State) when is_map(Member) ->
|
||||
Data0 = maps:get(data, State, #{}),
|
||||
Data = guild_data_index:put_member(Member, Data0),
|
||||
{reply, ok, maps:put(data, Data, State)};
|
||||
handle_call({very_large_guild_prime_member, _}, _From, State) ->
|
||||
{reply, ok, State};
|
||||
handle_call({very_large_guild_get_members, UserIds}, _From, State) when is_list(UserIds) ->
|
||||
Data = maps:get(data, State, #{}),
|
||||
MemberMap = guild_data_index:member_map(Data),
|
||||
Reply = lists:foldl(
|
||||
fun(UserId, Acc) ->
|
||||
case maps:get(UserId, MemberMap, undefined) of
|
||||
Member when is_map(Member) -> maps:put(UserId, Member, Acc);
|
||||
_ -> Acc
|
||||
end
|
||||
end,
|
||||
#{},
|
||||
UserIds
|
||||
),
|
||||
{reply, Reply, State};
|
||||
handle_call({get_counts}, _From, State) ->
|
||||
MemberCount = maps:get(member_count, State, 0),
|
||||
OnlineCount = guild_member_list:get_online_count(State),
|
||||
GuildId = maps:get(id, State, undefined),
|
||||
case is_integer(GuildId) of
|
||||
true -> guild_counts_cache:update(GuildId, MemberCount, OnlineCount);
|
||||
false -> ok
|
||||
end,
|
||||
{reply, #{member_count => MemberCount, presence_count => OnlineCount}, State};
|
||||
handle_call({get_large_guild_metadata}, _From, State) ->
|
||||
MemberCount = maps:get(member_count, State, 0),
|
||||
Data = maps:get(data, State, #{}),
|
||||
Guild = maps:get(<<"guild">>, Data, #{}),
|
||||
Features = maps:get(<<"features">>, Guild, []),
|
||||
{reply, #{member_count => MemberCount, features => Features}, State};
|
||||
handle_call({get_users_to_mention_by_roles, Request}, _From, State) ->
|
||||
spawn_async_reply(
|
||||
_From,
|
||||
fun() ->
|
||||
{reply, Reply, _} = guild_members:get_users_to_mention_by_roles(Request, State),
|
||||
Reply
|
||||
end
|
||||
),
|
||||
{noreply, State};
|
||||
handle_call({get_users_to_mention_by_user_ids, Request}, _From, State) ->
|
||||
spawn_async_reply(
|
||||
_From,
|
||||
fun() ->
|
||||
{reply, Reply, _} = guild_members:get_users_to_mention_by_user_ids(Request, State),
|
||||
Reply
|
||||
end
|
||||
),
|
||||
{noreply, State};
|
||||
handle_call({get_all_users_to_mention, Request}, _From, State) ->
|
||||
spawn_async_reply(
|
||||
_From,
|
||||
fun() ->
|
||||
{reply, Reply, _} = guild_members:get_all_users_to_mention(Request, State),
|
||||
Reply
|
||||
end
|
||||
),
|
||||
{noreply, State};
|
||||
handle_call({resolve_all_mentions, Request}, _From, State) ->
|
||||
spawn_async_reply(
|
||||
_From,
|
||||
fun() ->
|
||||
{reply, Reply, _} = guild_members:resolve_all_mentions(Request, State),
|
||||
Reply
|
||||
end
|
||||
),
|
||||
{noreply, State};
|
||||
handle_call({get_members_with_role, Request}, _From, State) ->
|
||||
spawn_async_reply(
|
||||
_From,
|
||||
fun() ->
|
||||
{reply, Reply, _} = guild_members:get_members_with_role(Request, State),
|
||||
Reply
|
||||
end
|
||||
),
|
||||
{noreply, State};
|
||||
handle_call({check_permission, Request}, _From, State) ->
|
||||
spawn_async_reply(
|
||||
_From,
|
||||
fun() ->
|
||||
#{user_id := UserId, permission := Permission, channel_id := ChannelId} = Request,
|
||||
true = is_integer(Permission),
|
||||
HasPermission =
|
||||
case owner_id(State) =:= UserId of
|
||||
true ->
|
||||
true;
|
||||
false ->
|
||||
Permissions = guild_permissions:get_member_permissions(
|
||||
UserId, ChannelId, State
|
||||
),
|
||||
(Permissions band Permission) =:= Permission
|
||||
end,
|
||||
#{has_permission => HasPermission}
|
||||
end
|
||||
),
|
||||
{noreply, State};
|
||||
handle_call({get_user_permissions, Request}, _From, State) ->
|
||||
spawn_async_reply(
|
||||
_From,
|
||||
fun() ->
|
||||
#{user_id := UserId, channel_id := ChannelId} = Request,
|
||||
Permissions = guild_permissions:get_member_permissions(UserId, ChannelId, State),
|
||||
#{permissions => Permissions}
|
||||
end
|
||||
),
|
||||
{noreply, State};
|
||||
handle_call({can_manage_roles, Request}, _From, State) ->
|
||||
guild_members:can_manage_roles(Request, State);
|
||||
handle_call({can_manage_role, Request}, _From, State) ->
|
||||
guild_members:can_manage_role(Request, State);
|
||||
handle_call({get_guild_data, Request}, _From, State) ->
|
||||
guild_data:get_guild_data(Request, State);
|
||||
handle_call({get_assignable_roles, Request}, _From, State) ->
|
||||
guild_members:get_assignable_roles(Request, State);
|
||||
handle_call({get_user_max_role_position, Request}, _From, State) ->
|
||||
#{user_id := UserId} = Request,
|
||||
Position = guild_permissions:get_max_role_position(UserId, State),
|
||||
{reply, #{position => Position}, State};
|
||||
handle_call({check_target_member, Request}, _From, State) ->
|
||||
guild_members:check_target_member(Request, State);
|
||||
handle_call({get_viewable_channels, Request}, _From, State) ->
|
||||
spawn_async_reply(
|
||||
_From,
|
||||
fun() ->
|
||||
{reply, Reply, _} = guild_members:get_viewable_channels(Request, State),
|
||||
Reply
|
||||
end
|
||||
),
|
||||
{noreply, State};
|
||||
handle_call({get_guild_member, Request}, _From, State) ->
|
||||
guild_data:get_guild_member(Request, State);
|
||||
handle_call({has_member, Request}, _From, State) ->
|
||||
guild_data:has_member(Request, State);
|
||||
handle_call({list_guild_members, Request}, _From, State) ->
|
||||
guild_data:list_guild_members(Request, State);
|
||||
handle_call({list_guild_members_cursor, Request}, _From, State) ->
|
||||
guild_member_list:get_members_cursor(Request, State);
|
||||
handle_call({get_vanity_url_channel}, _From, State) ->
|
||||
guild_data:get_vanity_url_channel(State);
|
||||
handle_call({get_first_viewable_text_channel}, _From, State) ->
|
||||
guild_data:get_first_viewable_text_channel(State);
|
||||
handle_call({get_category_channel_count, Request}, _From, State) ->
|
||||
#{category_id := CategoryId} = Request,
|
||||
Data = maps:get(data, State),
|
||||
Channels = maps:get(<<"channels">>, Data, []),
|
||||
Count = length([
|
||||
Ch
|
||||
|| Ch <- Channels,
|
||||
map_utils:get_integer(Ch, <<"parent_id">>, undefined) =:= CategoryId
|
||||
]),
|
||||
{reply, #{count => Count}, State};
|
||||
handle_call({get_channel_count}, _From, State) ->
|
||||
Data = maps:get(data, State),
|
||||
Channels = maps:get(<<"channels">>, Data, []),
|
||||
Count = length(Channels),
|
||||
{reply, #{count => Count}, State};
|
||||
handle_call({get_sessions}, _From, State) ->
|
||||
{reply, State, State};
|
||||
handle_call({get_push_base_state}, _From, State) ->
|
||||
{reply,
|
||||
#{
|
||||
id => maps:get(id, State, 0),
|
||||
data => maps:get(data, State, #{}),
|
||||
virtual_channel_access => maps:get(virtual_channel_access, State, #{})
|
||||
},
|
||||
State};
|
||||
handle_call({get_cluster_merge_state}, _From, State) ->
|
||||
{reply,
|
||||
#{
|
||||
sessions => maps:get(sessions, State, #{}),
|
||||
voice_states => maps:get(voice_states, State, #{}),
|
||||
virtual_channel_access => maps:get(virtual_channel_access, State, #{}),
|
||||
virtual_channel_access_pending => maps:get(virtual_channel_access_pending, State, #{}),
|
||||
virtual_channel_access_preserve => maps:get(virtual_channel_access_preserve, State, #{}),
|
||||
virtual_channel_access_move_pending =>
|
||||
maps:get(virtual_channel_access_move_pending, State, #{})
|
||||
},
|
||||
State}.
|
||||
|
||||
-spec spawn_async_reply(gen_server:from(), fun(() -> term())) -> ok.
|
||||
spawn_async_reply(From, ReplyFun) ->
|
||||
spawn(fun() ->
|
||||
Reply =
|
||||
try
|
||||
ReplyFun()
|
||||
catch
|
||||
_:_ ->
|
||||
#{error => async_handler_failed}
|
||||
end,
|
||||
gen_server:reply(From, Reply)
|
||||
end),
|
||||
ok.
|
||||
|
||||
-spec owner_id(guild_state()) -> user_id().
|
||||
owner_id(State) ->
|
||||
case resolve_data_map(State) of
|
||||
undefined ->
|
||||
0;
|
||||
Data ->
|
||||
Guild = maps:get(<<"guild">>, Data, #{}),
|
||||
type_conv:to_integer(maps:get(<<"owner_id">>, Guild, <<"0">>))
|
||||
end.
|
||||
|
||||
-spec resolve_data_map(guild_state() | map()) -> map() | undefined.
|
||||
resolve_data_map(State) when is_map(State) ->
|
||||
case maps:find(data, State) of
|
||||
{ok, Data} when is_map(Data) ->
|
||||
Data;
|
||||
{ok, Data} when is_map(Data) =:= false ->
|
||||
undefined;
|
||||
error ->
|
||||
case State of
|
||||
#{<<"members">> := _} ->
|
||||
State;
|
||||
_ ->
|
||||
undefined
|
||||
end
|
||||
end;
|
||||
resolve_data_map(_) ->
|
||||
undefined.
|
||||
@@ -460,4 +460,321 @@ normalize_nonce_test() ->
|
||||
?assertEqual(null, normalize_nonce(<<"this_nonce_is_way_too_long_to_be_valid">>)),
|
||||
?assertEqual(null, normalize_nonce(undefined)).
|
||||
|
||||
validate_user_ids_too_many_test() ->
|
||||
UserIds = lists:seq(1, 101),
|
||||
?assertEqual({error, too_many_user_ids}, validate_user_ids(UserIds)).
|
||||
|
||||
validate_user_ids_exactly_max_test() ->
|
||||
UserIds = lists:seq(1, 100),
|
||||
{ok, Parsed} = validate_user_ids(UserIds),
|
||||
?assertEqual(100, length(Parsed)).
|
||||
|
||||
validate_user_ids_non_list_test() ->
|
||||
{ok, []} = validate_user_ids(not_a_list).
|
||||
|
||||
validate_user_ids_filters_invalid_test() ->
|
||||
{ok, Parsed} = validate_user_ids([<<"1">>, <<"invalid">>, 3, -5, 0]),
|
||||
?assertEqual([1, 3], Parsed).
|
||||
|
||||
validate_user_ids_empty_test() ->
|
||||
{ok, []} = validate_user_ids([]).
|
||||
|
||||
parse_user_id_integer_test() ->
|
||||
?assertEqual({ok, 42}, parse_user_id(42)).
|
||||
|
||||
parse_user_id_binary_test() ->
|
||||
?assertEqual({ok, 123}, parse_user_id(<<"123">>)).
|
||||
|
||||
parse_user_id_zero_test() ->
|
||||
?assertEqual(error, parse_user_id(0)).
|
||||
|
||||
parse_user_id_negative_test() ->
|
||||
?assertEqual(error, parse_user_id(-1)).
|
||||
|
||||
parse_user_id_invalid_binary_test() ->
|
||||
?assertEqual(error, parse_user_id(<<"abc">>)).
|
||||
|
||||
parse_user_id_other_type_test() ->
|
||||
?assertEqual(error, parse_user_id(1.5)).
|
||||
|
||||
ensure_binary_binary_test() ->
|
||||
?assertEqual(<<"hello">>, ensure_binary(<<"hello">>)).
|
||||
|
||||
ensure_binary_integer_test() ->
|
||||
?assertEqual(<<>>, ensure_binary(42)).
|
||||
|
||||
ensure_binary_undefined_test() ->
|
||||
?assertEqual(<<>>, ensure_binary(undefined)).
|
||||
|
||||
ensure_limit_valid_test() ->
|
||||
?assertEqual(10, ensure_limit(10)).
|
||||
|
||||
ensure_limit_zero_test() ->
|
||||
?assertEqual(0, ensure_limit(0)).
|
||||
|
||||
ensure_limit_negative_test() ->
|
||||
?assertEqual(0, ensure_limit(-1)).
|
||||
|
||||
ensure_limit_non_integer_test() ->
|
||||
?assertEqual(0, ensure_limit(<<"10">>)).
|
||||
|
||||
validate_guild_id_integer_test() ->
|
||||
?assertEqual({ok, 123}, validate_guild_id(123)).
|
||||
|
||||
validate_guild_id_zero_test() ->
|
||||
?assertEqual({error, invalid_guild_id}, validate_guild_id(0)).
|
||||
|
||||
validate_guild_id_negative_test() ->
|
||||
?assertEqual({error, invalid_guild_id}, validate_guild_id(-1)).
|
||||
|
||||
validate_guild_id_atom_test() ->
|
||||
?assertEqual({error, invalid_guild_id}, validate_guild_id(undefined)).
|
||||
|
||||
build_chunk_data_basic_test() ->
|
||||
Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}],
|
||||
Result = build_chunk_data(Members, [], 0, 1, null),
|
||||
?assertEqual(Members, maps:get(<<"members">>, Result)),
|
||||
?assertEqual(0, maps:get(<<"chunk_index">>, Result)),
|
||||
?assertEqual(1, maps:get(<<"chunk_count">>, Result)),
|
||||
?assertNot(maps:is_key(<<"presences">>, Result)),
|
||||
?assertNot(maps:is_key(<<"nonce">>, Result)).
|
||||
|
||||
build_chunk_data_with_presences_test() ->
|
||||
Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}],
|
||||
Presences = [#{<<"user">> => #{<<"id">> => <<"1">>}, <<"status">> => <<"online">>}],
|
||||
Result = build_chunk_data(Members, Presences, 0, 1, null),
|
||||
?assertEqual(Presences, maps:get(<<"presences">>, Result)),
|
||||
?assertNot(maps:is_key(<<"nonce">>, Result)).
|
||||
|
||||
build_chunk_data_with_nonce_test() ->
|
||||
Members = [],
|
||||
Result = build_chunk_data(Members, [], 0, 1, <<"my_nonce">>),
|
||||
?assertEqual(<<"my_nonce">>, maps:get(<<"nonce">>, Result)).
|
||||
|
||||
build_chunk_data_with_presences_and_nonce_test() ->
|
||||
Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}],
|
||||
Presences = [#{<<"user">> => #{<<"id">> => <<"1">>}, <<"status">> => <<"online">>}],
|
||||
Result = build_chunk_data(Members, Presences, 2, 5, <<"nonce1">>),
|
||||
?assertEqual(Presences, maps:get(<<"presences">>, Result)),
|
||||
?assertEqual(<<"nonce1">>, maps:get(<<"nonce">>, Result)),
|
||||
?assertEqual(2, maps:get(<<"chunk_index">>, Result)),
|
||||
?assertEqual(5, maps:get(<<"chunk_count">>, Result)).
|
||||
|
||||
chunk_presences_aligns_with_member_chunks_test() ->
|
||||
Members1 = [
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>}},
|
||||
#{<<"user">> => #{<<"id">> => <<"2">>}}
|
||||
],
|
||||
Members2 = [
|
||||
#{<<"user">> => #{<<"id">> => <<"3">>}}
|
||||
],
|
||||
Presences = [
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>}, <<"status">> => <<"online">>},
|
||||
#{<<"user">> => #{<<"id">> => <<"3">>}, <<"status">> => <<"idle">>}
|
||||
],
|
||||
Result = chunk_presences(Presences, [Members1, Members2]),
|
||||
?assertEqual(2, length(Result)),
|
||||
[P1, P2] = Result,
|
||||
?assertEqual(1, length(P1)),
|
||||
?assertEqual(1, length(P2)).
|
||||
|
||||
chunk_presences_empty_presences_test() ->
|
||||
Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}],
|
||||
Result = chunk_presences([], [Members]),
|
||||
?assertEqual([[]], Result).
|
||||
|
||||
chunk_presences_no_matching_presences_test() ->
|
||||
Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}],
|
||||
Presences = [#{<<"user">> => #{<<"id">> => <<"999">>}, <<"status">> => <<"online">>}],
|
||||
Result = chunk_presences(Presences, [Members]),
|
||||
?assertEqual([[]], Result).
|
||||
|
||||
filter_members_by_ids_basic_test() ->
|
||||
Members = [
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>}},
|
||||
#{<<"user">> => #{<<"id">> => <<"2">>}},
|
||||
#{<<"user">> => #{<<"id">> => <<"3">>}}
|
||||
],
|
||||
Result = filter_members_by_ids(Members, [1, 3]),
|
||||
?assertEqual(2, length(Result)).
|
||||
|
||||
filter_members_by_ids_empty_ids_test() ->
|
||||
Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}],
|
||||
Result = filter_members_by_ids(Members, []),
|
||||
?assertEqual([], Result).
|
||||
|
||||
filter_members_by_ids_no_match_test() ->
|
||||
Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}],
|
||||
Result = filter_members_by_ids(Members, [999]),
|
||||
?assertEqual([], Result).
|
||||
|
||||
filter_members_by_ids_skips_invalid_members_test() ->
|
||||
Members = [#{}, #{<<"user">> => #{}}, #{<<"user">> => #{<<"id">> => <<"1">>}}],
|
||||
Result = filter_members_by_ids(Members, [1]),
|
||||
?assertEqual(1, length(Result)).
|
||||
|
||||
filter_members_by_query_case_insensitive_test() ->
|
||||
Members = [
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"Alice">>}},
|
||||
#{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"bob">>}}
|
||||
],
|
||||
Results = filter_members_by_query(Members, <<"ALICE">>, 10),
|
||||
?assertEqual(1, length(Results)).
|
||||
|
||||
filter_members_by_query_respects_limit_test() ->
|
||||
Members = [
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice1">>}},
|
||||
#{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"alice2">>}},
|
||||
#{<<"user">> => #{<<"id">> => <<"3">>, <<"username">> => <<"alice3">>}}
|
||||
],
|
||||
Results = filter_members_by_query(Members, <<"alice">>, 2),
|
||||
?assertEqual(2, length(Results)).
|
||||
|
||||
filter_members_by_query_empty_query_matches_all_test() ->
|
||||
Members = [
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>}},
|
||||
#{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"bob">>}}
|
||||
],
|
||||
Results = filter_members_by_query(Members, <<>>, 10),
|
||||
?assertEqual(2, length(Results)).
|
||||
|
||||
filter_members_by_query_no_match_test() ->
|
||||
Members = [
|
||||
#{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>}}
|
||||
],
|
||||
Results = filter_members_by_query(Members, <<"zzz">>, 10),
|
||||
?assertEqual(0, length(Results)).
|
||||
|
||||
filter_members_by_query_matches_nick_test() ->
|
||||
Members = [
|
||||
#{
|
||||
<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>},
|
||||
<<"nick">> => <<"SuperNick">>
|
||||
}
|
||||
],
|
||||
Results = filter_members_by_query(Members, <<"super">>, 10),
|
||||
?assertEqual(1, length(Results)).
|
||||
|
||||
get_display_name_null_nick_test() ->
|
||||
Member = #{
|
||||
<<"user">> => #{<<"username">> => <<"user">>},
|
||||
<<"nick">> => null
|
||||
},
|
||||
?assertEqual(<<"user">>, get_display_name(Member)).
|
||||
|
||||
get_display_name_non_binary_nick_test() ->
|
||||
Member = #{
|
||||
<<"user">> => #{<<"username">> => <<"user">>},
|
||||
<<"nick">> => 12345
|
||||
},
|
||||
?assertEqual(<<"user">>, get_display_name(Member)).
|
||||
|
||||
get_display_name_non_map_test() ->
|
||||
?assertEqual(<<>>, get_display_name(not_a_map)).
|
||||
|
||||
get_display_name_null_global_name_test() ->
|
||||
Member = #{<<"user">> => #{<<"username">> => <<"user">>, <<"global_name">> => null}},
|
||||
?assertEqual(<<"user">>, get_display_name(Member)).
|
||||
|
||||
get_display_name_non_binary_global_name_test() ->
|
||||
Member = #{<<"user">> => #{<<"username">> => <<"user">>, <<"global_name">> => 12345}},
|
||||
?assertEqual(<<"user">>, get_display_name(Member)).
|
||||
|
||||
get_username_null_test() ->
|
||||
?assertEqual(<<>>, get_username(#{<<"username">> => null})).
|
||||
|
||||
get_username_undefined_test() ->
|
||||
?assertEqual(<<>>, get_username(#{<<"username">> => undefined})).
|
||||
|
||||
get_username_non_binary_test() ->
|
||||
?assertEqual(<<>>, get_username(#{<<"username">> => 12345})).
|
||||
|
||||
get_username_missing_test() ->
|
||||
?assertEqual(<<>>, get_username(#{})).
|
||||
|
||||
extract_user_id_valid_test() ->
|
||||
?assertEqual(42, extract_user_id(#{<<"user">> => #{<<"id">> => <<"42">>}})).
|
||||
|
||||
extract_user_id_missing_user_test() ->
|
||||
?assertEqual(undefined, extract_user_id(#{})).
|
||||
|
||||
extract_user_id_non_map_test() ->
|
||||
?assertEqual(undefined, extract_user_id(not_a_map)).
|
||||
|
||||
presence_visible_online_test() ->
|
||||
?assertEqual(true, presence_visible(#{<<"status">> => <<"online">>})).
|
||||
|
||||
presence_visible_idle_test() ->
|
||||
?assertEqual(true, presence_visible(#{<<"status">> => <<"idle">>})).
|
||||
|
||||
presence_visible_dnd_test() ->
|
||||
?assertEqual(true, presence_visible(#{<<"status">> => <<"dnd">>})).
|
||||
|
||||
presence_visible_offline_test() ->
|
||||
?assertEqual(false, presence_visible(#{<<"status">> => <<"offline">>})).
|
||||
|
||||
presence_visible_invisible_test() ->
|
||||
?assertEqual(false, presence_visible(#{<<"status">> => <<"invisible">>})).
|
||||
|
||||
presence_visible_missing_status_test() ->
|
||||
?assertEqual(false, presence_visible(#{})).
|
||||
|
||||
normalize_nonce_exactly_max_length_test() ->
|
||||
Nonce = list_to_binary(lists:duplicate(32, $a)),
|
||||
?assertEqual(Nonce, normalize_nonce(Nonce)).
|
||||
|
||||
normalize_nonce_one_over_max_test() ->
|
||||
Nonce = list_to_binary(lists:duplicate(33, $a)),
|
||||
?assertEqual(null, normalize_nonce(Nonce)).
|
||||
|
||||
normalize_nonce_empty_binary_test() ->
|
||||
?assertEqual(<<>>, normalize_nonce(<<>>)).
|
||||
|
||||
normalize_nonce_integer_test() ->
|
||||
?assertEqual(null, normalize_nonce(42)).
|
||||
|
||||
normalize_nonce_null_atom_test() ->
|
||||
?assertEqual(null, normalize_nonce(null)).
|
||||
|
||||
parse_request_defaults_test() ->
|
||||
Data = #{<<"guild_id">> => 12345},
|
||||
{ok, Request} = parse_request(Data),
|
||||
?assertEqual(12345, maps:get(guild_id, Request)),
|
||||
?assertEqual(<<>>, maps:get(query, Request)),
|
||||
?assertEqual(0, maps:get(limit, Request)),
|
||||
?assertEqual([], maps:get(user_ids, Request)),
|
||||
?assertEqual(false, maps:get(presences, Request)),
|
||||
?assertEqual(null, maps:get(nonce, Request)).
|
||||
|
||||
parse_request_non_binary_query_test() ->
|
||||
Data = #{<<"guild_id">> => 123, <<"query">> => 42},
|
||||
{ok, Request} = parse_request(Data),
|
||||
?assertEqual(<<>>, maps:get(query, Request)).
|
||||
|
||||
parse_request_negative_limit_test() ->
|
||||
Data = #{<<"guild_id">> => 123, <<"limit">> => -5},
|
||||
{ok, Request} = parse_request(Data),
|
||||
?assertEqual(0, maps:get(limit, Request)).
|
||||
|
||||
parse_request_presences_not_true_test() ->
|
||||
Data = #{<<"guild_id">> => 123, <<"presences">> => <<"yes">>},
|
||||
{ok, Request} = parse_request(Data),
|
||||
?assertEqual(false, maps:get(presences, Request)).
|
||||
|
||||
parse_request_missing_guild_id_test() ->
|
||||
Data = #{<<"query">> => <<"test">>},
|
||||
?assertEqual({error, invalid_guild_id}, parse_request(Data)).
|
||||
|
||||
handle_request_invalid_data_test() ->
|
||||
?assertEqual({error, invalid_request}, handle_request(not_a_map, self(), #{})).
|
||||
|
||||
chunk_list_single_element_test() ->
|
||||
?assertEqual([[1]], chunk_list([1], 1)).
|
||||
|
||||
chunk_list_exact_multiple_test() ->
|
||||
?assertEqual([[1, 2], [3, 4]], chunk_list([1, 2, 3, 4], 2)).
|
||||
|
||||
chunk_list_large_size_test() ->
|
||||
?assertEqual([[1, 2, 3]], chunk_list([1, 2, 3], 1000)).
|
||||
|
||||
-endif.
|
||||
|
||||
@@ -82,11 +82,14 @@ register_new_session(Request, Pid, UserId, SessionId, State) ->
|
||||
user_roles => UserRoles,
|
||||
bot => Bot,
|
||||
is_staff => maps:get(is_staff, Request, false),
|
||||
previous_passive_updates => InitialLastMessageIds,
|
||||
previous_passive_channel_versions => InitialChannelVersions,
|
||||
previous_passive_voice_states => #{},
|
||||
viewable_channels => InitialViewableChannels
|
||||
},
|
||||
InitialPassiveState = #{
|
||||
previous_passive_updates => InitialLastMessageIds,
|
||||
previous_passive_channel_versions => InitialChannelVersions,
|
||||
previous_passive_voice_states => #{}
|
||||
},
|
||||
passive_sync_registry:store(SessionId, GuildId, InitialPassiveState),
|
||||
NewSessions = maps:put(SessionId, SessionData, Sessions),
|
||||
State1 = maps:put(sessions, NewSessions, State),
|
||||
State2 = subscribe_to_user_presence(UserId, State1),
|
||||
@@ -199,7 +202,9 @@ cleanup_disconnecting_session(undefined, State) ->
|
||||
cleanup_disconnecting_session(Session, State) ->
|
||||
UserId = maps:get(user_id, Session),
|
||||
SessionId = maps:get(session_id, Session),
|
||||
GuildId = maps:get(id, State),
|
||||
_ = maybe_notify_coordinator(session_disconnected, SessionId, UserId, State),
|
||||
passive_sync_registry:delete(SessionId, GuildId),
|
||||
StateAfterPresence = unsubscribe_from_user_presence(UserId, State),
|
||||
StateAfterMemberList = guild_member_list:unsubscribe_session(SessionId, StateAfterPresence),
|
||||
MemberSubs = maps:get(
|
||||
@@ -675,12 +680,10 @@ remove_session_removes_entry_test() ->
|
||||
mref => make_ref(),
|
||||
active_guilds => sets:new(),
|
||||
user_roles => [],
|
||||
bot => false,
|
||||
previous_passive_updates => #{},
|
||||
previous_passive_channel_versions => #{},
|
||||
previous_passive_voice_states => #{}
|
||||
bot => false
|
||||
},
|
||||
State = #{
|
||||
id => 42,
|
||||
sessions => #{SessionId => SessionData},
|
||||
presence_subscriptions => #{1 => 1},
|
||||
member_list_subscriptions => #{},
|
||||
@@ -699,12 +702,10 @@ remove_session_cleans_connect_pending_test() ->
|
||||
mref => make_ref(),
|
||||
active_guilds => sets:new(),
|
||||
user_roles => [],
|
||||
bot => false,
|
||||
previous_passive_updates => #{},
|
||||
previous_passive_channel_versions => #{},
|
||||
previous_passive_voice_states => #{}
|
||||
bot => false
|
||||
},
|
||||
State = #{
|
||||
id => 42,
|
||||
sessions => #{SessionId => SessionData},
|
||||
presence_subscriptions => #{1 => 1},
|
||||
member_list_subscriptions => #{},
|
||||
@@ -804,4 +805,222 @@ pending_connect_filtered_from_channel_sessions_test() ->
|
||||
[{ResultSid, _}] = Result,
|
||||
?assertEqual(<<"s1">>, ResultSid).
|
||||
|
||||
set_session_viewable_channels_test() ->
|
||||
Sessions = #{<<"s1">> => #{user_id => 1, pid => self()}},
|
||||
State = #{sessions => Sessions},
|
||||
ViewableChannels = #{100 => true, 200 => true},
|
||||
UpdatedState = set_session_viewable_channels(<<"s1">>, ViewableChannels, State),
|
||||
UpdatedSession = maps:get(<<"s1">>, maps:get(sessions, UpdatedState)),
|
||||
?assertEqual(ViewableChannels, maps:get(viewable_channels, UpdatedSession)).
|
||||
|
||||
set_session_viewable_channels_missing_session_test() ->
|
||||
State = #{sessions => #{}},
|
||||
Result = set_session_viewable_channels(<<"nonexistent">>, #{100 => true}, State),
|
||||
?assertEqual(State, Result).
|
||||
|
||||
set_session_active_guild_missing_session_test() ->
|
||||
State = #{sessions => #{}},
|
||||
Result = set_session_active_guild(<<"nonexistent">>, 42, State),
|
||||
?assertEqual(State, Result).
|
||||
|
||||
set_session_passive_guild_missing_session_test() ->
|
||||
State = #{sessions => #{}},
|
||||
Result = set_session_passive_guild(<<"nonexistent">>, 42, State),
|
||||
?assertEqual(State, Result).
|
||||
|
||||
is_session_active_missing_session_test() ->
|
||||
State = #{id => 42, sessions => #{}},
|
||||
?assertEqual(false, is_session_active(<<"nonexistent">>, State)).
|
||||
|
||||
filter_sessions_for_channel_excludes_specified_session_test() ->
|
||||
S1 = #{session_id => <<"s1">>, user_id => 10, pid => self(), viewable_channels => #{200 => true}},
|
||||
S2 = #{session_id => <<"s2">>, user_id => 11, pid => self(), viewable_channels => #{200 => true}},
|
||||
Sessions = #{<<"s1">> => S1, <<"s2">> => S2},
|
||||
State = #{sessions => Sessions, data => #{<<"members">> => #{}}},
|
||||
Result = filter_sessions_for_channel(Sessions, 200, <<"s1">>, State),
|
||||
?assertEqual(1, length(Result)),
|
||||
[{ResultSid, _}] = Result,
|
||||
?assertEqual(<<"s2">>, ResultSid).
|
||||
|
||||
filter_sessions_for_channel_falls_back_to_permission_check_test() ->
|
||||
GuildId = 42,
|
||||
UserId = 10,
|
||||
ChannelId = 200,
|
||||
ViewPerm = constants:view_channel_permission(),
|
||||
SessionData = #{
|
||||
session_id => <<"s1">>,
|
||||
user_id => UserId,
|
||||
pid => self()
|
||||
},
|
||||
Sessions = #{<<"s1">> => SessionData},
|
||||
State = #{
|
||||
id => GuildId,
|
||||
sessions => Sessions,
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => integer_to_binary(ViewPerm)}
|
||||
],
|
||||
<<"members">> => #{
|
||||
UserId => #{<<"user">> => #{<<"id">> => integer_to_binary(UserId)}, <<"roles">> => []}
|
||||
},
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => integer_to_binary(ChannelId), <<"permission_overwrites">> => []}
|
||||
]
|
||||
}
|
||||
},
|
||||
Result = filter_sessions_for_channel(Sessions, ChannelId, undefined, State),
|
||||
?assertEqual(1, length(Result)).
|
||||
|
||||
filter_sessions_for_channel_no_member_returns_empty_test() ->
|
||||
SessionData = #{
|
||||
session_id => <<"s1">>,
|
||||
user_id => 999,
|
||||
pid => self()
|
||||
},
|
||||
Sessions = #{<<"s1">> => SessionData},
|
||||
State = #{
|
||||
sessions => Sessions,
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"888">>},
|
||||
<<"members">> => [],
|
||||
<<"roles">> => [],
|
||||
<<"channels">> => []
|
||||
}
|
||||
},
|
||||
Result = filter_sessions_for_channel(Sessions, 200, undefined, State),
|
||||
?assertEqual([], Result).
|
||||
|
||||
filter_sessions_exclude_session_filters_pending_test() ->
|
||||
Sessions = #{
|
||||
<<"s1">> => #{pending_connect => true},
|
||||
<<"s2">> => #{},
|
||||
<<"s3">> => #{pending_connect => false}
|
||||
},
|
||||
Result = filter_sessions_exclude_session(Sessions, undefined),
|
||||
ResultIds = lists:sort([Sid || {Sid, _} <- Result]),
|
||||
?assertEqual([<<"s2">>, <<"s3">>], ResultIds).
|
||||
|
||||
subscribe_unsubscribe_presence_test_() ->
|
||||
{setup,
|
||||
fun() -> ensure_test_deps() end,
|
||||
fun(_) -> stop_test_deps() end,
|
||||
fun(_) ->
|
||||
[fun() ->
|
||||
State0 = #{presence_subscriptions => #{}},
|
||||
State1 = subscribe_to_user_presence(10, State0),
|
||||
Subs1 = maps:get(presence_subscriptions, State1),
|
||||
?assertEqual(1, maps:get(10, Subs1)),
|
||||
State2 = subscribe_to_user_presence(10, State1),
|
||||
Subs2 = maps:get(presence_subscriptions, State2),
|
||||
?assertEqual(2, maps:get(10, Subs2)),
|
||||
State3 = unsubscribe_from_user_presence(10, State2),
|
||||
Subs3 = maps:get(presence_subscriptions, State3),
|
||||
?assertEqual(1, maps:get(10, Subs3)),
|
||||
State4 = unsubscribe_from_user_presence(10, State3),
|
||||
Subs4 = maps:get(presence_subscriptions, State4),
|
||||
?assertEqual(0, maps:get(10, Subs4))
|
||||
end]
|
||||
end}.
|
||||
|
||||
unsubscribe_from_user_presence_zero_count_noop_test() ->
|
||||
State = #{presence_subscriptions => #{10 => 0}},
|
||||
Result = unsubscribe_from_user_presence(10, State),
|
||||
?assertEqual(State, Result).
|
||||
|
||||
unsubscribe_from_user_presence_missing_user_noop_test() ->
|
||||
State = #{presence_subscriptions => #{}},
|
||||
Result = unsubscribe_from_user_presence(999, State),
|
||||
?assertEqual(State, Result).
|
||||
|
||||
handle_user_offline_nonzero_count_noop_test() ->
|
||||
State = #{
|
||||
presence_subscriptions => #{10 => 1},
|
||||
member_presence => #{10 => #{<<"status">> => <<"online">>}}
|
||||
},
|
||||
Result = handle_user_offline(10, State),
|
||||
?assertEqual(State, Result).
|
||||
|
||||
handle_user_offline_missing_user_noop_test() ->
|
||||
State = #{presence_subscriptions => #{}},
|
||||
Result = handle_user_offline(999, State),
|
||||
?assertEqual(State, Result).
|
||||
|
||||
should_auto_stop_on_empty_default_test() ->
|
||||
State = #{},
|
||||
?assertEqual(true, should_auto_stop_on_empty(State)).
|
||||
|
||||
should_auto_stop_on_empty_disabled_test() ->
|
||||
State = #{disable_auto_stop_on_empty => true},
|
||||
?assertEqual(false, should_auto_stop_on_empty(State)).
|
||||
|
||||
should_auto_stop_on_empty_vlg_coordinator_test() ->
|
||||
State = #{very_large_guild_coordinator_pid => self()},
|
||||
?assertEqual(false, should_auto_stop_on_empty(State)).
|
||||
|
||||
build_viewable_channel_map_test() ->
|
||||
Map = build_viewable_channel_map([100, 200, 300]),
|
||||
?assertEqual(3, map_size(Map)),
|
||||
?assertEqual(true, maps:get(100, Map)),
|
||||
?assertEqual(true, maps:get(200, Map)),
|
||||
?assertEqual(true, maps:get(300, Map)).
|
||||
|
||||
build_viewable_channel_map_empty_test() ->
|
||||
?assertEqual(#{}, build_viewable_channel_map([])).
|
||||
|
||||
normalize_connect_queue_list_test() ->
|
||||
List = [#{a => 1}, #{a => 2}],
|
||||
Queue = normalize_connect_queue(List),
|
||||
?assert(queue:is_queue(Queue)),
|
||||
?assertEqual(2, queue:len(Queue)).
|
||||
|
||||
normalize_connect_queue_queue_test() ->
|
||||
Q = queue:from_list([1, 2, 3]),
|
||||
?assertEqual(Q, normalize_connect_queue(Q)).
|
||||
|
||||
normalize_connect_queue_undefined_test() ->
|
||||
?assertEqual(undefined, normalize_connect_queue(undefined)),
|
||||
?assertEqual(undefined, normalize_connect_queue(42)).
|
||||
|
||||
ensure_test_deps() ->
|
||||
ensure_mock_registered(presence_bus),
|
||||
ensure_mock_registered(presence_cache).
|
||||
|
||||
stop_test_deps() ->
|
||||
stop_mock_registered(presence_bus),
|
||||
stop_mock_registered(presence_cache).
|
||||
|
||||
ensure_mock_registered(Name) ->
|
||||
case whereis(Name) of
|
||||
undefined ->
|
||||
Pid = spawn(fun() -> mock_gen_server_loop() end),
|
||||
register(Name, Pid),
|
||||
Pid;
|
||||
Pid ->
|
||||
Pid
|
||||
end.
|
||||
|
||||
stop_mock_registered(Name) ->
|
||||
case whereis(Name) of
|
||||
undefined -> ok;
|
||||
Pid ->
|
||||
catch unregister(Name),
|
||||
Pid ! stop,
|
||||
ok
|
||||
end.
|
||||
|
||||
mock_gen_server_loop() ->
|
||||
receive
|
||||
{'$gen_call', From, {get, _}} ->
|
||||
gen_server:reply(From, not_found),
|
||||
mock_gen_server_loop();
|
||||
{'$gen_call', From, _Msg} ->
|
||||
gen_server:reply(From, ok),
|
||||
mock_gen_server_loop();
|
||||
stop ->
|
||||
ok;
|
||||
_ ->
|
||||
mock_gen_server_loop()
|
||||
end.
|
||||
|
||||
-endif.
|
||||
|
||||
@@ -703,4 +703,292 @@ guild_update_syncs_unavailability_cache_test() ->
|
||||
_ = guild_availability:update_unavailability_cache_for_state(CleanupState)
|
||||
end.
|
||||
|
||||
handle_guild_update_merges_fields_test() ->
|
||||
Data = #{
|
||||
<<"guild">> => #{<<"name">> => <<"Old">>, <<"icon">> => <<"abc">>},
|
||||
<<"roles">> => [],
|
||||
<<"members">> => [],
|
||||
<<"channels">> => []
|
||||
},
|
||||
EventData = #{<<"name">> => <<"New">>, <<"description">> => <<"desc">>},
|
||||
Result = handle_guild_update(EventData, Data),
|
||||
Guild = maps:get(<<"guild">>, Result),
|
||||
?assertEqual(<<"New">>, maps:get(<<"name">>, Guild)),
|
||||
?assertEqual(<<"abc">>, maps:get(<<"icon">>, Guild)),
|
||||
?assertEqual(<<"desc">>, maps:get(<<"description">>, Guild)).
|
||||
|
||||
handle_member_update_ignores_non_member_test() ->
|
||||
Data = #{
|
||||
<<"members">> => #{
|
||||
1 => #{<<"user">> => #{<<"id">> => <<"1">>}, <<"nick">> => <<"nick">>}
|
||||
}
|
||||
},
|
||||
EventData = #{<<"user">> => #{<<"id">> => <<"999">>}, <<"nick">> => <<"new">>},
|
||||
Result = handle_member_update(EventData, Data),
|
||||
?assertEqual(1, map_size(maps:get(<<"members">>, Result))),
|
||||
?assertEqual(undefined, guild_data_index:get_member(999, Result)).
|
||||
|
||||
handle_role_create_test() ->
|
||||
Data = #{
|
||||
<<"roles">> => [#{<<"id">> => <<"1">>, <<"name">> => <<"Everyone">>}],
|
||||
<<"members">> => [],
|
||||
<<"channels">> => []
|
||||
},
|
||||
EventData = #{<<"role">> => #{<<"id">> => <<"2">>, <<"name">> => <<"New">>}},
|
||||
Result = handle_role_create(EventData, Data),
|
||||
Roles = guild_data_index:role_list(Result),
|
||||
?assertEqual(2, length(Roles)),
|
||||
RoleIndex = guild_data_index:role_index(Result),
|
||||
?assertMatch(#{2 := _}, RoleIndex).
|
||||
|
||||
handle_role_update_replaces_role_test() ->
|
||||
Data = #{
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => <<"1">>, <<"name">> => <<"Old">>},
|
||||
#{<<"id">> => <<"2">>, <<"name">> => <<"Keep">>}
|
||||
],
|
||||
<<"members">> => [],
|
||||
<<"channels">> => []
|
||||
},
|
||||
EventData = #{<<"role">> => #{<<"id">> => <<"1">>, <<"name">> => <<"Updated">>}},
|
||||
Result = handle_role_update(EventData, Data),
|
||||
Roles = guild_data_index:role_list(Result),
|
||||
[R1, R2] = Roles,
|
||||
?assertEqual(<<"Updated">>, maps:get(<<"name">>, R1)),
|
||||
?assertEqual(<<"Keep">>, maps:get(<<"name">>, R2)).
|
||||
|
||||
handle_role_update_bulk_test() ->
|
||||
Data = #{
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => <<"1">>, <<"name">> => <<"A">>},
|
||||
#{<<"id">> => <<"2">>, <<"name">> => <<"B">>},
|
||||
#{<<"id">> => <<"3">>, <<"name">> => <<"C">>}
|
||||
],
|
||||
<<"members">> => [],
|
||||
<<"channels">> => []
|
||||
},
|
||||
EventData = #{
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => <<"1">>, <<"name">> => <<"A2">>},
|
||||
#{<<"id">> => <<"3">>, <<"name">> => <<"C2">>}
|
||||
]
|
||||
},
|
||||
Result = handle_role_update_bulk(EventData, Data),
|
||||
Roles = guild_data_index:role_list(Result),
|
||||
[R1, R2, R3] = Roles,
|
||||
?assertEqual(<<"A2">>, maps:get(<<"name">>, R1)),
|
||||
?assertEqual(<<"B">>, maps:get(<<"name">>, R2)),
|
||||
?assertEqual(<<"C2">>, maps:get(<<"name">>, R3)).
|
||||
|
||||
handle_role_delete_removes_role_from_list_test() ->
|
||||
Data = #{
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => <<"1">>, <<"name">> => <<"Keep">>},
|
||||
#{<<"id">> => <<"2">>, <<"name">> => <<"Delete">>}
|
||||
],
|
||||
<<"members">> => [],
|
||||
<<"channels">> => []
|
||||
},
|
||||
EventData = #{<<"role_id">> => <<"2">>},
|
||||
Result = handle_role_delete(EventData, Data),
|
||||
Roles = guild_data_index:role_list(Result),
|
||||
?assertEqual(1, length(Roles)),
|
||||
?assertEqual(<<"Keep">>, maps:get(<<"name">>, hd(Roles))).
|
||||
|
||||
handle_channel_update_test() ->
|
||||
Data = #{
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"100">>, <<"name">> => <<"old">>},
|
||||
#{<<"id">> => <<"101">>, <<"name">> => <<"keep">>}
|
||||
]
|
||||
},
|
||||
EventData = #{<<"id">> => <<"100">>, <<"name">> => <<"updated">>},
|
||||
Result = handle_channel_update(EventData, Data),
|
||||
Channels = guild_data_index:channel_list(Result),
|
||||
[C1, C2] = Channels,
|
||||
?assertEqual(<<"updated">>, maps:get(<<"name">>, C1)),
|
||||
?assertEqual(<<"keep">>, maps:get(<<"name">>, C2)).
|
||||
|
||||
handle_channel_update_bulk_test() ->
|
||||
Data = #{
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"1">>, <<"name">> => <<"A">>},
|
||||
#{<<"id">> => <<"2">>, <<"name">> => <<"B">>}
|
||||
]
|
||||
},
|
||||
EventData = #{
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"2">>, <<"name">> => <<"B2">>}
|
||||
]
|
||||
},
|
||||
Result = handle_channel_update_bulk(EventData, Data),
|
||||
Channels = guild_data_index:channel_list(Result),
|
||||
[C1, C2] = Channels,
|
||||
?assertEqual(<<"A">>, maps:get(<<"name">>, C1)),
|
||||
?assertEqual(<<"B2">>, maps:get(<<"name">>, C2)).
|
||||
|
||||
handle_channel_delete_test() ->
|
||||
Data = #{
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"100">>, <<"name">> => <<"general">>},
|
||||
#{<<"id">> => <<"101">>, <<"name">> => <<"random">>}
|
||||
]
|
||||
},
|
||||
EventData = #{<<"id">> => <<"100">>},
|
||||
Result = handle_channel_delete(EventData, Data),
|
||||
Channels = guild_data_index:channel_list(Result),
|
||||
?assertEqual(1, length(Channels)),
|
||||
?assertEqual(<<"random">>, maps:get(<<"name">>, hd(Channels))).
|
||||
|
||||
handle_message_create_updates_last_message_id_test() ->
|
||||
Data = #{
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"100">>, <<"last_message_id">> => <<"500">>},
|
||||
#{<<"id">> => <<"101">>, <<"last_message_id">> => <<"600">>}
|
||||
]
|
||||
},
|
||||
EventData = #{<<"channel_id">> => <<"100">>, <<"id">> => <<"700">>},
|
||||
Result = handle_message_create(EventData, Data),
|
||||
Channels = guild_data_index:channel_list(Result),
|
||||
[C1, C2] = Channels,
|
||||
?assertEqual(<<"700">>, maps:get(<<"last_message_id">>, C1)),
|
||||
?assertEqual(<<"600">>, maps:get(<<"last_message_id">>, C2)).
|
||||
|
||||
handle_channel_pins_update_test() ->
|
||||
Data = #{
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"100">>}
|
||||
]
|
||||
},
|
||||
EventData = #{<<"channel_id">> => <<"100">>, <<"last_pin_timestamp">> => <<"2024-01-01T00:00:00Z">>},
|
||||
Result = handle_channel_pins_update(EventData, Data),
|
||||
[Ch] = guild_data_index:channel_list(Result),
|
||||
?assertEqual(<<"2024-01-01T00:00:00Z">>, maps:get(<<"last_pin_timestamp">>, Ch)).
|
||||
|
||||
handle_emojis_update_test() ->
|
||||
Data = #{<<"emojis">> => []},
|
||||
EventData = #{<<"emojis">> => [#{<<"id">> => <<"1">>}]},
|
||||
Result = handle_emojis_update(EventData, Data),
|
||||
?assertEqual([#{<<"id">> => <<"1">>}], maps:get(<<"emojis">>, Result)).
|
||||
|
||||
handle_stickers_update_test() ->
|
||||
Data = #{<<"stickers">> => []},
|
||||
EventData = #{<<"stickers">> => [#{<<"id">> => <<"1">>}]},
|
||||
Result = handle_stickers_update(EventData, Data),
|
||||
?assertEqual([#{<<"id">> => <<"1">>}], maps:get(<<"stickers">>, Result)).
|
||||
|
||||
replace_item_by_id_test() ->
|
||||
Items = [
|
||||
#{<<"id">> => <<"1">>, <<"v">> => <<"a">>},
|
||||
#{<<"id">> => <<"2">>, <<"v">> => <<"b">>}
|
||||
],
|
||||
Result = replace_item_by_id(Items, <<"1">>, #{<<"id">> => <<"1">>, <<"v">> => <<"c">>}),
|
||||
[R1, R2] = Result,
|
||||
?assertEqual(<<"c">>, maps:get(<<"v">>, R1)),
|
||||
?assertEqual(<<"b">>, maps:get(<<"v">>, R2)).
|
||||
|
||||
replace_item_by_id_no_match_test() ->
|
||||
Items = [#{<<"id">> => <<"1">>, <<"v">> => <<"a">>}],
|
||||
Result = replace_item_by_id(Items, <<"999">>, #{<<"id">> => <<"999">>}),
|
||||
?assertEqual(Items, Result).
|
||||
|
||||
remove_item_by_id_test() ->
|
||||
Items = [
|
||||
#{<<"id">> => <<"1">>},
|
||||
#{<<"id">> => <<"2">>},
|
||||
#{<<"id">> => <<"3">>}
|
||||
],
|
||||
Result = remove_item_by_id(Items, <<"2">>),
|
||||
?assertEqual(2, length(Result)),
|
||||
Ids = [maps:get(<<"id">>, I) || I <- Result],
|
||||
?assertEqual([<<"1">>, <<"3">>], Ids).
|
||||
|
||||
remove_item_by_id_no_match_test() ->
|
||||
Items = [#{<<"id">> => <<"1">>}],
|
||||
?assertEqual(Items, remove_item_by_id(Items, <<"999">>)).
|
||||
|
||||
bulk_update_items_no_updates_test() ->
|
||||
Items = [#{<<"id">> => <<"1">>, <<"v">> => <<"a">>}],
|
||||
?assertEqual(Items, bulk_update_items(Items, [])).
|
||||
|
||||
bulk_update_items_missing_id_in_bulk_ignored_test() ->
|
||||
Items = [#{<<"id">> => <<"1">>, <<"v">> => <<"a">>}],
|
||||
BulkItems = [#{<<"v">> => <<"b">>}],
|
||||
?assertEqual(Items, bulk_update_items(Items, BulkItems)).
|
||||
|
||||
extract_role_ids_from_role_update_test() ->
|
||||
EventData = #{<<"role">> => #{<<"id">> => <<"42">>}},
|
||||
?assertEqual([42], extract_role_ids_from_role_update(EventData)).
|
||||
|
||||
extract_role_ids_from_role_update_missing_id_test() ->
|
||||
EventData = #{<<"role">> => #{}},
|
||||
?assertEqual([], extract_role_ids_from_role_update(EventData)).
|
||||
|
||||
extract_role_ids_from_role_update_missing_role_test() ->
|
||||
?assertEqual([], extract_role_ids_from_role_update(#{})).
|
||||
|
||||
extract_role_ids_from_role_update_bulk_test() ->
|
||||
EventData = #{<<"roles">> => [#{<<"id">> => <<"1">>}, #{<<"id">> => <<"2">>}]},
|
||||
?assertEqual([1, 2], extract_role_ids_from_role_update_bulk(EventData)).
|
||||
|
||||
extract_role_ids_from_role_update_bulk_empty_test() ->
|
||||
?assertEqual([], extract_role_ids_from_role_update_bulk(#{})).
|
||||
|
||||
extract_role_ids_from_role_delete_test() ->
|
||||
EventData = #{<<"role_id">> => <<"55">>},
|
||||
?assertEqual([55], extract_role_ids_from_role_delete(EventData)).
|
||||
|
||||
extract_role_ids_from_role_delete_missing_test() ->
|
||||
?assertEqual([], extract_role_ids_from_role_delete(#{})).
|
||||
|
||||
update_data_for_event_unknown_returns_data_unchanged_test() ->
|
||||
Data = #{<<"test">> => true},
|
||||
?assertEqual(Data, update_data_for_event(unknown_event, #{}, Data, #{})).
|
||||
|
||||
strip_role_from_members_no_affected_users_test() ->
|
||||
Data = #{
|
||||
<<"roles">> => [],
|
||||
<<"members">> => #{
|
||||
1 => #{<<"user">> => #{<<"id">> => <<"1">>}, <<"roles">> => [<<"100">>]}
|
||||
},
|
||||
<<"channels">> => []
|
||||
},
|
||||
Result = strip_role_from_members(<<"999">>, Data),
|
||||
M1 = guild_data_index:get_member(1, Result),
|
||||
?assertEqual([<<"100">>], maps:get(<<"roles">>, M1)).
|
||||
|
||||
strip_role_from_channel_overwrites_preserves_user_overwrites_test() ->
|
||||
Data = #{
|
||||
<<"channels">> => [
|
||||
#{
|
||||
<<"id">> => <<"500">>,
|
||||
<<"permission_overwrites">> => [
|
||||
#{<<"id">> => <<"100">>, <<"type">> => 0, <<"allow">> => <<"0">>, <<"deny">> => <<"0">>},
|
||||
#{<<"id">> => <<"1">>, <<"type">> => 1, <<"allow">> => <<"1024">>, <<"deny">> => <<"0">>}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
Result = strip_role_from_channel_overwrites(<<"100">>, Data),
|
||||
[Ch] = guild_data_index:channel_list(Result),
|
||||
Overwrites = maps:get(<<"permission_overwrites">>, Ch),
|
||||
?assertEqual(1, length(Overwrites)),
|
||||
?assertEqual(1, maps:get(<<"type">>, hd(Overwrites))).
|
||||
|
||||
cleanup_removed_member_sessions_removes_non_members_test() ->
|
||||
Data = #{
|
||||
<<"members">> => #{
|
||||
1 => #{<<"user">> => #{<<"id">> => <<"1">>}}
|
||||
}
|
||||
},
|
||||
Sessions = #{
|
||||
<<"s1">> => #{user_id => 1},
|
||||
<<"s2">> => #{user_id => 999}
|
||||
},
|
||||
State = #{data => Data, sessions => Sessions},
|
||||
Result = cleanup_removed_member_sessions(State),
|
||||
NewSessions = maps:get(sessions, Result),
|
||||
?assertEqual(1, map_size(NewSessions)),
|
||||
?assert(maps:is_key(<<"s1">>, NewSessions)).
|
||||
|
||||
-endif.
|
||||
|
||||
327
fluxer_gateway/src/guild/guild_subscription_handler.erl
Normal file
327
fluxer_gateway/src/guild/guild_subscription_handler.erl
Normal file
@@ -0,0 +1,327 @@
|
||||
%% 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_subscription_handler).
|
||||
|
||||
-export([
|
||||
handle_call/3,
|
||||
handle_cast/2
|
||||
]).
|
||||
|
||||
-type guild_state() :: map().
|
||||
-type user_id() :: integer().
|
||||
-type session_id() :: binary().
|
||||
-type channel_id() :: integer().
|
||||
|
||||
-spec handle_call(term(), gen_server:from(), guild_state()) ->
|
||||
{reply, term(), guild_state()}.
|
||||
handle_call({lazy_subscribe, Request}, _From, State) ->
|
||||
handle_lazy_subscribe(Request, State).
|
||||
|
||||
-spec handle_cast(term(), guild_state()) -> {noreply, guild_state()}.
|
||||
handle_cast({update_member_subscriptions, SessionId, MemberIds}, State) ->
|
||||
NewState0 = handle_update_member_subscriptions(SessionId, MemberIds, State),
|
||||
NewState = maybe_prune_very_large_guild_members(NewState0),
|
||||
{noreply, NewState};
|
||||
handle_cast({very_large_guild_member_list_deliver, Deliveries}, State) when is_list(Deliveries) ->
|
||||
_ = deliver_member_list_updates(Deliveries, State),
|
||||
{noreply, State}.
|
||||
|
||||
-spec handle_lazy_subscribe(map(), guild_state()) -> {reply, ok, guild_state()}.
|
||||
handle_lazy_subscribe(Request, State) ->
|
||||
case maps:get(disable_member_list_updates, State, false) of
|
||||
true ->
|
||||
{reply, ok, State};
|
||||
false ->
|
||||
#{session_id := SessionId, channel_id := ChannelId, ranges := Ranges} = Request,
|
||||
Sessions0 = maps:get(sessions, State, #{}),
|
||||
SessionUserId = get_session_user_id(SessionId, Sessions0),
|
||||
case
|
||||
is_integer(SessionUserId) andalso
|
||||
guild_permissions:can_view_channel(SessionUserId, ChannelId, undefined, State)
|
||||
of
|
||||
true ->
|
||||
GuildId = maps:get(id, State),
|
||||
ListId = guild_member_list:calculate_list_id(ChannelId, State),
|
||||
{NewState, ShouldSendSync, NormalizedRanges} =
|
||||
guild_member_list:subscribe_ranges(SessionId, ListId, Ranges, State),
|
||||
handle_lazy_subscribe_sync(
|
||||
ShouldSendSync, NormalizedRanges, GuildId, ListId, ChannelId, SessionId, NewState
|
||||
);
|
||||
false ->
|
||||
{reply, ok, State}
|
||||
end
|
||||
end.
|
||||
|
||||
-spec handle_lazy_subscribe_sync(
|
||||
boolean(), list(), integer(), term(), channel_id(), session_id(), guild_state()
|
||||
) ->
|
||||
{reply, ok, guild_state()}.
|
||||
handle_lazy_subscribe_sync(true, [], _GuildId, _ListId, _ChannelId, _SessionId, State) ->
|
||||
{reply, ok, State};
|
||||
handle_lazy_subscribe_sync(true, RangesToSend, GuildId, ListId, ChannelId, SessionId, State) ->
|
||||
SyncResponse = guild_member_list:build_sync_response(GuildId, ListId, RangesToSend, State),
|
||||
SyncResponseWithChannel = maps:put(
|
||||
<<"channel_id">>, integer_to_binary(ChannelId), SyncResponse
|
||||
),
|
||||
Sessions = maps:get(sessions, State, #{}),
|
||||
case maps:get(SessionId, Sessions, undefined) of
|
||||
#{pid := SessionPid} when is_pid(SessionPid) ->
|
||||
gen_server:cast(
|
||||
SessionPid, {dispatch, guild_member_list_update, SyncResponseWithChannel}
|
||||
);
|
||||
_ ->
|
||||
ok
|
||||
end,
|
||||
{reply, ok, State};
|
||||
handle_lazy_subscribe_sync(_, _, _GuildId, _ListId, _ChannelId, _SessionId, State) ->
|
||||
{reply, ok, State}.
|
||||
|
||||
-spec get_session_user_id(session_id(), map()) -> user_id() | undefined.
|
||||
get_session_user_id(SessionId, Sessions) ->
|
||||
case maps:get(SessionId, Sessions, undefined) of
|
||||
#{user_id := Uid} -> Uid;
|
||||
_ -> undefined
|
||||
end.
|
||||
|
||||
-spec handle_update_member_subscriptions(session_id(), [user_id()], guild_state()) -> guild_state().
|
||||
handle_update_member_subscriptions(SessionId, MemberIds, State) ->
|
||||
MemberSubs = maps:get(member_subscriptions, State, guild_subscriptions:init_state()),
|
||||
Sessions = maps:get(sessions, State, #{}),
|
||||
SessionUserId = get_session_user_id(SessionId, Sessions),
|
||||
StateWithPrimedMembers = maybe_prime_very_large_guild_members(MemberIds, State),
|
||||
FilteredMemberIds = filter_member_ids_with_mutual_channels(
|
||||
SessionUserId, MemberIds, StateWithPrimedMembers
|
||||
),
|
||||
OldSubscriptions = guild_subscriptions:get_user_ids_for_session(SessionId, MemberSubs),
|
||||
NewMemberSubs = guild_subscriptions:update_subscriptions(
|
||||
SessionId, FilteredMemberIds, MemberSubs
|
||||
),
|
||||
NewSubscriptions = guild_subscriptions:get_user_ids_for_session(SessionId, NewMemberSubs),
|
||||
Added = sets:to_list(sets:subtract(NewSubscriptions, OldSubscriptions)),
|
||||
Removed = sets:to_list(sets:subtract(OldSubscriptions, NewSubscriptions)),
|
||||
State1 = maps:put(member_subscriptions, NewMemberSubs, StateWithPrimedMembers),
|
||||
State2 = handle_added_subscriptions(Added, SessionId, State1),
|
||||
handle_removed_subscriptions(Removed, State2).
|
||||
|
||||
-spec handle_added_subscriptions([user_id()], session_id(), guild_state()) -> guild_state().
|
||||
handle_added_subscriptions(Added, SessionId, State) ->
|
||||
lists:foldl(
|
||||
fun(UserId, Acc) ->
|
||||
StateWithPresence = guild_sessions:subscribe_to_user_presence(UserId, Acc),
|
||||
guild_presence:send_cached_presence_to_session(UserId, SessionId, StateWithPresence)
|
||||
end,
|
||||
State,
|
||||
Added
|
||||
).
|
||||
|
||||
-spec handle_removed_subscriptions([user_id()], guild_state()) -> guild_state().
|
||||
handle_removed_subscriptions(Removed, State) ->
|
||||
lists:foldl(
|
||||
fun(UserId, Acc) -> guild_sessions:unsubscribe_from_user_presence(UserId, Acc) end,
|
||||
State,
|
||||
Removed
|
||||
).
|
||||
|
||||
-spec filter_member_ids_with_mutual_channels(user_id() | undefined, [user_id()], guild_state()) ->
|
||||
[user_id()].
|
||||
filter_member_ids_with_mutual_channels(undefined, _, _) ->
|
||||
[];
|
||||
filter_member_ids_with_mutual_channels(SessionUserId, MemberIds, State) ->
|
||||
SessionChannels = guild_visibility:viewable_channel_set(SessionUserId, State),
|
||||
lists:filtermap(
|
||||
fun(MemberId) ->
|
||||
case MemberId =:= SessionUserId of
|
||||
true ->
|
||||
false;
|
||||
false ->
|
||||
case has_shared_channels(SessionChannels, MemberId, State) of
|
||||
true -> {true, MemberId};
|
||||
false -> false
|
||||
end
|
||||
end
|
||||
end,
|
||||
MemberIds
|
||||
).
|
||||
|
||||
-spec has_shared_channels(sets:set(), user_id(), guild_state()) -> boolean().
|
||||
has_shared_channels(_, MemberId, _) when not is_integer(MemberId) ->
|
||||
false;
|
||||
has_shared_channels(SessionChannels, MemberId, State) ->
|
||||
CandidateChannels = guild_visibility:viewable_channel_set(MemberId, State),
|
||||
not sets:is_empty(sets:intersection(SessionChannels, CandidateChannels)).
|
||||
|
||||
-spec maybe_prime_very_large_guild_members([user_id()], guild_state()) -> guild_state().
|
||||
maybe_prime_very_large_guild_members(UserIds, State) when is_list(UserIds) ->
|
||||
case
|
||||
{
|
||||
maps:get(very_large_guild_coordinator_pid, State, undefined),
|
||||
maps:get(very_large_guild_shard_index, State, undefined)
|
||||
}
|
||||
of
|
||||
{CoordPid, ShardIndex} when is_pid(CoordPid), is_integer(ShardIndex), ShardIndex =/= 0 ->
|
||||
UniqueUserIds = lists:usort([U || U <- UserIds, is_integer(U), U > 0]),
|
||||
case UniqueUserIds of
|
||||
[] ->
|
||||
State;
|
||||
_ ->
|
||||
MembersReply =
|
||||
try gen_server:call(
|
||||
CoordPid, {very_large_guild_get_members, UniqueUserIds}, 10000
|
||||
) of
|
||||
Reply -> Reply
|
||||
catch
|
||||
_:_ -> #{}
|
||||
end,
|
||||
prime_members_from_reply(MembersReply, State)
|
||||
end;
|
||||
_ ->
|
||||
State
|
||||
end;
|
||||
maybe_prime_very_large_guild_members(_, State) ->
|
||||
State.
|
||||
|
||||
-spec prime_members_from_reply(term(), guild_state()) -> guild_state().
|
||||
prime_members_from_reply(MembersReply, State) when is_map(MembersReply) ->
|
||||
Data0 = maps:get(data, State, #{}),
|
||||
Data = maps:fold(
|
||||
fun(_UserId, Member, AccData) ->
|
||||
case is_map(Member) of
|
||||
true -> guild_data_index:put_member(Member, AccData);
|
||||
false -> AccData
|
||||
end
|
||||
end,
|
||||
Data0,
|
||||
MembersReply
|
||||
),
|
||||
maps:put(data, Data, State);
|
||||
prime_members_from_reply(_, State) ->
|
||||
State.
|
||||
|
||||
-spec maybe_prune_very_large_guild_members(guild_state()) -> guild_state().
|
||||
maybe_prune_very_large_guild_members(State) ->
|
||||
case
|
||||
{
|
||||
maps:get(very_large_guild_coordinator_pid, State, undefined),
|
||||
maps:get(very_large_guild_shard_index, State, undefined)
|
||||
}
|
||||
of
|
||||
{CoordPid, ShardIndex} when is_pid(CoordPid), is_integer(ShardIndex), ShardIndex =/= 0 ->
|
||||
prune_member_cache_to_needed_users(State);
|
||||
_ ->
|
||||
State
|
||||
end.
|
||||
|
||||
-spec prune_member_cache_to_needed_users(guild_state()) -> guild_state().
|
||||
prune_member_cache_to_needed_users(State) ->
|
||||
Data0 = maps:get(data, State, #{}),
|
||||
Members0 = maps:get(<<"members">>, Data0, #{}),
|
||||
case is_map(Members0) of
|
||||
false ->
|
||||
State;
|
||||
true ->
|
||||
NeededUserIds = needed_member_cache_user_ids(State),
|
||||
NeededSet = sets:from_list(NeededUserIds),
|
||||
FilteredMembers = maps:filter(
|
||||
fun(UserId, _Member) -> sets:is_element(UserId, NeededSet) end,
|
||||
Members0
|
||||
),
|
||||
case map_size(FilteredMembers) =:= map_size(Members0) of
|
||||
true ->
|
||||
State;
|
||||
false ->
|
||||
Data1 = guild_data_index:put_member_map(FilteredMembers, Data0),
|
||||
maps:put(data, Data1, State)
|
||||
end
|
||||
end.
|
||||
|
||||
-spec needed_member_cache_user_ids(guild_state()) -> [user_id()].
|
||||
needed_member_cache_user_ids(State) ->
|
||||
Sessions = maps:get(sessions, State, #{}),
|
||||
SessionUserIds = maps:fold(
|
||||
fun(_SessionId, SessionData, Acc) ->
|
||||
case maps:get(user_id, SessionData, undefined) of
|
||||
UserId when is_integer(UserId), UserId > 0 -> [UserId | Acc];
|
||||
_ -> Acc
|
||||
end
|
||||
end,
|
||||
[],
|
||||
Sessions
|
||||
),
|
||||
MemberSubs = maps:get(member_subscriptions, State, guild_subscriptions:init_state()),
|
||||
SubscribedUserIds = maps:keys(MemberSubs),
|
||||
lists:usort(SessionUserIds ++ SubscribedUserIds).
|
||||
|
||||
-spec deliver_member_list_updates([{session_id(), map()}], guild_state()) -> ok.
|
||||
deliver_member_list_updates(Deliveries, State) ->
|
||||
Sessions = maps:get(sessions, State, #{}),
|
||||
lists:foreach(
|
||||
fun({SessionId, Payload}) ->
|
||||
case maps:get(SessionId, Sessions, undefined) of
|
||||
#{pid := SessionPid} = SessionData when is_pid(SessionPid), is_map(Payload) ->
|
||||
case maps:get(pending_connect, SessionData, false) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
ChannelId = member_list_payload_channel_id(Payload),
|
||||
case can_session_view_channel(SessionData, ChannelId, State) of
|
||||
true ->
|
||||
gen_server:cast(
|
||||
SessionPid, {dispatch, guild_member_list_update, Payload}
|
||||
);
|
||||
false ->
|
||||
ok
|
||||
end
|
||||
end;
|
||||
_ ->
|
||||
ok
|
||||
end
|
||||
end,
|
||||
Deliveries
|
||||
),
|
||||
ok.
|
||||
|
||||
-spec member_list_payload_channel_id(map()) -> channel_id().
|
||||
member_list_payload_channel_id(Payload) ->
|
||||
ChannelIdBin = maps:get(<<"channel_id">>, Payload, undefined),
|
||||
ListIdBin = maps:get(<<"id">>, Payload, <<"0">>),
|
||||
case ChannelIdBin of
|
||||
Bin when is_binary(Bin) ->
|
||||
case type_conv:to_integer(Bin) of
|
||||
undefined -> 0;
|
||||
Id -> Id
|
||||
end;
|
||||
_ ->
|
||||
case type_conv:to_integer(ListIdBin) of
|
||||
undefined -> 0;
|
||||
Id -> Id
|
||||
end
|
||||
end.
|
||||
|
||||
-spec can_session_view_channel(map(), channel_id(), guild_state()) -> boolean().
|
||||
can_session_view_channel(_SessionData, ChannelId, _State) when not is_integer(ChannelId); ChannelId =< 0 ->
|
||||
false;
|
||||
can_session_view_channel(SessionData, ChannelId, State) ->
|
||||
case {maps:get(user_id, SessionData, undefined), maps:get(viewable_channels, SessionData, undefined)} of
|
||||
{UserId, ViewableChannels} when is_integer(UserId), is_map(ViewableChannels) ->
|
||||
maps:is_key(ChannelId, ViewableChannels) orelse
|
||||
guild_permissions:can_view_channel(UserId, ChannelId, undefined, State);
|
||||
{UserId, _} when is_integer(UserId) ->
|
||||
guild_permissions:can_view_channel(UserId, ChannelId, undefined, State);
|
||||
_ ->
|
||||
false
|
||||
end.
|
||||
@@ -1,24 +0,0 @@
|
||||
%% 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_sync).
|
||||
|
||||
-export([send_guild_sync/2]).
|
||||
|
||||
-spec send_guild_sync(pid(), binary()) -> ok.
|
||||
send_guild_sync(GuildPid, SessionId) ->
|
||||
gen_server:cast(GuildPid, {send_guild_sync, SessionId}).
|
||||
@@ -89,7 +89,7 @@ process_sync_flag(GuildSubData, _GuildId, GuildPid, SessionId, ActiveChanged) ->
|
||||
ShouldSync = maps:get(<<"sync">>, GuildSubData, false) =:= true orelse ActiveChanged,
|
||||
case ShouldSync of
|
||||
true ->
|
||||
guild_sync:send_guild_sync(GuildPid, SessionId);
|
||||
gen_server:cast(GuildPid, {send_guild_sync, SessionId});
|
||||
false ->
|
||||
ok
|
||||
end.
|
||||
|
||||
@@ -700,4 +700,348 @@ filter_connected_session_entries_excludes_pending_test() ->
|
||||
ResultIds = lists:sort([Sid || {Sid, _} <- Result]),
|
||||
?assertEqual([<<"s1">>, <<"s3">>], ResultIds).
|
||||
|
||||
administrator_sees_all_channels_test() ->
|
||||
GuildId = 50,
|
||||
UserId = 10,
|
||||
ChannelId = 100,
|
||||
Admin = constants:administrator_permission(),
|
||||
State = #{
|
||||
id => GuildId,
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => integer_to_binary(Admin)}
|
||||
],
|
||||
<<"members">> => [
|
||||
#{<<"user">> => #{<<"id">> => integer_to_binary(UserId)}, <<"roles">> => []}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{
|
||||
<<"id">> => integer_to_binary(ChannelId),
|
||||
<<"permission_overwrites">> => [
|
||||
#{
|
||||
<<"id">> => integer_to_binary(GuildId),
|
||||
<<"type">> => 0,
|
||||
<<"allow">> => <<"0">>,
|
||||
<<"deny">> => integer_to_binary(constants:view_channel_permission())
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
Channels = get_user_viewable_channels(UserId, State),
|
||||
?assertEqual([ChannelId], Channels).
|
||||
|
||||
owner_sees_all_channels_test() ->
|
||||
GuildId = 60,
|
||||
OwnerId = 10,
|
||||
ChannelId = 200,
|
||||
State = #{
|
||||
id => GuildId,
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => integer_to_binary(OwnerId)},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => <<"0">>}
|
||||
],
|
||||
<<"members">> => [
|
||||
#{<<"user">> => #{<<"id">> => integer_to_binary(OwnerId)}, <<"roles">> => []}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => integer_to_binary(ChannelId), <<"permission_overwrites">> => []}
|
||||
]
|
||||
}
|
||||
},
|
||||
Channels = get_user_viewable_channels(OwnerId, State),
|
||||
?assertEqual([ChannelId], Channels).
|
||||
|
||||
everyone_role_grants_view_test() ->
|
||||
GuildId = 70,
|
||||
UserId = 10,
|
||||
ChannelId = 300,
|
||||
ViewPerm = constants:view_channel_permission(),
|
||||
State = #{
|
||||
id => GuildId,
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => integer_to_binary(ViewPerm)}
|
||||
],
|
||||
<<"members">> => [
|
||||
#{<<"user">> => #{<<"id">> => integer_to_binary(UserId)}, <<"roles">> => []}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => integer_to_binary(ChannelId), <<"permission_overwrites">> => []}
|
||||
]
|
||||
}
|
||||
},
|
||||
Channels = get_user_viewable_channels(UserId, State),
|
||||
?assertEqual([ChannelId], Channels).
|
||||
|
||||
channel_overwrite_denies_view_test() ->
|
||||
GuildId = 80,
|
||||
UserId = 10,
|
||||
RoleId = 200,
|
||||
ChannelId = 400,
|
||||
ViewPerm = constants:view_channel_permission(),
|
||||
State = #{
|
||||
id => GuildId,
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => integer_to_binary(ViewPerm)},
|
||||
#{<<"id">> => integer_to_binary(RoleId), <<"permissions">> => <<"0">>}
|
||||
],
|
||||
<<"members">> => [
|
||||
#{
|
||||
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
|
||||
<<"roles">> => [integer_to_binary(RoleId)]
|
||||
}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{
|
||||
<<"id">> => integer_to_binary(ChannelId),
|
||||
<<"permission_overwrites">> => [
|
||||
#{
|
||||
<<"id">> => integer_to_binary(RoleId),
|
||||
<<"type">> => 0,
|
||||
<<"allow">> => <<"0">>,
|
||||
<<"deny">> => integer_to_binary(ViewPerm)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
Channels = get_user_viewable_channels(UserId, State),
|
||||
?assertEqual([], Channels).
|
||||
|
||||
role_overwrite_allows_view_test() ->
|
||||
GuildId = 90,
|
||||
UserId = 10,
|
||||
RoleId = 300,
|
||||
ChannelId = 500,
|
||||
ViewPerm = constants:view_channel_permission(),
|
||||
State = #{
|
||||
id => GuildId,
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => integer_to_binary(ViewPerm)},
|
||||
#{<<"id">> => integer_to_binary(RoleId), <<"permissions">> => <<"0">>}
|
||||
],
|
||||
<<"members">> => [
|
||||
#{
|
||||
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
|
||||
<<"roles">> => [integer_to_binary(RoleId)]
|
||||
}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{
|
||||
<<"id">> => integer_to_binary(ChannelId),
|
||||
<<"permission_overwrites">> => [
|
||||
#{
|
||||
<<"id">> => integer_to_binary(GuildId),
|
||||
<<"type">> => 0,
|
||||
<<"allow">> => <<"0">>,
|
||||
<<"deny">> => integer_to_binary(ViewPerm)
|
||||
},
|
||||
#{
|
||||
<<"id">> => integer_to_binary(RoleId),
|
||||
<<"type">> => 0,
|
||||
<<"allow">> => integer_to_binary(ViewPerm),
|
||||
<<"deny">> => <<"0">>
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
Channels = get_user_viewable_channels(UserId, State),
|
||||
?assertEqual([ChannelId], Channels).
|
||||
|
||||
user_overwrite_denies_view_test() ->
|
||||
GuildId = 91,
|
||||
UserId = 10,
|
||||
RoleId = 301,
|
||||
ChannelId = 501,
|
||||
ViewPerm = constants:view_channel_permission(),
|
||||
State = #{
|
||||
id => GuildId,
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => integer_to_binary(ViewPerm)},
|
||||
#{<<"id">> => integer_to_binary(RoleId), <<"permissions">> => <<"0">>}
|
||||
],
|
||||
<<"members">> => [
|
||||
#{
|
||||
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
|
||||
<<"roles">> => [integer_to_binary(RoleId)]
|
||||
}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{
|
||||
<<"id">> => integer_to_binary(ChannelId),
|
||||
<<"permission_overwrites">> => [
|
||||
#{
|
||||
<<"id">> => integer_to_binary(RoleId),
|
||||
<<"type">> => 0,
|
||||
<<"allow">> => integer_to_binary(ViewPerm),
|
||||
<<"deny">> => <<"0">>
|
||||
},
|
||||
#{
|
||||
<<"id">> => integer_to_binary(UserId),
|
||||
<<"type">> => 1,
|
||||
<<"allow">> => <<"0">>,
|
||||
<<"deny">> => integer_to_binary(ViewPerm)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
Channels = get_user_viewable_channels(UserId, State),
|
||||
?assertEqual([], Channels).
|
||||
|
||||
viewable_channel_set_uses_cached_session_data_test() ->
|
||||
UserId = 10,
|
||||
State = #{
|
||||
sessions => #{
|
||||
<<"s1">> => #{
|
||||
user_id => UserId,
|
||||
viewable_channels => #{100 => true, 200 => true}
|
||||
}
|
||||
},
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"members">> => [],
|
||||
<<"channels">> => [],
|
||||
<<"roles">> => []
|
||||
}
|
||||
},
|
||||
ChannelSet = viewable_channel_set(UserId, State),
|
||||
?assertEqual(true, sets:is_element(100, ChannelSet)),
|
||||
?assertEqual(true, sets:is_element(200, ChannelSet)),
|
||||
?assertEqual(false, sets:is_element(999, ChannelSet)).
|
||||
|
||||
have_shared_viewable_channel_shared_test() ->
|
||||
GuildId = 100,
|
||||
ViewPerm = constants:view_channel_permission(),
|
||||
State = #{
|
||||
id => GuildId,
|
||||
sessions => #{},
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => integer_to_binary(ViewPerm)}
|
||||
],
|
||||
<<"members">> => [
|
||||
#{<<"user">> => #{<<"id">> => <<"10">>}, <<"roles">> => []},
|
||||
#{<<"user">> => #{<<"id">> => <<"20">>}, <<"roles">> => []}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"500">>, <<"permission_overwrites">> => []}
|
||||
]
|
||||
}
|
||||
},
|
||||
?assertEqual(true, have_shared_viewable_channel(10, 20, State)).
|
||||
|
||||
have_shared_viewable_channel_no_shared_test() ->
|
||||
GuildId = 101,
|
||||
ViewPerm = constants:view_channel_permission(),
|
||||
RoleId = 200,
|
||||
State = #{
|
||||
id => GuildId,
|
||||
sessions => #{},
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => <<"0">>},
|
||||
#{<<"id">> => integer_to_binary(RoleId), <<"permissions">> => integer_to_binary(ViewPerm)}
|
||||
],
|
||||
<<"members">> => [
|
||||
#{<<"user">> => #{<<"id">> => <<"10">>}, <<"roles">> => [integer_to_binary(RoleId)]},
|
||||
#{<<"user">> => #{<<"id">> => <<"20">>}, <<"roles">> => []}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{
|
||||
<<"id">> => <<"500">>,
|
||||
<<"permission_overwrites">> => [
|
||||
#{
|
||||
<<"id">> => integer_to_binary(GuildId),
|
||||
<<"type">> => 0,
|
||||
<<"allow">> => <<"0">>,
|
||||
<<"deny">> => integer_to_binary(ViewPerm)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
?assertEqual(false, have_shared_viewable_channel(10, 20, State)).
|
||||
|
||||
update_viewable_map_for_channel_add_test() ->
|
||||
Map = #{100 => true},
|
||||
Result = update_viewable_map_for_channel(Map, 200, true),
|
||||
?assertEqual(true, maps:is_key(200, Result)),
|
||||
?assertEqual(true, maps:is_key(100, Result)).
|
||||
|
||||
update_viewable_map_for_channel_remove_test() ->
|
||||
Map = #{100 => true, 200 => true},
|
||||
Result = update_viewable_map_for_channel(Map, 100, false),
|
||||
?assertEqual(false, maps:is_key(100, Result)),
|
||||
?assertEqual(true, maps:is_key(200, Result)).
|
||||
|
||||
multiple_channels_partial_visibility_test() ->
|
||||
GuildId = 110,
|
||||
UserId = 10,
|
||||
ViewPerm = constants:view_channel_permission(),
|
||||
RoleId = 300,
|
||||
State = #{
|
||||
id => GuildId,
|
||||
data => #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"999">>},
|
||||
<<"roles">> => [
|
||||
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => integer_to_binary(ViewPerm)},
|
||||
#{<<"id">> => integer_to_binary(RoleId), <<"permissions">> => <<"0">>}
|
||||
],
|
||||
<<"members">> => [
|
||||
#{
|
||||
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
|
||||
<<"roles">> => []
|
||||
}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"100">>, <<"permission_overwrites">> => []},
|
||||
#{
|
||||
<<"id">> => <<"101">>,
|
||||
<<"permission_overwrites">> => [
|
||||
#{
|
||||
<<"id">> => integer_to_binary(GuildId),
|
||||
<<"type">> => 0,
|
||||
<<"allow">> => <<"0">>,
|
||||
<<"deny">> => integer_to_binary(ViewPerm)
|
||||
}
|
||||
]
|
||||
},
|
||||
#{<<"id">> => <<"102">>, <<"permission_overwrites">> => []}
|
||||
]
|
||||
}
|
||||
},
|
||||
Channels = lists:sort(get_user_viewable_channels(UserId, State)),
|
||||
?assertEqual([100, 102], Channels).
|
||||
|
||||
viewable_channel_map_test() ->
|
||||
Set = sets:from_list([10, 20, 30]),
|
||||
Map = viewable_channel_map(Set),
|
||||
?assertEqual(3, map_size(Map)),
|
||||
?assertEqual(true, maps:get(10, Map)),
|
||||
?assertEqual(true, maps:get(20, Map)),
|
||||
?assertEqual(true, maps:get(30, Map)).
|
||||
|
||||
viewable_channel_map_empty_test() ->
|
||||
Map = viewable_channel_map(sets:new()),
|
||||
?assertEqual(#{}, Map).
|
||||
|
||||
-endif.
|
||||
|
||||
159
fluxer_gateway/src/guild/guild_voice_handler.erl
Normal file
159
fluxer_gateway/src/guild/guild_voice_handler.erl
Normal file
@@ -0,0 +1,159 @@
|
||||
%% 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_voice_handler).
|
||||
|
||||
-export([
|
||||
handle_call/3,
|
||||
handle_cast/2
|
||||
]).
|
||||
|
||||
-type guild_state() :: map().
|
||||
|
||||
-spec handle_call(term(), gen_server:from(), guild_state()) ->
|
||||
{reply, term(), guild_state()}
|
||||
| {noreply, guild_state()}.
|
||||
handle_call({voice_state_update, Request}, _From, State) ->
|
||||
guild_voice:voice_state_update(Request, State);
|
||||
handle_call({get_voice_state, Request}, _From, State) ->
|
||||
guild_voice:get_voice_state(Request, State);
|
||||
handle_call({update_member_voice, Request}, _From, State) ->
|
||||
guild_voice:update_member_voice(Request, State);
|
||||
handle_call({disconnect_voice_user, Request}, _From, State) ->
|
||||
guild_voice:disconnect_voice_user(Request, State);
|
||||
handle_call({disconnect_voice_user_if_in_channel, Request}, _From, State) ->
|
||||
guild_voice:disconnect_voice_user_if_in_channel(Request, State);
|
||||
handle_call({disconnect_all_voice_users_in_channel, Request}, _From, State) ->
|
||||
guild_voice:disconnect_all_voice_users_in_channel(Request, State);
|
||||
handle_call({confirm_voice_connection_from_livekit, Request}, _From, State) ->
|
||||
guild_voice:confirm_voice_connection_from_livekit(Request, State);
|
||||
handle_call({move_member, Request}, _From, State) ->
|
||||
guild_voice:move_member(Request, State);
|
||||
handle_call({switch_voice_region, Request}, _From, State) ->
|
||||
guild_voice:switch_voice_region_handler(Request, State);
|
||||
handle_call({add_virtual_channel_access, UserId, ChannelId}, _From, State) ->
|
||||
NewState = guild_virtual_channel_access:add_virtual_access(UserId, ChannelId, State),
|
||||
guild_virtual_channel_access:dispatch_channel_visibility_change(
|
||||
UserId, ChannelId, add, NewState
|
||||
),
|
||||
{reply, ok, NewState};
|
||||
handle_call({store_pending_connection, ConnectionId, Metadata}, _From, State) ->
|
||||
PendingConnections = maps:get(pending_voice_connections, State, #{}),
|
||||
NewPendingConnections = maps:put(ConnectionId, Metadata, PendingConnections),
|
||||
NewState = maps:put(pending_voice_connections, NewPendingConnections, State),
|
||||
{reply, ok, NewState};
|
||||
handle_call({get_voice_states_for_channel, ChannelIdBin}, _From, State) ->
|
||||
VoiceStates = maps:get(voice_states, State, #{}),
|
||||
Filtered = maps:fold(
|
||||
fun(ConnId, VS, Acc) ->
|
||||
case maps:get(<<"channel_id">>, VS, null) of
|
||||
ChannelIdBin ->
|
||||
[#{
|
||||
connection_id => ConnId,
|
||||
user_id => maps:get(<<"user_id">>, VS, null),
|
||||
channel_id => ChannelIdBin
|
||||
} | Acc];
|
||||
_ ->
|
||||
Acc
|
||||
end
|
||||
end,
|
||||
[],
|
||||
VoiceStates
|
||||
),
|
||||
{reply, #{voice_states => Filtered}, State};
|
||||
handle_call({get_pending_joins_for_channel, ChannelIdBin}, _From, State) ->
|
||||
PendingConnections = maps:get(pending_voice_connections, State, #{}),
|
||||
ChannelIdInt = binary_to_integer(ChannelIdBin),
|
||||
Filtered = maps:fold(
|
||||
fun(ConnId, Metadata, Acc) ->
|
||||
case maps:get(channel_id, Metadata, undefined) of
|
||||
ChannelIdInt ->
|
||||
[#{
|
||||
connection_id => ConnId,
|
||||
user_id => integer_to_binary(maps:get(user_id, Metadata, 0)),
|
||||
token_nonce => maps:get(token_nonce, Metadata, null),
|
||||
expires_at => maps:get(expires_at, Metadata, 0)
|
||||
} | Acc];
|
||||
_ ->
|
||||
Acc
|
||||
end
|
||||
end,
|
||||
[],
|
||||
PendingConnections
|
||||
),
|
||||
{reply, #{pending_joins => Filtered}, State}.
|
||||
|
||||
-spec handle_cast(term(), guild_state()) -> {noreply, guild_state()}.
|
||||
handle_cast({relay_voice_state_update, VoiceState, OldChannelIdBin}, State) ->
|
||||
State1 = relay_upsert_voice_state(VoiceState, State),
|
||||
StateNoRelay = maps:remove(very_large_guild_coordinator_pid, State1),
|
||||
_ = guild_voice_broadcast:broadcast_voice_state_update(VoiceState, StateNoRelay, OldChannelIdBin),
|
||||
{noreply, State1};
|
||||
handle_cast(
|
||||
{relay_voice_server_update, GuildId, ChannelId, SessionId, Token, Endpoint, ConnectionId},
|
||||
State
|
||||
) ->
|
||||
StateNoRelay = maps:remove(very_large_guild_coordinator_pid, State),
|
||||
_ = guild_voice_broadcast:broadcast_voice_server_update_to_session(
|
||||
GuildId,
|
||||
ChannelId,
|
||||
SessionId,
|
||||
Token,
|
||||
Endpoint,
|
||||
ConnectionId,
|
||||
StateNoRelay
|
||||
),
|
||||
{noreply, State};
|
||||
handle_cast({store_pending_connection, ConnectionId, Metadata}, State) ->
|
||||
PendingConnections = maps:get(pending_voice_connections, State, #{}),
|
||||
NewPendingConnections = maps:put(ConnectionId, Metadata, PendingConnections),
|
||||
NewState = maps:put(pending_voice_connections, NewPendingConnections, State),
|
||||
{noreply, NewState};
|
||||
handle_cast({add_virtual_channel_access, UserId, ChannelId}, State) ->
|
||||
NewState = guild_virtual_channel_access:add_virtual_access(UserId, ChannelId, State),
|
||||
guild_virtual_channel_access:dispatch_channel_visibility_change(
|
||||
UserId, ChannelId, add, NewState
|
||||
),
|
||||
{noreply, NewState};
|
||||
handle_cast({remove_virtual_channel_access, UserId, ChannelId}, State) ->
|
||||
guild_virtual_channel_access:dispatch_channel_visibility_change(
|
||||
UserId, ChannelId, remove, State
|
||||
),
|
||||
NewState = guild_virtual_channel_access:remove_virtual_access(UserId, ChannelId, State),
|
||||
{noreply, NewState};
|
||||
handle_cast({cleanup_virtual_access_for_user, UserId}, State) ->
|
||||
NewState = guild_voice_disconnect:cleanup_virtual_channel_access_for_user(UserId, State),
|
||||
{noreply, NewState}.
|
||||
|
||||
-spec relay_upsert_voice_state(map(), guild_state()) -> guild_state().
|
||||
relay_upsert_voice_state(VoiceState, State) when is_map(VoiceState) ->
|
||||
ConnectionId = maps:get(<<"connection_id">>, VoiceState, undefined),
|
||||
case ConnectionId of
|
||||
undefined ->
|
||||
State;
|
||||
_ ->
|
||||
VoiceStates0 = maps:get(voice_states, State, #{}),
|
||||
ChannelId = maps:get(<<"channel_id">>, VoiceState, null),
|
||||
VoiceStates =
|
||||
case ChannelId of
|
||||
null -> maps:remove(ConnectionId, VoiceStates0);
|
||||
_ -> maps:put(ConnectionId, VoiceState, VoiceStates0)
|
||||
end,
|
||||
maps:put(voice_states, VoiceStates, State)
|
||||
end;
|
||||
relay_upsert_voice_state(_, State) ->
|
||||
State.
|
||||
225
fluxer_gateway/src/guild/passive_sync_registry.erl
Normal file
225
fluxer_gateway/src/guild/passive_sync_registry.erl
Normal file
@@ -0,0 +1,225 @@
|
||||
%% 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(passive_sync_registry).
|
||||
|
||||
-export([
|
||||
init/0,
|
||||
store/3,
|
||||
lookup/2,
|
||||
delete/2,
|
||||
delete_all_for_session/1
|
||||
]).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-endif.
|
||||
|
||||
-define(TABLE, passive_sync_registry).
|
||||
|
||||
-type session_id() :: binary().
|
||||
-type guild_id() :: integer().
|
||||
-type passive_state() :: #{
|
||||
previous_passive_updates := #{binary() => binary()},
|
||||
previous_passive_channel_versions := #{binary() => integer()},
|
||||
previous_passive_voice_states := #{binary() => map()}
|
||||
}.
|
||||
|
||||
-spec init() -> ok.
|
||||
init() ->
|
||||
case ets:whereis(?TABLE) of
|
||||
undefined ->
|
||||
_ = ets:new(?TABLE, [
|
||||
named_table,
|
||||
public,
|
||||
set,
|
||||
{read_concurrency, true},
|
||||
{write_concurrency, true}
|
||||
]),
|
||||
ok;
|
||||
_ ->
|
||||
ok
|
||||
end.
|
||||
|
||||
-spec store(session_id(), guild_id(), passive_state()) -> ok.
|
||||
store(SessionId, GuildId, PassiveState) ->
|
||||
ensure_table(),
|
||||
Key = {SessionId, GuildId},
|
||||
ets:insert(?TABLE, {Key, PassiveState, SessionId}),
|
||||
ok.
|
||||
|
||||
-spec lookup(session_id(), guild_id()) -> passive_state().
|
||||
lookup(SessionId, GuildId) ->
|
||||
ensure_table(),
|
||||
Key = {SessionId, GuildId},
|
||||
case ets:lookup(?TABLE, Key) of
|
||||
[{Key, PassiveState, _SessionId}] ->
|
||||
PassiveState;
|
||||
[] ->
|
||||
#{
|
||||
previous_passive_updates => #{},
|
||||
previous_passive_channel_versions => #{},
|
||||
previous_passive_voice_states => #{}
|
||||
}
|
||||
end.
|
||||
|
||||
-spec delete(session_id(), guild_id()) -> ok.
|
||||
delete(SessionId, GuildId) ->
|
||||
ensure_table(),
|
||||
Key = {SessionId, GuildId},
|
||||
ets:delete(?TABLE, Key),
|
||||
ok.
|
||||
|
||||
-spec delete_all_for_session(session_id()) -> ok.
|
||||
delete_all_for_session(SessionId) ->
|
||||
ensure_table(),
|
||||
ets:match_delete(?TABLE, {'_', '_', SessionId}),
|
||||
ok.
|
||||
|
||||
-spec ensure_table() -> ok.
|
||||
ensure_table() ->
|
||||
case ets:whereis(?TABLE) of
|
||||
undefined -> init();
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
-ifdef(TEST).
|
||||
|
||||
init_creates_table_test() ->
|
||||
cleanup_table(),
|
||||
ok = init(),
|
||||
?assertNotEqual(undefined, ets:whereis(?TABLE)),
|
||||
cleanup_table().
|
||||
|
||||
init_idempotent_test() ->
|
||||
cleanup_table(),
|
||||
ok = init(),
|
||||
ok = init(),
|
||||
?assertNotEqual(undefined, ets:whereis(?TABLE)),
|
||||
cleanup_table().
|
||||
|
||||
store_and_lookup_test() ->
|
||||
cleanup_table(),
|
||||
ok = init(),
|
||||
SessionId = <<"session_1">>,
|
||||
GuildId = 100,
|
||||
PassiveState = #{
|
||||
previous_passive_updates => #{<<"ch1">> => <<"msg1">>},
|
||||
previous_passive_channel_versions => #{<<"ch1">> => 5},
|
||||
previous_passive_voice_states => #{}
|
||||
},
|
||||
ok = store(SessionId, GuildId, PassiveState),
|
||||
Result = lookup(SessionId, GuildId),
|
||||
?assertEqual(PassiveState, Result),
|
||||
cleanup_table().
|
||||
|
||||
lookup_missing_returns_defaults_test() ->
|
||||
cleanup_table(),
|
||||
ok = init(),
|
||||
Result = lookup(<<"nonexistent">>, 999),
|
||||
?assertEqual(#{
|
||||
previous_passive_updates => #{},
|
||||
previous_passive_channel_versions => #{},
|
||||
previous_passive_voice_states => #{}
|
||||
}, Result),
|
||||
cleanup_table().
|
||||
|
||||
delete_removes_entry_test() ->
|
||||
cleanup_table(),
|
||||
ok = init(),
|
||||
SessionId = <<"session_1">>,
|
||||
GuildId = 100,
|
||||
PassiveState = #{
|
||||
previous_passive_updates => #{<<"ch1">> => <<"msg1">>},
|
||||
previous_passive_channel_versions => #{},
|
||||
previous_passive_voice_states => #{}
|
||||
},
|
||||
ok = store(SessionId, GuildId, PassiveState),
|
||||
ok = delete(SessionId, GuildId),
|
||||
Result = lookup(SessionId, GuildId),
|
||||
?assertEqual(#{
|
||||
previous_passive_updates => #{},
|
||||
previous_passive_channel_versions => #{},
|
||||
previous_passive_voice_states => #{}
|
||||
}, Result),
|
||||
cleanup_table().
|
||||
|
||||
delete_all_for_session_removes_all_guilds_test() ->
|
||||
cleanup_table(),
|
||||
ok = init(),
|
||||
SessionId = <<"session_1">>,
|
||||
OtherSessionId = <<"session_2">>,
|
||||
State1 = #{
|
||||
previous_passive_updates => #{<<"ch1">> => <<"msg1">>},
|
||||
previous_passive_channel_versions => #{},
|
||||
previous_passive_voice_states => #{}
|
||||
},
|
||||
State2 = #{
|
||||
previous_passive_updates => #{<<"ch2">> => <<"msg2">>},
|
||||
previous_passive_channel_versions => #{},
|
||||
previous_passive_voice_states => #{}
|
||||
},
|
||||
OtherState = #{
|
||||
previous_passive_updates => #{<<"ch3">> => <<"msg3">>},
|
||||
previous_passive_channel_versions => #{},
|
||||
previous_passive_voice_states => #{}
|
||||
},
|
||||
ok = store(SessionId, 100, State1),
|
||||
ok = store(SessionId, 200, State2),
|
||||
ok = store(OtherSessionId, 100, OtherState),
|
||||
ok = delete_all_for_session(SessionId),
|
||||
?assertEqual(#{
|
||||
previous_passive_updates => #{},
|
||||
previous_passive_channel_versions => #{},
|
||||
previous_passive_voice_states => #{}
|
||||
}, lookup(SessionId, 100)),
|
||||
?assertEqual(#{
|
||||
previous_passive_updates => #{},
|
||||
previous_passive_channel_versions => #{},
|
||||
previous_passive_voice_states => #{}
|
||||
}, lookup(SessionId, 200)),
|
||||
?assertEqual(OtherState, lookup(OtherSessionId, 100)),
|
||||
cleanup_table().
|
||||
|
||||
store_overwrites_existing_test() ->
|
||||
cleanup_table(),
|
||||
ok = init(),
|
||||
SessionId = <<"session_1">>,
|
||||
GuildId = 100,
|
||||
State1 = #{
|
||||
previous_passive_updates => #{<<"ch1">> => <<"msg1">>},
|
||||
previous_passive_channel_versions => #{},
|
||||
previous_passive_voice_states => #{}
|
||||
},
|
||||
State2 = #{
|
||||
previous_passive_updates => #{<<"ch1">> => <<"msg2">>},
|
||||
previous_passive_channel_versions => #{<<"ch1">> => 3},
|
||||
previous_passive_voice_states => #{}
|
||||
},
|
||||
ok = store(SessionId, GuildId, State1),
|
||||
ok = store(SessionId, GuildId, State2),
|
||||
Result = lookup(SessionId, GuildId),
|
||||
?assertEqual(State2, Result),
|
||||
cleanup_table().
|
||||
|
||||
cleanup_table() ->
|
||||
case ets:whereis(?TABLE) of
|
||||
undefined -> ok;
|
||||
_ -> ets:delete(?TABLE), ok
|
||||
end.
|
||||
|
||||
-endif.
|
||||
@@ -265,8 +265,9 @@ handle_cast({very_large_guild_member_list_deliver, DeliveriesByShard}, State) wh
|
||||
DeliveriesByShard
|
||||
),
|
||||
{noreply, State};
|
||||
handle_cast({dispatch, _Event, _EventData} = Msg, State) ->
|
||||
broadcast_cast(Msg, State),
|
||||
handle_cast({dispatch, #{event := Event, data := EventData} = Request}, State) ->
|
||||
broadcast_cast({dispatch, Request}, State),
|
||||
maybe_trigger_push(Event, EventData, State),
|
||||
{noreply, State};
|
||||
handle_cast(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
@@ -363,13 +364,6 @@ terminate(_Reason, State) ->
|
||||
code_change(_OldVsn, State, _Extra) ->
|
||||
{ok, State}.
|
||||
|
||||
-spec strip_members(map()) -> map().
|
||||
strip_members(Data) when is_map(Data) ->
|
||||
Data1 = maps:remove(<<"members">>, Data),
|
||||
maps:remove(<<"member_role_index">>, Data1);
|
||||
strip_members(Data) ->
|
||||
Data.
|
||||
|
||||
-spec determine_shard_count(map()) -> pos_integer().
|
||||
determine_shard_count(GuildState) ->
|
||||
Override = maps:get(very_large_guild_shard_count, GuildState, undefined),
|
||||
@@ -392,32 +386,9 @@ determine_shard_count(GuildState) ->
|
||||
|
||||
-spec start_shards(guild_id(), map(), pos_integer()) -> {#{shard_index() => shard_entry()}, ok}.
|
||||
start_shards(GuildId, Data, ShardCount) ->
|
||||
MemberCount = guild_data_index:member_count(Data),
|
||||
Shards = lists:foldl(
|
||||
fun(Index, Acc) ->
|
||||
DisableCache = Index =/= 0,
|
||||
ShardData =
|
||||
case DisableCache of
|
||||
true -> strip_members(Data);
|
||||
false -> Data
|
||||
end,
|
||||
ShardState0 = #{
|
||||
id => GuildId,
|
||||
data => ShardData,
|
||||
sessions => #{},
|
||||
member_count => MemberCount,
|
||||
disable_push_notifications => true,
|
||||
disable_member_list_updates => DisableCache,
|
||||
disable_auto_stop_on_empty => true,
|
||||
very_large_guild_coordinator_pid => self(),
|
||||
very_large_guild_shard_count => ShardCount,
|
||||
very_large_guild_shard_index => Index
|
||||
},
|
||||
ShardState =
|
||||
case DisableCache of
|
||||
true -> maps:put(disable_permission_cache_updates, true, ShardState0);
|
||||
false -> ShardState0
|
||||
end,
|
||||
ShardState = guild_common:build_shard_state(GuildId, Data, ShardCount, Index),
|
||||
case guild:start_link(ShardState) of
|
||||
{ok, Pid} ->
|
||||
MRef = monitor(process, Pid),
|
||||
@@ -439,37 +410,15 @@ restart_shard(ShardIndex, State) ->
|
||||
Data =
|
||||
case maps:get(0, Shards0, undefined) of
|
||||
#{pid := Pid0} ->
|
||||
case safe_call(Pid0, {get_push_base_state}, 5000) of
|
||||
case guild_common:safe_call(Pid0, {get_push_base_state}, 5000) of
|
||||
#{data := D} -> D;
|
||||
_ -> Fallback
|
||||
end;
|
||||
_ ->
|
||||
Fallback
|
||||
end,
|
||||
DisableCache = ShardIndex =/= 0,
|
||||
ShardData =
|
||||
case DisableCache of
|
||||
true -> strip_members(Data);
|
||||
false -> Data
|
||||
end,
|
||||
MemberCount = guild_data_index:member_count(Data),
|
||||
ShardState0 = #{
|
||||
id => GuildId,
|
||||
data => ShardData,
|
||||
sessions => #{},
|
||||
member_count => MemberCount,
|
||||
disable_push_notifications => true,
|
||||
disable_member_list_updates => DisableCache,
|
||||
disable_auto_stop_on_empty => true,
|
||||
very_large_guild_coordinator_pid => self(),
|
||||
very_large_guild_shard_count => maps:get(shard_count, State, 1),
|
||||
very_large_guild_shard_index => ShardIndex
|
||||
},
|
||||
ShardState =
|
||||
case DisableCache of
|
||||
true -> maps:put(disable_permission_cache_updates, true, ShardState0);
|
||||
false -> ShardState0
|
||||
end,
|
||||
ShardCount = maps:get(shard_count, State, 1),
|
||||
ShardState = guild_common:build_shard_state(GuildId, Data, ShardCount, ShardIndex),
|
||||
case guild:start_link(ShardState) of
|
||||
{ok, NewPid} ->
|
||||
MRef = monitor(process, NewPid),
|
||||
@@ -586,9 +535,9 @@ reload_shards(NewData, State) ->
|
||||
Payload =
|
||||
case Index of
|
||||
0 -> NewData;
|
||||
_ -> strip_members(NewData)
|
||||
_ -> guild_common:strip_members(NewData)
|
||||
end,
|
||||
_ = safe_call(Pid, {reload, Payload}, 20000),
|
||||
_ = guild_common:safe_call(Pid, {reload, Payload}, 20000),
|
||||
ok
|
||||
end,
|
||||
Shards
|
||||
@@ -635,15 +584,8 @@ do_prime_connected_members(State) ->
|
||||
ok.
|
||||
|
||||
-spec safe_call(pid(), term(), timeout()) -> term().
|
||||
safe_call(Pid, Msg, Timeout) when is_pid(Pid) ->
|
||||
try gen_server:call(Pid, Msg, Timeout) of
|
||||
Reply -> Reply
|
||||
catch
|
||||
exit:{timeout, _} -> {error, timeout};
|
||||
exit:{noproc, _} -> {error, noproc};
|
||||
exit:{normal, _} -> {error, noproc};
|
||||
_:Reason -> {error, Reason}
|
||||
end.
|
||||
safe_call(Pid, Msg, Timeout) ->
|
||||
guild_common:safe_call(Pid, Msg, Timeout).
|
||||
|
||||
-spec safe_call_to_session_shard(session_id(), term(), timeout(), state()) -> term().
|
||||
safe_call_to_session_shard(SessionId, Msg, Timeout, State) ->
|
||||
@@ -764,41 +706,7 @@ maybe_notify_member_list_virtual_access_cleanup(UserId, State) ->
|
||||
|
||||
-spec merge_cluster_state(map(), map()) -> map().
|
||||
merge_cluster_state(Acc, Frag) ->
|
||||
SessionsAcc = maps:get(sessions, Acc, #{}),
|
||||
SessionsFrag = maps:get(sessions, Frag, #{}),
|
||||
VoiceAcc = maps:get(voice_states, Acc, #{}),
|
||||
VoiceFrag = maps:get(voice_states, Frag, #{}),
|
||||
VAAcc = maps:get(virtual_channel_access, Acc, #{}),
|
||||
VAFrag = maps:get(virtual_channel_access, Frag, #{}),
|
||||
PendingAcc = maps:get(virtual_channel_access_pending, Acc, #{}),
|
||||
PendingFrag = maps:get(virtual_channel_access_pending, Frag, #{}),
|
||||
PreserveAcc = maps:get(virtual_channel_access_preserve, Acc, #{}),
|
||||
PreserveFrag = maps:get(virtual_channel_access_preserve, Frag, #{}),
|
||||
MoveAcc = maps:get(virtual_channel_access_move_pending, Acc, #{}),
|
||||
MoveFrag = maps:get(virtual_channel_access_move_pending, Frag, #{}),
|
||||
Acc#{
|
||||
sessions => maps:merge(SessionsAcc, SessionsFrag),
|
||||
voice_states => maps:merge(VoiceAcc, VoiceFrag),
|
||||
virtual_channel_access => merge_user_set_maps(VAAcc, VAFrag),
|
||||
virtual_channel_access_pending => merge_user_set_maps(PendingAcc, PendingFrag),
|
||||
virtual_channel_access_preserve => merge_user_set_maps(PreserveAcc, PreserveFrag),
|
||||
virtual_channel_access_move_pending => merge_user_set_maps(MoveAcc, MoveFrag)
|
||||
}.
|
||||
|
||||
-spec merge_user_set_maps(map(), map()) -> map().
|
||||
merge_user_set_maps(A, B) ->
|
||||
maps:fold(
|
||||
fun(UserId, SetB, Acc) ->
|
||||
case maps:get(UserId, Acc, undefined) of
|
||||
undefined ->
|
||||
maps:put(UserId, SetB, Acc);
|
||||
SetA ->
|
||||
maps:put(UserId, sets:union(SetA, SetB), Acc)
|
||||
end
|
||||
end,
|
||||
A,
|
||||
B
|
||||
).
|
||||
guild_common:merge_cluster_state(Acc, Frag).
|
||||
|
||||
-spec relay_to_other_shards(shard_index(), term(), state()) -> ok.
|
||||
relay_to_other_shards(SourceIndex, Msg, State) ->
|
||||
@@ -1382,13 +1290,13 @@ strip_members_removes_members_and_role_index_test() ->
|
||||
<<"channels">> => [#{<<"id">> => <<"10">>}],
|
||||
<<"roles">> => [#{<<"id">> => <<"role1">>}]
|
||||
},
|
||||
Stripped = strip_members(Data),
|
||||
Stripped = guild_common:strip_members(Data),
|
||||
?assertEqual(false, maps:is_key(<<"members">>, Stripped)),
|
||||
?assertEqual(false, maps:is_key(<<"member_role_index">>, Stripped)),
|
||||
?assertEqual([#{<<"id">> => <<"10">>}], maps:get(<<"channels">>, Stripped)),
|
||||
?assertEqual([#{<<"id">> => <<"role1">>}], maps:get(<<"roles">>, Stripped)),
|
||||
?assertEqual(#{}, strip_members(#{})),
|
||||
?assertEqual(not_a_map, strip_members(not_a_map)),
|
||||
?assertEqual(#{}, guild_common:strip_members(#{})),
|
||||
?assertEqual(not_a_map, guild_common:strip_members(not_a_map)),
|
||||
ok.
|
||||
|
||||
coordinator_stops_on_last_disconnect_test() ->
|
||||
@@ -1421,7 +1329,7 @@ merge_user_set_maps_test() ->
|
||||
SetB = sets:from_list([2, 3]),
|
||||
MapA = #{10 => SetA},
|
||||
MapB = #{10 => SetB, 20 => SetB},
|
||||
Merged = merge_user_set_maps(MapA, MapB),
|
||||
Merged = guild_common:merge_user_set_maps(MapA, MapB),
|
||||
?assert(maps:is_key(10, Merged)),
|
||||
?assert(maps:is_key(20, Merged)),
|
||||
MergedSet10 = maps:get(10, Merged),
|
||||
@@ -1430,7 +1338,7 @@ merge_user_set_maps_test() ->
|
||||
?assert(sets:is_element(3, MergedSet10)),
|
||||
?assertEqual(3, sets:size(MergedSet10)),
|
||||
?assertEqual(SetB, maps:get(20, Merged)),
|
||||
EmptyMerge = merge_user_set_maps(#{}, #{}),
|
||||
EmptyMerge = guild_common:merge_user_set_maps(#{}, #{}),
|
||||
?assertEqual(#{}, EmptyMerge),
|
||||
ok.
|
||||
|
||||
|
||||
@@ -199,22 +199,10 @@ handle_subscribe(SessionId, ChannelId, Ranges, State) ->
|
||||
NormalizedRanges = guild_member_list:normalize_ranges(Ranges),
|
||||
ListId = guild_member_list:calculate_list_id(ChannelId, Snapshot0),
|
||||
Subs0 = maps:get(subscriptions, State, #{}),
|
||||
ListSubs0 = maps:get(ListId, Subs0, #{}),
|
||||
OldRanges = maps:get(SessionId, ListSubs0, []),
|
||||
Subs =
|
||||
case NormalizedRanges of
|
||||
[] ->
|
||||
ListSubs1 = maps:remove(SessionId, ListSubs0),
|
||||
case map_size(ListSubs1) of
|
||||
0 -> maps:remove(ListId, Subs0);
|
||||
_ -> maps:put(ListId, ListSubs1, Subs0)
|
||||
end;
|
||||
_ ->
|
||||
ListSubs1 = maps:put(SessionId, NormalizedRanges, ListSubs0),
|
||||
maps:put(ListId, ListSubs1, Subs0)
|
||||
end,
|
||||
{Subs, _OldRanges, ShouldSync} =
|
||||
guild_member_list_common:update_subscriptions(SessionId, ListId, NormalizedRanges, Subs0),
|
||||
State1 = maps:put(subscriptions, Subs, State),
|
||||
case NormalizedRanges =/= [] andalso NormalizedRanges =/= OldRanges of
|
||||
case ShouldSync of
|
||||
true ->
|
||||
self() ! {send_initial_sync, SessionId, ChannelId, ListId, NormalizedRanges},
|
||||
State1;
|
||||
@@ -524,8 +512,7 @@ maybe_normalize_data(_Data0) ->
|
||||
|
||||
-spec member_user_id(map()) -> user_id().
|
||||
member_user_id(MemberData) ->
|
||||
User = maps:get(<<"user">>, MemberData, #{}),
|
||||
map_utils:get_integer(User, <<"id">>, 0).
|
||||
guild_member_list_common:get_member_user_id(MemberData).
|
||||
|
||||
-spec upsert_item_by_id(term(), map(), [map()]) -> [map()].
|
||||
upsert_item_by_id(Id, NewItem, Items) ->
|
||||
@@ -758,17 +745,7 @@ cleanup_virtual_access(UserId, State) ->
|
||||
-spec remove_session_subscriptions(session_id(), state()) -> state().
|
||||
remove_session_subscriptions(SessionId, State) ->
|
||||
Subs0 = maps:get(subscriptions, State, #{}),
|
||||
Subs = maps:fold(
|
||||
fun(ListId, ListSubs0, Acc) ->
|
||||
ListSubs = maps:remove(SessionId, ListSubs0),
|
||||
case map_size(ListSubs) of
|
||||
0 -> Acc;
|
||||
_ -> maps:put(ListId, ListSubs, Acc)
|
||||
end
|
||||
end,
|
||||
#{},
|
||||
Subs0
|
||||
),
|
||||
Subs = guild_member_list_common:remove_session_from_subscriptions(SessionId, Subs0),
|
||||
maps:put(subscriptions, Subs, State).
|
||||
|
||||
-spec safe_call(pid(), term(), timeout()) -> term().
|
||||
@@ -1485,5 +1462,640 @@ notify_channel_update_triggers_channel_sync_test() ->
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
session_connected_first_session_triggers_delta_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
gen_server:cast(Pid, {session_connected, <<"s1">>, 0, 42}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Counts = maps:get(user_session_counts, State, #{}),
|
||||
maps:get(42, Counts, 0) =:= 1
|
||||
end),
|
||||
State = sys:get_state(Pid),
|
||||
Routes = maps:get(session_routes, State, #{}),
|
||||
?assertEqual(0, maps:get(<<"s1">>, Routes)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
session_connected_second_session_no_delta_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
gen_server:cast(Pid, {session_connected, <<"s1">>, 0, 42}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
gen_server:cast(Pid, {session_connected, <<"s2">>, 1, 42}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(user_session_counts, State, #{}) =:= #{42 => 2}
|
||||
end),
|
||||
State = sys:get_state(Pid),
|
||||
Routes = maps:get(session_routes, State, #{}),
|
||||
?assertEqual(0, maps:get(<<"s1">>, Routes)),
|
||||
?assertEqual(1, maps:get(<<"s2">>, Routes)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
session_disconnected_last_session_triggers_delta_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
gen_server:cast(Pid, {session_connected, <<"s1">>, 0, 42}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Counts = maps:get(user_session_counts, State, #{}),
|
||||
maps:get(42, Counts, 0) =:= 1
|
||||
end),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
gen_server:cast(Pid, {session_disconnected, <<"s1">>, 42}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Counts = maps:get(user_session_counts, State, #{}),
|
||||
not maps:is_key(42, Counts)
|
||||
end),
|
||||
State = sys:get_state(Pid),
|
||||
Routes = maps:get(session_routes, State, #{}),
|
||||
?assertNot(maps:is_key(<<"s1">>, Routes)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
session_disconnected_not_last_no_delta_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
gen_server:cast(Pid, {session_connected, <<"s1">>, 0, 42}),
|
||||
gen_server:cast(Pid, {session_connected, <<"s2">>, 1, 42}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(user_session_counts, State, #{}) =:= #{42 => 2}
|
||||
end),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
gen_server:cast(Pid, {session_disconnected, <<"s1">>, 42}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(user_session_counts, State, #{}) =:= #{42 => 1}
|
||||
end),
|
||||
State = sys:get_state(Pid),
|
||||
?assertNot(maps:is_key(<<"s1">>, maps:get(session_routes, State, #{}))),
|
||||
?assert(maps:is_key(<<"s2">>, maps:get(session_routes, State, #{}))),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
virtual_access_added_and_removed_restores_state_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
gen_server:cast(Pid, {virtual_access_added, 10, 50}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
VA = maps:get(virtual_channel_access, State, #{}),
|
||||
maps:is_key(10, VA)
|
||||
end),
|
||||
gen_server:cast(Pid, {virtual_access_removed, 10, 50}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
VA = maps:get(virtual_channel_access, State, #{}),
|
||||
not maps:is_key(10, VA)
|
||||
end),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
virtual_access_multiple_channels_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
gen_server:cast(Pid, {virtual_access_added, 10, 50}),
|
||||
gen_server:cast(Pid, {virtual_access_added, 10, 60}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
VA = maps:get(virtual_channel_access, State, #{}),
|
||||
case maps:get(10, VA, undefined) of
|
||||
undefined -> false;
|
||||
Channels -> sets:size(Channels) =:= 2
|
||||
end
|
||||
end),
|
||||
gen_server:cast(Pid, {virtual_access_removed, 10, 50}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
VA = maps:get(virtual_channel_access, State, #{}),
|
||||
case maps:get(10, VA, undefined) of
|
||||
undefined -> false;
|
||||
Channels -> sets:size(Channels) =:= 1
|
||||
end
|
||||
end),
|
||||
State = sys:get_state(Pid),
|
||||
VA = maps:get(virtual_channel_access, State, #{}),
|
||||
Channels = maps:get(10, VA),
|
||||
?assertEqual(true, sets:is_element(60, Channels)),
|
||||
?assertEqual(false, sets:is_element(50, Channels)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
virtual_access_cleanup_removes_all_channels_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
gen_server:cast(Pid, {virtual_access_added, 10, 50}),
|
||||
gen_server:cast(Pid, {virtual_access_added, 10, 60}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
VA = maps:get(virtual_channel_access, State, #{}),
|
||||
maps:is_key(10, VA)
|
||||
end),
|
||||
gen_server:cast(Pid, {virtual_access_cleanup, 10}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
VA = maps:get(virtual_channel_access, State, #{}),
|
||||
not maps:is_key(10, VA)
|
||||
end),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
virtual_access_removed_nonexistent_user_noop_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
gen_server:cast(Pid, {virtual_access_removed, 999, 50}),
|
||||
timer:sleep(100),
|
||||
State = sys:get_state(Pid),
|
||||
VA = maps:get(virtual_channel_access, State, #{}),
|
||||
?assertNot(maps:is_key(999, VA)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
presence_update_multiple_users_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
gen_server:cast(Pid, {presence_update, 10, #{<<"status">> => <<"online">>}}),
|
||||
gen_server:cast(Pid, {presence_update, 20, #{<<"status">> => <<"idle">>}}),
|
||||
gen_server:cast(Pid, {presence_update, 30, #{<<"status">> => <<"dnd">>}}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Presence = maps:get(member_presence, State, #{}),
|
||||
maps:is_key(10, Presence) andalso maps:is_key(20, Presence) andalso maps:is_key(30, Presence)
|
||||
end),
|
||||
State = sys:get_state(Pid),
|
||||
Presence = maps:get(member_presence, State, #{}),
|
||||
?assertEqual(#{<<"status">> => <<"online">>}, maps:get(10, Presence)),
|
||||
?assertEqual(#{<<"status">> => <<"idle">>}, maps:get(20, Presence)),
|
||||
?assertEqual(#{<<"status">> => <<"dnd">>}, maps:get(30, Presence)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
presence_update_overwrite_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
gen_server:cast(Pid, {presence_update, 10, #{<<"status">> => <<"online">>}}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Presence = maps:get(member_presence, State, #{}),
|
||||
maps:get(10, Presence, #{}) =:= #{<<"status">> => <<"online">>}
|
||||
end),
|
||||
gen_server:cast(Pid, {presence_update, 10, #{<<"status">> => <<"offline">>}}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Presence = maps:get(member_presence, State, #{}),
|
||||
maps:get(10, Presence, #{}) =:= #{<<"status">> => <<"offline">>}
|
||||
end),
|
||||
gen_server:cast(Pid, {presence_update, 10, #{<<"status">> => <<"online">>}}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Presence = maps:get(member_presence, State, #{}),
|
||||
maps:get(10, Presence, #{}) =:= #{<<"status">> => <<"online">>}
|
||||
end),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
subscribe_multiple_sessions_to_same_channel_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
gen_server:cast(Pid, {session_connected, <<"s1">>, 0, 10}),
|
||||
gen_server:cast(Pid, {session_connected, <<"s2">>, 0, 20}),
|
||||
gen_server:cast(Pid, {subscribe, <<"s1">>, 10, [{0, 50}]}),
|
||||
gen_server:cast(Pid, {subscribe, <<"s2">>, 10, [{25, 75}]}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Subs = maps:get(subscriptions, State, #{}),
|
||||
case maps:get(<<"10">>, Subs, undefined) of
|
||||
undefined -> false;
|
||||
ListSubs ->
|
||||
maps:is_key(<<"s1">>, ListSubs) andalso maps:is_key(<<"s2">>, ListSubs)
|
||||
end
|
||||
end),
|
||||
State = sys:get_state(Pid),
|
||||
Subs = maps:get(subscriptions, State, #{}),
|
||||
ListSubs = maps:get(<<"10">>, Subs),
|
||||
?assertEqual([{0, 50}], maps:get(<<"s1">>, ListSubs)),
|
||||
?assertEqual([{25, 75}], maps:get(<<"s2">>, ListSubs)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
subscribe_invalid_ranges_filtered_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
gen_server:cast(Pid, {session_connected, <<"s1">>, 0, 10}),
|
||||
gen_server:cast(Pid, {subscribe, <<"s1">>, 10, [{100, 50}, {-1, 10}]}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
State = sys:get_state(Pid),
|
||||
Subs = maps:get(subscriptions, State, #{}),
|
||||
?assertEqual(#{}, Subs),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
upsert_member_with_no_user_field_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
MemberData = #{<<"nick">> => <<"orphan">>},
|
||||
gen_server:cast(Pid, {upsert_member, MemberData}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
upsert_member_then_update_roles_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
MemberData = #{
|
||||
<<"user">> => #{<<"id">> => <<"42">>, <<"username">> => <<"alice">>},
|
||||
<<"roles">> => [<<"100">>]
|
||||
},
|
||||
gen_server:cast(Pid, {upsert_member, MemberData}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Base = maps:get(base_data, State, #{}),
|
||||
Members = guild_data_index:member_map(Base),
|
||||
maps:is_key(42, Members)
|
||||
end),
|
||||
UpdatedMemberData = #{
|
||||
<<"user">> => #{<<"id">> => <<"42">>, <<"username">> => <<"alice">>},
|
||||
<<"roles">> => [<<"200">>, <<"300">>]
|
||||
},
|
||||
gen_server:cast(Pid, {upsert_member, UpdatedMemberData}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Base = maps:get(base_data, State, #{}),
|
||||
Members = guild_data_index:member_map(Base),
|
||||
case maps:get(42, Members, undefined) of
|
||||
undefined -> false;
|
||||
M -> maps:get(<<"roles">>, M, []) =:= [<<"200">>, <<"300">>]
|
||||
end
|
||||
end),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
remove_member_then_upsert_again_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
MemberData = #{
|
||||
<<"user">> => #{<<"id">> => <<"42">>, <<"username">> => <<"alice">>},
|
||||
<<"roles">> => []
|
||||
},
|
||||
gen_server:cast(Pid, {upsert_member, MemberData}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Base = maps:get(base_data, State, #{}),
|
||||
maps:is_key(42, guild_data_index:member_map(Base))
|
||||
end),
|
||||
gen_server:cast(Pid, {remove_member, 42}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Base = maps:get(base_data, State, #{}),
|
||||
not maps:is_key(42, guild_data_index:member_map(Base))
|
||||
end),
|
||||
gen_server:cast(Pid, {upsert_member, MemberData}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Base = maps:get(base_data, State, #{}),
|
||||
maps:is_key(42, guild_data_index:member_map(Base))
|
||||
end),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
multiple_rapid_presence_updates_coalesce_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
gen_server:cast(Pid, {presence_update, 10, #{<<"status">> => <<"online">>}}),
|
||||
gen_server:cast(Pid, {presence_update, 10, #{<<"status">> => <<"idle">>}}),
|
||||
gen_server:cast(Pid, {presence_update, 10, #{<<"status">> => <<"dnd">>}}),
|
||||
gen_server:cast(Pid, {presence_update, 10, #{<<"status">> => <<"offline">>}}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
State = sys:get_state(Pid),
|
||||
Presence = maps:get(member_presence, State, #{}),
|
||||
?assertEqual(#{<<"status">> => <<"offline">>}, maps:get(10, Presence)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
unknown_cast_ignored_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
gen_server:cast(Pid, {unknown_message, <<"data">>}),
|
||||
timer:sleep(50),
|
||||
?assertEqual(true, is_process_alive(Pid)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
unknown_info_ignored_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
Pid ! {unknown_info_message},
|
||||
timer:sleep(50),
|
||||
?assertEqual(true, is_process_alive(Pid)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
snapshot_includes_presence_and_sessions_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
gen_server:cast(Pid, {session_connected, <<"s1">>, 0, 10}),
|
||||
gen_server:cast(Pid, {presence_update, 10, #{<<"status">> => <<"online">>}}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
State = sys:get_state(Pid),
|
||||
Snapshot = maps:get(snapshot, State),
|
||||
?assertEqual(1, maps:get(id, Snapshot)),
|
||||
SnapshotPresence = maps:get(member_presence, Snapshot),
|
||||
?assertEqual(#{<<"status">> => <<"online">>}, maps:get(10, SnapshotPresence)),
|
||||
SnapshotSessions = maps:get(sessions, Snapshot),
|
||||
?assert(map_size(SnapshotSessions) > 0),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
channels_bulk_update_invalid_input_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
gen_server:cast(Pid, {channels_bulk_update, not_a_list}),
|
||||
timer:sleep(100),
|
||||
?assertEqual(true, is_process_alive(Pid)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
roles_bulk_update_invalid_input_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
gen_server:cast(Pid, {roles_bulk_update, not_a_list}),
|
||||
timer:sleep(100),
|
||||
?assertEqual(true, is_process_alive(Pid)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
upsert_channel_new_channel_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
NewChannel = #{<<"id">> => <<"999">>, <<"name">> => <<"new-channel">>},
|
||||
gen_server:cast(Pid, {upsert_channel, NewChannel}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Base = maps:get(base_data, State, #{}),
|
||||
Channels = guild_data_index:channel_list(Base),
|
||||
lists:any(fun(C) -> maps:get(<<"id">>, C, undefined) =:= <<"999">> end, Channels)
|
||||
end),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
member_removed_while_presence_update_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
MemberData = #{
|
||||
<<"user">> => #{<<"id">> => <<"42">>, <<"username">> => <<"alice">>},
|
||||
<<"roles">> => []
|
||||
},
|
||||
gen_server:cast(Pid, {upsert_member, MemberData}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Base = maps:get(base_data, State, #{}),
|
||||
maps:is_key(42, guild_data_index:member_map(Base))
|
||||
end),
|
||||
gen_server:cast(Pid, {presence_update, 42, #{<<"status">> => <<"online">>}}),
|
||||
gen_server:cast(Pid, {remove_member, 42}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
Base = maps:get(base_data, State, #{}),
|
||||
not maps:is_key(42, guild_data_index:member_map(Base))
|
||||
end),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
?assertEqual(true, is_process_alive(Pid)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
upsert_member_non_map_ignored_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
gen_server:cast(Pid, {upsert_member, not_a_map}),
|
||||
timer:sleep(100),
|
||||
?assertEqual(true, is_process_alive(Pid)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
full_sync_all_clears_pending_test() ->
|
||||
Shard0Pid = start_stub_shard0(),
|
||||
{ok, Pid} = start_link(#{
|
||||
id => 1,
|
||||
coordinator_pid => self(),
|
||||
shard0_pid => Shard0Pid
|
||||
}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
gen_server:cast(Pid, {notify_role_update}),
|
||||
ok = await(fun() ->
|
||||
State = sys:get_state(Pid),
|
||||
maps:get(compute_inflight, State, true) =:= false
|
||||
end),
|
||||
State = sys:get_state(Pid),
|
||||
?assertEqual(false, maps:get(pending_full_sync_all, State)),
|
||||
?assertEqual(false, maps:get(pending_delta, State)),
|
||||
?assertEqual(false, maps:get(pending_refresh_base_data, State)),
|
||||
catch gen_server:stop(Pid),
|
||||
unlink(Shard0Pid),
|
||||
exit(Shard0Pid, shutdown),
|
||||
ok.
|
||||
|
||||
-endif.
|
||||
|
||||
|
||||
@@ -389,7 +389,7 @@ get_voice_token(ChannelId, UserId, _SessionId, SessionPid, Latitude, Longitude)
|
||||
connection_id => ConnectionId
|
||||
}},
|
||||
ok;
|
||||
{error, {http_error, _Status, Body}} ->
|
||||
{error, {rpc_error, _Status, Body}} ->
|
||||
case parse_unclaimed_error(Body) of
|
||||
true -> SessionPid ! {voice_error, voice_unclaimed_account};
|
||||
false -> SessionPid ! {voice_error, voice_token_failed}
|
||||
@@ -448,7 +448,7 @@ get_dm_voice_token_and_create_state(
|
||||
IsMobile,
|
||||
State
|
||||
);
|
||||
{error, {http_error, _Status, Body}} ->
|
||||
{error, {rpc_error, _Status, Body}} ->
|
||||
case parse_unclaimed_error(Body) of
|
||||
true -> {reply, gateway_errors:error(voice_unclaimed_account), State};
|
||||
false -> {reply, gateway_errors:error(voice_token_failed), State}
|
||||
|
||||
@@ -149,4 +149,26 @@ handle_virtual_channel_access_for_move(UserId, ChannelId, _ConnectionsToMove, Gu
|
||||
|
||||
-spec cleanup_virtual_access_on_disconnect(integer(), pid()) -> ok.
|
||||
cleanup_virtual_access_on_disconnect(UserId, GuildPid) ->
|
||||
gen_server:cast(GuildPid, {cleanup_virtual_access_for_user, UserId}).
|
||||
GuildId = resolve_guild_id_from_pid(GuildPid),
|
||||
TargetPid = resolve_voice_server(GuildId, GuildPid),
|
||||
gen_server:cast(TargetPid, {cleanup_virtual_access_for_user, UserId}).
|
||||
|
||||
-spec resolve_guild_id_from_pid(pid()) -> integer() | undefined.
|
||||
resolve_guild_id_from_pid(GuildPid) ->
|
||||
try gen_server:call(GuildPid, {get_sessions}, 5000) of
|
||||
State when is_map(State) ->
|
||||
maps:get(id, State, undefined);
|
||||
_ ->
|
||||
undefined
|
||||
catch
|
||||
_:_ -> undefined
|
||||
end.
|
||||
|
||||
-spec resolve_voice_server(integer() | undefined, pid()) -> pid().
|
||||
resolve_voice_server(undefined, FallbackPid) ->
|
||||
FallbackPid;
|
||||
resolve_voice_server(GuildId, FallbackPid) ->
|
||||
case guild_voice_server:lookup(GuildId) of
|
||||
{ok, VoicePid} -> VoicePid;
|
||||
{error, not_found} -> FallbackPid
|
||||
end.
|
||||
|
||||
@@ -527,12 +527,6 @@ get_voice_token_and_create_state(Context, Member, ParsedViewerStreamKey, State)
|
||||
),
|
||||
VoiceState1 = maybe_attach_session_id(VoiceState0, SessionIdBin),
|
||||
VoiceState = maybe_attach_member(VoiceState1, Member),
|
||||
%% NOTE: We intentionally do NOT add the voice state to voice_states
|
||||
%% or broadcast it yet. The voice state will only be added and
|
||||
%% broadcast when LiveKit confirms the user has actually connected
|
||||
%% via confirm_voice_connection_from_livekit/2.
|
||||
%% This prevents users from appearing in voice channels before
|
||||
%% they're actually connected and ready to communicate.
|
||||
Now = erlang:system_time(millisecond),
|
||||
PendingMetadata = #{
|
||||
user_id => UserId,
|
||||
@@ -1059,7 +1053,7 @@ request_voice_token(GuildId, ChannelId, UserId, ConnectionId, VoicePermissions,
|
||||
endpoint => maps:get(<<"endpoint">>, Data),
|
||||
connection_id => maps:get(<<"connectionId">>, Data)
|
||||
}};
|
||||
{error, {http_error, _Status, Body}} ->
|
||||
{error, {rpc_error, _Status, Body}} ->
|
||||
case parse_unclaimed_error(Body) of
|
||||
true ->
|
||||
{error, voice_unclaimed_account};
|
||||
|
||||
@@ -48,8 +48,6 @@ handle_voice_disconnect(ConnectionId, _SessionId, UserId, VoiceStates0, State) -
|
||||
VoiceStates = voice_state_utils:ensure_voice_states(VoiceStates0),
|
||||
case maps:get(ConnectionId, VoiceStates, undefined) of
|
||||
undefined ->
|
||||
%% Voice state not in voice_states - check if it's still pending
|
||||
%% (user disconnected before LiveKit confirmation)
|
||||
State1 = clear_pending_voice_connection(ConnectionId, State),
|
||||
{reply, #{success => true}, State1};
|
||||
OldVoiceState ->
|
||||
@@ -118,7 +116,6 @@ disconnect_voice_user(#{user_id := UserId} = Request, State) ->
|
||||
end),
|
||||
case maps:size(UserVoiceStates) of
|
||||
0 ->
|
||||
%% No active voice states - also clean up any pending connections
|
||||
State1 = clear_pending_voice_connections_for_user(UserId, State),
|
||||
{reply, #{success => true}, State1};
|
||||
_ ->
|
||||
@@ -141,7 +138,6 @@ disconnect_voice_user(#{user_id := UserId} = Request, State) ->
|
||||
SpecificConnection ->
|
||||
case maps:get(SpecificConnection, VoiceStates, undefined) of
|
||||
undefined ->
|
||||
%% Not found in voice_states - also clean up pending connection
|
||||
State1 = clear_pending_voice_connection(SpecificConnection, State),
|
||||
{reply, #{success => true}, State1};
|
||||
VoiceState ->
|
||||
@@ -191,8 +187,6 @@ disconnect_voice_user_if_in_channel(
|
||||
end),
|
||||
case maps:size(UserVoiceStates) of
|
||||
0 ->
|
||||
%% Not found in voice_states - also clean up any pending connections
|
||||
%% for this user/channel (user disconnected before LiveKit confirmation)
|
||||
State1 = clear_pending_voice_connections_for_user_channel(
|
||||
UserId, ExpectedChannelId, State
|
||||
),
|
||||
@@ -215,8 +209,6 @@ disconnect_voice_user_if_in_channel(
|
||||
ConnId ->
|
||||
case maps:get(ConnId, VoiceStates, undefined) of
|
||||
undefined ->
|
||||
%% Not found in voice_states - also clean up pending connection
|
||||
%% (user disconnected before LiveKit confirmation)
|
||||
State1 = clear_pending_voice_connection(ConnId, State),
|
||||
{reply,
|
||||
#{success => true, ignored => true, reason => <<"connection_not_found">>},
|
||||
@@ -282,8 +274,6 @@ disconnect_all_voice_users_in_channel(#{channel_id := ChannelId}, State) ->
|
||||
ChannelVoiceStates = voice_state_utils:filter_voice_states(VoiceStates, fun(_, V) ->
|
||||
voice_state_utils:voice_state_channel_id(V) =:= ChannelId
|
||||
end),
|
||||
%% Also clean up any pending connections for this channel
|
||||
%% (users that requested tokens but haven't confirmed via LiveKit yet)
|
||||
State1 = clear_pending_voice_connections_for_channel(ChannelId, State),
|
||||
case maps:size(ChannelVoiceStates) of
|
||||
0 ->
|
||||
@@ -646,8 +636,6 @@ disconnect_voice_user_if_in_channel_skips_force_disconnect_test() ->
|
||||
ok
|
||||
end.
|
||||
|
||||
%% Tests for clear_pending_voice_connection/2
|
||||
|
||||
clear_pending_voice_connection_removes_connection_test() ->
|
||||
PendingConnections = #{
|
||||
<<"conn1">> => #{user_id => 1, channel_id => 100},
|
||||
@@ -670,8 +658,6 @@ clear_pending_voice_connection_handles_empty_pending_test() ->
|
||||
NewState = clear_pending_voice_connection(<<"conn">>, State),
|
||||
?assertEqual(#{}, maps:get(pending_voice_connections, NewState, #{})).
|
||||
|
||||
%% Tests for clear_pending_voice_connections_for_user/2
|
||||
|
||||
clear_pending_voice_connections_for_user_removes_all_user_connections_test() ->
|
||||
PendingConnections = #{
|
||||
<<"conn1">> => #{user_id => 5, channel_id => 100},
|
||||
@@ -685,8 +671,6 @@ clear_pending_voice_connections_for_user_removes_all_user_connections_test() ->
|
||||
?assertNot(maps:is_key(<<"conn2">>, NewPending)),
|
||||
?assert(maps:is_key(<<"conn3">>, NewPending)).
|
||||
|
||||
%% Tests for clear_pending_voice_connections_for_user_channel/3
|
||||
|
||||
clear_pending_voice_connections_for_user_channel_removes_matching_test() ->
|
||||
PendingConnections = #{
|
||||
<<"conn1">> => #{user_id => 5, channel_id => 100},
|
||||
@@ -700,8 +684,6 @@ clear_pending_voice_connections_for_user_channel_removes_matching_test() ->
|
||||
?assert(maps:is_key(<<"conn2">>, NewPending)),
|
||||
?assert(maps:is_key(<<"conn3">>, NewPending)).
|
||||
|
||||
%% Tests for clear_pending_voice_connections_for_channel/2
|
||||
|
||||
clear_pending_voice_connections_for_channel_removes_all_channel_connections_test() ->
|
||||
PendingConnections = #{
|
||||
<<"conn1">> => #{user_id => 5, channel_id => 100},
|
||||
@@ -715,8 +697,6 @@ clear_pending_voice_connections_for_channel_removes_all_channel_connections_test
|
||||
?assertNot(maps:is_key(<<"conn2">>, NewPending)),
|
||||
?assert(maps:is_key(<<"conn3">>, NewPending)).
|
||||
|
||||
%% Tests for disconnect handlers cleaning up pending connections
|
||||
|
||||
handle_voice_disconnect_cleans_pending_when_not_in_voice_states_test() ->
|
||||
PendingConnections = #{<<"conn1">> => #{user_id => 5, channel_id => 100}},
|
||||
State = #{
|
||||
|
||||
@@ -419,10 +419,9 @@ send_single_voice_server_update(GuildId, ChannelId, SessionInfo, GuildPid) ->
|
||||
<<"server_deaf">> => ServerDeaf,
|
||||
<<"member">> => Member
|
||||
},
|
||||
_ = gen_server:call(
|
||||
GuildPid,
|
||||
{store_pending_connection, NewConnectionId, PendingMetadata},
|
||||
10000
|
||||
_ = store_pending_connection(
|
||||
GuildId, GuildPid,
|
||||
NewConnectionId, PendingMetadata
|
||||
),
|
||||
guild_voice_broadcast:broadcast_voice_server_update_to_session(
|
||||
GuildId,
|
||||
@@ -441,6 +440,22 @@ send_single_voice_server_update(GuildId, ChannelId, SessionInfo, GuildPid) ->
|
||||
end
|
||||
end.
|
||||
|
||||
-spec store_pending_connection(integer(), pid(), binary(), map()) -> ok.
|
||||
store_pending_connection(GuildId, GuildPid, ConnectionId, Metadata) ->
|
||||
TargetPid = resolve_voice_server(GuildId, GuildPid),
|
||||
gen_server:call(
|
||||
TargetPid,
|
||||
{store_pending_connection, ConnectionId, Metadata},
|
||||
10000
|
||||
).
|
||||
|
||||
-spec resolve_voice_server(integer(), pid()) -> pid().
|
||||
resolve_voice_server(GuildId, FallbackPid) ->
|
||||
case guild_voice_server:lookup(GuildId) of
|
||||
{ok, VoicePid} -> VoicePid;
|
||||
{error, not_found} -> FallbackPid
|
||||
end.
|
||||
|
||||
-ifdef(TEST).
|
||||
|
||||
move_member_user_not_in_voice_test() ->
|
||||
|
||||
@@ -113,8 +113,8 @@ send_voice_server_update_for_region_switch(
|
||||
PendingMetadata = build_pending_metadata(
|
||||
UserId, GuildId, ChannelId, SessionId, ExistingVoiceState, TokenNonce
|
||||
),
|
||||
_ = gen_server:call(
|
||||
GuildPid, {store_pending_connection, ConnectionId, PendingMetadata}, 10000
|
||||
_ = store_pending_connection(
|
||||
GuildId, GuildPid, ConnectionId, PendingMetadata
|
||||
),
|
||||
guild_voice_broadcast:broadcast_voice_server_update_to_session(
|
||||
GuildId,
|
||||
@@ -154,6 +154,22 @@ build_pending_metadata(UserId, GuildId, ChannelId, SessionId, ExistingVoiceState
|
||||
expires_at => Now + 30000
|
||||
}.
|
||||
|
||||
-spec store_pending_connection(integer(), pid(), binary(), map()) -> ok.
|
||||
store_pending_connection(GuildId, GuildPid, ConnectionId, Metadata) ->
|
||||
TargetPid = resolve_voice_server(GuildId, GuildPid),
|
||||
gen_server:call(
|
||||
TargetPid,
|
||||
{store_pending_connection, ConnectionId, Metadata},
|
||||
10000
|
||||
).
|
||||
|
||||
-spec resolve_voice_server(integer(), pid()) -> pid().
|
||||
resolve_voice_server(GuildId, FallbackPid) ->
|
||||
case guild_voice_server:lookup(GuildId) of
|
||||
{ok, VoicePid} -> VoicePid;
|
||||
{error, not_found} -> FallbackPid
|
||||
end.
|
||||
|
||||
-ifdef(TEST).
|
||||
|
||||
switch_voice_region_handler_not_found_test() ->
|
||||
|
||||
360
fluxer_gateway/src/guild/voice/guild_voice_server.erl
Normal file
360
fluxer_gateway/src/guild/voice/guild_voice_server.erl
Normal file
@@ -0,0 +1,360 @@
|
||||
%% 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_voice_server).
|
||||
-behaviour(gen_server).
|
||||
|
||||
-export([
|
||||
start_link/2,
|
||||
stop/1,
|
||||
lookup/1
|
||||
]).
|
||||
|
||||
-export([
|
||||
init/1,
|
||||
handle_call/3,
|
||||
handle_cast/2,
|
||||
handle_info/2,
|
||||
terminate/2,
|
||||
code_change/3
|
||||
]).
|
||||
|
||||
-define(REGISTRY_TABLE, guild_voice_registry).
|
||||
-define(SWEEP_INTERVAL_MS, 10000).
|
||||
-define(GUILD_CALL_TIMEOUT, 10000).
|
||||
|
||||
-type voice_state() :: map().
|
||||
-type voice_state_map() :: #{binary() => voice_state()}.
|
||||
-type server_state() :: #{
|
||||
guild_id := integer(),
|
||||
guild_pid := pid(),
|
||||
voice_states := voice_state_map(),
|
||||
pending_voice_connections := map(),
|
||||
recently_disconnected_voice_states := map()
|
||||
}.
|
||||
|
||||
-spec start_link(integer(), pid()) -> {ok, pid()} | {error, term()}.
|
||||
start_link(GuildId, GuildPid) ->
|
||||
gen_server:start_link(?MODULE, #{guild_id => GuildId, guild_pid => GuildPid}, []).
|
||||
|
||||
-spec stop(pid()) -> ok.
|
||||
stop(Pid) ->
|
||||
gen_server:stop(Pid, normal, 5000).
|
||||
|
||||
-spec lookup(integer()) -> {ok, pid()} | {error, not_found}.
|
||||
lookup(GuildId) ->
|
||||
ensure_registry(),
|
||||
case ets:lookup(?REGISTRY_TABLE, GuildId) of
|
||||
[{_, Pid}] when is_pid(Pid) ->
|
||||
case is_process_alive(Pid) of
|
||||
true -> {ok, Pid};
|
||||
false ->
|
||||
ets:delete(?REGISTRY_TABLE, GuildId),
|
||||
{error, not_found}
|
||||
end;
|
||||
_ ->
|
||||
{error, not_found}
|
||||
end.
|
||||
|
||||
-spec init(map()) -> {ok, server_state()}.
|
||||
init(#{guild_id := GuildId, guild_pid := GuildPid}) ->
|
||||
process_flag(trap_exit, true),
|
||||
ensure_registry(),
|
||||
ets:insert(?REGISTRY_TABLE, {GuildId, self()}),
|
||||
erlang:send_after(?SWEEP_INTERVAL_MS, self(), sweep_pending_joins),
|
||||
{ok, #{
|
||||
guild_id => GuildId,
|
||||
guild_pid => GuildPid,
|
||||
voice_states => #{},
|
||||
pending_voice_connections => #{},
|
||||
recently_disconnected_voice_states => #{}
|
||||
}}.
|
||||
|
||||
-spec handle_call(term(), gen_server:from(), server_state()) ->
|
||||
{reply, term(), server_state()}.
|
||||
|
||||
handle_call({voice_state_update, Request}, _From, State) ->
|
||||
GuildState = build_guild_state(State),
|
||||
case guild_voice:voice_state_update(Request, GuildState) of
|
||||
{reply, Reply, NewGuildState} ->
|
||||
{reply, Reply, apply_guild_state(NewGuildState, State)}
|
||||
end;
|
||||
|
||||
handle_call({get_voice_state, Request}, _From, State) ->
|
||||
GuildState = build_guild_state(State),
|
||||
case guild_voice:get_voice_state(Request, GuildState) of
|
||||
{reply, Reply, NewGuildState} ->
|
||||
{reply, Reply, apply_guild_state(NewGuildState, State)}
|
||||
end;
|
||||
|
||||
handle_call({update_member_voice, Request}, _From, State) ->
|
||||
GuildState = build_guild_state(State),
|
||||
case guild_voice:update_member_voice(Request, GuildState) of
|
||||
{reply, Reply, NewGuildState} ->
|
||||
{reply, Reply, apply_guild_state(NewGuildState, State)}
|
||||
end;
|
||||
|
||||
handle_call({disconnect_voice_user, Request}, _From, State) ->
|
||||
GuildState = build_guild_state(State),
|
||||
case guild_voice:disconnect_voice_user(Request, GuildState) of
|
||||
{reply, Reply, NewGuildState} ->
|
||||
{reply, Reply, apply_guild_state(NewGuildState, State)}
|
||||
end;
|
||||
|
||||
handle_call({disconnect_voice_user_if_in_channel, Request}, _From, State) ->
|
||||
GuildState = build_guild_state(State),
|
||||
case guild_voice:disconnect_voice_user_if_in_channel(Request, GuildState) of
|
||||
{reply, Reply, NewGuildState} ->
|
||||
{reply, Reply, apply_guild_state(NewGuildState, State)}
|
||||
end;
|
||||
|
||||
handle_call({disconnect_all_voice_users_in_channel, Request}, _From, State) ->
|
||||
GuildState = build_guild_state(State),
|
||||
case guild_voice:disconnect_all_voice_users_in_channel(Request, GuildState) of
|
||||
{reply, Reply, NewGuildState} ->
|
||||
{reply, Reply, apply_guild_state(NewGuildState, State)}
|
||||
end;
|
||||
|
||||
handle_call({confirm_voice_connection_from_livekit, Request}, _From, State) ->
|
||||
GuildState = build_guild_state(State),
|
||||
case guild_voice:confirm_voice_connection_from_livekit(Request, GuildState) of
|
||||
{reply, Reply, NewGuildState} ->
|
||||
{reply, Reply, apply_guild_state(NewGuildState, State)}
|
||||
end;
|
||||
|
||||
handle_call({move_member, Request}, _From, State) ->
|
||||
GuildState = build_guild_state(State),
|
||||
case guild_voice:move_member(Request, GuildState) of
|
||||
{reply, Reply, NewGuildState} ->
|
||||
{reply, Reply, apply_guild_state(NewGuildState, State)}
|
||||
end;
|
||||
|
||||
handle_call({switch_voice_region, Request}, _From, State) ->
|
||||
GuildState = build_guild_state(State),
|
||||
case guild_voice:switch_voice_region_handler(Request, GuildState) of
|
||||
{reply, Reply, NewGuildState} ->
|
||||
{reply, Reply, apply_guild_state(NewGuildState, State)}
|
||||
end;
|
||||
|
||||
handle_call({store_pending_connection, ConnectionId, Metadata}, _From, State) ->
|
||||
PendingConnections = maps:get(pending_voice_connections, State, #{}),
|
||||
NewPendingConnections = maps:put(ConnectionId, Metadata, PendingConnections),
|
||||
NewState = maps:put(pending_voice_connections, NewPendingConnections, State),
|
||||
{reply, ok, NewState};
|
||||
|
||||
handle_call({get_voice_states_for_channel, ChannelIdBin}, _From, State) ->
|
||||
VoiceStates = maps:get(voice_states, State, #{}),
|
||||
Filtered = maps:fold(
|
||||
fun(ConnId, VS, Acc) ->
|
||||
case maps:get(<<"channel_id">>, VS, null) of
|
||||
ChannelIdBin ->
|
||||
[#{
|
||||
connection_id => ConnId,
|
||||
user_id => maps:get(<<"user_id">>, VS, null),
|
||||
channel_id => ChannelIdBin
|
||||
} | Acc];
|
||||
_ ->
|
||||
Acc
|
||||
end
|
||||
end,
|
||||
[],
|
||||
VoiceStates
|
||||
),
|
||||
{reply, #{voice_states => Filtered}, State};
|
||||
|
||||
handle_call({get_pending_joins_for_channel, ChannelIdBin}, _From, State) ->
|
||||
PendingConnections = maps:get(pending_voice_connections, State, #{}),
|
||||
ChannelIdInt = binary_to_integer(ChannelIdBin),
|
||||
Filtered = maps:fold(
|
||||
fun(ConnId, Metadata, Acc) ->
|
||||
case maps:get(channel_id, Metadata, undefined) of
|
||||
ChannelIdInt ->
|
||||
[#{
|
||||
connection_id => ConnId,
|
||||
user_id => integer_to_binary(maps:get(user_id, Metadata, 0)),
|
||||
token_nonce => maps:get(token_nonce, Metadata, null),
|
||||
expires_at => maps:get(expires_at, Metadata, 0)
|
||||
} | Acc];
|
||||
_ ->
|
||||
Acc
|
||||
end
|
||||
end,
|
||||
[],
|
||||
PendingConnections
|
||||
),
|
||||
{reply, #{pending_joins => Filtered}, State};
|
||||
|
||||
handle_call({get_voice_states_list}, _From, State) ->
|
||||
VoiceStates = maps:get(voice_states, State, #{}),
|
||||
{reply, maps:values(VoiceStates), State};
|
||||
|
||||
handle_call({get_voice_states_map}, _From, State) ->
|
||||
{reply, maps:get(voice_states, State, #{}), State};
|
||||
|
||||
handle_call({set_voice_states, VoiceStates}, _From, State) ->
|
||||
{reply, ok, maps:put(voice_states, VoiceStates, State)};
|
||||
|
||||
handle_call(_, _From, State) ->
|
||||
{reply, ok, State}.
|
||||
|
||||
-spec handle_cast(term(), server_state()) -> {noreply, server_state()}.
|
||||
|
||||
handle_cast({store_pending_connection, ConnectionId, Metadata}, State) ->
|
||||
PendingConnections = maps:get(pending_voice_connections, State, #{}),
|
||||
NewPendingConnections = maps:put(ConnectionId, Metadata, PendingConnections),
|
||||
NewState = maps:put(pending_voice_connections, NewPendingConnections, State),
|
||||
{noreply, NewState};
|
||||
|
||||
handle_cast({relay_voice_state_update, VoiceState, OldChannelIdBin}, State) ->
|
||||
GuildState = build_guild_state(State),
|
||||
State1 = relay_upsert_voice_state(VoiceState, State),
|
||||
GuildStateNoRelay = maps:remove(very_large_guild_coordinator_pid, GuildState),
|
||||
_ = guild_voice_broadcast:broadcast_voice_state_update(
|
||||
VoiceState, GuildStateNoRelay, OldChannelIdBin
|
||||
),
|
||||
{noreply, State1};
|
||||
|
||||
handle_cast(
|
||||
{relay_voice_server_update, GuildId, ChannelId, SessionId, Token, Endpoint, ConnectionId},
|
||||
State
|
||||
) ->
|
||||
GuildState = build_guild_state(State),
|
||||
GuildStateNoRelay = maps:remove(very_large_guild_coordinator_pid, GuildState),
|
||||
_ = guild_voice_broadcast:broadcast_voice_server_update_to_session(
|
||||
GuildId,
|
||||
ChannelId,
|
||||
SessionId,
|
||||
Token,
|
||||
Endpoint,
|
||||
ConnectionId,
|
||||
GuildStateNoRelay
|
||||
),
|
||||
{noreply, State};
|
||||
|
||||
handle_cast({cleanup_virtual_access_for_user, UserId}, State) ->
|
||||
GuildState = build_guild_state(State),
|
||||
NewGuildState = guild_voice_disconnect:cleanup_virtual_channel_access_for_user(
|
||||
UserId, GuildState
|
||||
),
|
||||
{noreply, apply_guild_state(NewGuildState, State)};
|
||||
|
||||
handle_cast(_, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec handle_info(term(), server_state()) ->
|
||||
{noreply, server_state()} | {stop, normal, server_state()}.
|
||||
|
||||
handle_info(sweep_pending_joins, State) ->
|
||||
GuildState = build_guild_state(State),
|
||||
NewGuildState = guild_voice_connection:sweep_expired_pending_joins(GuildState),
|
||||
erlang:send_after(?SWEEP_INTERVAL_MS, self(), sweep_pending_joins),
|
||||
{noreply, apply_guild_state(NewGuildState, State)};
|
||||
|
||||
handle_info({'EXIT', Pid, Reason}, #{guild_pid := GuildPid} = State) when Pid =:= GuildPid ->
|
||||
logger:info(
|
||||
"Voice server shutting down because guild process exited",
|
||||
#{guild_id => maps:get(guild_id, State), reason => Reason}
|
||||
),
|
||||
{stop, normal, State};
|
||||
|
||||
handle_info(_, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec terminate(term(), server_state()) -> ok.
|
||||
terminate(_Reason, #{guild_id := GuildId}) ->
|
||||
catch ets:delete(?REGISTRY_TABLE, GuildId),
|
||||
ok;
|
||||
terminate(_Reason, _State) ->
|
||||
ok.
|
||||
|
||||
-spec code_change(term(), server_state(), term()) -> {ok, server_state()}.
|
||||
code_change(_OldVsn, State, _Extra) ->
|
||||
{ok, State}.
|
||||
|
||||
-spec build_guild_state(server_state()) -> map().
|
||||
build_guild_state(#{guild_pid := GuildPid} = State) ->
|
||||
GuildData = fetch_guild_data(GuildPid),
|
||||
maps:merge(GuildData, #{
|
||||
voice_states => maps:get(voice_states, State, #{}),
|
||||
pending_voice_connections => maps:get(pending_voice_connections, State, #{}),
|
||||
recently_disconnected_voice_states =>
|
||||
maps:get(recently_disconnected_voice_states, State, #{})
|
||||
}).
|
||||
|
||||
-spec apply_guild_state(map(), server_state()) -> server_state().
|
||||
apply_guild_state(GuildState, State) ->
|
||||
State#{
|
||||
voice_states => maps:get(voice_states, GuildState, maps:get(voice_states, State, #{})),
|
||||
pending_voice_connections =>
|
||||
maps:get(
|
||||
pending_voice_connections,
|
||||
GuildState,
|
||||
maps:get(pending_voice_connections, State, #{})
|
||||
),
|
||||
recently_disconnected_voice_states =>
|
||||
maps:get(
|
||||
recently_disconnected_voice_states,
|
||||
GuildState,
|
||||
maps:get(recently_disconnected_voice_states, State, #{})
|
||||
)
|
||||
}.
|
||||
|
||||
-spec fetch_guild_data(pid()) -> map().
|
||||
fetch_guild_data(GuildPid) ->
|
||||
try gen_server:call(GuildPid, {get_sessions}, ?GUILD_CALL_TIMEOUT) of
|
||||
GuildState when is_map(GuildState) ->
|
||||
GuildState;
|
||||
_ ->
|
||||
#{}
|
||||
catch
|
||||
exit:{timeout, _} ->
|
||||
logger:warning("Voice server timed out fetching guild state", #{}),
|
||||
#{};
|
||||
exit:{noproc, _} ->
|
||||
#{};
|
||||
exit:{normal, _} ->
|
||||
#{}
|
||||
end.
|
||||
|
||||
-spec relay_upsert_voice_state(map(), server_state()) -> server_state().
|
||||
relay_upsert_voice_state(VoiceState, State) when is_map(VoiceState) ->
|
||||
ConnectionId = maps:get(<<"connection_id">>, VoiceState, undefined),
|
||||
case ConnectionId of
|
||||
undefined ->
|
||||
State;
|
||||
_ ->
|
||||
VoiceStates0 = maps:get(voice_states, State, #{}),
|
||||
ChannelId = maps:get(<<"channel_id">>, VoiceState, null),
|
||||
VoiceStates =
|
||||
case ChannelId of
|
||||
null -> maps:remove(ConnectionId, VoiceStates0);
|
||||
_ -> maps:put(ConnectionId, VoiceState, VoiceStates0)
|
||||
end,
|
||||
maps:put(voice_states, VoiceStates, State)
|
||||
end;
|
||||
relay_upsert_voice_state(_, State) ->
|
||||
State.
|
||||
|
||||
-spec ensure_registry() -> ok.
|
||||
ensure_registry() ->
|
||||
guild_ets_utils:ensure_table(?REGISTRY_TABLE, [
|
||||
named_table,
|
||||
public,
|
||||
set,
|
||||
{read_concurrency, true},
|
||||
{write_concurrency, true}
|
||||
]).
|
||||
Reference in New Issue
Block a user