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

633 lines
25 KiB
Erlang

%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild).
-feature(maybe_expr, enable).
-typing([eqwalizer]).
-behaviour(gen_server).
-export([start_link/1, update_counts/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-define(HIBERNATE_TIMEOUT, 60000).
-type guild_state() :: map().
-type call_reply() ::
{reply, term(), guild_state()}
| {noreply, guild_state()}
| {stop, term(), term(), guild_state()}.
-type cast_reply() :: {noreply, guild_state()}.
-type info_reply() ::
{noreply, guild_state()}
| {noreply, guild_state(), timeout() | hibernate}
| {stop, term(), guild_state()}.
-spec start_link(map()) -> gen_server:start_ret().
start_link(GuildState) -> gen_server:start_link(?MODULE, GuildState, []).
-spec update_counts(guild_state()) -> guild_state().
update_counts(State) -> guild_maintenance:update_counts(State).
-spec init(map()) -> {ok, guild_state(), timeout()}.
init(GuildState) ->
process_flag(trap_exit, true),
erlang:process_flag(fullsweep_after, 10),
State0 = guild_init:init_base_state(GuildState),
State1 = guild_init:init_member_list(State0),
State2 = guild_init:init_counts(State1),
State3 = guild_init:init_caches_and_timers(State2),
State4 = guild_init:init_voice_server(State3),
erlang:garbage_collect(),
{ok, State4, ?HIBERNATE_TIMEOUT}.
-spec handle_call(term(), gen_server:from(), guild_state()) -> call_reply().
handle_call({session_connect, Request}, {CallerPid, _}, State) ->
handle_session_connect_call(Request, CallerPid, State);
handle_call(export_handoff_state, _From, State) ->
{reply, {ok, guild_handoff:export_handoff_state(State)}, State};
handle_call({get_cached_voice_state_by_connection, ConnectionId}, _From, State) ->
handle_cached_voice_state_call(ConnectionId, State);
handle_call({dispatch, Request}, _From, State) ->
handle_dispatch_call(Request, State);
handle_call({reload, NewData}, _From, State) ->
handle_reload_call(NewData, State);
handle_call(get_voice_server_pid, _From, State) ->
guild_voice_lifecycle:reply_voice_server_pid(State);
handle_call({terminate}, _From, State) ->
{stop, normal, ok, State};
handle_call(Msg, From, State) when is_tuple(Msg) ->
route_call(element(1, Msg), Msg, From, State);
handle_call(_, _From, State) ->
{reply, ok, State}.
-spec route_call(atom(), term(), gen_server:from(), guild_state()) -> call_reply().
route_call(Tag, Msg, From, State) ->
case call_handler(Tag) of
query -> guild_query_handler:handle_call(Msg, From, State);
voice -> guild_voice_handler:handle_call(Msg, From, State);
subscription -> guild_subscription_handler:handle_call(Msg, From, State);
undefined -> {reply, ok, State}
end.
-spec call_handler(atom()) -> query | voice | subscription | undefined.
call_handler(Tag) -> query_call_handler(Tag).
-spec query_call_handler(atom()) -> query | voice | subscription | undefined.
query_call_handler(get_counts) -> query;
query_call_handler(get_user_counts) -> query;
query_call_handler(get_channel_member_counts) -> query;
query_call_handler(get_large_guild_metadata) -> query;
query_call_handler(get_users_to_mention_by_roles) -> query;
query_call_handler(get_users_to_mention_by_user_ids) -> query;
query_call_handler(get_all_users_to_mention) -> query;
query_call_handler(resolve_all_mentions) -> query;
query_call_handler(resolve_mention_sources) -> query;
query_call_handler(resolve_mention_sources_page) -> query;
query_call_handler(resolve_channel_mentions) -> query;
query_call_handler(get_members_with_role) -> query;
query_call_handler(check_permission) -> query;
query_call_handler(get_user_permissions) -> query;
query_call_handler(can_manage_roles) -> query;
query_call_handler(can_manage_role) -> query;
query_call_handler(get_guild_data) -> query;
query_call_handler(get_assignable_roles) -> query;
query_call_handler(get_user_max_role_position) -> query;
query_call_handler(check_target_member) -> query;
query_call_handler(get_viewable_channels) -> query;
query_call_handler(get_guild_member) -> query;
query_call_handler(get_guild_members_batch) -> query;
query_call_handler(Tag) -> query_call_handler_more(Tag).
-spec query_call_handler_more(atom()) -> query | voice | subscription | undefined.
query_call_handler_more(has_member) -> query;
query_call_handler_more(list_guild_members) -> query;
query_call_handler_more(search_guild_members) -> query;
query_call_handler_more(list_guild_members_cursor) -> query;
query_call_handler_more(get_vanity_url_channel) -> query;
query_call_handler_more(get_first_viewable_text_channel) -> query;
query_call_handler_more(get_category_channel_count) -> query;
query_call_handler_more(get_channel_count) -> query;
query_call_handler_more(get_sessions) -> query;
query_call_handler_more(get_push_base_state) -> query;
query_call_handler_more(get_cluster_merge_state) -> query;
query_call_handler_more(Tag) -> voice_call_handler(Tag).
-spec voice_call_handler(atom()) -> voice | subscription | undefined.
voice_call_handler(voice_state_update) -> voice;
voice_call_handler(get_voice_state) -> voice;
voice_call_handler(update_member_voice) -> voice;
voice_call_handler(disconnect_voice_user) -> voice;
voice_call_handler(disconnect_voice_user_if_in_channel) -> voice;
voice_call_handler(disconnect_all_voice_users_in_channel) -> voice;
voice_call_handler(confirm_voice_connection_from_livekit) -> voice;
voice_call_handler(move_member) -> voice;
voice_call_handler(switch_voice_region) -> voice;
voice_call_handler(add_virtual_channel_access) -> voice;
voice_call_handler(store_pending_connection) -> voice;
voice_call_handler(get_voice_states_for_channel) -> voice;
voice_call_handler(get_pending_joins_for_channel) -> voice;
voice_call_handler(Tag) -> subscription_call_handler(Tag).
-spec subscription_call_handler(atom()) -> subscription | undefined.
subscription_call_handler(lazy_subscribe) -> subscription;
subscription_call_handler(_) -> undefined.
-spec handle_cast(term(), guild_state()) -> cast_reply().
handle_cast({dispatch, Request}, State) ->
handle_dispatch_cast(Request, State);
handle_cast(
{session_connect_async,
#{guild_id := GuildId, attempt := Attempt, request := Request} = Msg},
State
) ->
handle_session_connect_async_cast(GuildId, Attempt, Request, Msg, State);
handle_cast({session_connect_worker_done, SessionId, Attempt, Result0, Computed}, State) ->
handle_session_connect_worker_done_cast(SessionId, Attempt, Result0, Computed, State);
handle_cast({set_session_active, SessionId}, State) ->
handle_set_session_active_cast(SessionId, State);
handle_cast({set_session_passive, SessionId}, State) ->
handle_set_session_passive_cast(SessionId, State);
handle_cast({drop_session_member_lists, SessionId}, State) when is_binary(SessionId) ->
{noreply, guild_member_list:unsubscribe_session(SessionId, State)};
handle_cast({set_session_typing_override, SessionId, TypingFlag}, State) ->
handle_set_session_typing_override_cast(SessionId, TypingFlag, State);
handle_cast({send_guild_sync, SessionId}, State) ->
handle_send_guild_sync_cast(SessionId, State);
handle_cast({send_members_chunk, SessionId, ChunkData}, State) ->
handle_send_members_chunk_cast(SessionId, ChunkData, State);
handle_cast({patch_everyone_perms, Bit}, State) when is_integer(Bit), Bit > 0 ->
{noreply, guild_maintenance:apply_everyone_perm_bit(Bit, State)};
handle_cast(Msg, State) when is_tuple(Msg) ->
route_cast(element(1, Msg), Msg, State);
handle_cast(_, State) ->
{noreply, State}.
-spec route_cast(atom(), term(), guild_state()) -> cast_reply().
route_cast(Tag, Msg, State) ->
case cast_handler(Tag) of
voice -> guild_voice_handler:handle_cast(Msg, State);
subscription -> guild_subscription_handler:handle_cast(Msg, State);
undefined -> {noreply, State}
end.
-spec cast_handler(atom()) -> voice | subscription | undefined.
cast_handler(relay_voice_state_update) -> voice;
cast_handler(relay_voice_server_update) -> voice;
cast_handler(store_pending_connection) -> voice;
cast_handler(add_virtual_channel_access) -> voice;
cast_handler(remove_virtual_channel_access) -> voice;
cast_handler(cleanup_virtual_access_for_user) -> voice;
cast_handler(update_member_subscriptions) -> subscription;
cast_handler(_) -> undefined.
-spec handle_info(term(), guild_state()) -> info_reply().
handle_info({presence, UserId, Payload}, State) ->
handle_presence_info(UserId, Payload, State);
handle_info({'EXIT', Pid, Reason}, State) ->
handle_exit_info(Pid, Reason, State);
handle_info({'DOWN', Ref, process, _Pid, Reason}, State) ->
handle_down_info(Ref, Reason, State);
handle_info(count_cache_refresh, State) ->
State1 = update_counts(State),
_ = guild_maintenance:schedule_count_cache_refresh(State1),
{noreply, State1};
handle_info(availability_recheck, State) ->
{noreply, guild_availability:handle_availability_recheck(State)};
handle_info(passive_sync, State) ->
guild_passive_sync:handle_passive_sync(State);
handle_info(presence_reconcile, State) ->
guild_presence_reconcile:start_async(State),
_ = guild_presence_reconcile:schedule(),
{noreply, State};
handle_info({presence_reconcile_apply, PresenceById}, State) when is_map(PresenceById) ->
{noreply, guild_presence_reconcile:apply_reconcile_result(PresenceById, State)};
handle_info({reconcile_user_presence, UserId}, State) ->
{noreply, guild_presence_reconcile:reconcile_user(UserId, State)};
handle_info({clear_stale_cached_voice_states, ConnectionIds}, State) ->
handle_clear_stale_cached_voice_states_info(ConnectionIds, State);
handle_info(flush_lazy_subscribe_buffer, State) ->
guild_subscription_handler:handle_info(flush_lazy_subscribe_buffer, State);
handle_info(flush_member_list_sync_batch, State) ->
{noreply, guild_member_list:flush_pending_member_list_syncs(State)};
handle_info({check_auto_stop_empty, Token}, State) ->
handle_auto_stop_info(Token, State);
handle_info(check_auto_stop_empty, State) ->
{noreply, State};
handle_info(timeout, State) ->
{noreply, State, hibernate};
handle_info(_, State) ->
{noreply, State}.
-spec handle_session_connect_call(term(), pid(), guild_state()) -> call_reply().
handle_session_connect_call(Request, CallerPid, State) when is_map(Request) ->
guild_sessions:handle_session_connect(
Request, session_connect_pid(Request, CallerPid), State
).
-spec session_connect_pid(map(), pid()) -> pid().
session_connect_pid(#{session_pid := Pid}, _CallerPid) when is_pid(Pid) ->
Pid;
session_connect_pid(#{session_pid := Pid}, _CallerPid) ->
erlang:error({bad_session_pid, Pid});
session_connect_pid(_Request, CallerPid) ->
CallerPid.
-spec handle_cached_voice_state_call(term(), guild_state()) -> call_reply().
handle_cached_voice_state_call(ConnectionId, State) when is_binary(ConnectionId) ->
guild_voice_lifecycle:reply_cached_voice_state(ConnectionId, State).
-spec handle_reload_call(term(), guild_state()) -> call_reply().
handle_reload_call(NewData, State) when is_map(NewData) ->
guild_init:handle_reload(NewData, State).
-spec handle_dispatch_cast(term(), guild_state()) -> cast_reply().
handle_dispatch_cast(#{event := Event, data := EventData}, State) ->
{noreply, dispatch_event(Event, EventData, State)}.
-spec handle_session_connect_async_cast(term(), term(), term(), map(), guild_state()) ->
cast_reply().
handle_session_connect_async_cast(GuildId, Attempt, Request, Msg, State) when
is_integer(GuildId), is_integer(Attempt), Attempt >= 0, is_map(Request)
->
NewState = guild_connect_async:enqueue_session_connect_async(
GuildId, Attempt, Request, Msg, State
),
{noreply, NewState}.
-spec handle_session_connect_worker_done_cast(term(), term(), term(), term(), guild_state()) ->
cast_reply().
handle_session_connect_worker_done_cast(SessionId, Attempt, Result0, Computed, State) when
is_binary(SessionId), is_integer(Attempt), Attempt >= 0, is_map(Computed)
->
finalize_session_connect_worker_done(SessionId, Attempt, Result0, Computed, State);
handle_session_connect_worker_done_cast(undefined, Attempt, Result0, Computed, State) when
is_integer(Attempt), Attempt >= 0, is_map(Computed)
->
finalize_session_connect_worker_done(undefined, Attempt, Result0, Computed, State).
-spec finalize_session_connect_worker_done(
binary() | undefined, non_neg_integer(), term(), map(), guild_state()
) -> cast_reply().
finalize_session_connect_worker_done(SessionId, Attempt, Result0, Computed, State) ->
NewState = guild_connect_async:finalize_session_connect_async(
SessionId, Attempt, session_connect_result(Result0), Computed, State
),
{noreply, NewState}.
-spec session_connect_result(term()) ->
{ok, map()} | {ok_unavailable, map()} | {error, term()}.
session_connect_result({ok, Result}) when is_map(Result) ->
{ok, Result};
session_connect_result({ok_unavailable, Result}) when is_map(Result) ->
{ok_unavailable, Result};
session_connect_result({error, _Reason} = Error) ->
Error.
-spec handle_set_session_active_cast(term(), guild_state()) -> cast_reply().
handle_set_session_active_cast(SessionId, State) when is_binary(SessionId) ->
{noreply, guild_sessions:set_session_active_guild(SessionId, state_guild_id(State), State)}.
-spec handle_set_session_passive_cast(term(), guild_state()) -> cast_reply().
handle_set_session_passive_cast(SessionId, State) when is_binary(SessionId) ->
{noreply,
guild_sessions:set_session_passive_guild(SessionId, state_guild_id(State), State)}.
-spec handle_set_session_typing_override_cast(term(), term(), guild_state()) -> cast_reply().
handle_set_session_typing_override_cast(SessionId, TypingFlag, State) when
is_binary(SessionId), is_boolean(TypingFlag)
->
{noreply, guild_sessions:handle_set_typing_override(SessionId, TypingFlag, State)}.
-spec handle_send_guild_sync_cast(term(), guild_state()) -> cast_reply().
handle_send_guild_sync_cast(SessionId, State) when is_binary(SessionId) ->
{noreply, guild_sessions:handle_send_guild_sync(SessionId, State)}.
-spec handle_send_members_chunk_cast(term(), term(), guild_state()) -> cast_reply().
handle_send_members_chunk_cast(SessionId, ChunkData, State) when
is_binary(SessionId), is_map(ChunkData)
->
guild_sessions:handle_send_members_chunk(SessionId, ChunkData, State),
{noreply, State}.
-spec handle_presence_info(term(), term(), guild_state()) -> info_reply().
handle_presence_info(UserId, Payload, State) when is_integer(UserId), is_map(Payload) ->
guild_presence:handle_bus_presence(UserId, Payload, State).
-spec handle_exit_info(term(), term(), guild_state()) -> info_reply().
handle_exit_info(Pid, Reason, State) when is_pid(Pid) ->
handle_exit(Pid, Reason, State).
-spec handle_down_info(term(), term(), guild_state()) -> info_reply().
handle_down_info(Ref, Reason, State) when is_reference(Ref) ->
handle_down(Ref, Reason, State).
-spec handle_clear_stale_cached_voice_states_info(term(), guild_state()) -> info_reply().
handle_clear_stale_cached_voice_states_info(ConnectionIds, State) when is_list(ConnectionIds) ->
{noreply,
guild_voice_lifecycle:clear_stale_cached_voice_states(binary_ids(ConnectionIds), State)}.
-spec binary_ids([term()]) -> [binary()].
binary_ids(Ids) ->
[Id || Id <- Ids, is_binary(Id)].
-spec handle_auto_stop_info(term(), guild_state()) -> info_reply().
handle_auto_stop_info(Token, State) when is_reference(Token) ->
handle_auto_stop(Token, State).
-spec handle_exit(pid(), term(), guild_state()) -> info_reply().
handle_exit(Pid, Reason, State) ->
case maps:get(voice_server_pid, State, undefined) of
Pid -> {noreply, guild_voice_lifecycle:handle_voice_server_exit(Pid, Reason, State)};
_ -> handle_non_voice_exit(Pid, Reason, State)
end.
-spec handle_non_voice_exit(pid(), term(), guild_state()) -> info_reply().
handle_non_voice_exit(Pid, Reason, State) ->
case maps:get(broadcaster_pid, State, undefined) of
Pid ->
{noreply, maps:remove(broadcaster_pid, State)};
_ ->
{stop, linked_process_exit_reason(Pid, Reason), State}
end.
-spec handle_down(reference(), term(), guild_state()) -> info_reply().
handle_down(Ref, Reason, State) ->
WorkerRefs = session_connect_worker_refs(State),
case maps:is_key(Ref, WorkerRefs) of
true ->
handle_session_connect_worker_down(Ref, Reason, WorkerRefs, State);
false ->
guild_sessions:handle_session_down(Ref, State)
end.
-spec handle_session_connect_worker_down(reference(), term(), map(), guild_state()) ->
info_reply().
handle_session_connect_worker_down(Ref, Reason, WorkerRefs, State) ->
State1 = State#{session_connect_worker_refs => maps:remove(Ref, WorkerRefs)},
handle_session_connect_worker_down_reason(Reason, State1).
-spec handle_session_connect_worker_down_reason(term(), guild_state()) -> info_reply().
handle_session_connect_worker_down_reason(normal, State) ->
{noreply, State};
handle_session_connect_worker_down_reason(_Reason, State) ->
State1 = guild_connect_async:decrement_session_connect_inflight(State),
{noreply, guild_connect_async:maybe_start_session_connect_workers(State1)}.
-spec handle_auto_stop(reference(), guild_state()) ->
{noreply, guild_state()} | {stop, normal, guild_state()}.
handle_auto_stop(Token, State) ->
case maps:get(auto_stop_pending, State, undefined) of
#{token := Token} ->
auto_stop_pending_reply(State);
_ ->
{noreply, State}
end.
-spec terminate(term(), guild_state() | term()) -> ok.
terminate(Reason, State) when is_map(State) ->
safe_cleanup(
fun() ->
PresenceSubs = presence_subscriptions(State),
lists:foreach(fun safe_unsubscribe_presence/1, maps:keys(PresenceSubs))
end,
"presence_unsubscribe"
),
safe_cleanup(
fun() ->
guild_maintenance:maybe_delete_permission_cache(
maps:get(id, State, undefined), State
)
end,
"permission_cache_delete"
),
safe_cleanup(
fun() ->
cleanup_per_guild_ets(State)
end,
"ets_cleanup"
),
safe_cleanup(
fun() ->
cleanup_voice_server(State)
end,
"voice_cleanup"
),
safe_cleanup(
fun() ->
cleanup_member_list_subs(State)
end,
"member_list_subs_cleanup"
),
safe_cleanup(
fun() ->
cleanup_member_list_engine(State)
end,
"member_list_engine_cleanup"
),
maybe_report_crash(Reason, State),
ok;
terminate(Reason, State) ->
maybe_report_crash(Reason, State),
ok.
-spec code_change(term(), guild_state(), term()) -> {ok, guild_state()}.
code_change(_OldVsn, State, _Extra) ->
erlang:process_flag(fullsweep_after, 10),
erlang:garbage_collect(),
{ok, State}.
-spec safe_unsubscribe_presence(integer()) -> ok.
safe_unsubscribe_presence(UserId) ->
try presence_bus:unsubscribe(UserId) of
_ -> ok
catch
throw:_Reason -> ok;
error:_Reason -> ok;
exit:_Reason -> ok
end.
-spec safe_cleanup(fun(() -> term()), string()) -> ok.
safe_cleanup(Fun, Label) ->
try Fun() of
_ -> ok
catch
Class:Reason ->
logger:warning(
"guild_terminate_cleanup_failed: step=~s class=~p reason=~p",
[Label, Class, Reason]
),
ok
end.
-spec cleanup_per_guild_ets(guild_state()) -> ok.
cleanup_per_guild_ets(State) ->
Data = maps:get(data, State, #{}),
safe_delete_ets(maps:get(members_ets, Data, undefined)),
safe_delete_ets(maps:get(member_presence, State, undefined)),
safe_delete_ets(maps:get(viewable_channels_cache, State, undefined)),
ok.
-spec cleanup_voice_server(guild_state()) -> ok.
cleanup_voice_server(State) ->
case maps:get(voice_server_pid, State, undefined) of
Pid when is_pid(Pid) ->
stop_voice_server_if_alive(Pid);
_ ->
ok
end.
-spec stop_voice_server_if_alive(pid()) -> ok.
stop_voice_server_if_alive(Pid) ->
case process_liveness:is_alive(Pid) of
true -> safe_stop_voice_server(Pid);
false -> ok
end.
-spec safe_stop_voice_server(pid()) -> ok.
safe_stop_voice_server(Pid) ->
try gen_server:stop(Pid, shutdown, 5000) of
_ -> ok
catch
exit:_Reason -> ok
end.
-spec cleanup_member_list_subs(guild_state()) -> ok.
cleanup_member_list_subs(State) ->
case maps:get(member_list_subscriptions, State, undefined) of
Tab when Tab =/= undefined ->
guild_member_list_subs:destroy(Tab);
_ ->
ok
end.
-spec cleanup_member_list_engine(guild_state()) -> ok.
cleanup_member_list_engine(State) ->
_ = guild_member_list_channel_engine:destroy_all(State),
case maps:get(member_list_engine, State, undefined) of
Ref when Ref =/= undefined ->
guild_member_list_engine:destroy(Ref);
_ ->
ok
end.
-spec safe_delete_ets(term()) -> ok.
safe_delete_ets(undefined) ->
ok;
safe_delete_ets(Tab) ->
try ets:delete(eqwalizer:dynamic_cast(Tab)) of
_ -> ok
catch
error:badarg -> ok
end.
-spec linked_process_exit_reason(pid(), term()) -> term().
linked_process_exit_reason(_Pid, normal) -> normal;
linked_process_exit_reason(_Pid, shutdown) -> shutdown;
linked_process_exit_reason(_Pid, {shutdown, _} = Reason) -> Reason;
linked_process_exit_reason(Pid, Reason) -> {linked_process_exit, Pid, Reason}.
-spec handle_dispatch_call(term(), guild_state()) -> {reply, ok, guild_state()}.
handle_dispatch_call(#{event := Event, data := EventData}, State) ->
{reply, ok, dispatch_event(Event, EventData, State)}.
-spec dispatch_event(term(), term(), guild_state()) -> guild_state().
dispatch_event(Event, EventData, State) ->
ParsedEventData = parse_event_data(EventData),
{noreply, NewState} = guild_dispatch:handle_dispatch(
Event, ParsedEventData, State
),
StateAfterPrune = guild_maintenance:maybe_prune_invalid_member_subscriptions(
Event, NewState
),
ok = maybe_refresh_permission_cache(Event, ParsedEventData, State, StateAfterPrune),
StateAfterPrune.
-spec parse_event_data(term()) -> map().
parse_event_data(D) when is_binary(D) -> require_map(json:decode(D));
parse_event_data(D) when is_map(D) -> D.
-spec maybe_refresh_permission_cache(term(), map(), guild_state(), guild_state()) -> ok.
maybe_refresh_permission_cache(Event, EventData, OldState, NewState) ->
case event_mutates_guild_data(Event) of
true ->
guild_maintenance:maybe_put_permission_cache(Event, EventData, OldState, NewState);
false ->
ok
end.
-spec event_mutates_guild_data(term()) -> boolean().
event_mutates_guild_data(E) ->
lists:member(E, [
guild_member_add,
guild_member_update,
guild_member_remove,
guild_role_create,
guild_role_update,
guild_role_update_bulk,
guild_role_delete,
channel_create,
channel_update,
channel_update_bulk,
channel_delete,
guild_update
]).
-spec state_guild_id(guild_state()) -> integer().
state_guild_id(#{id := GuildId}) when is_integer(GuildId) ->
GuildId.
-spec session_connect_worker_refs(guild_state()) -> map().
session_connect_worker_refs(State) ->
require_map(maps:get(session_connect_worker_refs, State, #{})).
-spec auto_stop_pending_reply(guild_state()) ->
{noreply, guild_state()} | {stop, normal, guild_state()}.
auto_stop_pending_reply(State) ->
CleanState = maps:remove(auto_stop_pending, State),
case map_size(sessions_map(State)) of
0 -> {stop, normal, CleanState};
_ -> {noreply, CleanState}
end.
-spec sessions_map(guild_state()) -> map().
sessions_map(State) ->
require_map(maps:get(sessions, State, #{})).
-spec presence_subscriptions(guild_state()) -> map().
presence_subscriptions(State) ->
require_map(maps:get(presence_subscriptions, State, #{})).
-spec require_map(term()) -> map().
require_map(Value) when is_map(Value) ->
Value;
require_map(Value) ->
erlang:error({badmap, Value}).
-spec maybe_report_crash(term(), term()) -> ok.
maybe_report_crash(normal, _) ->
ok;
maybe_report_crash(shutdown, _) ->
ok;
maybe_report_crash({shutdown, _}, _) ->
ok;
maybe_report_crash(_Reason, _State) ->
ok.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
handle_non_voice_exit_broadcaster_keeps_guild_alive_test() ->
BPid = list_to_pid("<0.250.0>"),
State = #{id => 42, broadcaster_pid => BPid},
?assertEqual({noreply, #{id => 42}}, handle_non_voice_exit(BPid, killed, State)).
handle_non_voice_exit_other_linked_stops_guild_test() ->
BPid = list_to_pid("<0.250.0>"),
OtherPid = list_to_pid("<0.251.0>"),
State = #{id => 42, broadcaster_pid => BPid},
?assertMatch(
{stop, {linked_process_exit, OtherPid, boom}, State},
handle_non_voice_exit(OtherPid, boom, State)
).
-endif.