%% 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 . -module(guild_availability). -export([ is_guild_unavailable_for_user/2, is_user_staff/2, check_unavailability_transition/2, handle_unavailability_transition/2, get_cached_unavailability_mode/1, is_guild_unavailable_for_user_from_cache/2, update_unavailability_cache_for_state/1 ]). -type guild_state() :: map(). -type user_id() :: integer(). -type guild_id() :: integer(). -type unavailability_mode() :: available | unavailable_for_everyone | unavailable_for_everyone_but_staff. -type transition_result() :: {unavailable_enabled, boolean()} | unavailable_disabled | no_change. -define(GUILD_UNAVAILABILITY_CACHE, guild_unavailability_cache). -define(STAFF_USER_FLAG, 16#1). -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. -spec is_guild_unavailable_for_user(user_id(), guild_state()) -> boolean(). is_guild_unavailable_for_user(UserId, State) -> case get_unavailability_mode_from_state(State) of unavailable_for_everyone -> true; unavailable_for_everyone_but_staff -> not is_user_staff(UserId, State); available -> false end. -spec is_user_staff(user_id(), guild_state()) -> boolean(). is_user_staff(UserId, State) -> case is_user_staff_from_sessions(UserId, State) of true -> true; false -> false; undefined -> case guild_permissions:find_member_by_user_id(UserId, State) of undefined -> false; Member -> User = maps:get(<<"user">>, Member, #{}), is_user_staff_from_user_data(User) end end. -spec is_user_staff_from_sessions(user_id(), guild_state()) -> boolean() | undefined. is_user_staff_from_sessions(UserId, State) -> Sessions = maps:get(sessions, State, #{}), maps:fold( fun(_SessionId, SessionData, Acc) -> case Acc of undefined -> SessionUserId = maps:get(user_id, SessionData, undefined), SessionIsStaff = maps:get(is_staff, SessionData, undefined), case {SessionUserId =:= UserId, SessionIsStaff} of {true, true} -> true; {true, false} -> false; _ -> undefined end; _ -> Acc end end, undefined, Sessions ). -spec get_cached_unavailability_mode(guild_id()) -> unavailability_mode(). get_cached_unavailability_mode(GuildId) -> ensure_unavailability_cache_table(), case ets:lookup(?GUILD_UNAVAILABILITY_CACHE, GuildId) of [{GuildId, Mode}] -> normalize_unavailability_mode(Mode); [] -> available end. -spec is_guild_unavailable_for_user_from_cache(guild_id(), map()) -> boolean(). is_guild_unavailable_for_user_from_cache(GuildId, UserData) -> case get_cached_unavailability_mode(GuildId) of unavailable_for_everyone -> true; unavailable_for_everyone_but_staff -> not is_user_staff_from_user_data(UserData); available -> false end. -spec update_unavailability_cache_for_state(guild_state()) -> unavailability_mode(). update_unavailability_cache_for_state(State) -> GuildId = maps:get(id, State), Mode = get_unavailability_mode_from_state(State), set_cached_unavailability_mode(GuildId, Mode), Mode. -spec check_unavailability_transition(guild_state(), guild_state()) -> transition_result(). check_unavailability_transition(OldState, NewState) -> OldMode = get_unavailability_mode_from_state(OldState), NewMode = get_unavailability_mode_from_state(NewState), case {OldMode, NewMode} of {available, unavailable_for_everyone} -> {unavailable_enabled, false}; {available, unavailable_for_everyone_but_staff} -> {unavailable_enabled, true}; {unavailable_for_everyone, available} -> unavailable_disabled; {unavailable_for_everyone_but_staff, available} -> unavailable_disabled; {unavailable_for_everyone_but_staff, unavailable_for_everyone} -> {unavailable_enabled, false}; {unavailable_for_everyone, unavailable_for_everyone_but_staff} -> {unavailable_enabled, true}; _ -> no_change end. -spec handle_unavailability_transition(guild_state(), guild_state()) -> guild_state(). handle_unavailability_transition(OldState, NewState) -> _ = update_unavailability_cache_for_state(NewState), GuildId = maps:get(id, NewState), case check_unavailability_transition(OldState, NewState) of {unavailable_enabled, StaffOnly} -> disconnect_ineligible_sessions(StaffOnly, NewState, GuildId); unavailable_disabled -> Sessions = maps:get(sessions, NewState, #{}), BulkPresences = presence_utils:collect_guild_member_presences(NewState), lists:foreach( fun({_SessionId, SessionData}) -> case maps:get(pending_connect, SessionData, false) of true -> ok; false -> UserId = maps:get(user_id, SessionData), Pid = maps:get(pid, SessionData), GuildState = guild_data:get_guild_state(UserId, NewState), gen_server:cast(Pid, {dispatch, guild_create, GuildState}), presence_utils:send_presence_bulk(Pid, GuildId, UserId, BulkPresences) end end, maps:to_list(Sessions) ), NewState; no_change -> NewState end. -spec disconnect_ineligible_sessions(boolean(), guild_state(), guild_id()) -> guild_state(). disconnect_ineligible_sessions(StaffOnly, State, GuildId) -> Sessions = maps:get(sessions, State, #{}), {FinalState, _DisconnectedUsers} = lists:foldl( fun({SessionId, SessionData}, {AccState, ProcessedUsers}) -> UserId = maps:get(user_id, SessionData), case should_disconnect_user(UserId, StaffOnly, AccState) of true -> Pid = maps:get(pid, SessionData, undefined), maybe_send_guild_leave(Pid, GuildId), {VoiceState, UpdatedUsers} = maybe_disconnect_voice_for_user(UserId, ProcessedUsers, AccState), NewState = guild_sessions:remove_session(SessionId, VoiceState), {NewState, UpdatedUsers}; false -> {AccState, ProcessedUsers} end end, {State, sets:new()}, maps:to_list(Sessions) ), FinalState. -spec should_disconnect_user(user_id(), boolean(), guild_state()) -> boolean(). should_disconnect_user(UserId, true, State) -> not is_user_staff(UserId, State); should_disconnect_user(_UserId, false, _State) -> true. -spec maybe_send_guild_leave(pid() | undefined, guild_id()) -> ok. maybe_send_guild_leave(Pid, GuildId) when is_pid(Pid) -> gen_server:cast(Pid, {guild_leave, GuildId, forced_unavailable}), ok; maybe_send_guild_leave(_Pid, _GuildId) -> ok. -spec maybe_disconnect_voice_for_user(user_id(), sets:set(user_id()), guild_state()) -> {guild_state(), sets:set(user_id())}. maybe_disconnect_voice_for_user(UserId, ProcessedUsers, State) -> case sets:is_element(UserId, ProcessedUsers) of true -> {State, ProcessedUsers}; false -> {reply, _Result, VoiceState} = guild_voice_disconnect:disconnect_voice_user( #{user_id => UserId, connection_id => null}, State ), {VoiceState, sets:add_element(UserId, ProcessedUsers)} end. -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. -spec set_cached_unavailability_mode(guild_id(), unavailability_mode()) -> ok. set_cached_unavailability_mode(GuildId, available) -> ensure_unavailability_cache_table(), ets:delete(?GUILD_UNAVAILABILITY_CACHE, GuildId), ok; set_cached_unavailability_mode(GuildId, Mode) -> ensure_unavailability_cache_table(), ets:insert(?GUILD_UNAVAILABILITY_CACHE, {GuildId, Mode}), ok. -spec normalize_unavailability_mode(term()) -> unavailability_mode(). normalize_unavailability_mode(unavailable_for_everyone) -> unavailable_for_everyone; normalize_unavailability_mode(unavailable_for_everyone_but_staff) -> unavailable_for_everyone_but_staff; normalize_unavailability_mode(_) -> available. -spec get_unavailability_mode_from_state(guild_state()) -> unavailability_mode(). get_unavailability_mode_from_state(State) -> Data = maps:get(data, State, #{}), Guild = maps:get(<<"guild">>, Data, #{}), Features = maps:get(<<"features">>, Guild, []), get_unavailability_mode_from_features(Features). -spec get_unavailability_mode_from_features(term()) -> unavailability_mode(). get_unavailability_mode_from_features(Features) when is_list(Features) -> HasUnavailableForEveryone = lists:member(<<"UNAVAILABLE_FOR_EVERYONE">>, Features), HasUnavailableForEveryoneButStaff = lists:member(<<"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF">>, Features), case {HasUnavailableForEveryone, HasUnavailableForEveryoneButStaff} of {true, _} -> unavailable_for_everyone; {false, true} -> unavailable_for_everyone_but_staff; {false, false} -> available end; get_unavailability_mode_from_features(_) -> available. -spec is_user_staff_from_user_data(map()) -> boolean(). is_user_staff_from_user_data(UserData) when is_map(UserData) -> case parse_is_staff_value(maps:get(<<"is_staff">>, UserData, undefined)) of undefined -> is_user_staff_from_flags(UserData); IsStaff -> IsStaff end; is_user_staff_from_user_data(_) -> false. -spec parse_is_staff_value(term()) -> boolean() | undefined. parse_is_staff_value(true) -> true; parse_is_staff_value(false) -> false; parse_is_staff_value(<<"true">>) -> true; parse_is_staff_value(<<"false">>) -> false; parse_is_staff_value(_) -> undefined. -spec is_user_staff_from_flags(map()) -> boolean(). is_user_staff_from_flags(UserData) -> FlagsValue = maps:get(<<"flags">>, UserData, 0), Flags = type_conv:to_integer(FlagsValue), case Flags of undefined -> false; Value when is_integer(Value) -> (Value band ?STAFF_USER_FLAG) =:= ?STAFF_USER_FLAG end. -ifdef(TEST). -spec cleanup_unavailability_cache(guild_id()) -> ok. cleanup_unavailability_cache(GuildId) -> set_cached_unavailability_mode(GuildId, available). disconnect_ineligible_sessions_staff_only_test() -> Parent = self(), GuildId = 99001, NonStaffPid = start_session_capture(non_staff, Parent), StaffPid = start_session_capture(staff, Parent), try BaseState = state_for_unavailability_transition_test(GuildId, NonStaffPid, StaffPid), OldState = BaseState, NewState = BaseState#{ data => #{ <<"guild">> => #{ <<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF">>] }, <<"members">> => [ #{<<"user">> => #{<<"id">> => <<"1001">>, <<"flags">> => <<"0">>}}, #{<<"user">> => #{<<"id">> => <<"1002">>, <<"flags">> => <<"1">>}} ] } }, UpdatedState = handle_unavailability_transition(OldState, NewState), Sessions = maps:get(sessions, UpdatedState, #{}), ?assertEqual(1, map_size(Sessions)), ?assert(maps:is_key(<<"staff">>, Sessions)), ?assertEqual(unavailable_for_everyone_but_staff, get_cached_unavailability_mode(GuildId)), receive {non_staff, {'$gen_cast', {guild_leave, GuildId, forced_unavailable}}} -> ok after 1000 -> ?assert(false) end, receive {staff, {'$gen_cast', {guild_leave, GuildId, forced_unavailable}}} -> ?assert(false) after 200 -> ok end after cleanup_unavailability_cache(GuildId), NonStaffPid ! stop, StaffPid ! stop end. disconnect_ineligible_sessions_everyone_test() -> Parent = self(), GuildId = 99002, UserOnePid = start_session_capture(user_one, Parent), UserTwoPid = start_session_capture(user_two, Parent), try BaseState = state_for_unavailability_transition_test(GuildId, UserOnePid, UserTwoPid), OldState = BaseState, NewState = BaseState#{ data => #{ <<"guild">> => #{ <<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE">>] }, <<"members">> => [ #{<<"user">> => #{<<"id">> => <<"1001">>, <<"flags">> => <<"0">>}}, #{<<"user">> => #{<<"id">> => <<"1002">>, <<"flags">> => <<"1">>}} ] } }, UpdatedState = handle_unavailability_transition(OldState, NewState), ?assertEqual(#{}, maps:get(sessions, UpdatedState, #{})), ?assertEqual(unavailable_for_everyone, get_cached_unavailability_mode(GuildId)), receive {user_one, {'$gen_cast', {guild_leave, GuildId, forced_unavailable}}} -> ok after 1000 -> ?assert(false) end, receive {user_two, {'$gen_cast', {guild_leave, GuildId, forced_unavailable}}} -> ok after 1000 -> ?assert(false) end after cleanup_unavailability_cache(GuildId), UserOnePid ! stop, UserTwoPid ! stop end. is_guild_unavailable_for_user_from_cache_is_staff_test() -> GuildId = 99003, try set_cached_unavailability_mode(GuildId, unavailable_for_everyone_but_staff), ?assertEqual( true, is_guild_unavailable_for_user_from_cache( GuildId, #{<<"is_staff">> => false} ) ), ?assertEqual( false, is_guild_unavailable_for_user_from_cache( GuildId, #{<<"is_staff">> => true} ) ) after cleanup_unavailability_cache(GuildId) end. -spec start_session_capture(atom(), pid()) -> pid(). start_session_capture(Tag, Parent) -> spawn(fun() -> session_capture_loop(Tag, Parent) end). -spec session_capture_loop(atom(), pid()) -> ok. session_capture_loop(Tag, Parent) -> receive stop -> ok; {'$gen_cast', Msg} -> Parent ! {Tag, {'$gen_cast', Msg}}, session_capture_loop(Tag, Parent); _Other -> session_capture_loop(Tag, Parent) end. -spec state_for_unavailability_transition_test(guild_id(), pid(), pid()) -> guild_state(). state_for_unavailability_transition_test(GuildId, NonStaffPid, StaffPid) -> #{ id => GuildId, sessions => #{ <<"non_staff">> => #{ session_id => <<"non_staff">>, user_id => 1001, pid => NonStaffPid, mref => make_ref(), active_guilds => sets:new(), user_roles => [], bot => false, is_staff => false, previous_passive_updates => #{}, previous_passive_channel_versions => #{}, previous_passive_voice_states => #{} }, <<"staff">> => #{ session_id => <<"staff">>, user_id => 1002, pid => StaffPid, mref => make_ref(), active_guilds => sets:new(), user_roles => [], bot => false, is_staff => true, previous_passive_updates => #{}, previous_passive_channel_versions => #{}, previous_passive_voice_states => #{} } }, presence_subscriptions => #{}, member_list_subscriptions => #{}, member_subscriptions => guild_subscriptions:init_state(), data => #{ <<"guild">> => #{ <<"features">> => [] }, <<"members">> => [ #{<<"user">> => #{<<"id">> => <<"1001">>, <<"flags">> => <<"0">>}}, #{<<"user">> => #{<<"id">> => <<"1002">>, <<"flags">> => <<"1">>}} ] }, voice_states => #{}, pending_voice_connections => #{} }. is_guild_unavailable_for_user_unavailable_for_everyone_test() -> State = #{ data => #{ <<"guild">> => #{ <<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE">>] }, <<"members">> => [] } }, ?assertEqual(true, is_guild_unavailable_for_user(123, State)). is_guild_unavailable_for_user_available_test() -> State = #{ data => #{ <<"guild">> => #{ <<"features">> => [] }, <<"members">> => [] } }, ?assertEqual(false, is_guild_unavailable_for_user(123, State)). check_unavailability_transition_no_change_test() -> State = #{ data => #{ <<"guild">> => #{ <<"features">> => [] } } }, ?assertEqual(no_change, check_unavailability_transition(State, State)). check_unavailability_transition_enabled_test() -> OldState = #{ data => #{ <<"guild">> => #{ <<"features">> => [] } } }, NewState = #{ data => #{ <<"guild">> => #{ <<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE">>] } } }, ?assertEqual({unavailable_enabled, false}, check_unavailability_transition(OldState, NewState)). check_unavailability_transition_disabled_test() -> OldState = #{ data => #{ <<"guild">> => #{ <<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE">>] } } }, NewState = #{ data => #{ <<"guild">> => #{ <<"features">> => [] } } }, ?assertEqual(unavailable_disabled, check_unavailability_transition(OldState, NewState)). -endif.