initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,637 @@
%% 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(call).
-behaviour(gen_server).
-export([start_link/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-record(state, {
channel_id,
message_id,
region,
ringing = [],
pending_ringing = [],
recipients = [],
voice_states = #{},
sessions = #{},
pending_connections = #{},
initiator_ready = false,
ringing_timers = #{},
idle_timer = undefined,
created_at,
participants_history = sets:new() :: sets:set(integer())
}).
-type state() :: #state{
channel_id :: integer(),
message_id :: integer(),
region :: term(),
ringing :: [integer()],
pending_ringing :: [integer()],
recipients :: [integer()],
voice_states :: map(),
sessions :: map(),
pending_connections :: map(),
initiator_ready :: boolean(),
ringing_timers :: map(),
idle_timer :: reference() | undefined,
created_at :: integer(),
participants_history :: sets:set(integer())
}.
-define(RING_TIMEOUT_MS, 30000).
-define(IDLE_TIMEOUT_MS, 120000).
-spec start_link(map()) -> {ok, pid()} | {error, term()} | ignore.
start_link(CallData) ->
gen_server:start_link(?MODULE, CallData, []).
-spec init(map()) -> {ok, state()}.
init(CallData) ->
#{
channel_id := ChannelId,
message_id := MessageId,
region := Region,
ringing := Ringing,
recipients := Recipients
} = CallData,
State = #state{
channel_id = ChannelId,
message_id = MessageId,
region = Region,
ringing = [],
pending_ringing = Ringing,
recipients = Recipients,
created_at = erlang:system_time(millisecond)
},
ReadyState = ensure_initiator_ready(State),
{StateWithRinging, Dispatched} = maybe_dispatch_pending_ringing(ReadyState, false),
StateWithIdleTimer = reset_idle_timer(StateWithRinging),
dispatch_call_create(StateWithIdleTimer),
case Dispatched of
false ->
case StateWithIdleTimer#state.ringing of
[] -> ok;
_ -> dispatch_call_update(StateWithIdleTimer)
end;
true ->
ok
end,
{ok, StateWithIdleTimer}.
handle_call({get_state}, _From, State) ->
CallData = #{
channel_id => integer_to_binary(State#state.channel_id),
message_id => integer_to_binary(State#state.message_id),
region => State#state.region,
ringing => integer_list_to_binaries(State#state.ringing),
voice_states => [format_voice_state(VS) || VS <- maps:values(State#state.voice_states)],
created_at => State#state.created_at
},
{reply, {ok, CallData}, State};
handle_call({update_region, NewRegion}, _From, State) ->
NewState = State#state{region = NewRegion},
dispatch_call_update(NewState),
{reply, ok, NewState};
handle_call({ring_recipients, Recipients}, _From, State) ->
CurrentVoiceUsers = maps:keys(State#state.voice_states),
PendingAdditions = [U || U <- Recipients, not lists:member(U, CurrentVoiceUsers)],
NewPending = lists:usort(State#state.pending_ringing ++ PendingAdditions),
StateWithPending = State#state{pending_ringing = NewPending},
{UpdatedState, _} = maybe_dispatch_pending_ringing(StateWithPending),
{reply, ok, UpdatedState};
handle_call({stop_ringing, Recipients}, _From, State) ->
CancelledState = cancel_ringing_timers(Recipients, State),
NewRinging = CancelledState#state.ringing -- Recipients,
NewPending = CancelledState#state.pending_ringing -- Recipients,
StateWithoutRecipients = CancelledState#state{
ringing = NewRinging, pending_ringing = NewPending
},
{UpdatedState, _} = maybe_dispatch_state_update(CancelledState, StateWithoutRecipients),
{reply, ok, UpdatedState};
handle_call({join, UserId, VoiceState, SessionId, SessionPid}, _From, State) ->
handle_join_internal(UserId, VoiceState, SessionId, SessionPid, undefined, State);
handle_call({join, UserId, VoiceState, SessionId, SessionPid, ConnectionId}, _From, State) ->
handle_join_internal(UserId, VoiceState, SessionId, SessionPid, ConnectionId, State);
handle_call({confirm_connection, ConnectionId}, _From, State) ->
ReadyState = ensure_initiator_ready(State),
case
voice_pending_common:confirm_pending_connection(
ConnectionId, ReadyState#state.pending_connections
)
of
{not_found, _} ->
{DispatchedState, _} = maybe_dispatch_pending_ringing(ReadyState),
{reply, #{success => true, already_confirmed => true}, DispatchedState};
{confirmed, NewPending} ->
logger:info(
"[call] Confirmed voice connection ~p for channel ~p",
[ConnectionId, ReadyState#state.channel_id]
),
StateWithPending = ReadyState#state{pending_connections = NewPending},
{DispatchedState, _} = maybe_dispatch_pending_ringing(StateWithPending),
{reply, #{success => true}, DispatchedState}
end;
handle_call({disconnect_user_if_in_channel, UserId, ExpectedChannelId, ConnectionId}, _From, State) ->
CleanupFun = fun(_U, _S) -> ok end,
case
voice_disconnect_common:disconnect_user_if_in_channel(
UserId,
ExpectedChannelId,
State#state.voice_states,
State#state.sessions,
CleanupFun
)
of
{not_found, _, _} ->
NewPending = voice_pending_common:remove_pending_connection(
ConnectionId, State#state.pending_connections
),
{reply, #{success => true, ignored => true, reason => <<"not_in_call">>}, State#state{
pending_connections = NewPending
}};
{channel_mismatch, _, _} ->
{reply, #{success => true, ignored => true, reason => <<"channel_mismatch">>}, State};
{ok, NewVoiceStates, NewSessions} ->
NewPending = voice_pending_common:remove_pending_connection(
ConnectionId, State#state.pending_connections
),
BaseState = State#state{
voice_states = NewVoiceStates,
sessions = NewSessions,
pending_connections = NewPending
},
CancelledTimersState = cancel_ringing_timers([UserId], BaseState),
RingCleanupState = remove_users_from_ringing([UserId], CancelledTimersState),
{UpdatedState, Dispatched} = maybe_dispatch_state_update(BaseState, RingCleanupState),
case maps:size(UpdatedState#state.voice_states) of
0 ->
dispatch_call_delete(UpdatedState),
{stop, normal, #{success => true}, UpdatedState};
_ ->
case Dispatched of
true -> ok;
false -> dispatch_call_update(UpdatedState)
end,
{reply, #{success => true}, UpdatedState}
end
end;
handle_call({leave, SessionId}, _From, State) ->
case maps:get(SessionId, State#state.sessions, undefined) of
{UserId, _Pid, Ref} ->
demonitor(Ref, [flush]),
NewVoiceStates = maps:remove(UserId, State#state.voice_states),
NewSessions = maps:remove(SessionId, State#state.sessions),
BaseState = State#state{
voice_states = NewVoiceStates,
sessions = NewSessions
},
CancelledTimersState = cancel_ringing_timers([UserId], BaseState),
RingCleanupState = remove_users_from_ringing([UserId], CancelledTimersState),
{UpdatedState, Dispatched} = maybe_dispatch_state_update(BaseState, RingCleanupState),
case maps:size(UpdatedState#state.voice_states) of
0 ->
dispatch_call_delete(UpdatedState),
{stop, normal, ok, UpdatedState};
_ ->
case Dispatched of
true -> ok;
false -> dispatch_call_update(UpdatedState)
end,
{reply, ok, UpdatedState}
end;
undefined ->
{reply, {error, not_found}, State}
end;
handle_call({update_voice_state, UserId, VoiceState}, _From, State) ->
case maps:is_key(UserId, State#state.voice_states) of
true ->
NewVoiceStates = maps:put(UserId, VoiceState, State#state.voice_states),
NewState = State#state{voice_states = NewVoiceStates},
dispatch_call_update(NewState),
{reply, ok, NewState};
false ->
{reply, {error, not_in_call}, State}
end;
handle_call({get_sessions}, _From, State) ->
StateMap = #{
sessions => State#state.sessions,
voice_states => State#state.voice_states
},
{reply, StateMap, State};
handle_call(_Request, _From, State) ->
{reply, ok, State}.
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info({'DOWN', _Ref, process, Pid, _Reason}, State) ->
case find_session_by_pid(Pid, State#state.sessions) of
{ok, SessionId, UserId} ->
NewVoiceStates = maps:remove(UserId, State#state.voice_states),
NewSessions = maps:remove(SessionId, State#state.sessions),
BaseState = State#state{
voice_states = NewVoiceStates,
sessions = NewSessions
},
CancelledTimersState = cancel_ringing_timers([UserId], BaseState),
RingCleanupState = remove_users_from_ringing([UserId], CancelledTimersState),
{UpdatedState, Dispatched} = maybe_dispatch_state_update(BaseState, RingCleanupState),
case maps:size(UpdatedState#state.voice_states) of
0 ->
dispatch_call_delete(UpdatedState),
{stop, normal, UpdatedState};
_ ->
case Dispatched of
true -> ok;
false -> dispatch_call_update(UpdatedState)
end,
{noreply, UpdatedState}
end;
not_found ->
{noreply, State}
end;
handle_info({ring_timeout, UserId}, State) ->
case maps:get(UserId, State#state.ringing_timers, undefined) of
undefined ->
{noreply, State};
_ ->
CancelState = cancel_ringing_timers([UserId], State),
RingCleanupState = remove_users_from_ringing([UserId], CancelState),
{UpdatedState, _} = maybe_dispatch_state_update(State, RingCleanupState),
HasParticipants = maps:size(UpdatedState#state.voice_states) > 0,
HasPendingRinging = length(UpdatedState#state.ringing) > 0,
case HasParticipants orelse HasPendingRinging of
true ->
{noreply, UpdatedState};
false ->
dispatch_call_delete(UpdatedState),
{stop, normal, UpdatedState}
end
end;
handle_info({pending_connection_timeout, ConnectionId}, State) ->
case
voice_pending_common:get_pending_connection(
ConnectionId, State#state.pending_connections
)
of
undefined ->
{noreply, State};
#{user_id := UserId, session_id := SessionId} ->
logger:warning(
"[call] Pending connection ~p timed out for user ~p in channel ~p",
[ConnectionId, UserId, State#state.channel_id]
),
case maps:get(SessionId, State#state.sessions, undefined) of
{UserId, SessionPid, _Ref} when is_pid(SessionPid) ->
case erlang:is_process_alive(SessionPid) of
true ->
logger:warning(
"[call] Pending connection ~p timed out, but session is still alive; keeping user ~p in call",
[ConnectionId, UserId]
),
NewPending = voice_pending_common:remove_pending_connection(
ConnectionId, State#state.pending_connections
),
{noreply, State#state{pending_connections = NewPending}};
false ->
disconnect_user_after_pending_timeout(
ConnectionId, UserId, SessionId, State
)
end;
_ ->
disconnect_user_after_pending_timeout(ConnectionId, UserId, SessionId, State)
end
end;
handle_info(idle_timeout, State) ->
HasParticipants = maps:size(State#state.voice_states) > 0,
HasPendingRinging = length(State#state.ringing) > 0,
case HasParticipants orelse HasPendingRinging of
true ->
{noreply, reset_idle_timer(State)};
false ->
logger:info(
"[call] Idle timeout - deleting empty call for channel ~p",
[State#state.channel_id]
),
dispatch_call_delete(State),
{stop, normal, State}
end;
handle_info(_Info, State) ->
{noreply, State}.
disconnect_user_after_pending_timeout(ConnectionId, UserId, SessionId, State) ->
NewPending = voice_pending_common:remove_pending_connection(
ConnectionId, State#state.pending_connections
),
NewVoiceStates = maps:remove(UserId, State#state.voice_states),
NewSessions =
case maps:get(SessionId, State#state.sessions, undefined) of
undefined ->
State#state.sessions;
{_, _, Ref} ->
demonitor(Ref, [flush]),
maps:remove(SessionId, State#state.sessions)
end,
NewState = State#state{
pending_connections = NewPending,
voice_states = NewVoiceStates,
sessions = NewSessions
},
case maps:size(NewVoiceStates) of
0 ->
dispatch_call_delete(NewState),
{stop, normal, NewState};
_ ->
dispatch_call_update(NewState),
{noreply, NewState}
end.
terminate(_Reason, _State) ->
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
dispatch_call_create(State) ->
Event = #{
channel_id => integer_to_binary(State#state.channel_id),
message_id => integer_to_binary(State#state.message_id),
region => State#state.region,
ringing => integer_list_to_binaries(State#state.ringing),
voice_states => [format_voice_state(VS) || VS <- maps:values(State#state.voice_states)]
},
lists:foreach(
fun(RecipientId) ->
presence_manager:dispatch_to_user(RecipientId, call_create, Event)
end,
State#state.recipients
).
maybe_dispatch_pending_ringing(State) ->
maybe_dispatch_pending_ringing(State, true).
dispatch_call_update(State) ->
Event = #{
channel_id => integer_to_binary(State#state.channel_id),
message_id => integer_to_binary(State#state.message_id),
region => State#state.region,
ringing => integer_list_to_binaries(State#state.ringing),
voice_states => [format_voice_state(VS) || VS <- maps:values(State#state.voice_states)]
},
lists:foreach(
fun(RecipientId) ->
presence_manager:dispatch_to_user(RecipientId, call_update, Event)
end,
State#state.recipients
).
dispatch_call_delete(State) ->
Event = #{
channel_id => integer_to_binary(State#state.channel_id)
},
lists:foreach(
fun(RecipientId) ->
presence_manager:dispatch_to_user(RecipientId, call_delete, Event)
end,
State#state.recipients
),
notify_call_ended(cancel_all_ringing_timers(State)).
notify_call_ended(State) ->
Participants = sets:to_list(State#state.participants_history),
EndedAt = erlang:system_time(millisecond),
Request = #{
<<"type">> => <<"call_ended">>,
<<"channel_id">> => integer_to_binary(State#state.channel_id),
<<"message_id">> => integer_to_binary(State#state.message_id),
<<"participants">> => integer_list_to_binaries(Participants),
<<"ended_timestamp">> => EndedAt
},
spawn(fun() ->
case rpc_client:call(Request) of
{ok, _} ->
logger:debug("[call] Successfully notified API of call end for channel ~p", [
State#state.channel_id
]);
{error, Reason} ->
logger:warning("[call] Failed to notify API of call end: ~p", [Reason])
end
end).
ensure_initiator_ready(State) ->
case State#state.initiator_ready of
true ->
State;
false ->
State#state{initiator_ready = true}
end.
maybe_dispatch_pending_ringing(State, DispatchUpdates) ->
case State#state.initiator_ready of
false ->
{State, false};
true ->
PendingUnique = lists:usort(State#state.pending_ringing),
case PendingUnique of
[] ->
{State#state{pending_ringing = []}, false};
_ ->
ConnectedUsers = maps:keys(State#state.voice_states),
AlreadyRinging = State#state.ringing,
ToAdd =
[
User
|| User <- PendingUnique,
not lists:member(User, ConnectedUsers),
not lists:member(User, AlreadyRinging)
],
NewRinging =
case ToAdd of
[] -> AlreadyRinging;
_ -> lists:usort(AlreadyRinging ++ ToAdd)
end,
StateWithRinging = State#state{pending_ringing = [], ringing = NewRinging},
StateWithTimers = start_ringing_timers(ToAdd, StateWithRinging),
case ToAdd of
[] ->
{StateWithTimers, false};
_ when DispatchUpdates ->
dispatch_call_update(StateWithTimers),
{StateWithTimers, true};
_ ->
{StateWithTimers, false}
end
end
end.
maybe_dispatch_state_update(PrevState, NewState) ->
case PrevState#state.initiator_ready of
true ->
case PrevState#state.ringing =:= NewState#state.ringing of
true ->
{NewState, false};
false ->
dispatch_call_update(NewState),
{NewState, true}
end;
false ->
{NewState, false}
end.
remove_users_from_ringing(Users, State) ->
{NewRinging, NewPending} =
lists:foldl(
fun(User, {RingingAcc, PendingAcc}) ->
{lists:delete(User, RingingAcc), lists:delete(User, PendingAcc)}
end,
{State#state.ringing, State#state.pending_ringing},
Users
),
State#state{ringing = NewRinging, pending_ringing = NewPending}.
start_ringing_timers([], State) ->
State;
start_ringing_timers([User | Rest], State) ->
case maps:is_key(User, State#state.ringing_timers) of
true ->
start_ringing_timers(Rest, State);
false ->
Ref = erlang:send_after(?RING_TIMEOUT_MS, self(), {ring_timeout, User}),
UpdatedTimers = maps:put(User, Ref, State#state.ringing_timers),
start_ringing_timers(Rest, State#state{ringing_timers = UpdatedTimers})
end.
cancel_ringing_timers([], State) ->
State;
cancel_ringing_timers([User | Rest], State) ->
case maps:is_key(User, State#state.ringing_timers) of
true ->
Ref = maps:get(User, State#state.ringing_timers),
erlang:cancel_timer(Ref),
UpdatedTimers = maps:remove(User, State#state.ringing_timers),
cancel_ringing_timers(Rest, State#state{ringing_timers = UpdatedTimers});
false ->
cancel_ringing_timers(Rest, State)
end.
cancel_all_ringing_timers(State) ->
TimerRefs = maps:values(State#state.ringing_timers),
[erlang:cancel_timer(Ref) || Ref <- TimerRefs],
State#state{ringing_timers = #{}}.
reset_idle_timer(State) ->
case State#state.idle_timer of
undefined -> ok;
OldRef -> erlang:cancel_timer(OldRef)
end,
NewRef = erlang:send_after(?IDLE_TIMEOUT_MS, self(), idle_timeout),
State#state{idle_timer = NewRef}.
format_voice_state(VoiceState) ->
maps:map(
fun
(<<"user_id">>, V) when is_integer(V) -> integer_to_binary(V);
(<<"channel_id">>, V) when is_integer(V) -> integer_to_binary(V);
(<<"guild_id">>, V) when is_integer(V) -> integer_to_binary(V);
(_, V) -> V
end,
VoiceState
).
integer_list_to_binaries(Values) ->
lists:map(fun integer_to_binary/1, Values).
find_session_by_pid(Pid, Sessions) ->
maps:fold(
fun
(SessionId, {UserId, P, _Ref}, _) when P =:= Pid ->
{ok, SessionId, UserId};
(_, _, Acc) ->
Acc
end,
not_found,
Sessions
).
handle_join_internal(UserId, VoiceState, SessionId, SessionPid, ConnectionId, State) ->
CleanState = cancel_ringing_timers([UserId], State),
BaseState = remove_users_from_ringing([UserId], CleanState),
NewVoiceStates = maps:put(UserId, VoiceState, BaseState#state.voice_states),
SessionRef = monitor(process, SessionPid),
NewSessions = maps:put(SessionId, {UserId, SessionPid, SessionRef}, BaseState#state.sessions),
NewParticipantsHistory = sets:add_element(UserId, BaseState#state.participants_history),
NewPending =
case ConnectionId of
undefined ->
BaseState#state.pending_connections;
_ ->
PendingMetadata = #{
user_id => UserId,
channel_id => BaseState#state.channel_id,
connection_id => ConnectionId,
session_id => SessionId
},
erlang:send_after(30000, self(), {pending_connection_timeout, ConnectionId}),
voice_pending_common:add_pending_connection(
ConnectionId, PendingMetadata, BaseState#state.pending_connections
)
end,
NewState = BaseState#state{
voice_states = NewVoiceStates,
sessions = NewSessions,
pending_connections = NewPending,
participants_history = NewParticipantsHistory
},
StateWithTimer = reset_idle_timer(NewState),
{UpdatedState, Dispatched} = maybe_dispatch_state_update(BaseState, StateWithTimer),
case Dispatched of
true -> ok;
false -> dispatch_call_update(UpdatedState)
end,
{reply, ok, UpdatedState}.

View File

@@ -0,0 +1,170 @@
%% 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(call_manager).
-behaviour(gen_server).
-include_lib("fluxer_gateway/include/timeout_config.hrl").
-export([start_link/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-type channel_id() :: integer().
-type call_ref() :: {pid(), reference()}.
-type call_data() :: map().
-type state() :: #{calls := #{channel_id() => call_ref()}}.
-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-spec init([]) -> {ok, state()}.
init([]) ->
process_flag(trap_exit, true),
{ok, #{calls => #{}}}.
-spec handle_call(Request, From, State) -> Result when
Request ::
{create, channel_id(), call_data()}
| {lookup, channel_id()}
| {get_or_create, channel_id(), call_data()}
| {terminate_call, channel_id()}
| get_local_count
| get_global_count
| term(),
From :: gen_server:from(),
State :: state(),
Result :: {reply, Reply, state()},
Reply ::
{ok, pid()}
| {error, already_exists}
| {error, not_found}
| {error, term()}
| ok
| {ok, non_neg_integer()}.
handle_call({create, ChannelId, CallData}, _From, State) ->
do_create_call(ChannelId, CallData, State);
handle_call({lookup, ChannelId}, _From, State) ->
do_lookup_call(ChannelId, State);
handle_call({get_or_create, ChannelId, CallData}, _From, State) ->
do_get_or_create_call(ChannelId, CallData, State);
handle_call({terminate_call, ChannelId}, _From, State) ->
do_terminate_call(ChannelId, State);
handle_call(get_local_count, _From, #{calls := Calls} = State) ->
{reply, {ok, process_registry:get_count(Calls)}, State};
handle_call(get_global_count, _From, #{calls := Calls} = State) ->
{reply, {ok, process_registry:get_count(Calls)}, State};
handle_call(_Request, _From, State) ->
{reply, ok, State}.
-spec handle_cast(term(), state()) -> {noreply, state()}.
handle_cast(_Msg, State) ->
{noreply, State}.
-spec handle_info(Info, State) -> {noreply, state()} when
Info :: {'DOWN', reference(), process, pid(), term()} | term(),
State :: state().
handle_info({'DOWN', _Ref, process, Pid, _Reason}, #{calls := Calls} = State) ->
NewCalls = process_registry:cleanup_on_down(Pid, Calls),
{noreply, State#{calls := NewCalls}};
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(Reason, State) -> ok when
Reason :: term(),
State :: state().
terminate(_Reason, #{calls := _Calls}) ->
ok.
-spec code_change(OldVsn, State, Extra) -> {ok, state()} when
OldVsn :: term(),
State :: state() | {state, map()},
Extra :: term().
code_change(_OldVsn, {state, Calls}, _Extra) ->
{ok, #{calls => Calls}};
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
-spec do_create_call(channel_id(), call_data(), state()) ->
{reply, {ok, pid()} | {error, already_exists | term()}, state()}.
do_create_call(ChannelId, CallData, #{calls := Calls} = State) ->
case maps:get(ChannelId, Calls, undefined) of
{Pid, _Ref} when is_pid(Pid) ->
{reply, {error, already_exists}, State};
undefined ->
CallName = process_registry:build_process_name(call, ChannelId),
case whereis(CallName) of
undefined ->
case call:start_link(CallData) of
{ok, Pid} ->
case process_registry:register_and_monitor(CallName, Pid, Calls) of
{ok, RegisteredPid, Ref, NewCalls0} ->
CleanCalls = maps:remove(CallName, NewCalls0),
NewCalls = maps:put(
ChannelId, {RegisteredPid, Ref}, CleanCalls
),
NewState = State#{calls := NewCalls},
{reply, {ok, RegisteredPid}, NewState};
{error, Reason} ->
{reply, {error, Reason}, State}
end;
{error, Reason} ->
{reply, {error, Reason}, State}
end;
_ExistingPid ->
{reply, {error, already_exists}, State}
end
end.
-spec do_lookup_call(channel_id(), state()) -> {reply, {ok, pid()} | {error, not_found}, state()}.
do_lookup_call(ChannelId, #{calls := Calls} = State) ->
case maps:get(ChannelId, Calls, undefined) of
{Pid, _Ref} when is_pid(Pid) ->
{reply, {ok, Pid}, State};
undefined ->
CallName = process_registry:build_process_name(call, ChannelId),
case process_registry:lookup_or_monitor(CallName, ChannelId, Calls) of
{ok, Pid, _Ref, NewCalls} ->
{reply, {ok, Pid}, State#{calls := NewCalls}};
{error, not_found} ->
{reply, {error, not_found}, State}
end
end.
-spec do_get_or_create_call(channel_id(), call_data(), state()) ->
{reply, {ok, pid()} | {error, term()}, state()}.
do_get_or_create_call(ChannelId, CallData, #{calls := Calls} = State) ->
case maps:get(ChannelId, Calls, undefined) of
{Pid, _Ref} when is_pid(Pid) ->
{reply, {ok, Pid}, State};
undefined ->
do_create_call(ChannelId, CallData, State)
end.
-spec do_terminate_call(channel_id(), state()) -> {reply, ok | {error, not_found}, state()}.
do_terminate_call(ChannelId, #{calls := Calls} = State) ->
case maps:get(ChannelId, Calls, undefined) of
{Pid, Ref} ->
demonitor(Ref, [flush]),
gen_server:stop(Pid, normal, ?SHUTDOWN_TIMEOUT),
CallName = process_registry:build_process_name(call, ChannelId),
process_registry:safe_unregister(CallName),
NewCalls = maps:remove(ChannelId, Calls),
{reply, ok, State#{calls := NewCalls}};
undefined ->
{reply, {error, not_found}, State}
end.

View File

@@ -0,0 +1,24 @@
{application, fluxer_gateway, [
{description, "Fluxer Gateway"},
{vsn, "0.0.0"},
{registered, []},
{mod, {fluxer_gateway_app, []}},
{applications, [
kernel,
stdlib,
crypto,
public_key,
ssl,
inets,
jsx,
jose,
cowboy,
hackney,
base64url,
ezstd
]},
{env, []},
{modules, []},
{licenses, ["AGPL-3.0-or-later"]},
{links, []}
]}.

View File

@@ -0,0 +1,54 @@
%% 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(fluxer_gateway_app).
-behaviour(application).
-export([start/2, stop/1]).
start(_StartType, _StartArgs) ->
fluxer_gateway_env:load(),
WsPort = fluxer_gateway_env:get(ws_port),
RpcPort = fluxer_gateway_env:get(rpc_port),
Dispatch = cowboy_router:compile([
{'_', [
{<<"/_health">>, health_handler, []},
{<<"/">>, gateway_handler, []}
]}
]),
{ok, _} = cowboy:start_clear(http, [{port, WsPort}], #{
env => #{dispatch => Dispatch},
max_frame_size => 4096
}),
RpcDispatch = cowboy_router:compile([
{'_', [
{<<"/_rpc">>, gateway_rpc_http_handler, []},
{<<"/_admin/reload">>, hot_reload_handler, []}
]}
]),
{ok, _} = cowboy:start_clear(rpc_http, [{port, RpcPort}], #{
env => #{dispatch => RpcDispatch}
}),
fluxer_gateway_sup:start_link().
stop(_State) ->
ok.

View File

@@ -0,0 +1,268 @@
%% 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(fluxer_gateway_env).
-export([load/0, get/1, get_optional/1, get_map/0, patch/1, update/1]).
-define(APP, fluxer_gateway).
-define(CONFIG_TERM_KEY, {fluxer_gateway, runtime_config}).
-type config() :: map().
-spec load() -> config().
load() ->
set_config(build_config()).
-spec get(atom()) -> term().
get(Key) when is_atom(Key) ->
Map = get_map(),
maps:get(Key, Map, undefined).
-spec get_optional(atom()) -> term().
get_optional(Key) when is_atom(Key) ->
?MODULE:get(Key).
-spec get_map() -> config().
get_map() ->
ensure_loaded().
-spec patch(map()) -> config().
patch(Patch) when is_map(Patch) ->
Map = get_map(),
set_config(maps:merge(Map, Patch)).
-spec update(fun((config()) -> config())) -> config().
update(Fun) when is_function(Fun, 1) ->
Map = get_map(),
set_config(Fun(Map)).
-spec set_config(config()) -> config().
set_config(Config) when is_map(Config) ->
persistent_term:put(?CONFIG_TERM_KEY, Config),
Config.
-spec ensure_loaded() -> config().
ensure_loaded() ->
case persistent_term:get(?CONFIG_TERM_KEY, undefined) of
Map when is_map(Map) ->
Map;
_ ->
load()
end.
-spec build_config() -> config().
build_config() ->
#{
ws_port => env_int("FLUXER_GATEWAY_WS_PORT", ws_port, 8080),
rpc_port => env_int("FLUXER_GATEWAY_RPC_PORT", rpc_port, 8081),
api_host => env_string("API_HOST", api_host, "api"),
api_canary_host => env_optional_string("API_CANARY_HOST", api_canary_host),
rpc_secret_key => env_binary("GATEWAY_RPC_SECRET", rpc_secret_key, undefined),
identify_rate_limit_enabled => env_bool("FLUXER_GATEWAY_IDENTIFY_RATE_LIMIT_ENABLED", identify_rate_limit_enabled, false),
push_enabled => env_bool("FLUXER_GATEWAY_PUSH_ENABLED", push_enabled, true),
push_user_guild_settings_cache_mb => env_int("FLUXER_GATEWAY_PUSH_USER_GUILD_SETTINGS_CACHE_MB",
push_user_guild_settings_cache_mb, 1024),
push_subscriptions_cache_mb => env_int("FLUXER_GATEWAY_PUSH_SUBSCRIPTIONS_CACHE_MB",
push_subscriptions_cache_mb, 1024),
push_blocked_ids_cache_mb => env_int("FLUXER_GATEWAY_PUSH_BLOCKED_IDS_CACHE_MB",
push_blocked_ids_cache_mb, 1024),
presence_cache_shards => env_optional_int("FLUXER_GATEWAY_PRESENCE_CACHE_SHARDS", presence_cache_shards),
presence_bus_shards => env_optional_int("FLUXER_GATEWAY_PRESENCE_BUS_SHARDS", presence_bus_shards),
presence_shards => env_optional_int("FLUXER_GATEWAY_PRESENCE_SHARDS", presence_shards),
guild_shards => env_optional_int("FLUXER_GATEWAY_GUILD_SHARDS", guild_shards),
metrics_host => env_optional_string("FLUXER_METRICS_HOST", metrics_host),
push_badge_counts_cache_mb => app_env_int(push_badge_counts_cache_mb, 256),
push_badge_counts_cache_ttl_seconds => app_env_int(push_badge_counts_cache_ttl_seconds, 60),
media_proxy_endpoint => env_optional_binary("MEDIA_PROXY_ENDPOINT", media_proxy_endpoint),
vapid_email => env_binary("VAPID_EMAIL", vapid_email, <<"support@fluxer.app">>),
vapid_public_key => env_binary("VAPID_PUBLIC_KEY", vapid_public_key, undefined),
vapid_private_key => env_binary("VAPID_PRIVATE_KEY", vapid_private_key, undefined),
gateway_metrics_enabled => app_env_optional_bool(gateway_metrics_enabled),
gateway_metrics_report_interval_ms => app_env_optional_int(gateway_metrics_report_interval_ms)
}.
-spec env_int(string(), atom(), integer()) -> integer().
env_int(EnvVar, AppKey, Default) when is_atom(AppKey), is_integer(Default) ->
case os:getenv(EnvVar) of
false ->
app_env_int(AppKey, Default);
Value ->
parse_int(Value, Default)
end.
-spec env_optional_int(string(), atom()) -> integer() | undefined.
env_optional_int(EnvVar, AppKey) when is_atom(AppKey) ->
case os:getenv(EnvVar) of
false ->
app_env_optional_int(AppKey);
Value ->
parse_int(Value, undefined)
end.
-spec env_bool(string(), atom(), boolean()) -> boolean().
env_bool(EnvVar, AppKey, Default) when is_atom(AppKey), is_boolean(Default) ->
case os:getenv(EnvVar) of
false ->
app_env_bool(AppKey, Default);
Value ->
parse_bool(Value, Default)
end.
-spec env_string(string(), atom(), string()) -> string().
env_string(EnvVar, AppKey, Default) when is_atom(AppKey) ->
case os:getenv(EnvVar) of
false ->
app_env_string(AppKey, Default);
Value ->
Value
end.
-spec env_optional_string(string(), atom()) -> string() | undefined.
env_optional_string(EnvVar, AppKey) when is_atom(AppKey) ->
case os:getenv(EnvVar) of
false ->
app_env_optional_string(AppKey);
Value ->
Value
end.
-spec env_binary(string(), atom(), binary() | undefined) -> binary() | undefined.
env_binary(EnvVar, AppKey, Default) when is_atom(AppKey) ->
case os:getenv(EnvVar) of
false ->
app_env_binary(AppKey, Default);
Value ->
to_binary(Value, Default)
end.
-spec env_optional_binary(string(), atom()) -> binary() | undefined.
env_optional_binary(EnvVar, AppKey) when is_atom(AppKey) ->
case os:getenv(EnvVar) of
false ->
app_env_optional_binary(AppKey);
Value ->
to_binary(Value, undefined)
end.
-spec parse_int(string(), integer() | undefined) -> integer() | undefined.
parse_int(Value, Default) ->
Str = string:trim(Value),
try
list_to_integer(Str)
catch
_:_ -> Default
end.
-spec parse_bool(string(), boolean()) -> boolean().
parse_bool(Value, Default) ->
Str = string:lowercase(string:trim(Value)),
case Str of
"true" -> true;
"1" -> true;
"false" -> false;
"0" -> false;
_ -> Default
end.
-spec to_binary(string(), binary() | undefined) -> binary() | undefined.
to_binary(Value, Default) ->
try
list_to_binary(Value)
catch
_:_ -> Default
end.
-spec app_env_int(atom(), integer()) -> integer().
app_env_int(Key, Default) ->
case application:get_env(?APP, Key) of
{ok, Value} when is_integer(Value) ->
Value;
_ ->
Default
end.
-spec app_env_optional_int(atom()) -> integer() | undefined.
app_env_optional_int(Key) ->
case application:get_env(?APP, Key) of
{ok, Value} when is_integer(Value) ->
Value;
_ ->
undefined
end.
-spec app_env_bool(atom(), boolean()) -> boolean().
app_env_bool(Key, Default) ->
case application:get_env(?APP, Key) of
{ok, Value} when is_boolean(Value) ->
Value;
_ ->
Default
end.
-spec app_env_optional_bool(atom()) -> boolean() | undefined.
app_env_optional_bool(Key) ->
case application:get_env(?APP, Key) of
{ok, Value} when is_boolean(Value) ->
Value;
_ ->
undefined
end.
-spec app_env_string(atom(), string()) -> string().
app_env_string(Key, Default) ->
case application:get_env(?APP, Key) of
{ok, Value} when is_list(Value) ->
Value;
{ok, Value} when is_binary(Value) ->
binary_to_list(Value);
_ ->
Default
end.
-spec app_env_optional_string(atom()) -> string() | undefined.
app_env_optional_string(Key) ->
case application:get_env(?APP, Key) of
{ok, Value} when is_list(Value) ->
Value;
{ok, Value} when is_binary(Value) ->
binary_to_list(Value);
_ ->
undefined
end.
-spec app_env_binary(atom(), binary() | undefined) -> binary() | undefined.
app_env_binary(Key, Default) ->
case application:get_env(?APP, Key) of
{ok, Value} when is_binary(Value) ->
Value;
{ok, Value} when is_list(Value) ->
list_to_binary(Value);
_ ->
Default
end.
-spec app_env_optional_binary(atom()) -> binary() | undefined.
app_env_optional_binary(Key) ->
case application:get_env(?APP, Key) of
{ok, Value} when is_binary(Value) ->
Value;
{ok, Value} when is_list(Value) ->
list_to_binary(Value);
_ ->
undefined
end.

View File

@@ -0,0 +1,92 @@
%% 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(fluxer_gateway_sup).
-behaviour(supervisor).
-export([start_link/0, init/1]).
start_link() ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
init([]) ->
SessionManager = #{
id => session_manager,
start => {session_manager, start_link, []},
restart => permanent,
shutdown => 5000,
type => worker
},
PresenceManager = #{
id => presence_manager,
start => {presence_manager, start_link, []},
restart => permanent,
shutdown => 5000,
type => worker
},
GuildManager = #{
id => guild_manager,
start => {guild_manager, start_link, []},
restart => permanent,
shutdown => 5000,
type => worker
},
Push = #{
id => push,
start => {push, start_link, []},
restart => permanent,
shutdown => 5000,
type => worker
},
CallManager = #{
id => call_manager,
start => {call_manager, start_link, []},
restart => permanent,
shutdown => 5000,
type => worker
},
PresenceBus = #{
id => presence_bus,
start => {presence_bus, start_link, []},
restart => permanent,
shutdown => 5000,
type => worker
},
PresenceCache = #{
id => presence_cache,
start => {presence_cache, start_link, []},
restart => permanent,
shutdown => 5000,
type => worker
},
GatewayMetricsCollector = #{
id => gateway_metrics_collector,
start => {gateway_metrics_collector, start_link, []},
restart => permanent,
shutdown => 5000,
type => worker
},
{ok,
{{one_for_one, 5, 10}, [
SessionManager,
PresenceCache,
PresenceBus,
PresenceManager,
GuildManager,
CallManager,
Push,
GatewayMetricsCollector
]}}.

View File

@@ -0,0 +1,77 @@
%% 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(gateway_codec).
-export([
encode/2,
decode/2,
parse_encoding/1
]).
-type encoding() :: json.
-export_type([encoding/0]).
-spec parse_encoding(binary() | undefined) -> encoding().
parse_encoding(_) -> json.
-spec encode(map(), encoding()) -> {ok, iodata(), text | binary} | {error, term()}.
encode(Message, json) ->
try
Encoded = jsx:encode(Message),
{ok, Encoded, text}
catch
_:Reason ->
{error, {encode_failed, Reason}}
end.
-spec decode(binary(), encoding()) -> {ok, map()} | {error, term()}.
decode(Data, json) ->
try
Decoded = jsx:decode(Data, [{return_maps, true}]),
{ok, Decoded}
catch
_:Reason ->
{error, {decode_failed, Reason}}
end.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
parse_encoding_test() ->
?assertEqual(json, parse_encoding(<<"json">>)),
?assertEqual(json, parse_encoding(<<"etf">>)),
?assertEqual(json, parse_encoding(undefined)),
?assertEqual(json, parse_encoding(<<"invalid">>)).
encode_json_test() ->
Message = #{<<"op">> => 0, <<"d">> => #{<<"test">> => true}},
{ok, Encoded, text} = encode(Message, json),
?assert(is_binary(Encoded)).
decode_json_test() ->
Data = <<"{\"op\":0,\"d\":{\"test\":true}}">>,
{ok, Decoded} = decode(Data, json),
?assertEqual(0, maps:get(<<"op">>, Decoded)).
roundtrip_json_test() ->
Original = #{<<"op">> => 10, <<"d">> => #{<<"heartbeat_interval">> => 41250}},
{ok, Encoded, _} = encode(Original, json),
{ok, Decoded} = decode(iolist_to_binary(Encoded), json),
?assertEqual(Original, Decoded).
-endif.

View File

@@ -0,0 +1,106 @@
%% 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(gateway_compress).
-export([
new_context/1,
compress/2,
decompress/2,
parse_compression/1,
close_context/1,
get_type/1
]).
-type compression() :: none | zstd_stream.
-export_type([compression/0]).
-record(compress_ctx, {type :: compression()}).
-type compress_ctx() :: #compress_ctx{}.
-export_type([compress_ctx/0]).
-spec parse_compression(binary() | undefined) -> compression().
parse_compression(<<"none">>) -> none;
parse_compression(<<"zstd-stream">>) -> zstd_stream;
parse_compression(_) -> none.
-spec new_context(compression()) -> compress_ctx().
new_context(none) ->
#compress_ctx{type = none};
new_context(zstd_stream) ->
#compress_ctx{type = zstd_stream}.
-spec close_context(compress_ctx()) -> ok.
close_context(_Ctx) ->
ok.
-spec get_type(compress_ctx()) -> compression().
get_type(#compress_ctx{type = Type}) ->
Type.
-spec compress(iodata(), compress_ctx()) -> {ok, binary(), compress_ctx()} | {error, term()}.
compress(Data, Ctx = #compress_ctx{type = none}) ->
{ok, iolist_to_binary(Data), Ctx};
compress(Data, Ctx = #compress_ctx{type = zstd_stream}) ->
try
Binary = iolist_to_binary(Data),
case ezstd:compress(Binary, 3) of
Compressed when is_binary(Compressed) ->
{ok, Compressed, Ctx};
{error, Reason} ->
{error, {compress_failed, Reason}}
end
catch
_:Exception ->
{error, {compress_failed, Exception}}
end.
-spec decompress(binary(), compress_ctx()) -> {ok, binary(), compress_ctx()} | {error, term()}.
decompress(Data, Ctx = #compress_ctx{type = none}) ->
{ok, Data, Ctx};
decompress(Data, Ctx = #compress_ctx{type = zstd_stream}) ->
try
case ezstd:decompress(Data) of
Decompressed when is_binary(Decompressed) ->
{ok, Decompressed, Ctx};
{error, Reason} ->
{error, {decompress_failed, Reason}}
end
catch
_:Exception ->
{error, {decompress_failed, Exception}}
end.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
parse_compression_test() ->
?assertEqual(none, parse_compression(undefined)),
?assertEqual(none, parse_compression(<<>>)),
?assertEqual(zstd_stream, parse_compression(<<"zstd-stream">>)),
?assertEqual(none, parse_compression(<<"none">>)).
zstd_roundtrip_test() ->
Ctx = new_context(zstd_stream),
Data = <<"hello world, this is a test message for zstd compression">>,
{ok, Compressed, Ctx2} = compress(Data, Ctx),
?assert(is_binary(Compressed)),
{ok, Decompressed, _} = decompress(Compressed, Ctx2),
?assertEqual(Data, Decompressed),
ok = close_context(Ctx2).
-endif.

View File

@@ -0,0 +1,143 @@
%% 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(gateway_errors).
-export([
error/1,
error_code/1,
error_message/1,
error_category/1,
is_recoverable/1
]).
-spec error(atom()) -> {error, atom(), atom()}.
error(ErrorAtom) ->
{error, error_category(ErrorAtom), ErrorAtom}.
-spec error_code(atom()) -> binary().
error_code(voice_connection_not_found) -> <<"VOICE_CONNECTION_NOT_FOUND">>;
error_code(voice_channel_not_found) -> <<"VOICE_CHANNEL_NOT_FOUND">>;
error_code(voice_channel_not_voice) -> <<"VOICE_INVALID_CHANNEL_TYPE">>;
error_code(voice_member_not_found) -> <<"VOICE_MEMBER_NOT_FOUND">>;
error_code(voice_user_not_in_voice) -> <<"VOICE_USER_NOT_IN_VOICE">>;
error_code(voice_guild_not_found) -> <<"VOICE_GUILD_NOT_FOUND">>;
error_code(voice_permission_denied) -> <<"VOICE_PERMISSION_DENIED">>;
error_code(voice_member_timed_out) -> <<"VOICE_MEMBER_TIMED_OUT">>;
error_code(voice_channel_full) -> <<"VOICE_CHANNEL_FULL">>;
error_code(voice_missing_connection_id) -> <<"VOICE_MISSING_CONNECTION_ID">>;
error_code(voice_invalid_user_id) -> <<"VOICE_INVALID_USER_ID">>;
error_code(voice_invalid_channel_id) -> <<"VOICE_INVALID_CHANNEL_ID">>;
error_code(voice_invalid_state) -> <<"VOICE_INVALID_STATE">>;
error_code(voice_user_mismatch) -> <<"VOICE_USER_MISMATCH">>;
error_code(voice_token_failed) -> <<"VOICE_TOKEN_FAILED">>;
error_code(voice_guild_id_missing) -> <<"VOICE_GUILD_ID_MISSING">>;
error_code(voice_invalid_guild_id) -> <<"VOICE_INVALID_GUILD_ID">>;
error_code(voice_moderator_missing_connect) -> <<"VOICE_PERMISSION_DENIED">>;
error_code(dm_channel_not_found) -> <<"DM_CHANNEL_NOT_FOUND">>;
error_code(dm_not_recipient) -> <<"DM_NOT_RECIPIENT">>;
error_code(dm_invalid_channel_type) -> <<"DM_INVALID_CHANNEL_TYPE">>;
error_code(validation_invalid_snowflake) -> <<"VALIDATION_INVALID_SNOWFLAKE">>;
error_code(validation_null_snowflake) -> <<"VALIDATION_NULL_SNOWFLAKE">>;
error_code(validation_invalid_snowflake_list) -> <<"VALIDATION_INVALID_SNOWFLAKE_LIST">>;
error_code(validation_expected_list) -> <<"VALIDATION_EXPECTED_LIST">>;
error_code(validation_expected_map) -> <<"VALIDATION_EXPECTED_MAP">>;
error_code(validation_missing_field) -> <<"VALIDATION_MISSING_FIELD">>;
error_code(validation_invalid_params) -> <<"VALIDATION_INVALID_PARAMS">>;
error_code(internal_error) -> <<"INTERNAL_ERROR">>;
error_code(timeout) -> <<"TIMEOUT">>;
error_code(unknown_error) -> <<"UNKNOWN_ERROR">>;
error_code(_) -> <<"UNKNOWN_ERROR">>.
-spec error_message(atom()) -> binary().
error_message(voice_connection_not_found) -> <<"Voice connection not found">>;
error_message(voice_channel_not_found) -> <<"Voice channel not found">>;
error_message(voice_channel_not_voice) -> <<"Channel is not a voice channel">>;
error_message(voice_member_not_found) -> <<"Member not found">>;
error_message(voice_user_not_in_voice) -> <<"User is not in a voice channel">>;
error_message(voice_guild_not_found) -> <<"Guild not found">>;
error_message(voice_permission_denied) -> <<"Missing voice permissions">>;
error_message(voice_member_timed_out) -> <<"Voice member is timed out">>;
error_message(voice_channel_full) -> <<"Voice channel is full">>;
error_message(voice_missing_connection_id) -> <<"Connection ID is required">>;
error_message(voice_invalid_user_id) -> <<"Invalid user ID">>;
error_message(voice_invalid_channel_id) -> <<"Invalid channel ID">>;
error_message(voice_invalid_state) -> <<"Invalid voice state">>;
error_message(voice_user_mismatch) -> <<"User does not match connection">>;
error_message(voice_token_failed) -> <<"Failed to obtain voice token">>;
error_message(voice_guild_id_missing) -> <<"Guild ID is required">>;
error_message(voice_invalid_guild_id) -> <<"Invalid guild ID">>;
error_message(voice_moderator_missing_connect) -> <<"Moderator missing connect permission">>;
error_message(dm_channel_not_found) -> <<"DM channel not found">>;
error_message(dm_not_recipient) -> <<"Not a recipient of this channel">>;
error_message(dm_invalid_channel_type) -> <<"Not a DM or Group DM channel">>;
error_message(validation_invalid_snowflake) -> <<"Invalid snowflake ID format">>;
error_message(validation_null_snowflake) -> <<"Snowflake ID cannot be null">>;
error_message(validation_invalid_snowflake_list) -> <<"Invalid snowflake ID in list">>;
error_message(validation_expected_list) -> <<"Expected a list">>;
error_message(validation_expected_map) -> <<"Expected a map">>;
error_message(validation_missing_field) -> <<"Missing required field">>;
error_message(validation_invalid_params) -> <<"Invalid parameters">>;
error_message(internal_error) -> <<"Internal server error">>;
error_message(timeout) -> <<"Request timed out">>;
error_message(unknown_error) -> <<"An unknown error occurred">>;
error_message(_) -> <<"An unknown error occurred">>.
-spec error_category(atom()) -> atom().
error_category(voice_connection_not_found) -> not_found;
error_category(voice_channel_not_found) -> not_found;
error_category(voice_channel_not_voice) -> validation_error;
error_category(voice_member_not_found) -> not_found;
error_category(voice_user_not_in_voice) -> not_found;
error_category(voice_guild_not_found) -> not_found;
error_category(voice_permission_denied) -> permission_denied;
error_category(voice_member_timed_out) -> permission_denied;
error_category(voice_channel_full) -> permission_denied;
error_category(voice_missing_connection_id) -> validation_error;
error_category(voice_invalid_user_id) -> validation_error;
error_category(voice_invalid_channel_id) -> validation_error;
error_category(voice_invalid_state) -> validation_error;
error_category(voice_user_mismatch) -> validation_error;
error_category(voice_token_failed) -> voice_error;
error_category(voice_guild_id_missing) -> validation_error;
error_category(voice_invalid_guild_id) -> validation_error;
error_category(voice_moderator_missing_connect) -> permission_denied;
error_category(dm_channel_not_found) -> not_found;
error_category(dm_not_recipient) -> permission_denied;
error_category(dm_invalid_channel_type) -> validation_error;
error_category(validation_invalid_snowflake) -> validation_error;
error_category(validation_null_snowflake) -> validation_error;
error_category(validation_invalid_snowflake_list) -> validation_error;
error_category(validation_expected_list) -> validation_error;
error_category(validation_expected_map) -> validation_error;
error_category(validation_missing_field) -> validation_error;
error_category(validation_invalid_params) -> validation_error;
error_category(internal_error) -> unknown;
error_category(timeout) -> timeout;
error_category(unknown_error) -> unknown;
error_category(_) -> unknown.
-spec is_recoverable(atom()) -> boolean().
is_recoverable(not_found) -> true;
is_recoverable(permission_denied) -> true;
is_recoverable(voice_error) -> true;
is_recoverable(validation_error) -> true;
is_recoverable(timeout) -> true;
is_recoverable(unknown) -> true;
is_recoverable(rate_limited) -> false;
is_recoverable(auth_failed) -> false;
is_recoverable(_) -> true.

View File

@@ -0,0 +1,786 @@
%% 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(gateway_handler).
-behaviour(cowboy_websocket).
-export([init/2, websocket_init/1, websocket_handle/2, websocket_info/2, terminate/3]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-record(state, {
version,
encoding = json :: gateway_codec:encoding(),
compress_ctx :: gateway_compress:compress_ctx(),
session_pid,
heartbeat_state = #{},
socket_pid,
peer_ip,
rate_limit_state = #{events => [], window_start => undefined}
}).
init(Req, _Opts) ->
QS = cowboy_req:parse_qs(Req),
Version =
case proplists:get_value(<<"v">>, QS) of
<<"1">> -> 1;
_ -> undefined
end,
Encoding = gateway_codec:parse_encoding(proplists:get_value(<<"encoding">>, QS)),
Compression = gateway_compress:parse_compression(proplists:get_value(<<"compress">>, QS)),
CompressCtx = gateway_compress:new_context(Compression),
PeerIPBinary = extract_client_ip(Req),
{cowboy_websocket, Req, #state{
version = Version,
encoding = Encoding,
compress_ctx = CompressCtx,
socket_pid = self(),
peer_ip = PeerIPBinary
}}.
websocket_init(State = #state{version = Version}) ->
gateway_metrics_collector:inc_connections(),
case Version of
1 ->
CompressionType = gateway_compress:get_type(State#state.compress_ctx),
FreshCompressCtx = gateway_compress:new_context(CompressionType),
FreshState0 = State#state{compress_ctx = FreshCompressCtx},
HeartbeatInterval = constants:heartbeat_interval(),
HelloMessage = #{
<<"op">> => constants:opcode_to_num(hello),
<<"d">> => #{
<<"heartbeat_interval">> => HeartbeatInterval
}
},
schedule_heartbeat_check(),
NewState = FreshState0#state{
heartbeat_state = #{
last_ack => erlang:system_time(millisecond),
waiting_for_ack => false
}
},
case encode_and_compress(HelloMessage, NewState) of
{ok, Frame, NewState2} ->
{[Frame], NewState2};
{error, {compress_failed, CompressionType, Reason}} ->
logger:warning(
"[gateway_handler] Failed to compress HELLO frame, type=~p, reason=~p",
[CompressionType, Reason]
),
close_with_reason(decode_error, compression_error_reason(CompressionType), NewState);
{error, _Reason} ->
close_with_reason(decode_error, <<"Encode failed">>, NewState)
end;
_ ->
close_with_reason(invalid_api_version, <<"Invalid API version">>, State)
end.
websocket_handle({text, Text}, State) ->
handle_incoming_data(Text, State);
websocket_handle({binary, Binary}, State) ->
handle_incoming_data(Binary, State);
websocket_handle(_, State) ->
{ok, State}.
handle_incoming_data(Data, State = #state{encoding = Encoding, compress_ctx = CompressCtx}) ->
MaxSize = constants:max_payload_size(),
case byte_size(Data) =< MaxSize of
true ->
case gateway_codec:decode(Data, Encoding) of
{ok, #{<<"op">> := Op} = Payload} ->
logger:debug("handle_incoming_data: received op ~p", [Op]),
NewState = State#state{compress_ctx = CompressCtx},
OpAtom = constants:gateway_opcode(Op),
logger:debug("handle_incoming_data: op ~p converted to atom ~p", [Op, OpAtom]),
case check_rate_limit(NewState) of
{ok, RateLimitedState} ->
handle_gateway_payload(OpAtom, Payload, RateLimitedState);
rate_limited ->
close_with_reason(rate_limited, <<"Rate limited">>, NewState)
end;
{ok, _} ->
close_with_reason(decode_error, <<"Invalid payload">>, State#state{
compress_ctx = CompressCtx
});
{error, _Reason} ->
close_with_reason(decode_error, <<"Decode failed">>, State)
end;
false ->
close_with_reason(decode_error, <<"Payload too large">>, State)
end.
websocket_info({heartbeat_check}, State = #state{heartbeat_state = HeartbeatState}) ->
Now = erlang:system_time(millisecond),
LastAck = maps:get(last_ack, HeartbeatState, Now),
WaitingForAck = maps:get(waiting_for_ack, HeartbeatState, false),
HeartbeatTimeout = constants:heartbeat_timeout(),
HeartbeatInterval = constants:heartbeat_interval(),
if
WaitingForAck andalso (Now - LastAck) > HeartbeatTimeout ->
gateway_metrics_collector:inc_heartbeat_failure(),
close_with_reason(session_timeout, <<"Heartbeat timeout">>, State);
(Now - LastAck) >= (HeartbeatInterval * 0.9) ->
Message = #{
<<"op">> => constants:opcode_to_num(heartbeat),
<<"d">> => null
},
schedule_heartbeat_check(),
NewState = State#state{heartbeat_state = HeartbeatState#{waiting_for_ack => true}},
case encode_and_compress(Message, NewState) of
{ok, Frame, NewState2} ->
{[Frame], NewState2};
{error, _} ->
{ok, NewState}
end;
true ->
schedule_heartbeat_check(),
{ok, State}
end;
websocket_info({dispatch, Event, Data, Seq}, State) ->
logger:debug("websocket_info: dispatch event ~p with seq ~p", [Event, Seq]),
EventName =
if
is_binary(Event) -> Event;
is_atom(Event) -> constants:dispatch_event_atom(Event);
true -> <<"UNKNOWN">>
end,
DataPreview =
case is_map(Data) of
true -> maps:with([<<"guild_id">>, <<"chunk_index">>, <<"chunk_count">>, <<"nonce">>], Data);
false -> Data
end,
logger:debug(
"websocket_info: dispatch data preview: ~p",
[DataPreview]
),
Message = #{
<<"op">> => constants:opcode_to_num(dispatch),
<<"t">> => EventName,
<<"d">> => Data,
<<"s">> => Seq
},
case encode_and_compress(Message, State) of
{ok, Frame, NewState} ->
logger:debug(
"websocket_info: dispatch ~p (seq ~p) encoded and sent successfully",
[EventName, Seq]
),
{[Frame], NewState};
{error, Reason} ->
logger:error("websocket_info: encode_and_compress failed for ~p: ~p", [EventName, Reason]),
{ok, State}
end;
websocket_info({'DOWN', _, process, Pid, _}, State = #state{session_pid = SessionPid}) when
Pid =:= SessionPid
->
Message = #{
<<"op">> => constants:opcode_to_num(invalid_session),
<<"d">> => false
},
NewState = State#state{session_pid = undefined},
case encode_and_compress(Message, NewState) of
{ok, Frame, NewState2} ->
{[Frame], NewState2};
{error, _} ->
{ok, NewState}
end;
websocket_info(_, State) ->
{ok, State}.
terminate(_Reason, _Req, #state{compress_ctx = CompressCtx}) ->
gateway_metrics_collector:inc_disconnections(),
gateway_compress:close_context(CompressCtx),
ok;
terminate(_Reason, _Req, _State) ->
gateway_metrics_collector:inc_disconnections(),
ok.
validate_identify_data(Data) ->
try
Token = maps:get(<<"token">>, Data),
Properties = maps:get(<<"properties">>, Data),
IgnoredEventsRaw = maps:get(<<"ignored_events">>, Data, []),
InitialGuildIdRaw = maps:get(<<"initial_guild_id">>, Data, undefined),
case is_map(Properties) of
true ->
Os = maps:get(<<"os">>, Properties),
Browser = maps:get(<<"browser">>, Properties),
Device = maps:get(<<"device">>, Properties),
case is_binary(Os) andalso is_binary(Browser) andalso is_binary(Device) of
true ->
Presence = maps:get(<<"presence">>, Data, null),
case parse_ignored_events(IgnoredEventsRaw) of
{ok, IgnoredEvents} ->
FlagsRaw = maps:get(<<"flags">>, Data, 0),
case FlagsRaw of
Flags when is_integer(Flags), Flags >= 0 ->
{ok, Token, Properties, Presence, IgnoredEvents, Flags, parse_initial_guild_id(InitialGuildIdRaw)};
_ ->
{error, invalid_properties}
end;
{error, Reason} ->
{error, Reason}
end;
false ->
{error, invalid_properties}
end;
false ->
{error, invalid_properties}
end
catch
error:{badkey, _} ->
{error, missing_required_field}
end.
parse_ignored_events(undefined) ->
{ok, []};
parse_ignored_events(null) ->
{ok, []};
parse_ignored_events(Events) when is_list(Events) ->
case lists:all(fun(E) -> is_binary(E) end, Events) of
true ->
Normalized = lists:usort([normalize_event_name(E) || E <- Events]),
{ok, Normalized};
false ->
{error, invalid_ignored_events}
end;
parse_ignored_events(_) ->
{error, invalid_ignored_events}.
parse_initial_guild_id(undefined) ->
undefined;
parse_initial_guild_id(null) ->
undefined;
parse_initial_guild_id(Value) when is_binary(Value) ->
case validation:validate_snowflake(<<"initial_guild_id">>, Value) of
{ok, GuildId} ->
GuildId;
{error, _, Reason} ->
logger:warning(
"[gateway_handler] Invalid initial_guild_id ~p: ~p",
[Value, Reason]
),
undefined
end;
parse_initial_guild_id(_) ->
undefined.
normalize_event_name(Event) ->
list_to_binary(string:uppercase(binary_to_list(Event))).
handle_gateway_payload(
heartbeat,
#{<<"d">> := Seq},
State = #state{heartbeat_state = HeartbeatState, session_pid = SessionPid}
) ->
AckOk =
try
case {SessionPid, Seq} of
{undefined, _} -> true;
{_Pid, null} -> true;
{Pid, SeqNum} when is_integer(SeqNum) ->
case gen_server:call(Pid, {heartbeat_ack, SeqNum}, 5000) of
true -> true;
ok -> true;
_ -> false
end;
_ ->
false
end
catch
exit:_ -> false
end,
case AckOk of
true ->
NewHeartbeatState = HeartbeatState#{
last_ack => erlang:system_time(millisecond),
waiting_for_ack => false
},
gateway_metrics_collector:inc_heartbeat_success(),
AckMessage = #{<<"op">> => constants:opcode_to_num(heartbeat_ack)},
NewState = State#state{heartbeat_state = NewHeartbeatState},
case encode_and_compress(AckMessage, NewState) of
{ok, Frame, NewState2} ->
{[Frame], NewState2};
{error, _} ->
{ok, NewState}
end;
false ->
gateway_metrics_collector:inc_heartbeat_failure(),
close_with_reason(invalid_seq, <<"Invalid sequence">>, State)
end;
handle_gateway_payload(
identify,
#{<<"d">> := Data},
State = #state{session_pid = undefined, peer_ip = PeerIP}
) ->
case validate_identify_data(Data) of
{ok, Token, Properties, Presence, IgnoredEvents, Flags, InitialGuildId} ->
SessionId = utils:generate_session_id(),
SocketPid = self(),
IdentifyData0 = #{
token => Token,
properties => Properties,
presence => Presence,
ignored_events => IgnoredEvents,
flags => Flags
},
IdentifyData =
case InitialGuildId of
undefined -> IdentifyData0;
Id -> maps:put(initial_guild_id, Id, IdentifyData0)
end,
Request = #{
session_id => SessionId,
peer_ip => PeerIP,
identify_data => IdentifyData,
version => State#state.version
},
case gen_server:call(session_manager, {start, Request, SocketPid}, 10000) of
{success, Pid} when is_pid(Pid) ->
monitor(process, Pid),
{ok, State#state{session_pid = Pid}};
{error, invalid_token} ->
close_with_reason(authentication_failed, <<"Invalid token">>, State);
{error, rate_limited} ->
close_with_reason(rate_limited, <<"Rate limited">>, State);
{error, identify_rate_limited} ->
gateway_metrics_collector:inc_identify_rate_limited(),
Message = #{
<<"op">> => constants:opcode_to_num(invalid_session),
<<"d">> => false
},
case encode_and_compress(Message, State) of
{ok, Frame, NewState} ->
{[Frame], NewState};
{error, _} ->
{ok, State}
end;
_ ->
close_with_reason(unknown_error, <<"Failed to start session">>, State)
end;
{error, _Reason} ->
close_with_reason(decode_error, <<"Invalid identify payload">>, State)
end;
handle_gateway_payload(identify, _, State = #state{session_pid = _}) ->
close_with_reason(already_authenticated, <<"Already authenticated">>, State);
handle_gateway_payload(
presence_update, #{<<"d">> := _Data}, State = #state{session_pid = undefined}
) ->
close_with_reason(not_authenticated, <<"Not authenticated">>, State);
handle_gateway_payload(presence_update, #{<<"d">> := Data}, State = #state{session_pid = Pid}) when
is_pid(Pid)
->
Status = utils:parse_status(maps:get(<<"status">>, Data)),
AdjustedStatus =
case Status of
offline -> invisible;
Other -> Other
end,
Afk = maps:get(<<"afk">>, Data, false),
Mobile = maps:get(<<"mobile">>, Data, false),
gen_server:cast(
Pid, {presence_update, #{status => AdjustedStatus, afk => Afk, mobile => Mobile}}
),
{ok, State};
handle_gateway_payload(resume, #{<<"d">> := Data}, State) ->
Token = maps:get(<<"token">>, Data),
SessionId = maps:get(<<"session_id">>, Data),
Seq = maps:get(<<"seq">>, Data),
case gen_server:call(session_manager, {lookup, SessionId}, 5000) of
{ok, Pid} when is_pid(Pid) ->
handle_resume_with_session(Pid, Token, SessionId, Seq, State);
{error, not_found} ->
handle_resume_session_not_found(SessionId, State)
end;
handle_gateway_payload(
voice_state_update, #{<<"d">> := _Data}, State = #state{session_pid = undefined}
) ->
close_with_reason(not_authenticated, <<"Not authenticated">>, State);
handle_gateway_payload(
voice_state_update, #{<<"d">> := Data}, State = #state{session_pid = Pid}
) when
is_pid(Pid)
->
logger:debug("[gateway_handler] Processing voice state update: ~p", [Data]),
try gen_server:call(Pid, {voice_state_update, Data}, 15000) of
ok ->
logger:debug("[gateway_handler] Voice state update succeeded"),
{ok, State};
{error, Category, ErrorAtom} when is_atom(ErrorAtom) ->
logger:warning("[gateway_handler] Voice state update failed: Category=~p, Error=~p", [
Category, ErrorAtom
]),
send_gateway_error(ErrorAtom, State);
UnexpectedResponse ->
logger:error(
"[gateway_handler] Voice state update returned unexpected response: ~p, Data: ~p", [
UnexpectedResponse, Data
]
),
send_gateway_error(internal_error, State)
catch
exit:{timeout, _} ->
logger:error("[gateway_handler] Voice state update timed out (>15s) for Data: ~p", [
Data
]),
send_gateway_error(timeout, State);
Class:ExReason:Stacktrace ->
logger:error(
"[gateway_handler] Voice state update crashed: ~p:~p~nStacktrace: ~p~nData: ~p", [
Class, ExReason, Stacktrace, Data
]
),
send_gateway_error(internal_error, State)
end;
handle_gateway_payload(call_connect, #{<<"d">> := _Data}, State = #state{session_pid = undefined}) ->
close_with_reason(not_authenticated, <<"Not authenticated">>, State);
handle_gateway_payload(call_connect, #{<<"d">> := Data}, State = #state{session_pid = Pid}) when
is_pid(Pid)
->
ChannelId = maps:get(<<"channel_id">>, Data),
gen_server:cast(Pid, {call_connect, ChannelId}),
{ok, State};
handle_gateway_payload(
request_guild_members, #{<<"d">> := _Data}, State = #state{session_pid = undefined}
) ->
close_with_reason(not_authenticated, <<"Not authenticated">>, State);
handle_gateway_payload(
request_guild_members, #{<<"d">> := Data}, State = #state{session_pid = Pid}
) when
is_pid(Pid)
->
SocketPid = self(),
spawn(fun() ->
try
case gen_server:call(Pid, {get_state}, 5000) of
SessionState when is_map(SessionState) ->
case guild_request_members:handle_request(Data, SocketPid, SessionState) of
ok ->
logger:debug("[gateway_handler] Guild members request completed successfully");
{error, ErrorReason} ->
logger:warning(
"[gateway_handler] Guild members request failed: ~p",
[ErrorReason]
)
end;
Other ->
logger:warning(
"[gateway_handler] Failed to get session state for guild members request: ~p",
[Other]
)
end
catch
Class:ExceptionReason:Stacktrace ->
logger:error(
"[gateway_handler] Guild members request crashed: ~p:~p~nStacktrace: ~p",
[Class, ExceptionReason, Stacktrace]
)
end
end),
{ok, State};
handle_gateway_payload(
lazy_request, #{<<"d">> := _Data}, State = #state{session_pid = undefined}
) ->
close_with_reason(not_authenticated, <<"Not authenticated">>, State);
handle_gateway_payload(
lazy_request, #{<<"d">> := Data}, State = #state{session_pid = Pid}
) when
is_pid(Pid)
->
logger:debug("lazy_request received with data: ~p", [Data]),
SocketPid = self(),
spawn(fun() ->
try
logger:debug("lazy_request: fetching session state"),
case gen_server:call(Pid, {get_state}, 5000) of
SessionState when is_map(SessionState) ->
logger:debug("lazy_request: session state retrieved, calling unified subscriptions handler"),
guild_unified_subscriptions:handle_subscriptions(Data, SocketPid, SessionState);
Other ->
logger:warning("lazy_request: unexpected session state: ~p", [Other])
end
catch
Class:Reason:StackTrace ->
logger:error("lazy_request: exception ~p:~p", [Class, Reason], #{stacktrace => StackTrace})
end
end),
{ok, State};
handle_gateway_payload(_, _, State) ->
close_with_reason(unknown_opcode, <<"Unknown opcode">>, State).
schedule_heartbeat_check() ->
erlang:send_after(constants:heartbeat_interval() div 3, self(), {heartbeat_check}).
check_rate_limit(State = #state{rate_limit_state = RateLimitState}) ->
Now = erlang:system_time(millisecond),
Events = maps:get(events, RateLimitState, []),
WindowStart = maps:get(window_start, RateLimitState, Now),
WindowDuration = 60000,
MaxEvents = 120,
EventsInWindow = [T || T <- Events, (Now - T) < WindowDuration],
EventsCount = length(EventsInWindow),
case EventsCount >= MaxEvents of
true ->
rate_limited;
false ->
NewEvents = [Now | EventsInWindow],
NewRateLimitState = #{
events => NewEvents,
window_start => WindowStart
},
{ok, State#state{rate_limit_state = NewRateLimitState}}
end.
extract_client_ip(Req) ->
case cowboy_req:header(<<"x-forwarded-for">>, Req) of
undefined ->
{PeerIP, _Port} = cowboy_req:peer(Req),
list_to_binary(inet:ntoa(PeerIP));
ForwardedFor ->
case parse_forwarded_for(ForwardedFor) of
<<>> ->
{PeerIP, _Port} = cowboy_req:peer(Req),
list_to_binary(inet:ntoa(PeerIP));
IP ->
IP
end
end.
parse_forwarded_for(HeaderValue) ->
case binary:split(HeaderValue, <<",">>) of
[First | _] ->
case normalize_forwarded_ip(First) of
{ok, IP} -> IP;
error -> <<>>
end;
[] ->
<<>>
end.
normalize_forwarded_ip(Value) ->
Trimmed = string:trim(Value),
case Trimmed of
<<>> ->
error;
_ ->
case Trimmed of
<<"[", _/binary>> ->
case strip_ipv6_brackets(Trimmed) of
{ok, IPv6} ->
validate_ip(IPv6);
error ->
error
end;
_ ->
Cleaned = strip_ipv4_port(Trimmed),
validate_ip(Cleaned)
end
end.
strip_ipv6_brackets(<<"[", Rest/binary>>) ->
case binary:match(Rest, <<"]">>) of
{Pos, _Len} when Pos > 0 ->
{ok, binary:part(Rest, 0, Pos)};
_ ->
error
end;
strip_ipv6_brackets(_) ->
error.
strip_ipv4_port(IP) ->
case binary:match(IP, <<".">>) of
nomatch ->
IP;
_ ->
case binary:split(IP, <<":">>, [global]) of
[Addr, _Port] ->
Addr;
_ ->
IP
end
end.
validate_ip(IP) ->
case inet:parse_address(binary_to_list(IP)) of
{ok, Parsed} ->
{ok, list_to_binary(inet:ntoa(Parsed))};
{error, _Reason} ->
error
end.
handle_resume_with_session(Pid, Token, SessionId, Seq, State) ->
case gen_server:call(Pid, {token_verify, Token}, 5000) of
true ->
handle_resume_with_verified_token(Pid, SessionId, Seq, State);
false ->
handle_resume_invalid_token(SessionId, State)
end.
handle_resume_with_verified_token(Pid, SessionId, Seq, State) ->
SocketPid = self(),
case gen_server:call(Pid, {resume, Seq, SocketPid}, 5000) of
{ok, MissedEvents} when is_list(MissedEvents) ->
handle_resume_success(Pid, SessionId, Seq, MissedEvents, State);
invalid_seq ->
handle_resume_invalid_seq(Seq, State)
end.
handle_resume_success(Pid, _SessionId, Seq, MissedEvents, State) ->
gateway_metrics_collector:inc_resume_success(),
SocketPid = self(),
monitor(process, Pid),
lists:foreach(
fun(Event) when is_map(Event) ->
SocketPid !
{dispatch, maps:get(event, Event), maps:get(data, Event), maps:get(seq, Event)}
end,
MissedEvents
),
SocketPid ! {dispatch, resumed, null, Seq},
{ok, State#state{
session_pid = Pid,
heartbeat_state = #{
last_ack => erlang:system_time(millisecond),
waiting_for_ack => false
}
}}.
handle_resume_invalid_seq(_Seq, State) ->
gateway_metrics_collector:inc_resume_failure(),
close_with_reason(invalid_seq, <<"Invalid sequence">>, State).
handle_resume_invalid_token(_SessionId, State) ->
gateway_metrics_collector:inc_resume_failure(),
close_with_reason(authentication_failed, <<"Invalid token">>, State).
handle_resume_session_not_found(_SessionId, State) ->
gateway_metrics_collector:inc_resume_failure(),
Message = #{
<<"op">> => constants:opcode_to_num(invalid_session),
<<"d">> => false
},
case encode_and_compress(Message, State) of
{ok, Frame, NewState} ->
{[Frame], NewState};
{error, _} ->
{ok, State}
end.
send_gateway_error(ErrorAtom, State) when is_atom(ErrorAtom) ->
ErrorCode = gateway_errors:error_code(ErrorAtom),
ErrorMessage = gateway_errors:error_message(ErrorAtom),
Message = #{
<<"op">> => constants:opcode_to_num(gateway_error),
<<"d">> => #{
<<"code">> => ErrorCode,
<<"message">> => ErrorMessage
}
},
case encode_and_compress(Message, State) of
{ok, Frame, NewState} ->
{[Frame], NewState};
{error, _} ->
{ok, State}
end.
encode_and_compress(Message, State = #state{encoding = Encoding, compress_ctx = CompressCtx}) ->
case gateway_codec:encode(Message, Encoding) of
{ok, Encoded, FrameType} ->
case gateway_compress:compress(Encoded, CompressCtx) of
{ok, Compressed, NewCompressCtx} ->
Frame = make_frame(Compressed, FrameType, NewCompressCtx),
{ok, Frame, State#state{compress_ctx = NewCompressCtx}};
{error, Reason} ->
{error, {compress_failed, gateway_compress:get_type(CompressCtx), Reason}}
end;
{error, Reason} ->
{error, {encode_failed, Reason}}
end.
compression_error_reason(zstd_stream) ->
<<"Compression failed: zstd-stream">>;
compression_error_reason(_) ->
<<"Encode failed">>.
close_with_reason(Reason, Message, State) ->
gateway_metrics_collector:inc_websocket_close(Reason),
CloseCode = constants:close_code_to_num(Reason),
{[{close, CloseCode, Message}], State}.
make_frame(Data, FrameType, CompressCtx) ->
case gateway_compress:get_type(CompressCtx) of
none -> {FrameType, Data};
_ -> {binary, Data}
end.
-ifdef(TEST).
parse_forwarded_for_ipv4_test() ->
?assertEqual(<<"203.0.113.7">>, parse_forwarded_for(<<"203.0.113.7">>)).
parse_forwarded_for_ipv4_with_port_test() ->
?assertEqual(<<"203.0.113.7">>, parse_forwarded_for(<<"203.0.113.7:8080">>)).
parse_forwarded_for_ipv4_with_port_and_extra_entries_test() ->
Header = <<" 203.0.113.7:8080 , 10.0.0.1">>,
?assertEqual(<<"203.0.113.7">>, parse_forwarded_for(Header)).
parse_forwarded_for_ipv6_test() ->
?assertEqual(<<"2001:db8::1">>, parse_forwarded_for(<<"2001:db8::1">>)).
parse_forwarded_for_ipv6_with_brackets_test() ->
?assertEqual(<<"2001:db8::1">>, parse_forwarded_for(<<"[2001:db8::1]">>)).
parse_forwarded_for_ipv6_with_brackets_and_port_test() ->
?assertEqual(<<"2001:db8::1">>, parse_forwarded_for(<<"[2001:db8::1]:443">>)).
parse_forwarded_for_ipv6_with_spaces_test() ->
?assertEqual(<<"2001:db8::1">>, parse_forwarded_for(<<" [2001:db8::1] ">>)).
parse_forwarded_for_invalid_ip_test() ->
?assertEqual(<<>>, parse_forwarded_for(<<"not_an_ip">>)).
parse_forwarded_for_invalid_ipv4_octet_test() ->
?assertEqual(<<>>, parse_forwarded_for(<<"203.0.113.300">>)).
parse_forwarded_for_unterminated_bracket_test() ->
?assertEqual(<<>>, parse_forwarded_for(<<"[2001:db8::1">>)).
-endif.

View File

@@ -0,0 +1,215 @@
%% 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(gateway_rpc_call).
-export([execute_method/2]).
execute_method(<<"call.get">>, #{<<"channel_id">> := ChannelIdBin}) ->
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
{ok, Pid} ->
case gen_server:call(Pid, {get_state}, 5000) of
{ok, CallData} ->
CallData;
_ ->
throw({error, <<"Failed to get call state">>})
end;
{error, not_found} ->
null;
not_found ->
null
end;
execute_method(<<"call.create">>, Params) ->
#{
<<"channel_id">> := ChannelIdBin,
<<"message_id">> := MessageIdBin,
<<"region">> := Region,
<<"ringing">> := RingingBins,
<<"recipients">> := RecipientsBins
} = Params,
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
MessageId = validation:snowflake_or_throw(<<"message_id">>, MessageIdBin),
Ringing = validation:snowflake_list_or_throw(<<"ringing">>, RingingBins),
Recipients = validation:snowflake_list_or_throw(<<"recipients">>, RecipientsBins),
CallData = #{
channel_id => ChannelId,
message_id => MessageId,
region => Region,
ringing => Ringing,
recipients => Recipients
},
case gen_server:call(call_manager, {create, ChannelId, CallData}, 10000) of
{ok, Pid} ->
case gen_server:call(Pid, {get_state}, 5000) of
{ok, CallState} ->
CallState;
_ ->
throw({error, <<"Failed to get call state after creation">>})
end;
{error, already_exists} ->
throw({error, <<"Call already exists">>});
{error, Reason} ->
throw({error, iolist_to_binary(io_lib:format("Failed to create call: ~p", [Reason]))})
end;
execute_method(<<"call.update_region">>, #{
<<"channel_id">> := ChannelIdBin, <<"region">> := Region
}) ->
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
{ok, Pid} ->
case gen_server:call(Pid, {update_region, Region}, 5000) of
ok ->
true;
_ ->
throw({error, <<"Failed to update region">>})
end;
not_found ->
throw({error, <<"Call not found">>})
end;
execute_method(<<"call.ring">>, Params) ->
#{<<"channel_id">> := ChannelIdBin, <<"recipients">> := RecipientsBin} = Params,
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
Recipients = validation:snowflake_list_or_throw(<<"recipients">>, RecipientsBin),
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
{ok, Pid} ->
case gen_server:call(Pid, {ring_recipients, Recipients}, 5000) of
ok ->
true;
_ ->
throw({error, <<"Failed to ring recipients">>})
end;
not_found ->
throw({error, <<"Call not found">>})
end;
execute_method(<<"call.stop_ringing">>, Params) ->
#{<<"channel_id">> := ChannelIdBin, <<"recipients">> := RecipientsBin} = Params,
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
Recipients = validation:snowflake_list_or_throw(<<"recipients">>, RecipientsBin),
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
{ok, Pid} ->
case gen_server:call(Pid, {stop_ringing, Recipients}, 5000) of
ok ->
true;
_ ->
throw({error, <<"Failed to stop ringing">>})
end;
not_found ->
throw({error, <<"Call not found">>})
end;
execute_method(<<"call.join">>, Params) ->
#{
<<"channel_id">> := ChannelIdBin,
<<"user_id">> := UserIdBin,
<<"session_id">> := SessionIdBin,
<<"voice_state">> := VoiceState
} = Params,
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
SessionId = SessionIdBin,
case gen_server:call(session_manager, {lookup, SessionId}, 5000) of
{ok, SessionPid} ->
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
{ok, CallPid} ->
case
gen_server:call(
CallPid, {join, UserId, VoiceState, SessionId, SessionPid}, 5000
)
of
ok ->
true;
_ ->
throw({error, <<"Failed to join call">>})
end;
not_found ->
throw({error, <<"Call not found">>})
end;
not_found ->
throw({error, <<"Session not found">>})
end;
execute_method(<<"call.leave">>, #{<<"channel_id">> := ChannelIdBin, <<"session_id">> := SessionId}) ->
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
{ok, Pid} ->
case gen_server:call(Pid, {leave, SessionId}, 5000) of
ok ->
true;
_ ->
throw({error, <<"Failed to leave call">>})
end;
not_found ->
throw({error, <<"Call not found">>})
end;
execute_method(<<"call.delete">>, #{<<"channel_id">> := ChannelIdBin}) ->
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
case gen_server:call(call_manager, {terminate_call, ChannelId}, 5000) of
ok ->
true;
{error, not_found} ->
throw({error, <<"Call not found">>});
_ ->
throw({error, <<"Failed to delete call">>})
end;
execute_method(<<"call.confirm_connection">>, Params) ->
#{<<"channel_id">> := ChannelIdBin, <<"connection_id">> := ConnectionId} = Params,
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
logger:debug(
"[gateway_rpc_call] call.confirm_connection channel_id=~p connection_id=~p",
[ChannelId, ConnectionId]
),
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
{ok, Pid} ->
gen_server:call(Pid, {confirm_connection, ConnectionId}, 5000);
{error, not_found} ->
logger:debug(
"[gateway_rpc_call] call.confirm_connection call not found for channel_id=~p", [
ChannelId
]
),
#{success => true, call_not_found => true};
not_found ->
logger:debug(
"[gateway_rpc_call] call.confirm_connection call manager returned not_found for channel_id=~p",
[ChannelId]
),
#{success => true, call_not_found => true}
end;
execute_method(<<"call.disconnect_user_if_in_channel">>, Params) ->
#{<<"channel_id">> := ChannelIdBin, <<"user_id">> := UserIdBin} = Params,
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
ConnectionId = maps:get(<<"connection_id">>, Params, undefined),
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
{ok, Pid} ->
gen_server:call(
Pid, {disconnect_user_if_in_channel, UserId, ChannelId, ConnectionId}, 5000
);
{error, not_found} ->
#{success => true, call_not_found => true};
not_found ->
#{success => true, call_not_found => true}
end.

View File

@@ -0,0 +1,785 @@
%% 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(gateway_rpc_guild).
-export([execute_method/2]).
execute_method(<<"guild.dispatch">>, #{
<<"guild_id">> := GuildIdBin, <<"event">> := Event, <<"data">> := Data
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
EventAtom = constants:dispatch_event_atom(Event),
case gen_server:call(Pid, {dispatch, #{event => EventAtom, data => Data}}, 10000) of
ok -> true;
_ -> throw({error, <<"Dispatch failed">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.get_counts">>, #{<<"guild_id">> := GuildIdBin}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
case gen_server:call(Pid, {get_counts}, 10000) of
#{member_count := MemberCount, presence_count := PresenceCount} ->
#{
<<"member_count">> => MemberCount,
<<"presence_count">> => PresenceCount
};
_ ->
throw({error, <<"Failed to get counts">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.get_data">>, #{<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case validation:validate_optional_snowflake(UserIdBin) of
{ok, UserId} ->
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{user_id => UserId},
case gen_server:call(Pid, {get_guild_data, Request}, 10000) of
#{guild_data := null, error_reason := <<"forbidden">>} ->
throw({error, <<"forbidden">>});
#{guild_data := null} ->
throw({error, <<"Guild data not available for user">>});
#{guild_data := GuildData} ->
GuildData;
_ ->
throw({error, <<"Failed to get guild data">>})
end;
_ ->
throw({error, <<"guild_not_found">>})
end
end;
execute_method(<<"guild.get_member">>, #{<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{user_id => UserId},
case gen_server:call(Pid, {get_guild_member, Request}, 10000) of
#{success := true, member_data := MemberData} ->
#{
<<"success">> => true,
<<"member_data">> => MemberData
};
#{success := false} ->
#{<<"success">> => false};
_ ->
throw({error, <<"Failed to get guild member">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.has_member">>, #{<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{user_id => UserId},
case gen_server:call(Pid, {has_member, Request}, 10000) of
#{has_member := HasMember} when is_boolean(HasMember) ->
#{<<"has_member">> => HasMember};
_ ->
throw({error, <<"Failed to determine membership">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.list_members">>, #{
<<"guild_id">> := GuildIdBin, <<"limit">> := Limit, <<"offset">> := Offset
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{limit => Limit, offset => Offset},
case gen_server:call(Pid, {list_guild_members, Request}, 10000) of
#{members := Members, total := Total} ->
#{
<<"members">> => Members,
<<"total">> => Total
};
_ ->
throw({error, <<"Failed to list guild members">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.start">>, #{<<"guild_id">> := GuildIdBin}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, _Pid} ->
true;
_ ->
throw({error, <<"Failed to start guild">>})
end;
execute_method(<<"guild.stop">>, #{<<"guild_id">> := GuildIdBin}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(guild_manager, {stop_guild, GuildId}, 10000) of
ok ->
true;
_ ->
throw({error, <<"Failed to stop guild">>})
end;
execute_method(<<"guild.reload">>, #{<<"guild_id">> := GuildIdBin}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(guild_manager, {reload_guild, GuildId}, 10000) of
ok ->
true;
{error, not_found} ->
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 20000) of
{ok, _Pid} -> true;
_ -> throw({error, <<"Failed to reload guild">>})
end;
_ ->
throw({error, <<"Failed to reload guild">>})
end;
execute_method(<<"guild.reload_all">>, #{<<"guild_ids">> := GuildIdsBin}) ->
GuildIds = validation:snowflake_list_or_throw(<<"guild_ids">>, GuildIdsBin),
case gen_server:call(guild_manager, {reload_all_guilds, GuildIds}, 60000) of
#{count := Count} ->
#{<<"count">> => Count};
_ ->
throw({error, <<"Failed to reload guilds">>})
end;
execute_method(<<"guild.shutdown">>, #{<<"guild_id">> := GuildIdBin}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(guild_manager, {shutdown_guild, GuildId}, 10000) of
ok ->
true;
{error, timeout} ->
case gen_server:call(guild_manager, {stop_guild, GuildId}, 10000) of
ok -> true;
_ -> throw({error, <<"Failed to shutdown guild">>})
end;
_ ->
throw({error, <<"Failed to shutdown guild">>})
end;
execute_method(<<"guild.get_user_permissions">>, #{
<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin, <<"channel_id">> := ChannelIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
ChannelId =
case ChannelIdBin of
<<"0">> ->
undefined;
_ ->
validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin)
end,
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{user_id => UserId, channel_id => ChannelId},
case gen_server:call(Pid, {get_user_permissions, Request}, 10000) of
#{permissions := Permissions} ->
#{<<"permissions">> => integer_to_binary(Permissions)};
_ ->
throw({error, <<"Failed to get permissions">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.check_permission">>, #{
<<"guild_id">> := GuildIdBin,
<<"user_id">> := UserIdBin,
<<"permission">> := PermissionBin,
<<"channel_id">> := ChannelIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
Permission = validation:snowflake_or_throw(<<"permission">>, PermissionBin),
ChannelId =
case ChannelIdBin of
<<"0">> ->
undefined;
_ ->
validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin)
end,
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{
user_id => UserId,
permission => Permission,
channel_id => ChannelId
},
case gen_server:call(Pid, {check_permission, Request}, 10000) of
#{has_permission := HasPermission} ->
#{<<"has_permission">> => HasPermission};
_ ->
throw({error, <<"Failed to check permission">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.can_manage_roles">>, #{
<<"guild_id">> := GuildIdBin,
<<"user_id">> := UserIdBin,
<<"target_user_id">> := TargetUserIdBin,
<<"role_id">> := RoleIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
TargetUserId = validation:snowflake_or_throw(<<"target_user_id">>, TargetUserIdBin),
RoleId = validation:snowflake_or_throw(<<"role_id">>, RoleIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{
user_id => UserId,
target_user_id => TargetUserId,
role_id => RoleId
},
case gen_server:call(Pid, {can_manage_roles, Request}, 10000) of
#{can_manage := CanManage} ->
#{<<"can_manage">> => CanManage};
_ ->
throw({error, <<"Failed to check role management">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.can_manage_role">>, #{
<<"guild_id">> := GuildIdBin,
<<"user_id">> := UserIdBin,
<<"role_id">> := RoleIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
RoleId = validation:snowflake_or_throw(<<"role_id">>, RoleIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{user_id => UserId, role_id => RoleId},
case gen_server:call(Pid, {can_manage_role, Request}, 10000) of
#{can_manage := CanManage} ->
#{<<"can_manage">> => CanManage};
_ ->
throw({error, <<"Failed to check role management">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.get_assignable_roles">>, #{
<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{user_id => UserId},
case gen_server:call(Pid, {get_assignable_roles, Request}, 10000) of
#{role_ids := RoleIds} ->
#{
<<"role_ids">> => [
integer_to_binary(RoleId)
|| RoleId <- RoleIds
]
};
_ ->
throw({error, <<"Failed to get assignable roles">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.get_user_max_role_position">>, #{
<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{user_id => UserId},
case gen_server:call(Pid, {get_user_max_role_position, Request}, 10000) of
#{position := Position} ->
#{<<"position">> => Position};
_ ->
throw({error, <<"Failed to get max role position">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.get_members_with_role">>, #{
<<"guild_id">> := GuildIdBin, <<"role_id">> := RoleIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
RoleId = validation:snowflake_or_throw(<<"role_id">>, RoleIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{role_id => RoleId},
case gen_server:call(Pid, {get_members_with_role, Request}, 10000) of
#{user_ids := UserIds} ->
#{
<<"user_ids">> => [
integer_to_binary(UserId)
|| UserId <- UserIds
]
};
_ ->
throw({error, <<"Failed to get members with role">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.check_target_member">>, #{
<<"guild_id">> := GuildIdBin,
<<"user_id">> := UserIdBin,
<<"target_user_id">> := TargetUserIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
TargetUserId = validation:snowflake_or_throw(<<"target_user_id">>, TargetUserIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{user_id => UserId, target_user_id => TargetUserId},
case gen_server:call(Pid, {check_target_member, Request}, 10000) of
#{can_manage := CanManage} ->
#{<<"can_manage">> => CanManage};
_ ->
throw({error, <<"Failed to check target member">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.get_viewable_channels">>, #{
<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{user_id => UserId},
case gen_server:call(Pid, {get_viewable_channels, Request}, 10000) of
#{channel_ids := ChannelIds} ->
#{
<<"channel_ids">> => [
integer_to_binary(ChannelId)
|| ChannelId <- ChannelIds
]
};
_ ->
throw({error, <<"Failed to get viewable channels">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.get_users_to_mention_by_roles">>, #{
<<"guild_id">> := GuildIdBin,
<<"channel_id">> := ChannelIdBin,
<<"role_ids">> := RoleIds,
<<"author_id">> := AuthorIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
AuthorId = validation:snowflake_or_throw(<<"author_id">>, AuthorIdBin),
RoleIdsList = validation:snowflake_list_or_throw(<<"role_ids">>, RoleIds),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{
channel_id => ChannelId,
role_ids => RoleIdsList,
author_id => AuthorId
},
case gen_server:call(Pid, {get_users_to_mention_by_roles, Request}, 10000) of
#{user_ids := UserIds} ->
#{
<<"user_ids">> => [
integer_to_binary(UserId)
|| UserId <- UserIds
]
};
_ ->
throw({error, <<"Failed to get users">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.get_users_to_mention_by_user_ids">>, #{
<<"guild_id">> := GuildIdBin,
<<"channel_id">> := ChannelIdBin,
<<"user_ids">> := UserIds,
<<"author_id">> := AuthorIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
AuthorId = validation:snowflake_or_throw(<<"author_id">>, AuthorIdBin),
UserIdsList = validation:snowflake_list_or_throw(<<"user_ids">>, UserIds),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{
channel_id => ChannelId,
user_ids => UserIdsList,
author_id => AuthorId
},
case gen_server:call(Pid, {get_users_to_mention_by_user_ids, Request}, 10000) of
#{user_ids := ResultUserIds} ->
#{
<<"user_ids">> => [
integer_to_binary(UserId)
|| UserId <- ResultUserIds
]
};
_ ->
throw({error, <<"Failed to get users">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.get_all_users_to_mention">>, #{
<<"guild_id">> := GuildIdBin, <<"channel_id">> := ChannelIdBin, <<"author_id">> := AuthorIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
AuthorId = validation:snowflake_or_throw(<<"author_id">>, AuthorIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{
channel_id => ChannelId,
author_id => AuthorId
},
case gen_server:call(Pid, {get_all_users_to_mention, Request}, 10000) of
#{user_ids := UserIds} ->
#{
<<"user_ids">> => [
integer_to_binary(UserId)
|| UserId <- UserIds
]
};
_ ->
throw({error, <<"Failed to get users">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.resolve_all_mentions">>, #{
<<"guild_id">> := GuildIdBin,
<<"channel_id">> := ChannelIdBin,
<<"author_id">> := AuthorIdBin,
<<"mention_everyone">> := MentionEveryone,
<<"mention_here">> := MentionHere,
<<"role_ids">> := RoleIds,
<<"user_ids">> := UserIds
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
AuthorId = validation:snowflake_or_throw(<<"author_id">>, AuthorIdBin),
RoleIdsList = validation:snowflake_list_or_throw(<<"role_ids">>, RoleIds),
UserIdsList = validation:snowflake_list_or_throw(<<"user_ids">>, UserIds),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{
channel_id => ChannelId,
author_id => AuthorId,
mention_everyone => MentionEveryone,
mention_here => MentionHere,
role_ids => RoleIdsList,
user_ids => UserIdsList
},
case gen_server:call(Pid, {resolve_all_mentions, Request}, 10000) of
#{user_ids := ResultUserIds} ->
#{
<<"user_ids">> => [
integer_to_binary(UserId)
|| UserId <- ResultUserIds
]
};
_ ->
throw({error, <<"Failed to resolve mentions">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.get_vanity_url_channel">>, #{<<"guild_id">> := GuildIdBin}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
case gen_server:call(Pid, {get_vanity_url_channel}, 10000) of
#{channel_id := ChannelId} when ChannelId =/= null ->
#{<<"channel_id">> => integer_to_binary(ChannelId)};
#{channel_id := null} ->
#{<<"channel_id">> => null};
_ ->
throw({error, <<"Failed to get vanity URL channel">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.get_first_viewable_text_channel">>, #{<<"guild_id">> := GuildIdBin}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
case gen_server:call(Pid, {get_first_viewable_text_channel}, 10000) of
#{channel_id := ChannelId} when ChannelId =/= null ->
#{<<"channel_id">> => integer_to_binary(ChannelId)};
#{channel_id := null} ->
#{<<"channel_id">> => null};
_ ->
throw({error, <<"Failed to get first viewable text channel">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(
<<"guild.update_member_voice">>,
#{
<<"guild_id">> := GuildIdBin,
<<"user_id">> := UserIdBin,
<<"mute">> := Mute,
<<"deaf">> := Deaf
}
) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{user_id => UserId, mute => Mute, deaf => Deaf},
case gen_server:call(Pid, {update_member_voice, Request}, 10000) of
#{success := true} ->
#{<<"success">> => true};
#{error := Error} ->
throw({error, Error})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(
<<"guild.disconnect_voice_user">>,
#{
<<"guild_id">> := GuildIdBin,
<<"user_id">> := UserIdBin
} = Params
) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
ConnectionId = maps:get(<<"connection_id">>, Params, null),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{user_id => UserId, connection_id => ConnectionId},
case gen_server:call(Pid, {disconnect_voice_user, Request}, 10000) of
#{success := true} ->
#{<<"success">> => true};
#{error := Error} ->
throw({error, Error})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(
<<"guild.disconnect_voice_user_if_in_channel">>,
#{
<<"guild_id">> := GuildIdBin,
<<"user_id">> := UserIdBin,
<<"expected_channel_id">> := ExpectedChannelIdBin
} = Params
) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
ExpectedChannelId = validation:snowflake_or_throw(
<<"expected_channel_id">>, ExpectedChannelIdBin
),
ConnectionId = maps:get(<<"connection_id">>, Params, undefined),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request =
case ConnectionId of
undefined ->
#{
user_id => UserId,
expected_channel_id => ExpectedChannelId
};
ConnId ->
#{
user_id => UserId,
expected_channel_id => ExpectedChannelId,
connection_id => ConnId
}
end,
case gen_server:call(Pid, {disconnect_voice_user_if_in_channel, Request}, 10000) of
#{success := true, ignored := true} ->
#{<<"success">> => true, <<"ignored">> => true};
#{success := true} ->
#{<<"success">> => true};
#{error := Error} ->
throw({error, Error})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.disconnect_all_voice_users_in_channel">>, #{
<<"guild_id">> := GuildIdBin,
<<"channel_id">> := ChannelIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{channel_id => ChannelId},
case gen_server:call(Pid, {disconnect_all_voice_users_in_channel, Request}, 10000) of
#{success := true, disconnected_count := Count} ->
#{<<"success">> => true, <<"disconnected_count">> => Count};
#{error := Error} ->
throw({error, Error})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.confirm_voice_connection_from_livekit">>, Params) ->
GuildIdBin = maps:get(<<"guild_id">>, Params),
ConnectionId = maps:get(<<"connection_id">>, Params),
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{connection_id => ConnectionId},
case gen_server:call(Pid, {confirm_voice_connection_from_livekit, Request}, 10000) of
#{success := true} ->
#{<<"success">> => true};
#{success := false, error := Error} ->
#{<<"success">> => false, <<"error">> => Error};
#{error := Error} ->
throw({error, Error})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.move_member">>, #{
<<"guild_id">> := GuildIdBin,
<<"user_id">> := UserIdBin,
<<"moderator_id">> := ModeratorIdBin,
<<"channel_id">> := ChannelIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
ModeratorId = validation:snowflake_or_throw(<<"moderator_id">>, ModeratorIdBin),
case validation:validate_optional_snowflake(ChannelIdBin) of
{ok, ChannelId} ->
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{
user_id => UserId,
moderator_id => ModeratorId,
channel_id => ChannelId
},
case gen_server:call(Pid, {move_member, Request}, 10000) of
#{
success := true,
needs_token := true,
session_data := SessionData,
connections_to_move := ConnectionsToMove
} when
ChannelId =/= null
->
spawn(fun() ->
guild_voice:handle_virtual_channel_access_for_move(
UserId, ChannelId, ConnectionsToMove, Pid
),
guild_voice:send_voice_server_updates_for_move(
GuildId, ChannelId, SessionData, Pid
)
end),
#{<<"success">> => true};
#{success := true, user_id := DisconnectedUserId} when
ChannelId =:= null
->
spawn(fun() ->
guild_voice:cleanup_virtual_access_on_disconnect(
DisconnectedUserId, Pid
)
end),
#{<<"success">> => true};
#{success := true} ->
#{<<"success">> => true};
#{error := Error} ->
throw({error, Error})
end;
_ ->
throw({error, <<"Guild not found">>})
end
end;
execute_method(<<"guild.get_voice_state">>, #{
<<"guild_id">> := GuildIdBin,
<<"user_id">> := UserIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{user_id => UserId},
case gen_server:call(Pid, {get_voice_state, Request}, 10000) of
#{voice_state := null} ->
#{<<"voice_state">> => null};
#{voice_state := VoiceState} ->
#{<<"voice_state">> => VoiceState};
_ ->
throw({error, <<"Failed to get voice state">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.switch_voice_region">>, #{
<<"guild_id">> := GuildIdBin,
<<"channel_id">> := ChannelIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{channel_id => ChannelId},
case gen_server:call(Pid, {switch_voice_region, Request}, 10000) of
#{success := true} ->
spawn(fun() ->
guild_voice:switch_voice_region(GuildId, ChannelId, Pid)
end),
#{<<"success">> => true};
#{error := Error} ->
throw({error, Error})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.get_category_channel_count">>, #{
<<"guild_id">> := GuildIdBin,
<<"category_id">> := CategoryIdBin
}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
CategoryId = validation:snowflake_or_throw(<<"category_id">>, CategoryIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
Request = #{category_id => CategoryId},
case gen_server:call(Pid, {get_category_channel_count, Request}, 10000) of
#{count := Count} ->
#{<<"count">> => Count};
_ ->
throw({error, <<"Failed to get category channel count">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end;
execute_method(<<"guild.get_channel_count">>, #{<<"guild_id">> := GuildIdBin}) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
{ok, Pid} ->
case gen_server:call(Pid, {get_channel_count}, 10000) of
#{count := Count} ->
#{<<"count">> => Count};
_ ->
throw({error, <<"Failed to get channel count">>})
end;
_ ->
throw({error, <<"Guild not found">>})
end.

View File

@@ -0,0 +1,133 @@
%% 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(gateway_rpc_http_handler).
-export([init/2]).
-define(JSON_HEADERS, #{<<"content-type">> => <<"application/json">>}).
init(Req0, State) ->
case cowboy_req:method(Req0) of
<<"POST">> ->
handle_post(Req0, State);
_ ->
Req = cowboy_req:reply(405, #{<<"allow">> => <<"POST">>}, <<>>, Req0),
{ok, Req, State}
end.
handle_post(Req0, State) ->
case authorize(Req0) of
ok ->
case read_body(Req0) of
{ok, Decoded, Req1} ->
case maps:get(<<"method">>, Decoded, undefined) of
undefined ->
respond(400, #{<<"error">> => <<"Missing method">>}, Req1, State);
Method when is_binary(Method) ->
ParamsValue = maps:get(<<"params">>, Decoded, #{}),
case is_map(ParamsValue) of
true ->
execute_method(Method, ParamsValue, Req1, State);
false ->
respond(
400, #{<<"error">> => <<"Invalid params">>}, Req1, State
)
end;
_ ->
respond(400, #{<<"error">> => <<"Invalid method">>}, Req1, State)
end;
{error, ErrorBody, Req1} ->
respond(400, ErrorBody, Req1, State)
end;
{error, Req1} ->
{ok, Req1, State}
end.
authorize(Req0) ->
case cowboy_req:header(<<"authorization">>, Req0) of
undefined ->
Req = cowboy_req:reply(
401,
?JSON_HEADERS,
jsx:encode(#{<<"error">> => <<"Unauthorized">>}),
Req0
),
{error, Req};
AuthHeader ->
case fluxer_gateway_env:get(rpc_secret_key) of
undefined ->
Req = cowboy_req:reply(
500,
?JSON_HEADERS,
jsx:encode(#{<<"error">> => <<"RPC secret not configured">>}),
Req0
),
{error, Req};
Secret when is_binary(Secret) ->
Expected = <<"Bearer ", Secret/binary>>,
case AuthHeader of
Expected ->
ok;
_ ->
Req = cowboy_req:reply(
401,
?JSON_HEADERS,
jsx:encode(#{<<"error">> => <<"Unauthorized">>}),
Req0
),
{error, Req}
end
end
end.
read_body(Req0) ->
read_body(Req0, <<>>).
read_body(Req0, Acc) ->
case cowboy_req:read_body(Req0) of
{ok, Body, Req1} ->
FullBody = <<Acc/binary, Body/binary>>,
decode_body(FullBody, Req1);
{more, Body, Req1} ->
read_body(Req1, <<Acc/binary, Body/binary>>)
end.
decode_body(Body, Req0) ->
case catch jsx:decode(Body, [return_maps]) of
{'EXIT', _Reason} ->
{error, #{<<"error">> => <<"Invalid JSON payload">>}, Req0};
Decoded when is_map(Decoded) ->
{ok, Decoded, Req0};
_ ->
{error, #{<<"error">> => <<"Invalid request body">>}, Req0}
end.
execute_method(Method, Params, Req0, State) ->
try
Result = gateway_rpc_router:execute(Method, Params),
respond(200, #{<<"result">> => Result}, Req0, State)
catch
throw:{error, Message} ->
respond(400, #{<<"error">> => Message}, Req0, State);
_:_ ->
respond(500, #{<<"error">> => <<"Internal error">>}, Req0, State)
end.
respond(Status, Body, Req0, State) ->
Req = cowboy_req:reply(Status, ?JSON_HEADERS, jsx:encode(Body), Req0),
{ok, Req, State}.

View File

@@ -0,0 +1,80 @@
%% 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(gateway_rpc_misc).
-export([execute_method/2, get_local_node_stats/0]).
execute_method(<<"process.memory_stats">>, Params) ->
Limit =
case maps:get(<<"limit">>, Params, undefined) of
undefined ->
100;
LimitValue ->
validation:snowflake_or_throw(<<"limit">>, LimitValue)
end,
Guilds = process_memory_stats:get_guild_memory_stats(Limit),
#{<<"guilds">> => Guilds};
execute_method(<<"process.node_stats">>, _Params) ->
get_local_node_stats().
get_local_node_stats() ->
SessionCount =
case gen_server:call(session_manager, get_global_count, 1000) of
{ok, SC} -> SC;
_ -> 0
end,
GuildCount =
case gen_server:call(guild_manager, get_global_count, 1000) of
{ok, GC} -> GC;
_ -> 0
end,
PresenceCount =
case gen_server:call(presence_manager, get_global_count, 1000) of
{ok, PC} -> PC;
_ -> 0
end,
CallCount =
case gen_server:call(call_manager, get_global_count, 1000) of
{ok, CC} -> CC;
_ -> 0
end,
MemoryInfo = erlang:memory(),
TotalMemory = proplists:get_value(total, MemoryInfo, 0),
ProcessMemory = proplists:get_value(processes, MemoryInfo, 0),
SystemMemory = proplists:get_value(system, MemoryInfo, 0),
#{
<<"status">> => <<"healthy">>,
<<"sessions">> => SessionCount,
<<"guilds">> => GuildCount,
<<"presences">> => PresenceCount,
<<"calls">> => CallCount,
<<"memory">> => #{
<<"total">> => TotalMemory,
<<"processes">> => ProcessMemory,
<<"system">> => SystemMemory
},
<<"process_count">> => erlang:system_info(process_count),
<<"process_limit">> => erlang:system_info(process_limit),
<<"uptime_seconds">> => element(1, erlang:statistics(wall_clock)) div 1000
}.

View File

@@ -0,0 +1,182 @@
%% 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(gateway_rpc_presence).
-export([execute_method/2]).
execute_method(<<"presence.dispatch">>, #{
<<"user_id">> := UserIdBin, <<"event">> := Event, <<"data">> := Data
}) ->
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
EventAtom = constants:dispatch_event_atom(Event),
case presence_manager:dispatch_to_user(UserId, EventAtom, Data) of
ok ->
true;
{error, not_found} ->
handle_offline_dispatch(EventAtom, UserId, Data)
end;
execute_method(<<"presence.join_guild">>, #{
<<"user_id">> := UserIdBin, <<"guild_id">> := GuildIdBin
}) ->
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(presence_manager, {lookup, UserId}, 10000) of
{ok, Pid} ->
case gen_server:call(Pid, {join_guild, GuildId}, 10000) of
ok -> true;
_ -> throw({error, <<"Join guild failed">>})
end;
not_found ->
true;
{error, _} ->
true;
_ ->
true
end;
execute_method(<<"presence.leave_guild">>, #{
<<"user_id">> := UserIdBin, <<"guild_id">> := GuildIdBin
}) ->
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(presence_manager, {lookup, UserId}, 10000) of
{ok, Pid} ->
case gen_server:call(Pid, {leave_guild, GuildId}, 10000) of
ok -> true;
_ -> throw({error, <<"Leave guild failed">>})
end;
not_found ->
true;
{error, _} ->
true;
_ ->
true
end;
execute_method(<<"presence.terminate_sessions">>, #{
<<"user_id">> := UserIdBin, <<"session_id_hashes">> := SessionIdHashes
}) ->
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
case gen_server:call(presence_manager, {lookup, UserId}, 10000) of
{ok, Pid} ->
case gen_server:call(Pid, {terminate_session, SessionIdHashes}, 10000) of
ok -> true;
_ -> throw({error, <<"Terminate session failed">>})
end;
not_found ->
true;
{error, _} ->
true;
_ ->
true
end;
execute_method(<<"presence.terminate_all_sessions">>, #{
<<"user_id">> := UserIdBin
}) ->
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
case presence_manager:terminate_all_sessions(UserId) of
ok -> true;
_ -> throw({error, <<"Terminate all sessions failed">>})
end;
execute_method(<<"presence.has_active">>, #{<<"user_id">> := UserIdBin}) ->
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
case gen_server:call(presence_manager, {lookup, UserId}, 10000) of
{ok, _Pid} ->
#{<<"has_active">> => true};
_ ->
#{<<"has_active">> => false}
end;
execute_method(<<"presence.add_temporary_guild">>, #{
<<"user_id">> := UserIdBin, <<"guild_id">> := GuildIdBin
}) ->
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(presence_manager, {lookup, UserId}, 10000) of
{ok, Pid} ->
case gen_server:call(Pid, {add_temporary_guild, GuildId}, 10000) of
ok -> true;
_ -> throw({error, <<"Add temporary guild failed">>})
end;
not_found ->
true;
{error, _} ->
true;
_ ->
true
end;
execute_method(<<"presence.remove_temporary_guild">>, #{
<<"user_id">> := UserIdBin, <<"guild_id">> := GuildIdBin
}) ->
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
case gen_server:call(presence_manager, {lookup, UserId}, 10000) of
{ok, Pid} ->
case gen_server:call(Pid, {remove_temporary_guild, GuildId}, 10000) of
ok -> true;
_ -> throw({error, <<"Remove temporary guild failed">>})
end;
not_found ->
true;
{error, _} ->
true;
_ ->
true
end;
execute_method(<<"presence.sync_group_dm_recipients">>, #{
<<"user_id">> := UserIdBin, <<"recipients_by_channel">> := RecipientsByChannel
}) ->
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
NormalizedRecipients =
maps:from_list([
{
validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
[validation:snowflake_or_throw(<<"recipient_id">>, RBin) || RBin <- Recipients]
}
|| {ChannelIdBin, Recipients} <- maps:to_list(RecipientsByChannel)
]),
case gen_server:call(presence_manager, {lookup, UserId}, 10000) of
{ok, Pid} ->
gen_server:cast(Pid, {sync_group_dm_recipients, NormalizedRecipients}),
true;
not_found ->
true;
{error, _} ->
true;
_ ->
true
end.
handle_offline_dispatch(message_create, UserId, Data) ->
AuthorIdBin = maps:get(<<"id">>, maps:get(<<"author">>, Data, #{}), <<"0">>),
AuthorId = validation:snowflake_or_throw(<<"author_id">>, AuthorIdBin),
push:handle_message_create(#{
message_data => Data,
user_ids => [UserId],
guild_id => 0,
author_id => AuthorId
}),
true;
handle_offline_dispatch(relationship_add, UserId, _Data) ->
sync_blocked_ids_for_user(UserId),
true;
handle_offline_dispatch(relationship_remove, UserId, _Data) ->
sync_blocked_ids_for_user(UserId),
true;
handle_offline_dispatch(_Event, _UserId, _Data) ->
true.
sync_blocked_ids_for_user(_UserId) ->
ok.

View File

@@ -0,0 +1,43 @@
%% 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(gateway_rpc_push).
-export([execute_method/2]).
execute_method(<<"push.sync_user_guild_settings">>, #{
<<"user_id">> := UserIdBin,
<<"guild_id">> := GuildIdBin,
<<"user_guild_settings">> := UserGuildSettings
}) ->
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
push:sync_user_guild_settings(UserId, GuildId, UserGuildSettings),
true;
execute_method(<<"push.sync_user_blocked_ids">>, #{
<<"user_id">> := UserIdBin, <<"blocked_user_ids">> := BlockedUserIds
}) ->
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
BlockedIds = validation:snowflake_list_or_throw(<<"blocked_user_ids">>, BlockedUserIds),
push:sync_user_blocked_ids(UserId, BlockedIds),
true;
execute_method(<<"push.invalidate_badge_count">>, #{
<<"user_id">> := UserIdBin
}) ->
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
push:invalidate_user_badge_count(UserId),
true.

View File

@@ -0,0 +1,36 @@
%% 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(gateway_rpc_router).
-export([execute/2]).
execute(Method, Params) ->
case Method of
<<"guild.", _/binary>> ->
gateway_rpc_guild:execute_method(Method, Params);
<<"presence.", _/binary>> ->
gateway_rpc_presence:execute_method(Method, Params);
<<"push.", _/binary>> ->
gateway_rpc_push:execute_method(Method, Params);
<<"call.", _/binary>> ->
gateway_rpc_call:execute_method(Method, Params);
<<"process.", _/binary>> ->
gateway_rpc_misc:execute_method(Method, Params);
_ ->
throw({error, <<"Unknown method: ", Method/binary>>})
end.

View File

@@ -0,0 +1,29 @@
%% 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(health_handler).
-export([init/2]).
init(Req0, State) ->
Req = cowboy_req:reply(
200,
#{<<"content-type">> => <<"text/plain">>},
<<"OK">>,
Req0
),
{ok, Req, State}.

View File

@@ -0,0 +1,357 @@
%% 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(hot_reload).
-export([
reload_module/1,
reload_modules/1,
reload_modules/2,
reload_beams/2,
reload_all_changed/0,
reload_all_changed/1,
get_loaded_modules/0,
get_module_info/1
]).
-define(CRITICAL_MODULES, [
code,
kernel,
erlang,
init,
erl_prim_loader,
prim_file,
prim_inet,
prim_zip,
zlib,
otp_ring0,
erts_internal,
erts_code_purger,
application,
application_controller,
application_master,
supervisor,
gen_server,
gen_event,
gen_statem,
proc_lib,
error_handler,
heart,
logger,
logger_handler_watcher,
logger_server,
logger_config,
logger_simple_h
]).
-type purge_mode() :: none | soft | hard.
-type reload_opts() :: #{purge => purge_mode()}.
-spec reload_module(atom()) -> {ok, map()} | {error, term()}.
reload_module(Module) when is_atom(Module) ->
case is_critical_module(Module) of
true ->
{error, {critical_module, Module}};
false ->
{ok, Result} = reload_modules([Module], #{purge => soft}),
case Result of
[One] ->
case maps:get(status, One) of
ok -> {ok, One};
error -> {error, maps:get(reason, One, unknown)}
end;
_ ->
{error, unexpected_result}
end
end.
-spec reload_modules([atom()]) -> {ok, [map()]}.
reload_modules(Modules) when is_list(Modules) ->
reload_modules(Modules, #{purge => soft}).
-spec reload_modules([atom()], reload_opts()) -> {ok, [map()]}.
reload_modules(Modules, Opts) when is_list(Modules), is_map(Opts) ->
Purge = maps:get(purge, Opts, soft),
Results = lists:map(
fun(Module) ->
reload_one(Module, Purge)
end,
Modules
),
{ok, Results}.
-spec reload_beams([{atom(), binary()}], reload_opts()) -> {ok, [map()]}.
reload_beams(Pairs, Opts) when is_list(Pairs), is_map(Opts) ->
Purge = maps:get(purge, Opts, soft),
Results =
lists:map(
fun({Module, BeamBin}) ->
reload_one_beam(Module, BeamBin, Purge)
end,
Pairs
),
{ok, Results}.
-spec reload_all_changed() -> {ok, [map()]}.
reload_all_changed() ->
reload_all_changed(soft).
-spec reload_all_changed(purge_mode()) -> {ok, [map()]}.
reload_all_changed(Purge) ->
ChangedModules = get_changed_modules(),
reload_modules(ChangedModules, #{purge => Purge}).
-spec get_loaded_modules() -> [atom()].
get_loaded_modules() ->
[M || {M, _} <- code:all_loaded(), is_fluxer_module(M)].
-spec get_module_info(atom()) -> {ok, map()} | {error, not_loaded}.
get_module_info(Module) when is_atom(Module) ->
case code:is_loaded(Module) of
false ->
{error, not_loaded};
{file, BeamPath} ->
LoadedTime = get_loaded_time(Module),
DiskTime = get_disk_time(BeamPath),
LoadedMd5 = loaded_md5(Module),
DiskMd5 = disk_md5(BeamPath),
{ok, #{
module => Module,
beam_path => BeamPath,
loaded_time => LoadedTime,
disk_time => DiskTime,
loaded_md5 => hex_or_null(LoadedMd5),
disk_md5 => hex_or_null(DiskMd5),
changed => (code:module_status(Module) =:= modified),
is_critical => is_critical_module(Module)
}}
end.
reload_one(Module, Purge) ->
case is_critical_module(Module) of
true ->
#{module => Module, status => error, reason => {critical_module, Module}};
false ->
do_reload_one(Module, Purge)
end.
reload_one_beam(Module, BeamBin, Purge) ->
case is_critical_module(Module) of
true ->
#{module => Module, status => error, reason => {critical_module, Module}};
false ->
do_reload_one_beam(Module, BeamBin, Purge)
end.
do_reload_one(Module, Purge) ->
OldLoadedMd5 = loaded_md5(Module),
OldBeamPath = code:which(Module),
OldDiskMd5 = disk_md5(OldBeamPath),
ok = maybe_purge_before_load(Module, Purge),
case code:load_file(Module) of
{module, Module} ->
NewLoadedMd5 = loaded_md5(Module),
NewBeamPath = code:which(Module),
NewDiskMd5 = disk_md5(NewBeamPath),
Verified = (NewLoadedMd5 =/= undefined) andalso (NewDiskMd5 =/= undefined) andalso (NewLoadedMd5 =:= NewDiskMd5),
{PurgedOld, LingeringCount} = maybe_purge_old_after_load(Module, Purge),
#{
module => Module,
status => ok,
old_loaded_md5 => hex_or_null(OldLoadedMd5),
old_disk_md5 => hex_or_null(OldDiskMd5),
new_loaded_md5 => hex_or_null(NewLoadedMd5),
new_disk_md5 => hex_or_null(NewDiskMd5),
verified => Verified,
purged_old_code => PurgedOld,
lingering_count => LingeringCount
};
{error, Reason} ->
#{
module => Module,
status => error,
reason => Reason,
old_loaded_md5 => hex_or_null(OldLoadedMd5),
old_disk_md5 => hex_or_null(OldDiskMd5),
verified => false,
purged_old_code => false,
lingering_count => 0
}
end.
do_reload_one_beam(Module, BeamBin, Purge) ->
OldLoadedMd5 = loaded_md5(Module),
ExpectedMd5 =
case beam_lib:md5(BeamBin) of
{ok, {Module, Md5}} ->
Md5;
{ok, {Other, _}} ->
erlang:error({beam_module_mismatch, Module, Other});
_ ->
erlang:error(invalid_beam)
end,
ok = maybe_purge_before_load(Module, Purge),
Filename = atom_to_list(Module) ++ ".beam(hot)",
case code:load_binary(Module, Filename, BeamBin) of
{module, Module} ->
NewLoadedMd5 = loaded_md5(Module),
Verified = (NewLoadedMd5 =:= ExpectedMd5),
{PurgedOld, LingeringCount} = maybe_purge_old_after_load(Module, Purge),
#{
module => Module,
status => ok,
old_loaded_md5 => hex_or_null(OldLoadedMd5),
expected_md5 => hex_or_null(ExpectedMd5),
new_loaded_md5 => hex_or_null(NewLoadedMd5),
verified => Verified,
purged_old_code => PurgedOld,
lingering_count => LingeringCount
};
{error, Reason} ->
#{
module => Module,
status => error,
reason => Reason,
old_loaded_md5 => hex_or_null(OldLoadedMd5),
expected_md5 => hex_or_null(ExpectedMd5),
verified => false,
purged_old_code => false,
lingering_count => 0
}
end.
maybe_purge_before_load(_Module, none) ->
ok;
maybe_purge_before_load(_Module, soft) ->
ok;
maybe_purge_before_load(Module, hard) ->
_ = code:purge(Module),
ok.
maybe_purge_old_after_load(_Module, none) ->
{false, 0};
maybe_purge_old_after_load(Module, hard) ->
_ = code:soft_purge(Module),
Purged = code:purge(Module),
{Purged, case Purged of true -> 0; false -> count_lingering(Module) end};
maybe_purge_old_after_load(Module, soft) ->
Purged = wait_soft_purge(Module, 40, 50),
{Purged, case Purged of true -> 0; false -> count_lingering(Module) end}.
wait_soft_purge(_Module, 0, _SleepMs) ->
false;
wait_soft_purge(Module, N, SleepMs) ->
case code:soft_purge(Module) of
true ->
true;
false ->
receive after SleepMs -> ok end,
wait_soft_purge(Module, N - 1, SleepMs)
end.
count_lingering(Module) ->
lists:foldl(
fun(Pid, Acc) ->
case erlang:check_process_code(Pid, Module) of
true -> Acc + 1;
false -> Acc
end
end,
0,
processes()
).
get_changed_modules() ->
Modified = code:modified_modules(),
[M || M <- Modified, is_fluxer_module(M), not is_critical_module(M)].
is_critical_module(Module) ->
lists:member(Module, ?CRITICAL_MODULES).
is_fluxer_module(Module) ->
ModuleStr = atom_to_list(Module),
lists:prefix("fluxer_", ModuleStr) orelse
lists:prefix("gateway", ModuleStr) orelse
lists:prefix("session", ModuleStr) orelse
lists:prefix("guild", ModuleStr) orelse
lists:prefix("presence", ModuleStr) orelse
lists:prefix("push", ModuleStr) orelse
lists:prefix("call", ModuleStr) orelse
lists:prefix("health", ModuleStr) orelse
lists:prefix("hot_reload", ModuleStr) orelse
lists:prefix("rpc_client", ModuleStr) orelse
lists:prefix("rendezvous", ModuleStr) orelse
lists:prefix("process_", ModuleStr) orelse
lists:prefix("metrics_", ModuleStr) orelse
lists:prefix("dm_voice", ModuleStr) orelse
lists:prefix("voice_", ModuleStr) orelse
lists:prefix("constants", ModuleStr) orelse
lists:prefix("validation", ModuleStr) orelse
lists:prefix("backoff_", ModuleStr) orelse
lists:prefix("list_ops", ModuleStr) orelse
lists:prefix("map_utils", ModuleStr) orelse
lists:prefix("type_conv", ModuleStr) orelse
lists:prefix("utils", ModuleStr) orelse
lists:prefix("user_utils", ModuleStr) orelse
lists:prefix("custom_status", ModuleStr).
loaded_md5(Module) ->
try
Module:module_info(md5)
catch
_:_ -> undefined
end.
disk_md5(Path) when is_list(Path) ->
case beam_lib:md5(Path) of
{ok, {_M, Md5}} -> Md5;
_ -> undefined
end;
disk_md5(_) ->
undefined.
hex_or_null(undefined) ->
null;
hex_or_null(Bin) when is_binary(Bin) ->
binary:encode_hex(Bin, lowercase).
get_loaded_time(Module) ->
try
case Module:module_info(compile) of
CompileInfo when is_list(CompileInfo) ->
proplists:get_value(time, CompileInfo, undefined);
_ ->
undefined
end
catch
_:_ -> undefined
end.
get_disk_time(BeamPath) when is_list(BeamPath) ->
case file:read_file_info(BeamPath) of
{ok, FileInfo} ->
element(6, FileInfo);
_ ->
undefined
end;
get_disk_time(_) ->
undefined.

View File

@@ -0,0 +1,266 @@
%% 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(hot_reload_handler).
-export([init/2]).
-define(JSON_HEADERS, #{<<"content-type">> => <<"application/json">>}).
-define(MAX_MODULES, 600).
-define(MAX_BODY_BYTES, 26214400).
init(Req0, State) ->
case cowboy_req:method(Req0) of
<<"POST">> ->
handle_post(Req0, State);
_ ->
Req = cowboy_req:reply(405, #{<<"allow">> => <<"POST">>}, <<>>, Req0),
{ok, Req, State}
end.
handle_post(Req0, State) ->
case authorize(Req0) of
ok ->
case read_body(Req0) of
{ok, Decoded, Req1} ->
handle_reload(Decoded, Req1, State);
{error, Status, ErrorBody, Req1} ->
respond(Status, ErrorBody, Req1, State)
end;
{error, Req1} ->
{ok, Req1, State}
end.
authorize(Req0) ->
case cowboy_req:header(<<"authorization">>, Req0) of
undefined ->
Req = cowboy_req:reply(
401,
?JSON_HEADERS,
jsx:encode(#{<<"error">> => <<"Unauthorized">>}),
Req0
),
{error, Req};
AuthHeader ->
case os:getenv("GATEWAY_ADMIN_SECRET") of
false ->
Req = cowboy_req:reply(
500,
?JSON_HEADERS,
jsx:encode(#{<<"error">> => <<"GATEWAY_ADMIN_SECRET not configured">>}),
Req0
),
{error, Req};
Secret ->
Expected = <<"Bearer ", (list_to_binary(Secret))/binary>>,
case AuthHeader of
Expected ->
ok;
_ ->
Req = cowboy_req:reply(
401,
?JSON_HEADERS,
jsx:encode(#{<<"error">> => <<"Unauthorized">>}),
Req0
),
{error, Req}
end
end
end.
read_body(Req0) ->
case cowboy_req:body_length(Req0) of
Length when is_integer(Length), Length > ?MAX_BODY_BYTES ->
{error, 413, #{<<"error">> => <<"Request body too large">>}, Req0};
_ ->
read_body(Req0, <<>>)
end.
read_body(Req0, Acc) ->
case cowboy_req:read_body(Req0, #{length => 1048576}) of
{ok, Body, Req1} ->
FullBody = <<Acc/binary, Body/binary>>,
decode_body(FullBody, Req1);
{more, Body, Req1} ->
NewAcc = <<Acc/binary, Body/binary>>,
case byte_size(NewAcc) > ?MAX_BODY_BYTES of
true ->
{error, 413, #{<<"error">> => <<"Request body too large">>}, Req1};
false ->
read_body(Req1, NewAcc)
end
end.
decode_body(<<>>, Req0) ->
{ok, #{}, Req0};
decode_body(Body, Req0) ->
case catch jsx:decode(Body, [return_maps]) of
{'EXIT', _Reason} ->
{error, 400, #{<<"error">> => <<"Invalid JSON payload">>}, Req0};
Decoded when is_map(Decoded) ->
{ok, Decoded, Req0};
_ ->
{error, 400, #{<<"error">> => <<"Invalid request body">>}, Req0}
end.
handle_reload(Params, Req0, State) ->
try
Purge = parse_purge(maps:get(<<"purge">>, Params, <<"soft">>)),
case maps:get(<<"beams">>, Params, undefined) of
undefined ->
handle_modules_reload(Params, Purge, Req0, State);
Beams when is_list(Beams) ->
case length(Beams) =< ?MAX_MODULES of
true ->
Pairs = decode_beams(Beams),
{ok, Results} = hot_reload:reload_beams(Pairs, #{purge => Purge}),
respond(200, #{<<"results">> => Results}, Req0, State);
false ->
respond(400, #{<<"error">> => <<"Too many modules">>}, Req0, State)
end;
_ ->
respond(400, #{<<"error">> => <<"beams must be an array">>}, Req0, State)
end
catch
error:badarg ->
respond(400, #{<<"error">> => <<"Invalid module name or beam payload">>}, Req0, State);
error:invalid_beam ->
respond(400, #{<<"error">> => <<"Invalid module name or beam payload">>}, Req0, State);
error:{beam_module_mismatch, _, _} ->
respond(400, #{<<"error">> => <<"Invalid module name or beam payload">>}, Req0, State);
_:Reason ->
logger:error("hot_reload_handler: Error during reload: ~p", [Reason]),
respond(500, #{<<"error">> => <<"Internal error">>}, Req0, State)
end.
handle_modules_reload(Params, Purge, Req0, State) ->
case maps:get(<<"modules">>, Params, []) of
[] ->
{ok, Results} = hot_reload:reload_all_changed(Purge),
respond(200, #{<<"results">> => Results}, Req0, State);
Modules when is_list(Modules) ->
case length(Modules) =< ?MAX_MODULES of
true ->
ModuleAtoms = lists:map(fun to_module_atom/1, Modules),
{ok, Results} = hot_reload:reload_modules(ModuleAtoms, #{purge => Purge}),
respond(200, #{<<"results">> => Results}, Req0, State);
false ->
respond(400, #{<<"error">> => <<"Too many modules">>}, Req0, State)
end;
_ ->
respond(400, #{<<"error">> => <<"modules must be an array">>}, Req0, State)
end.
decode_beams(Beams) ->
lists:map(
fun(Elem) ->
case Elem of
#{<<"module">> := Mod0, <<"beam_b64">> := B640} ->
ModBin = to_binary(Mod0),
Module = to_module_atom(ModBin),
B64Bin = to_binary(B640),
BeamBin = base64:decode(B64Bin),
case beam_lib:md5(BeamBin) of
{ok, {Module, _}} -> ok;
{ok, {Other, _}} -> erlang:error({beam_module_mismatch, Module, Other});
_ -> erlang:error(invalid_beam)
end,
{Module, BeamBin};
_ ->
erlang:error(badarg)
end
end,
Beams
).
to_binary(B) when is_binary(B) ->
B;
to_binary(L) when is_list(L) ->
list_to_binary(L);
to_binary(_) ->
erlang:error(badarg).
parse_purge(<<"none">>) -> none;
parse_purge(<<"soft">>) -> soft;
parse_purge(<<"hard">>) -> hard;
parse_purge(none) -> none;
parse_purge(soft) -> soft;
parse_purge(hard) -> hard;
parse_purge(_) -> soft.
to_module_atom(B) when is_binary(B) ->
case is_allowed_module_name(B) of
true -> erlang:binary_to_atom(B, utf8);
false -> erlang:error(badarg)
end;
to_module_atom(L) when is_list(L) ->
to_module_atom(list_to_binary(L));
to_module_atom(_) ->
erlang:error(badarg).
is_allowed_module_name(Bin) when is_binary(Bin) ->
byte_size(Bin) > 0 andalso byte_size(Bin) < 128 andalso
is_safe_chars(Bin) andalso has_allowed_prefix(Bin).
is_safe_chars(Bin) ->
lists:all(
fun(C) ->
(C >= $a andalso C =< $z) orelse
(C >= $0 andalso C =< $9) orelse
(C =:= $_)
end,
binary_to_list(Bin)
).
has_allowed_prefix(Bin) ->
Prefixes = [
<<"fluxer_">>,
<<"gateway">>,
<<"session">>,
<<"guild">>,
<<"presence">>,
<<"push">>,
<<"call">>,
<<"health">>,
<<"hot_reload">>,
<<"rpc_client">>,
<<"rendezvous">>,
<<"process_">>,
<<"metrics_">>,
<<"dm_voice">>,
<<"voice_">>,
<<"constants">>,
<<"validation">>,
<<"backoff_">>,
<<"list_ops">>,
<<"map_utils">>,
<<"type_conv">>,
<<"utils">>,
<<"user_utils">>,
<<"custom_status">>
],
lists:any(
fun(P) ->
Sz = byte_size(P),
byte_size(Bin) >= Sz andalso binary:part(Bin, 0, Sz) =:= P
end,
Prefixes
).
respond(Status, Body, Req0, State) ->
Req = cowboy_req:reply(Status, ?JSON_HEADERS, jsx:encode(Body), Req0),
{ok, Req, State}.

View File

@@ -0,0 +1,101 @@
%% 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(rendezvous_router).
-export([select/2, group_keys/2]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-define(HASH_LIMIT, 16#FFFFFFFF).
-spec select(term(), pos_integer()) -> non_neg_integer().
select(Key, ShardCount) when ShardCount > 0 ->
Indices = lists:seq(0, ShardCount - 1),
{Index, _Weight} =
lists:foldl(
fun(CurrentIndex, {BestIndex, BestWeight}) ->
Weight = weight(Key, CurrentIndex),
case
(Weight > BestWeight) orelse
(Weight =:= BestWeight andalso CurrentIndex < BestIndex)
of
true ->
{CurrentIndex, Weight};
false ->
{BestIndex, BestWeight}
end
end,
{0, -1},
Indices
),
Index;
select(_Key, _ShardCount) ->
0.
-spec group_keys([term()], pos_integer()) -> [{non_neg_integer(), [term()]}].
group_keys(Keys, ShardCount) when is_list(Keys), ShardCount > 0 ->
Sorted =
maps:to_list(
lists:foldl(
fun(Key, Acc) ->
Index = select(Key, ShardCount),
Existing = maps:get(Index, Acc, []),
maps:put(Index, [Key | Existing], Acc)
end,
#{},
Keys
)
),
lists:sort(
fun({IdxA, _}, {IdxB, _}) -> IdxA =< IdxB end,
[{Index, lists:usort(Group)} || {Index, Group} <- Sorted]
);
group_keys(_Keys, _ShardCount) ->
[].
-spec weight(term(), non_neg_integer()) -> non_neg_integer().
weight(Key, Index) ->
erlang:phash2({Key, Index}, ?HASH_LIMIT).
-ifdef(TEST).
select_returns_valid_index_test() ->
?assertEqual(0, select(test_key, 1)),
Index = select(test_key, 5),
?assert(Index >= 0),
?assert(Index < 5).
select_is_stable_for_same_inputs_test() ->
?assertEqual(select(<<"abc">>, 8), select(<<"abc">>, 8)),
?assertEqual(select(12345, 3), select(12345, 3)).
group_keys_sorts_and_deduplicates_test() ->
Keys = [1, 2, 3, 1, 2],
Groups = group_keys(Keys, 2),
?assertMatch([{_, _}, {_, _}], Groups),
lists:foreach(
fun({_Index, GroupKeys}) ->
?assertEqual(GroupKeys, lists:usort(GroupKeys))
end,
Groups
).
group_keys_handles_empty_test() ->
?assertEqual([], group_keys([], 4)).
-endif.

View File

@@ -0,0 +1,85 @@
%% 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(rpc_client).
-export([
call/1,
call/2,
get_rpc_url/0,
get_rpc_url/1,
get_rpc_headers/0
]).
-type rpc_request() :: map().
-type rpc_response() :: {ok, map()} | {error, term()}.
-spec call(rpc_request()) -> rpc_response().
call(Request) ->
call(Request, #{}).
-spec call(rpc_request(), map()) -> rpc_response().
call(Request, _Options) ->
Url = get_rpc_url(),
Headers = get_rpc_headers(),
Body = jsx:encode(Request),
case
hackney:request(post, Url, Headers, Body, [{recv_timeout, 30000}, {connect_timeout, 5000}])
of
{ok, 200, _RespHeaders, ClientRef} ->
case hackney:body(ClientRef) of
{ok, RespBody} ->
Response = jsx:decode(RespBody, [return_maps]),
Data = maps:get(<<"data">>, Response, #{}),
{ok, Data};
{error, Reason} ->
logger:error("[rpc_client] Failed to read response body: ~p", [Reason]),
{error, {body_read_failed, Reason}}
end;
{ok, StatusCode, _RespHeaders, ClientRef} ->
case hackney:body(ClientRef) of
{ok, RespBody} ->
hackney:close(ClientRef),
logger:error("[rpc_client] RPC request failed with status ~p: ~s", [
StatusCode, RespBody
]),
{error, {http_error, StatusCode, RespBody}};
{error, Reason} ->
hackney:close(ClientRef),
logger:error(
"[rpc_client] Failed to read error response body (status ~p): ~p", [
StatusCode, Reason
]
),
{error, {http_error, StatusCode, body_read_failed}}
end;
{error, Reason} ->
logger:error("[rpc_client] RPC request failed: ~p", [Reason]),
{error, Reason}
end.
get_rpc_url() ->
ApiHost = fluxer_gateway_env:get(api_host),
get_rpc_url(ApiHost).
get_rpc_url(ApiHost) ->
"http://" ++ ApiHost ++ "/_rpc".
get_rpc_headers() ->
RpcSecretKey = fluxer_gateway_env:get(rpc_secret_key),
[{<<"Authorization">>, <<"Bearer ", RpcSecretKey/binary>>}].

View File

@@ -0,0 +1,572 @@
%% 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).
-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]).
-import(guild_permissions, [
get_member_permissions/3,
get_max_role_position/2,
can_view_channel/4
]).
-import(type_conv, [to_integer/1]).
-import(guild_voice, [
voice_state_update/2,
get_voice_state/2,
update_member_voice/2,
disconnect_voice_user/2,
disconnect_voice_user_if_in_channel/2,
disconnect_all_voice_users_in_channel/2,
confirm_voice_connection_from_livekit/2,
move_member/2
]).
-import(guild_data, [
get_guild_data/2,
get_guild_member/2,
list_guild_members/2,
get_vanity_url_channel/1,
get_first_viewable_text_channel/1
]).
-import(guild_members, [
get_users_to_mention_by_roles/2,
get_users_to_mention_by_user_ids/2,
get_all_users_to_mention/2,
resolve_all_mentions/2,
get_members_with_role/2,
can_manage_roles/2,
get_assignable_roles/2,
check_target_member/2,
get_viewable_channels/2
]).
-import(guild_sessions, [
handle_session_connect/3,
handle_session_down/2,
set_session_active_guild/3,
set_session_passive_guild/3
]).
-import(guild_dispatch, [
handle_dispatch/3
]).
start_link(GuildState) ->
gen_server:start_link(?MODULE, GuildState, []).
init(GuildState) ->
process_flag(trap_exit, true),
StateWithVoice =
case maps:is_key(voice_states, GuildState) of
true -> GuildState;
false -> maps:put(voice_states, #{}, GuildState)
end,
StateWithPresenceSubs = maps:put(presence_subscriptions, #{}, StateWithVoice),
StateWithMemberListSubs = maps:put(member_list_subscriptions, #{}, StateWithPresenceSubs),
StateWithMemberSubs = maps:put(
member_subscriptions, guild_subscriptions:init_state(), StateWithMemberListSubs
),
Data = maps:get(data, StateWithMemberSubs, #{}),
Members = maps:get(<<"members">>, Data, []),
MemberCount = length(Members),
OnlineCount = count_online_members(Members),
StateWithCounts = maps:put(member_count, MemberCount, StateWithMemberSubs),
StateWithPresences = maps:put(presences, #{}, maps:put(online_count, OnlineCount, StateWithCounts)),
guild_passive_sync:schedule_passive_sync(StateWithPresences),
{ok, StateWithPresences}.
handle_call({session_connect, Request}, {CallerPid, _}, State) ->
SessionPid = maps:get(session_pid, Request, CallerPid),
guild_sessions:handle_session_connect(Request, SessionPid, State);
handle_call({get_counts}, _From, State) ->
MemberCount = maps:get(member_count, State, 0),
OnlineCount = maps:get(online_count, State, 0),
{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) ->
guild_members:get_users_to_mention_by_roles(Request, State);
handle_call({get_users_to_mention_by_user_ids, Request}, _From, State) ->
guild_members:get_users_to_mention_by_user_ids(Request, State);
handle_call({get_all_users_to_mention, Request}, _From, State) ->
guild_members:get_all_users_to_mention(Request, State);
handle_call({resolve_all_mentions, Request}, _From, State) ->
guild_members:resolve_all_mentions(Request, State);
handle_call({get_members_with_role, Request}, _From, State) ->
guild_members:get_members_with_role(Request, State);
handle_call({check_permission, Request}, _From, State) ->
#{user_id := UserId, permission := Permission, channel_id := ChannelId} = Request,
true = is_integer(Permission),
HasPermission =
case owner_id(State) =:= UserId of
true ->
true;
false ->
Permissions = get_member_permissions(UserId, ChannelId, State),
(Permissions band Permission) =:= Permission
end,
{reply, #{has_permission => HasPermission}, State};
handle_call({get_user_permissions, Request}, _From, State) ->
#{user_id := UserId, channel_id := ChannelId} = Request,
Permissions = get_member_permissions(UserId, ChannelId, State),
{reply, #{permissions => Permissions}, 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 = 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) ->
guild_members:get_viewable_channels(Request, 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({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({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({get_sessions}, _From, State) ->
{reply, State, 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({reload, NewData}, _From, State) ->
OldData = maps:get(data, State),
NewState0 = maps:put(data, NewData, State),
GuildId = maps:get(id, State),
NewGuild = maps:get(<<"guild">>, NewData, #{}),
Sessions = maps:get(sessions, State, #{}),
Pids = [maps:get(pid, S) || {_Sid, S} <- maps:to_list(Sessions)],
EventData = maps:put(<<"guild_id">>, integer_to_binary(GuildId), NewGuild),
lists:foreach(
fun(Pid) ->
gen_server:cast(Pid, {dispatch, guild_update, EventData})
end,
Pids
),
NewState = cleanup_removed_member_subscriptions(OldData, NewData, NewState0),
{reply, ok, NewState};
handle_call({dispatch, Request}, _From, State) ->
#{event := Event, data := EventData} = Request,
ParsedEventData =
case is_binary(EventData) of
true -> jsx:decode(EventData, [{return_maps, true}]);
false -> EventData
end,
{noreply, NewState} = handle_dispatch(Event, ParsedEventData, State),
StateAfterPrune = prune_invalid_member_subscriptions(NewState),
{reply, ok, StateAfterPrune};
handle_call({terminate}, _From, State) ->
{stop, normal, ok, State};
handle_call({lazy_subscribe, Request}, _From, State) ->
#{session_id := SessionId, channel_id := ChannelId, ranges := Ranges} = Request,
Sessions0 = maps:get(sessions, State, #{}),
SessionUserId =
case maps:get(SessionId, Sessions0, undefined) of
#{user_id := Uid} -> Uid;
_ -> undefined
end,
case is_integer(SessionUserId) andalso
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),
case {ShouldSendSync, NormalizedRanges} of
{true, []} ->
{reply, ok, NewState};
{true, RangesToSend} ->
SyncResponse = guild_member_list:build_sync_response(GuildId, ListId, RangesToSend, NewState),
SyncResponseWithChannel = maps:put(<<"channel_id">>, integer_to_binary(ChannelId), SyncResponse),
Sessions = maps:get(sessions, NewState, #{}),
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, NewState};
_ ->
{reply, ok, NewState}
end;
false ->
{reply, ok, State}
end;
handle_call(_, _From, State) ->
{reply, ok, State}.
handle_cast({dispatch, Request}, State) ->
#{event := Event, data := EventData} = Request,
ParsedEventData =
case is_binary(EventData) of
true -> jsx:decode(EventData, [{return_maps, true}]);
false -> EventData
end,
handle_dispatch(Event, ParsedEventData, 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};
handle_cast({set_session_active, SessionId}, State) ->
GuildId = maps:get(id, State),
NewState = set_session_active_guild(SessionId, GuildId, State),
{noreply, NewState};
handle_cast({set_session_passive, SessionId}, State) ->
GuildId = maps:get(id, State),
NewState = set_session_passive_guild(SessionId, GuildId, State),
{noreply, NewState};
handle_cast({update_member_subscriptions, SessionId, MemberIds}, State) ->
MemberSubs = maps:get(member_subscriptions, State, guild_subscriptions:init_state()),
Sessions = maps:get(sessions, State, #{}),
SessionUserId =
case maps:get(SessionId, Sessions, undefined) of
undefined -> undefined;
SessionData -> maps:get(user_id, SessionData, undefined)
end,
FilteredMemberIds = filter_member_ids_with_mutual_channels(SessionUserId, MemberIds, State),
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, State),
State2 = 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,
State1,
Added
),
State3 = lists:foldl(
fun(UserId, Acc) -> guild_sessions:unsubscribe_from_user_presence(UserId, Acc) end,
State2,
Removed
),
{noreply, State3};
handle_cast({set_session_typing_override, SessionId, TypingFlag}, State) ->
GuildId = maps:get(id, State),
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
{noreply, State};
SessionData ->
NewSessionData = session_passive:set_typing_override(GuildId, TypingFlag, SessionData),
NewSessions = maps:put(SessionId, NewSessionData, Sessions),
NewState = maps:put(sessions, NewSessions, State),
logger:debug("[guild] Set typing override to ~p for session ~p in guild ~p", [
TypingFlag, SessionId, GuildId
]),
{noreply, NewState}
end;
handle_cast({send_guild_sync, SessionId}, State) ->
GuildId = maps:get(id, State),
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
logger:warning("[guild] Session ~p not found for send_guild_sync", [SessionId]),
{noreply, State};
SessionData ->
case session_passive:is_guild_synced(GuildId, SessionData) of
true ->
logger:debug("[guild] Guild ~p already synced for session ~p, skipping", [GuildId, SessionId]),
{noreply, State};
false ->
UserId = maps:get(user_id, SessionData),
SessionPid = maps:get(pid, SessionData),
GuildData = guild_data:get_guild_state(UserId, State),
gen_server:cast(SessionPid, {dispatch, guild_sync, GuildData}),
NewSessionData = session_passive:mark_guild_synced(GuildId, SessionData),
NewSessions = maps:put(SessionId, NewSessionData, Sessions),
{noreply, maps:put(sessions, NewSessions, State)}
end
end;
handle_cast({send_members_chunk, SessionId, ChunkData}, State) ->
GuildId = maps:get(id, State),
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
logger:warning("[guild] Session ~p not found for send_members_chunk", [SessionId]),
{noreply, State};
SessionData ->
SessionPid = maps:get(pid, SessionData),
ChunkWithGuildId = maps:put(<<"guild_id">>, integer_to_binary(GuildId), ChunkData),
gen_server:cast(SessionPid, {dispatch, guild_members_chunk, ChunkWithGuildId}),
{noreply, State}
end;
handle_cast(_, State) ->
{noreply, State}.
handle_info({presence, UserId, Payload}, State) ->
guild_presence:handle_bus_presence(UserId, Payload, State);
handle_info({'DOWN', Ref, process, _Pid, _Reason}, State) ->
guild_sessions:handle_session_down(Ref, State);
handle_info(passive_sync, State) ->
guild_passive_sync:handle_passive_sync(State);
handle_info(_, State) ->
{noreply, State}.
filter_member_ids_with_mutual_channels(SessionUserId, MemberIds, State) ->
case SessionUserId of
undefined ->
[];
_ ->
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
)
end.
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)).
prune_invalid_member_subscriptions(State) ->
MemberSubs = maps:get(member_subscriptions, State, guild_subscriptions:init_state()),
Sessions = maps:get(sessions, State, #{}),
InvalidPairs = build_invalid_subscription_pairs(MemberSubs, Sessions, State),
lists:foldl(
fun({SessionId, UserId}, AccState) ->
remove_member_subscription(SessionId, UserId, AccState)
end,
State,
InvalidPairs
).
build_invalid_subscription_pairs(MemberSubs, Sessions, State) ->
lists:foldl(
fun({SessionId, SessionData}, Acc) ->
SessionUserId = maps:get(user_id, SessionData, undefined),
case SessionUserId of
undefined ->
Acc;
_ ->
SessionChannels = guild_visibility:viewable_channel_set(SessionUserId, State),
SubscriptionIds = guild_subscriptions:get_user_ids_for_session(SessionId, MemberSubs),
InvalidIds =
[MemberId
|| MemberId <- sets:to_list(SubscriptionIds),
not has_shared_channels(SessionChannels, MemberId, State)
],
lists:foldl(
fun(MemberId, Pairs) -> [{SessionId, MemberId} | Pairs] end,
Acc,
InvalidIds
)
end
end,
[],
maps:to_list(Sessions)
).
remove_member_subscription(SessionId, UserId, State) ->
MemberSubs = maps:get(member_subscriptions, State, guild_subscriptions:init_state()),
NewMemberSubs = guild_subscriptions:unsubscribe(SessionId, UserId, MemberSubs),
State1 = maps:put(member_subscriptions, NewMemberSubs, State),
guild_sessions:unsubscribe_from_user_presence(UserId, State1).
terminate(Reason, State) when is_map(State) ->
PresenceSubs = maps:get(presence_subscriptions, State, #{}),
lists:foreach(
fun(UserId) ->
presence_bus:unsubscribe(UserId)
end,
maps:keys(PresenceSubs)
),
maybe_report_crash(Reason, State),
ok;
terminate(Reason, State) ->
maybe_report_crash(Reason, State),
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
maybe_report_crash(normal, _State) ->
ok;
maybe_report_crash(shutdown, _State) ->
ok;
maybe_report_crash({shutdown, _}, _State) ->
ok;
maybe_report_crash(Reason, State) ->
GuildId =
case State of
#{id := Id} ->
integer_to_binary(Id);
#{data := Data} when is_map(Data) ->
case maps:get(<<"id">>, Data, undefined) of
undefined -> <<"unknown">>;
Id -> Id
end;
_ ->
<<"unknown">>
end,
Stacktrace = iolist_to_binary(io_lib:format("~p", [Reason])),
metrics_client:crash(GuildId, Stacktrace),
ok.
cleanup_removed_member_subscriptions(OldData, NewData, State) ->
OldMembers = maps:get(<<"members">>, OldData, []),
NewMembers = maps:get(<<"members">>, NewData, []),
OldMemberIds = sets:from_list([member_user_id(M) || M <- OldMembers]),
NewMemberIds = sets:from_list([member_user_id(M) || M <- NewMembers]),
RemovedIds = sets:subtract(OldMemberIds, NewMemberIds),
PresenceSubs = maps:get(presence_subscriptions, State, #{}),
NewPresenceSubs = lists:foldl(
fun(UserId, Subs) ->
case maps:is_key(UserId, Subs) of
true ->
presence_bus:unsubscribe(UserId),
maps:remove(UserId, Subs);
false ->
Subs
end
end,
PresenceSubs,
sets:to_list(RemovedIds)
),
maps:put(presence_subscriptions, NewPresenceSubs, State).
member_user_id(Member) ->
User = maps:get(<<"user">>, Member, #{}),
map_utils:get_integer(User, <<"id">>, undefined).
owner_id(State) ->
case resolve_data_map(State) of
undefined ->
0;
Data ->
Guild = maps:get(<<"guild">>, Data, #{}),
to_integer(maps:get(<<"owner_id">>, Guild, <<"0">>))
end.
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 ->
Data;
error ->
case State of
#{<<"members">> := _} ->
State;
_ ->
undefined
end
end;
resolve_data_map(_) ->
undefined.
count_online_members(Members) ->
lists:foldl(
fun(Member, Count) ->
Presence = maps:get(<<"presence">>, Member, <<"offline">>),
case Presence of
<<"offline">> -> Count;
_ -> Count + 1
end
end,
0,
Members
).
update_counts(State) ->
Data = maps:get(data, State, #{}),
Members = maps:get(<<"members">>, Data, []),
MemberCount = length(Members),
OnlineCount = count_online_members(Members),
maps:put(member_count, MemberCount, maps:put(online_count, OnlineCount, State)).

View File

@@ -0,0 +1,143 @@
%% 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_availability).
-export([
is_guild_unavailable_for_user/2,
is_user_staff/2,
check_unavailability_transition/2,
handle_unavailability_transition/2
]).
-import(guild_permissions, [find_member_by_user_id/2]).
-import(guild_data, [get_guild_state/2]).
is_guild_unavailable_for_user(UserId, State) ->
Data = maps:get(data, State),
Guild = maps:get(<<"guild">>, Data),
Features = maps:get(<<"features">>, Guild, []),
HasUnavailableForEveryone = lists:member(<<"UNAVAILABLE_FOR_EVERYONE">>, Features),
HasUnavailableForEveryoneButStaff =
lists:member(<<"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF">>, Features),
case {HasUnavailableForEveryone, HasUnavailableForEveryoneButStaff} of
{true, _} ->
true;
{false, true} ->
not is_user_staff(UserId, State);
{false, false} ->
false
end.
is_user_staff(UserId, State) ->
case find_member_by_user_id(UserId, State) of
undefined ->
false;
Member ->
User = maps:get(<<"user">>, Member, #{}),
Flags = utils:binary_to_integer_safe(maps:get(<<"flags">>, User, <<"0">>)),
(Flags band 16#1) =:= 16#1
end.
check_unavailability_transition(OldState, NewState) ->
OldData = maps:get(data, OldState),
OldGuild = maps:get(<<"guild">>, OldData),
OldFeatures = maps:get(<<"features">>, OldGuild, []),
NewData = maps:get(data, NewState),
NewGuild = maps:get(<<"guild">>, NewData),
NewFeatures = maps:get(<<"features">>, NewGuild, []),
OldUnavailableForEveryone = lists:member(<<"UNAVAILABLE_FOR_EVERYONE">>, OldFeatures),
NewUnavailableForEveryone = lists:member(<<"UNAVAILABLE_FOR_EVERYONE">>, NewFeatures),
OldUnavailableForEveryoneButStaff =
lists:member(<<"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF">>, OldFeatures),
NewUnavailableForEveryoneButStaff =
lists:member(<<"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF">>, NewFeatures),
OldIsUnavailable = OldUnavailableForEveryone orelse OldUnavailableForEveryoneButStaff,
NewIsUnavailable = NewUnavailableForEveryone orelse NewUnavailableForEveryoneButStaff,
case {OldIsUnavailable, NewIsUnavailable} of
{false, true} ->
{unavailable_enabled, NewUnavailableForEveryoneButStaff};
{true, false} ->
unavailable_disabled;
_ ->
case
{OldUnavailableForEveryoneButStaff, NewUnavailableForEveryoneButStaff,
OldUnavailableForEveryone, NewUnavailableForEveryone}
of
{true, false, false, true} ->
{unavailable_enabled, false};
{false, true, true, false} ->
{unavailable_enabled, true};
_ ->
no_change
end
end.
handle_unavailability_transition(OldState, NewState) ->
GuildId = maps:get(id, NewState),
UnavailablePayload = #{
<<"id">> => integer_to_binary(GuildId),
<<"unavailable">> => true
},
case check_unavailability_transition(OldState, NewState) of
{unavailable_enabled, StaffOnly} ->
Sessions = maps:get(sessions, NewState, #{}),
lists:foreach(
fun({_SessionId, SessionData}) ->
UserId = maps:get(user_id, SessionData),
Pid = maps:get(pid, SessionData),
ShouldBeUnavailable =
case StaffOnly of
true -> not is_user_staff(UserId, NewState);
false -> true
end,
case ShouldBeUnavailable of
true ->
gen_server:cast(Pid, {dispatch, guild_delete, UnavailablePayload});
false ->
ok
end
end,
maps:to_list(Sessions)
);
unavailable_disabled ->
Sessions = maps:get(sessions, NewState, #{}),
GuildId = maps:get(id, NewState),
BulkPresences = presence_utils:collect_guild_member_presences(NewState),
lists:foreach(
fun({_SessionId, SessionData}) ->
UserId = maps:get(user_id, SessionData),
Pid = maps:get(pid, SessionData),
GuildState = get_guild_state(UserId, NewState),
gen_server:cast(Pid, {dispatch, guild_create, GuildState}),
presence_utils:send_presence_bulk(Pid, GuildId, UserId, BulkPresences)
end,
maps:to_list(Sessions)
);
no_change ->
ok
end.

View File

@@ -0,0 +1,75 @@
%% 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_client).
-export([
voice_state_update/3
]).
-export_type([
voice_state_update_success/0,
voice_state_update_error/0,
voice_state_update_result/0
]).
-define(DEFAULT_TIMEOUT, 12000).
-type voice_state_update_success() :: #{
success := true,
token => binary(),
endpoint => binary(),
connection_id => binary(),
voice_state => map(),
needs_token => boolean()
}.
-type voice_state_update_error() :: {error, atom(), atom()}.
-type voice_state_update_result() ::
{ok, voice_state_update_success()}
| {error, timeout}
| {error, noproc}
| {error, atom(), atom()}.
-spec voice_state_update(pid(), map(), timeout()) -> voice_state_update_result().
voice_state_update(GuildPid, Request, Timeout) ->
try gen_server:call(GuildPid, {voice_state_update, Request}, Timeout) of
Response when is_map(Response) ->
case maps:get(success, Response, false) of
true -> {ok, Response};
false -> {error, unknown, internal_error}
end;
{error, Category, ErrorAtom} when is_atom(Category), is_atom(ErrorAtom) ->
{error, Category, ErrorAtom}
catch
exit:{timeout, _} ->
{error, timeout};
exit:{noproc, _} ->
{error, noproc};
exit:{normal, _} ->
{error, noproc}
end.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
module_exports_test() ->
Exports = guild_client:module_info(exports),
?assert(lists:member({voice_state_update, 3}, Exports)).
-endif.

View File

@@ -0,0 +1,313 @@
%% 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_data).
-export([get_guild_data/2]).
-export([get_guild_member/2]).
-export([has_member/2]).
-export([list_guild_members/2]).
-export([get_vanity_url_channel/1]).
-export([get_first_viewable_text_channel/1]).
-export([get_guild_state/2]).
-export([find_everyone_viewable_text_channel/2]).
-type guild_state() :: map().
-type guild_reply(T) :: {reply, T, guild_state()}.
-type guild_data_map() :: map().
-type guild_member() :: map().
-type channel_list() :: [map()].
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec get_guild_data(map(), guild_state()) -> guild_reply(map()).
get_guild_data(#{user_id := UserId}, State) ->
Data = guild_data_map(State),
case UserId of
null ->
GuildData = build_complete_guild_data(Data, State),
Reply = #{guild_data => GuildData},
{reply, Reply, State};
_ ->
Members = map_utils:ensure_list(maps:get(<<"members">>, Data, [])),
case member_in_list(UserId, Members) of
false ->
{reply, #{guild_data => null, error_reason => <<"forbidden">>}, State};
true ->
GuildData = build_complete_guild_data(Data, State),
{reply, #{guild_data => GuildData}, State}
end
end.
-spec get_guild_member(map(), guild_state()) -> guild_reply(map()).
get_guild_member(#{user_id := UserId}, State) ->
case find_member_by_user_id(UserId, State) of
undefined ->
{reply, #{success => false, member_data => null}, State};
Member ->
{reply, #{success => true, member_data => Member}, State}
end.
-spec has_member(map(), guild_state()) -> guild_reply(map()).
has_member(#{user_id := UserId}, State) ->
case find_member_by_user_id(UserId, State) of
undefined ->
{reply, #{has_member => false}, State};
_ ->
{reply, #{has_member => true}, State}
end.
-spec list_guild_members(map(), guild_state()) -> guild_reply(map()).
list_guild_members(#{limit := Limit, offset := Offset}, State) ->
Data = guild_data_map(State),
AllMembers = map_utils:ensure_list(maps:get(<<"members">>, Data, [])),
TotalCount = length(AllMembers),
PaginatedMembers = paginate_members(AllMembers, Limit, Offset),
{reply, #{members => PaginatedMembers, total => TotalCount}, State}.
-spec get_vanity_url_channel(guild_state()) -> guild_reply(map()).
get_vanity_url_channel(State) ->
Channels = channels_from_state(State),
EveryoneChannelId = find_everyone_viewable_text_channel(Channels, State),
{reply, #{channel_id => EveryoneChannelId}, State}.
-spec get_first_viewable_text_channel(guild_state()) -> guild_reply(map()).
get_first_viewable_text_channel(State) ->
Channels = channels_from_state(State),
EveryoneChannelId = find_everyone_viewable_text_channel(Channels, State),
{reply, #{channel_id => EveryoneChannelId}, State}.
-spec get_guild_state(integer(), guild_state()) -> map().
get_guild_state(UserId, State) ->
Data = guild_data_map(State),
GuildId = map_utils:get_integer(State, id, 0),
AllChannels = channels_from_data(Data),
AllMembers = map_utils:ensure_list(maps:get(<<"members">>, Data, [])),
Member = find_member_by_user_id(UserId, State),
{ViewableChannels, JoinedAt} = derive_member_view(UserId, Member, State, AllChannels),
OnlineCount = guild_member_list:get_online_count(State),
OwnMemberList = case Member of
undefined -> [];
M -> [M]
end,
#{
<<"id">> => integer_to_binary(GuildId),
<<"properties">> => maps:get(<<"guild">>, Data, #{}),
<<"roles">> => map_utils:ensure_list(maps:get(<<"roles">>, Data, [])),
<<"channels">> => ViewableChannels,
<<"emojis">> => maps:get(<<"emojis">>, Data, []),
<<"stickers">> => maps:get(<<"stickers">>, Data, []),
<<"members">> => OwnMemberList,
<<"member_count">> => length(AllMembers),
<<"online_count">> => OnlineCount,
<<"presences">> => [],
<<"voice_states">> => guild_voice:get_voice_states_list(State),
<<"joined_at">> => JoinedAt
}.
-spec find_everyone_viewable_text_channel(channel_list(), guild_state()) -> integer() | null.
find_everyone_viewable_text_channel(Channels, State) ->
GuildId = map_utils:get_integer(State, id, 0),
Data = guild_data_map(State),
Roles = map_utils:ensure_list(maps:get(<<"roles">>, Data, [])),
EveryonePerms = role_permissions_for_id(Roles, GuildId),
lists:foldl(
fun(Channel, Acc) ->
case Acc of
null ->
select_first_viewable(Channel, GuildId, EveryonePerms);
_ ->
Acc
end
end,
null,
map_utils:ensure_list(Channels)
).
find_member_by_user_id(UserId, State) ->
guild_permissions:find_member_by_user_id(UserId, State).
-spec guild_data_map(guild_state()) -> guild_data_map().
guild_data_map(State) ->
map_utils:ensure_map(map_utils:get_safe(State, data, #{})).
-spec build_complete_guild_data(guild_data_map(), guild_state()) -> map().
build_complete_guild_data(Data, _State) ->
GuildProperties = maps:get(<<"guild">>, Data, #{}),
maps:merge(GuildProperties, #{
<<"roles">> => map_utils:ensure_list(maps:get(<<"roles">>, Data, [])),
<<"channels">> => map_utils:ensure_list(maps:get(<<"channels">>, Data, [])),
<<"emojis">> => map_utils:ensure_list(maps:get(<<"emojis">>, Data, [])),
<<"stickers">> => map_utils:ensure_list(maps:get(<<"stickers">>, Data, []))
}).
-spec channels_from_state(guild_state()) -> channel_list().
channels_from_state(State) ->
Data = guild_data_map(State),
channels_from_data(Data).
-spec channels_from_data(guild_data_map()) -> channel_list().
channels_from_data(Data) ->
map_utils:ensure_list(maps:get(<<"channels">>, Data, [])).
-spec member_in_list(integer(), [guild_member()]) -> boolean().
member_in_list(UserId, Members) ->
lists:any(fun(Member) -> member_matches(UserId, Member) end, Members).
-spec member_matches(integer(), guild_member()) -> boolean().
member_matches(UserId, Member) ->
MemberUser = map_utils:ensure_map(maps:get(<<"user">>, Member, #{})),
case map_utils:get_integer(MemberUser, <<"id">>, undefined) of
undefined -> false;
Id -> Id =:= UserId
end.
-spec paginate_members([guild_member()], non_neg_integer(), non_neg_integer()) -> [guild_member()].
paginate_members(Members, Limit, Offset) ->
case Offset >= length(Members) of
true ->
[];
false ->
Remaining = lists:nthtail(Offset, Members),
case length(Remaining) > Limit of
true -> lists:sublist(Remaining, Limit);
false -> Remaining
end
end.
-spec derive_member_view(integer(), guild_member() | undefined, guild_state(), channel_list()) ->
{channel_list(), term()}.
derive_member_view(_UserId, undefined, _State, _Channels) ->
{[], null};
derive_member_view(UserId, Member, State, Channels) ->
Filtered =
lists:filter(
fun(Channel) ->
ChannelId = map_utils:get_integer(Channel, <<"id">>, undefined),
case ChannelId of
undefined -> false;
_ -> guild_permissions:can_view_channel(UserId, ChannelId, Member, State)
end
end,
Channels
),
JoinedAt = maps:get(<<"joined_at">>, Member, null),
{Filtered, JoinedAt}.
-spec role_permissions_for_id(list(), integer()) -> integer().
role_permissions_for_id(Roles, GuildId) ->
lists:foldl(
fun(Role, Acc) ->
case map_utils:get_integer(Role, <<"id">>, undefined) of
GuildId -> map_utils:get_integer(Role, <<"permissions">>, 0);
_ -> Acc
end
end,
0,
map_utils:ensure_list(Roles)
).
-spec select_first_viewable(map(), integer(), integer()) -> integer() | null.
select_first_viewable(Channel, GuildId, BasePerms) ->
ChannelType = map_utils:get_integer(Channel, <<"type">>, undefined),
ChannelId = map_utils:get_integer(Channel, <<"id">>, undefined),
select_first_viewable(ChannelType, ChannelId, Channel, GuildId, BasePerms).
select_first_viewable(0, ChannelId, Channel, GuildId, BasePerms) when is_integer(ChannelId) ->
case (BasePerms band constants:administrator_permission()) =/= 0 of
true ->
ChannelId;
false ->
FinalPerms = guild_permissions:apply_channel_overwrites(
BasePerms, GuildId, [GuildId], Channel, GuildId
),
case (FinalPerms band constants:view_channel_permission()) =/= 0 of
true -> ChannelId;
false -> null
end
end;
select_first_viewable(_, _, _, _, _) ->
null.
-ifdef(TEST).
get_guild_data_membership_gate_test() ->
State = test_state(),
{reply, Reply1, _} = get_guild_data(#{user_id => 999}, State),
?assertEqual(null, maps:get(guild_data, Reply1)),
?assertEqual(<<"forbidden">>, maps:get(error_reason, Reply1)),
{reply, Reply2, _} = get_guild_data(#{user_id => 200}, State),
Guild = maps:get(guild_data, Reply2),
?assertEqual(<<"Fluxer">>, maps:get(<<"name">>, Guild)),
Roles = maps:get(<<"roles">>, Guild, []),
?assert(length(Roles) > 0).
get_guild_state_filters_channels_test() ->
State = test_state(),
GuildState = get_guild_state(200, State),
Channels = maps:get(<<"channels">>, GuildState),
?assert(lists:any(fun(Chan) -> maps:get(<<"id">>, Chan) =:= <<"500">> end, Channels)),
?assertEqual(<<"2024-01-01T00:00:00Z">>, maps:get(<<"joined_at">>, GuildState)).
find_everyone_viewable_text_channel_test() ->
State = test_state(),
Data = guild_data_map(State),
Channels = maps:get(<<"channels">>, Data),
ChannelId = find_everyone_viewable_text_channel(Channels, State),
?assertEqual(500, ChannelId).
test_state() ->
GuildId = 100,
ViewPerm = constants:view_channel_permission(),
#{
id => GuildId,
data => #{
<<"guild">> => #{<<"name">> => <<"Fluxer">>},
<<"roles">> => [
#{
<<"id">> => integer_to_binary(GuildId),
<<"permissions">> => integer_to_binary(ViewPerm)
}
],
<<"channels">> => [
#{
<<"id">> => <<"500">>,
<<"type">> => 0,
<<"permission_overwrites">> => []
},
#{
<<"id">> => <<"501">>,
<<"type">> => 2,
<<"permission_overwrites">> => []
}
],
<<"members">> => [
#{
<<"user">> => #{<<"id">> => <<"200">>},
<<"roles">> => [integer_to_binary(GuildId)],
<<"joined_at">> => <<"2024-01-01T00:00:00Z">>
}
],
<<"emojis">> => [],
<<"stickers">> => []
}
}.
-endif.

View File

@@ -0,0 +1,510 @@
%% 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_dispatch).
-export([
handle_dispatch/3,
extract_and_remove_session_id/1,
decorate_member_data/3,
extract_member_for_event/3,
collect_and_send_push_notifications/3,
normalize_event/1
]).
-import(guild_permissions, [find_member_by_user_id/2]).
-import(guild_state, [update_state/3]).
-import(guild_sessions, [
filter_sessions_for_channel/4,
filter_sessions_for_manage_channels/4,
filter_sessions_exclude_session/2
]).
-import(session_passive, [should_receive_event/5]).
normalize_event(Event) when is_atom(Event) -> Event;
normalize_event(<<"MESSAGE_CREATE">>) -> message_create;
normalize_event(<<"MESSAGE_UPDATE">>) -> message_update;
normalize_event(<<"MESSAGE_DELETE">>) -> message_delete;
normalize_event(<<"MESSAGE_DELETE_BULK">>) -> message_delete_bulk;
normalize_event(<<"MESSAGE_REACTION_ADD">>) -> message_reaction_add;
normalize_event(<<"MESSAGE_REACTION_REMOVE">>) -> message_reaction_remove;
normalize_event(<<"MESSAGE_REACTION_REMOVE_ALL">>) -> message_reaction_remove_all;
normalize_event(<<"MESSAGE_REACTION_REMOVE_EMOJI">>) -> message_reaction_remove_emoji;
normalize_event(<<"CHANNEL_CREATE">>) -> channel_create;
normalize_event(<<"CHANNEL_UPDATE">>) -> channel_update;
normalize_event(<<"CHANNEL_UPDATE_BULK">>) -> channel_update_bulk;
normalize_event(<<"CHANNEL_DELETE">>) -> channel_delete;
normalize_event(<<"CHANNEL_PINS_UPDATE">>) -> channel_pins_update;
normalize_event(<<"TYPING_START">>) -> typing_start;
normalize_event(<<"INVITE_CREATE">>) -> invite_create;
normalize_event(<<"INVITE_DELETE">>) -> invite_delete;
normalize_event(<<"GUILD_UPDATE">>) -> guild_update;
normalize_event(EventBinary) when is_binary(EventBinary) -> EventBinary.
handle_dispatch(Event, EventData, State) ->
case should_skip_dispatch(Event, State) of
true ->
{noreply, State};
false ->
NormalizedEvent = normalize_event(Event),
process_dispatch(NormalizedEvent, EventData, State)
end.
should_skip_dispatch(guild_update, _State) ->
false;
should_skip_dispatch(_Event, State) ->
Data = maps:get(data, State),
Guild = maps:get(<<"guild">>, Data),
Features = maps:get(<<"features">>, Guild, []),
lists:member(<<"UNAVAILABLE_FOR_EVERYONE">>, Features) orelse
lists:member(<<"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF">>, Features).
process_dispatch(Event, EventData, State) ->
GuildId = maps:get(id, State),
{SessionIdOpt, CleanData} = extract_session_id_if_needed(Event, EventData),
DecoratedData = maps:put(<<"guild_id">>, integer_to_binary(GuildId), CleanData),
FinalData = decorate_member_data(Event, DecoratedData, State),
UpdatedState = update_state(Event, FinalData, State),
Sessions = maps:get(sessions, UpdatedState, #{}),
FilteredSessions = filter_sessions_for_event(
Event, FinalData, SessionIdOpt, Sessions, UpdatedState
),
dispatch_to_sessions(FilteredSessions, Event, FinalData, UpdatedState),
maybe_send_push_notifications(Event, FinalData, GuildId, UpdatedState),
maybe_broadcast_member_list_update(Event, FinalData, State, UpdatedState),
{noreply, UpdatedState}.
extract_session_id_if_needed(Event, EventData) ->
case Event of
message_reaction_add -> extract_and_remove_session_id(EventData);
message_reaction_remove -> extract_and_remove_session_id(EventData);
_ -> {undefined, EventData}
end.
filter_sessions_for_event(Event, FinalData, SessionIdOpt, Sessions, UpdatedState) ->
case is_channel_scoped_event(Event) of
true ->
ChannelId = extract_channel_id(Event, FinalData),
filter_sessions_for_channel(Sessions, ChannelId, SessionIdOpt, UpdatedState);
false ->
case is_invite_event(Event) of
true ->
ChannelIdBin = maps:get(<<"channel_id">>, FinalData, <<"0">>),
ChannelId = validation:snowflake_or_default(<<"channel_id">>, ChannelIdBin, 0),
filter_sessions_for_manage_channels(
Sessions, ChannelId, SessionIdOpt, UpdatedState
);
false ->
case is_bulk_update_event(Event) of
true ->
filter_sessions_exclude_session(Sessions, SessionIdOpt);
false ->
filter_sessions_exclude_session(Sessions, SessionIdOpt)
end
end
end.
is_channel_scoped_event(channel_create) -> true;
is_channel_scoped_event(channel_update) -> true;
is_channel_scoped_event(message_create) -> true;
is_channel_scoped_event(message_update) -> true;
is_channel_scoped_event(message_delete) -> true;
is_channel_scoped_event(message_delete_bulk) -> true;
is_channel_scoped_event(message_reaction_add) -> true;
is_channel_scoped_event(message_reaction_remove) -> true;
is_channel_scoped_event(message_reaction_remove_all) -> true;
is_channel_scoped_event(message_reaction_remove_emoji) -> true;
is_channel_scoped_event(typing_start) -> true;
is_channel_scoped_event(channel_pins_update) -> true;
is_channel_scoped_event(_) -> false.
is_invite_event(invite_create) -> true;
is_invite_event(invite_delete) -> true;
is_invite_event(_) -> false.
is_bulk_update_event(channel_update_bulk) -> true;
is_bulk_update_event(_) -> false.
extract_channel_id(Event, FinalData) ->
case Event of
channel_create ->
ChannelIdBin = maps:get(<<"id">>, FinalData, <<"0">>),
validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0);
channel_update ->
ChannelIdBin = maps:get(<<"id">>, FinalData, <<"0">>),
validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0);
_ ->
ChannelIdBin = maps:get(<<"channel_id">>, FinalData, <<"0">>),
validation:snowflake_or_default(<<"channel_id">>, ChannelIdBin, 0)
end.
dispatch_to_sessions(FilteredSessions, Event, FinalData, UpdatedState) ->
GuildId = maps:get(id, UpdatedState),
case is_bulk_update_event(Event) of
true ->
dispatch_bulk_update(FilteredSessions, Event, FinalData, UpdatedState);
false ->
dispatch_standard(FilteredSessions, Event, FinalData, GuildId, UpdatedState)
end.
dispatch_bulk_update(FilteredSessions, Event, FinalData, UpdatedState) ->
GuildId = maps:get(id, UpdatedState),
BulkChannels = maps:get(<<"channels">>, FinalData, []),
lists:foreach(
fun({_Sid, SessionData}) ->
Pid = maps:get(pid, SessionData),
UserId = maps:get(user_id, SessionData),
Member = find_member_by_user_id(UserId, UpdatedState),
case should_receive_event(Event, FinalData, GuildId, SessionData, UpdatedState) of
false ->
ok;
true ->
FilteredChannels = lists:filter(
fun(Channel) ->
ChannelIdBin = maps:get(<<"id">>, Channel, <<"0">>),
ChannelId = validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0),
case Member of
undefined ->
false;
_ ->
guild_permissions:can_view_channel(
UserId, ChannelId, Member, UpdatedState
)
end
end,
BulkChannels
),
case FilteredChannels of
[] ->
ok;
_ when is_pid(Pid) ->
CustomData = maps:put(<<"channels">>, FilteredChannels, FinalData),
gen_server:cast(Pid, {dispatch, Event, CustomData})
end
end
end,
FilteredSessions
).
dispatch_standard(FilteredSessions, Event, FinalData, GuildId, State) ->
lists:foreach(
fun({_Sid, SessionData}) ->
Pid = maps:get(pid, SessionData),
case is_pid(Pid) andalso should_receive_event(Event, FinalData, GuildId, SessionData, State) of
true ->
gen_server:cast(Pid, {dispatch, Event, FinalData});
false ->
ok
end
end,
FilteredSessions
).
maybe_send_push_notifications(message_create, FinalData, GuildId, UpdatedState) ->
spawn(fun() ->
collect_and_send_push_notifications(FinalData, GuildId, UpdatedState)
end);
maybe_send_push_notifications(_Event, _FinalData, _GuildId, _UpdatedState) ->
ok.
maybe_broadcast_member_list_update(guild_member_add, EventData, OldState, UpdatedState) ->
UserId = extract_user_id_from_event(EventData),
guild_member_list:broadcast_member_list_updates(UserId, OldState, UpdatedState);
maybe_broadcast_member_list_update(guild_member_remove, EventData, OldState, UpdatedState) ->
UserId = extract_user_id_from_event(EventData),
guild_member_list:broadcast_member_list_updates(UserId, OldState, UpdatedState);
maybe_broadcast_member_list_update(guild_member_update, EventData, OldState, UpdatedState) ->
UserId = extract_user_id_from_event(EventData),
guild_member_list:broadcast_member_list_updates(UserId, OldState, UpdatedState);
maybe_broadcast_member_list_update(guild_role_create, _EventData, _OldState, UpdatedState) ->
guild_member_list:broadcast_all_member_list_updates(UpdatedState);
maybe_broadcast_member_list_update(guild_role_update, _EventData, _OldState, UpdatedState) ->
guild_member_list:broadcast_all_member_list_updates(UpdatedState);
maybe_broadcast_member_list_update(guild_role_update_bulk, _EventData, _OldState, UpdatedState) ->
guild_member_list:broadcast_all_member_list_updates(UpdatedState);
maybe_broadcast_member_list_update(guild_role_delete, _EventData, _OldState, UpdatedState) ->
guild_member_list:broadcast_all_member_list_updates(UpdatedState);
maybe_broadcast_member_list_update(channel_update, EventData, _OldState, UpdatedState) ->
ChannelIdBin = maps:get(<<"id">>, EventData, <<"0">>),
ChannelId = validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0),
guild_member_list:broadcast_member_list_updates_for_channel(ChannelId, UpdatedState);
maybe_broadcast_member_list_update(channel_update_bulk, EventData, _OldState, UpdatedState) ->
Channels = maps:get(<<"channels">>, EventData, []),
lists:foreach(
fun(Channel) ->
ChannelIdBin = maps:get(<<"id">>, Channel, <<"0">>),
ChannelId = validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0),
guild_member_list:broadcast_member_list_updates_for_channel(ChannelId, UpdatedState)
end,
Channels
);
maybe_broadcast_member_list_update(_Event, _FinalData, _OldState, _UpdatedState) ->
ok.
extract_user_id_from_event(EventData) ->
MUser = maps:get(<<"user">>, EventData, #{}),
utils:binary_to_integer_safe(maps:get(<<"id">>, MUser, <<"0">>)).
extract_and_remove_session_id(Data) ->
case maps:get(<<"session_id">>, Data, undefined) of
undefined -> {undefined, Data};
SessionId -> {SessionId, maps:remove(<<"session_id">>, Data)}
end.
decorate_member_data(Event, Data, State) ->
case extract_member_for_event(Event, Data, State) of
undefined ->
Data;
MemberData ->
add_member_to_data(Event, Data, MemberData)
end.
add_member_to_data(Event, Data, MemberData) ->
case is_message_event(Event) of
true ->
case maps:is_key(<<"author">>, Data) of
true ->
CleanMemberData = maps:remove(<<"user">>, MemberData),
maps:put(<<"member">>, CleanMemberData, Data);
false ->
Data
end;
false ->
case is_user_event(Event) of
true ->
case maps:is_key(<<"user_id">>, Data) of
true -> maps:put(<<"member">>, MemberData, Data);
false -> Data
end;
false ->
Data
end
end.
is_message_event(message_create) -> true;
is_message_event(message_update) -> true;
is_message_event(_) -> false.
is_user_event(typing_start) -> true;
is_user_event(message_reaction_add) -> true;
is_user_event(message_reaction_remove) -> true;
is_user_event(_) -> false.
extract_member_for_event(Event, Data, State) ->
UserId = extract_user_id_for_event(Event, Data),
case UserId of
undefined -> undefined;
Id -> find_member_by_user_id(Id, State)
end.
extract_user_id_for_event(Event, Data) ->
case is_message_event(Event) of
true ->
AuthorId = maps:get(<<"id">>, maps:get(<<"author">>, Data, #{}), undefined),
case AuthorId of
undefined ->
undefined;
_ ->
case validation:validate_snowflake(<<"author.id">>, AuthorId) of
{ok, Id} ->
Id;
{error, _, Reason} ->
logger:warning("[guild_dispatch] Invalid field: ~p", [Reason]),
undefined
end
end;
false ->
case is_user_event(Event) of
true ->
UserId = maps:get(<<"user_id">>, Data, undefined),
case UserId of
undefined ->
undefined;
_ ->
case validation:validate_snowflake(<<"user_id">>, UserId) of
{ok, Id} ->
Id;
{error, _, Reason} ->
logger:warning("[guild_dispatch] Invalid field: ~p", [Reason]),
undefined
end
end;
false ->
undefined
end
end.
collect_and_send_push_notifications(MessageData, GuildId, State) ->
case should_send_push_notifications(State) of
false ->
ok;
true ->
send_push_notifications(MessageData, GuildId, State)
end.
should_send_push_notifications(State) ->
Data = maps:get(data, State),
Guild = maps:get(<<"guild">>, Data),
DisabledOperationsBin = maps:get(<<"disabled_operations">>, Guild, <<"0">>),
DisabledOperations = validation:snowflake_or_default(
<<"disabled_operations">>, DisabledOperationsBin, 0
),
(DisabledOperations band 1) =:= 0.
send_push_notifications(MessageData, GuildId, State) ->
Data = maps:get(data, State),
Members = maps:get(<<"members">>, Data, []),
Sessions = maps:get(sessions, State, #{}),
ChannelIdBin = maps:get(<<"channel_id">>, MessageData),
ChannelId = validation:snowflake_or_default(<<"channel_id">>, ChannelIdBin, 0),
case find_eligible_users_for_push(Members, Sessions, ChannelId, State) of
[] ->
ok;
EligibleUserIds ->
UserRolesMap = build_user_roles_map(Members, EligibleUserIds),
send_push_to_eligible_users(MessageData, GuildId, EligibleUserIds, UserRolesMap, Data)
end.
find_eligible_users_for_push(Members, Sessions, ChannelId, State) ->
lists:filtermap(
fun(Member) ->
is_user_eligible_for_push(Member, Sessions, ChannelId, State)
end,
Members
).
is_user_eligible_for_push(Member, Sessions, ChannelId, State) ->
MUser = maps:get(<<"user">>, Member, #{}),
UserIdBin = maps:get(<<"id">>, MUser, <<"0">>),
UserId = validation:snowflake_or_default(<<"user.id">>, UserIdBin, 0),
case guild_permissions:can_view_channel(UserId, ChannelId, Member, State) of
false ->
false;
true ->
check_user_session_eligibility(UserId, Sessions)
end.
check_user_session_eligibility(UserId, Sessions) ->
UserSessions = maps:filter(
fun(_Sid, Session) ->
maps:get(user_id, Session) =:= UserId
end,
Sessions
),
case map_size(UserSessions) of
0 ->
{true, UserId};
_ ->
HasMobile = lists:any(
fun(Session) -> maps:get(mobile, Session, false) end,
maps:values(UserSessions)
),
AllAfk = lists:all(
fun(Session) -> maps:get(afk, Session, false) end,
maps:values(UserSessions)
),
case (not HasMobile) andalso AllAfk of
true -> {true, UserId};
false -> false
end
end.
send_push_to_eligible_users(MessageData, GuildId, EligibleUserIds, UserRolesMap, Data) ->
Guild = maps:get(<<"guild">>, Data),
AuthorIdBin = maps:get(<<"id">>, maps:get(<<"author">>, MessageData, #{}), <<"0">>),
AuthorId = validation:snowflake_or_default(<<"author.id">>, AuthorIdBin, 0),
DefaultMessageNotifications = maps:get(<<"default_message_notifications">>, Guild, 0),
GuildName = maps:get(<<"name">>, Guild, <<"Unknown">>),
ChannelIdBin = maps:get(<<"channel_id">>, MessageData),
ChannelName = find_channel_name(ChannelIdBin, Data),
push:handle_message_create(#{
message_data => MessageData,
user_ids => EligibleUserIds,
guild_id => GuildId,
author_id => AuthorId,
guild_default_notifications => DefaultMessageNotifications,
guild_name => GuildName,
channel_name => ChannelName,
user_roles => UserRolesMap
}).
find_channel_name(ChannelIdBin, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
lists:foldl(
fun(Channel, Acc) ->
case maps:get(<<"id">>, Channel, <<"">>) of
ChannelIdBin -> maps:get(<<"name">>, Channel, <<"unknown">>);
_ -> Acc
end
end,
<<"unknown">>,
Channels
).
build_user_roles_map(Members, EligibleUserIds) ->
EligibleSet = sets:from_list(EligibleUserIds),
lists:foldl(
fun(Member, Acc) ->
case get_member_user_id(Member) of
0 -> Acc;
UserId ->
case sets:is_element(UserId, EligibleSet) of
true ->
Roles = extract_role_ids(Member),
maps:put(UserId, Roles, Acc);
false ->
Acc
end
end
end,
#{},
Members
).
get_member_user_id(Member) ->
User = maps:get(<<"user">>, Member, #{}),
case maps:get(<<"id">>, User, undefined) of
undefined ->
0;
Id ->
validation:snowflake_or_default(<<"member.user.id">>, Id, 0)
end.
extract_role_ids(Member) ->
Roles = maps:get(<<"roles">>, Member, []),
lists:foldl(
fun(Role, Acc) ->
case validation:validate_snowflake(<<"role">>, Role) of
{ok, RoleId} -> [RoleId | Acc];
_ -> Acc
end
end,
[],
Roles
).

View File

@@ -0,0 +1,368 @@
%% 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_manager).
-behaviour(gen_server).
-include_lib("fluxer_gateway/include/timeout_config.hrl").
-export([start_link/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-type guild_id() :: integer().
-type shard_map() :: #{pid => pid(), ref => reference()}.
-type state() :: #{
shards => #{non_neg_integer() => shard_map()},
shard_count => pos_integer()
}.
-record(shard, {
pid :: pid(),
ref :: reference()
}).
-record(state, {
shards = #{} :: #{non_neg_integer() => #shard{}},
shard_count = 1 :: pos_integer()
}).
-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-spec init(list()) -> {ok, state()}.
init([]) ->
process_flag(trap_exit, true),
{ShardCount, Source} = determine_shard_count(),
ShardMap = start_shards(ShardCount, #{}),
maybe_log_shard_source(guild_manager, ShardCount, Source),
{ok, #{shards => ShardMap, shard_count => ShardCount}}.
-spec handle_call(term(), gen_server:from(), state()) -> {reply, term(), state()}.
handle_call({start_or_lookup, GuildId} = Request, _From, State) ->
{Reply, NewState} = forward_call(GuildId, Request, State),
{reply, Reply, NewState};
handle_call({stop_guild, GuildId} = Request, _From, State) ->
{Reply, NewState} = forward_call(GuildId, Request, State),
{reply, Reply, NewState};
handle_call({reload_guild, GuildId} = Request, _From, State) ->
{Reply, NewState} = forward_call(GuildId, Request, State),
{reply, Reply, NewState};
handle_call({shutdown_guild, GuildId} = Request, _From, State) ->
{Reply, NewState} = forward_call(GuildId, Request, State),
{reply, Reply, NewState};
handle_call({reload_all_guilds, GuildIds}, _From, State) ->
{Reply, NewState} = handle_reload_all(GuildIds, State),
{reply, Reply, NewState};
handle_call(get_local_count, _From, State) ->
{Count, NewState} = aggregate_counts(get_local_count, State),
{reply, {ok, Count}, NewState};
handle_call(get_global_count, _From, State) ->
{Count, NewState} = aggregate_counts(get_global_count, State),
{reply, {ok, Count}, NewState};
handle_call(Request, _From, State) ->
logger:warning("[guild_manager] unknown request ~p", [Request]),
{reply, ok, State}.
-spec handle_cast(term(), state()) -> {noreply, state()}.
handle_cast(_Msg, State) ->
{noreply, State}.
-spec handle_info(term(), state()) -> {noreply, state()}.
handle_info({'DOWN', Ref, process, _Pid, Reason}, State) ->
Shards = maps:get(shards, State),
case find_shard_by_ref(Ref, Shards) of
{ok, Index} ->
logger:warning("[guild_manager] shard ~p crashed: ~p", [Index, Reason]),
{_Shard, NewState} = restart_shard(Index, State),
{noreply, NewState};
not_found ->
{noreply, State}
end;
handle_info({'EXIT', Pid, Reason}, State) ->
Shards = maps:get(shards, State),
case find_shard_by_pid(Pid, Shards) of
{ok, Index} ->
logger:warning("[guild_manager] shard ~p exited: ~p", [Index, Reason]),
{_Shard, NewState} = restart_shard(Index, State),
{noreply, NewState};
not_found ->
{noreply, State}
end;
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(term(), state()) -> ok.
terminate(_Reason, State) ->
Shards = maps:get(shards, State),
lists:foreach(
fun(ShardMap) ->
Pid = maps:get(pid, ShardMap),
catch gen_server:stop(Pid, shutdown, 5000)
end,
maps:values(Shards)
),
ok.
-spec code_change(term(), term(), term()) -> {ok, state()}.
code_change(_OldVsn, #state{shards = OldShards, shard_count = ShardCount}, _Extra) ->
NewShards = maps:map(
fun(_Index, #shard{pid = Pid, ref = Ref}) ->
#{pid => Pid, ref => Ref}
end,
OldShards
),
{ok, #{shards => NewShards, shard_count => ShardCount}};
code_change(_OldVsn, State, _Extra) when is_map(State) ->
{ok, State}.
-spec determine_shard_count() -> {pos_integer(), configured | auto}.
determine_shard_count() ->
case fluxer_gateway_env:get(guild_shards) of
Value when is_integer(Value), Value > 0 ->
{Value, configured};
_ ->
{default_shard_count(), auto}
end.
-spec start_shards(pos_integer(), #{}) -> #{non_neg_integer() => shard_map()}.
start_shards(Count, Acc) ->
lists:foldl(
fun(Index, MapAcc) ->
case start_shard(Index) of
{ok, Shard} ->
maps:put(Index, Shard, MapAcc);
{error, Reason} ->
logger:warning("[guild_manager] failed to start shard ~p: ~p", [Index, Reason]),
MapAcc
end
end,
Acc,
lists:seq(0, Count - 1)
).
-spec start_shard(non_neg_integer()) -> {ok, shard_map()} | {error, term()}.
start_shard(Index) ->
case guild_manager_shard:start_link(Index) of
{ok, Pid} ->
Ref = erlang:monitor(process, Pid),
{ok, #{pid => Pid, ref => Ref}};
Error ->
Error
end.
-spec restart_shard(non_neg_integer(), state()) -> {shard_map(), state()}.
restart_shard(Index, State) ->
Shards = maps:get(shards, State),
case start_shard(Index) of
{ok, Shard} ->
Updated = State#{shards => maps:put(Index, Shard, Shards)},
{Shard, Updated};
{error, Reason} ->
logger:error("[guild_manager] failed to restart shard ~p: ~p", [Index, Reason]),
Dummy = #{pid => spawn(fun() -> exit(normal) end), ref => make_ref()},
{Dummy, State}
end.
-spec forward_call(guild_id(), term(), state()) -> {term(), state()}.
forward_call(GuildId, Request, State) ->
{Index, State1} = ensure_shard(GuildId, State),
Shards = maps:get(shards, State1),
ShardMap = maps:get(Index, Shards),
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(GuildId, Request, State2);
Reply ->
{Reply, State1}
end.
-spec ensure_shard(guild_id(), state()) -> {non_neg_integer(), state()}.
ensure_shard(GuildId, State) ->
Count = maps:get(shard_count, State),
Index = select_shard(GuildId, Count),
ensure_shard_for_index(Index, State).
-spec ensure_shard_for_index(non_neg_integer(), state()) -> {non_neg_integer(), state()}.
ensure_shard_for_index(Index, State) ->
Shards = maps:get(shards, State),
case maps:get(Index, Shards, undefined) of
undefined ->
{_Shard, NewState} = restart_shard(Index, State),
{Index, NewState};
ShardMap when is_map(ShardMap) ->
Pid = maps:get(pid, ShardMap),
case erlang:is_process_alive(Pid) of
true ->
{Index, State};
false ->
{_Shard, NewState} = restart_shard(Index, State),
{Index, NewState}
end
end.
-spec select_shard(guild_id(), pos_integer()) -> non_neg_integer().
select_shard(GuildId, Count) when Count > 0 ->
rendezvous_router:select(GuildId, Count).
-spec aggregate_counts(term(), state()) -> {non_neg_integer(), state()}.
aggregate_counts(Request, State) ->
Shards = maps:get(shards, State),
Counts =
[
begin
Pid = maps:get(pid, ShardMap),
case catch gen_server:call(Pid, Request, ?DEFAULT_GEN_SERVER_TIMEOUT) of
{ok, Count} -> Count;
_ -> 0
end
end
|| ShardMap <- maps:values(Shards)
],
{lists:sum(Counts), State}.
-spec handle_reload_all([guild_id()], state()) -> {#{count => non_neg_integer()}, state()}.
handle_reload_all([], State) ->
Shards = maps:get(shards, State),
{Replies, FinalState} =
lists:foldl(
fun({_Index, ShardMap}, {AccReplies, AccState}) ->
Pid = maps:get(pid, ShardMap),
case catch gen_server:call(Pid, {reload_all_guilds, []}, 60000) of
Reply ->
{AccReplies ++ [Reply], AccState}
end
end,
{[], State},
maps:to_list(Shards)
),
Count = lists:sum([maps:get(count, Reply, 0) || Reply <- Replies]),
{#{count => Count}, FinalState};
handle_reload_all(GuildIds, State) ->
Count = maps:get(shard_count, State),
Groups = group_ids_by_shard(GuildIds, Count),
{TotalCount, FinalState} =
lists:foldl(
fun({Index, Ids}, {AccCount, AccState}) ->
{ShardIdx, State1} = ensure_shard_for_index(Index, AccState),
Shards = maps:get(shards, State1),
ShardMap = maps:get(ShardIdx, Shards),
Pid = maps:get(pid, ShardMap),
case catch gen_server:call(Pid, {reload_all_guilds, Ids}, 60000) of
#{count := CountReply} ->
{AccCount + CountReply, State1};
_ ->
{AccCount, State1}
end
end,
{0, State},
Groups
),
{#{count => TotalCount}, FinalState}.
-spec group_ids_by_shard([guild_id()], pos_integer()) -> [{non_neg_integer(), [guild_id()]}].
group_ids_by_shard(GuildIds, ShardCount) ->
lists:foldl(
fun(GuildId, Acc) ->
Index = select_shard(GuildId, ShardCount),
case lists:keytake(Index, 1, Acc) of
{value, {Index, Ids}, Rest} ->
[{Index, [GuildId | Ids]} | Rest];
false ->
[{Index, [GuildId]} | Acc]
end
end,
[],
GuildIds
).
-spec find_shard_by_ref(reference(), #{non_neg_integer() => shard_map()}) ->
{ok, non_neg_integer()} | not_found.
find_shard_by_ref(Ref, Shards) ->
maps:fold(
fun
(Index, ShardMap, _) when is_map(ShardMap) ->
case maps:get(ref, ShardMap) of
R when R =:= Ref -> {ok, Index};
_ -> not_found
end;
(_, _, Acc) ->
Acc
end,
not_found,
Shards
).
-spec find_shard_by_pid(pid(), #{non_neg_integer() => shard_map()}) ->
{ok, non_neg_integer()} | not_found.
find_shard_by_pid(Pid, Shards) ->
maps:fold(
fun
(Index, ShardMap, _) when is_map(ShardMap) ->
case maps:get(pid, ShardMap) of
P when P =:= Pid -> {ok, Index};
_ -> not_found
end;
(_, _, Acc) ->
Acc
end,
not_found,
Shards
).
-spec default_shard_count() -> pos_integer().
default_shard_count() ->
Candidates = [
erlang:system_info(logical_processors_available), erlang:system_info(schedulers_online)
],
lists:max([C || C <- Candidates, is_integer(C), C > 0] ++ [1]).
-spec maybe_log_shard_source(atom(), pos_integer(), configured | auto) -> ok.
maybe_log_shard_source(Name, Count, configured) ->
logger:info("[~p] starting with ~p shards (configured)", [Name, Count]),
ok;
maybe_log_shard_source(Name, Count, auto) ->
logger:info("[~p] starting with ~p shards (auto)", [Name, Count]),
ok.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
determine_shard_count_configured_test() ->
with_runtime_config(guild_shards, 4, fun() ->
?assertMatch({4, configured}, determine_shard_count())
end).
determine_shard_count_auto_test() ->
with_runtime_config(guild_shards, undefined, fun() ->
{Count, auto} = determine_shard_count(),
?assert(Count > 0)
end).
with_runtime_config(Key, Value, Fun) ->
Original = fluxer_gateway_env:get(Key),
fluxer_gateway_env:patch(#{Key => Value}),
Result = Fun(),
fluxer_gateway_env:update(fun(Map) ->
case Original of
undefined -> maps:remove(Key, Map);
Val -> maps:put(Key, Val, Map)
end
end),
Result.
-endif.

View File

@@ -0,0 +1,530 @@
%% 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_manager_shard).
-behaviour(gen_server).
-include_lib("fluxer_gateway/include/timeout_config.hrl").
-define(GUILD_API_CANARY_PERCENTAGE, 5).
-export([start_link/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-type guild_id() :: integer().
-type guild_ref() :: {pid(), reference()}.
-type guild_data() :: #{binary() => term()}.
-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()]}
}.
-record(state, {
guilds = #{} :: #{guild_id() => guild_ref() | loading},
api_host :: string(),
api_canary_host :: undefined | string(),
pending_requests = #{} :: #{guild_id() => [gen_server:from()]}
}).
-spec start_link(non_neg_integer()) -> {ok, pid()} | {error, term()}.
start_link(ShardIndex) ->
gen_server:start_link(?MODULE, #{shard_index => ShardIndex}, []).
-spec init(map()) -> {ok, state()}.
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),
{ok, #{
guilds => #{},
api_host => ApiHost,
api_canary_host => ApiCanaryHost,
pending_requests => #{}
}}.
-spec handle_call(Request, From, State) -> Result when
Request ::
{start_or_lookup, guild_id()}
| {stop_guild, guild_id()}
| {reload_guild, guild_id()}
| {reload_all_guilds, [guild_id()]}
| {shutdown_guild, guild_id()}
| get_local_count
| get_global_count
| term(),
From :: gen_server:from(),
State :: state(),
Result ::
{reply, Reply, state()}
| {noreply, state()},
Reply ::
{ok, pid()}
| {error, term()}
| ok
| {ok, non_neg_integer()}.
handle_call({start_or_lookup, GuildId}, From, State) ->
do_start_or_lookup(GuildId, From, State);
handle_call({stop_guild, GuildId}, _From, State) ->
do_stop_guild(GuildId, State);
handle_call({reload_guild, GuildId}, From, State) ->
do_reload_guild(GuildId, From, State);
handle_call({reload_all_guilds, GuildIds}, From, State) ->
Guilds = maps:get(guilds, State),
GuildsToReload =
case GuildIds of
[] ->
[{GuildId, Pid} || {GuildId, {Pid, _Ref}} <- maps:to_list(Guilds)];
Ids ->
lists:filtermap(
fun(GuildId) ->
case maps:get(GuildId, Guilds, undefined) of
{Pid, _Ref} -> {true, {GuildId, Pid}};
_ -> false
end
end,
Ids
)
end,
Manager = self(),
spawn(fun() ->
try
reload_guilds_in_batches(GuildsToReload, Manager, State, 10, 100),
gen_server:cast(Manager, {all_guilds_reloaded, From, length(GuildsToReload)})
catch
Class:Error:Stacktrace ->
logger:error(
"[guild_manager] Spawned process failed: ~p:~p~n~p",
[Class, Error, Stacktrace]
),
gen_server:cast(Manager, {all_guilds_reloaded, From, 0})
end
end),
{noreply, State};
handle_call({shutdown_guild, GuildId}, _From, State) ->
do_shutdown_guild(GuildId, State);
handle_call(get_local_count, _From, State) ->
Guilds = maps:get(guilds, State),
Count = process_registry:get_count(Guilds),
{reply, {ok, Count}, State};
handle_call(get_global_count, _From, State) ->
Guilds = maps:get(guilds, State),
Count = process_registry:get_count(Guilds),
{reply, {ok, Count}, State};
handle_call(_Unknown, _From, State) ->
{reply, ok, State}.
-spec handle_cast(Request, State) -> {noreply, state()} when
Request ::
{guild_data_fetched, guild_id(), fetch_result()}
| {guild_data_reloaded, guild_id(), pid(), gen_server:from(), fetch_result()}
| {all_guilds_reloaded, gen_server:from(), non_neg_integer()}
| term(),
State :: state().
handle_cast({guild_data_fetched, GuildId, Result}, State) ->
Pending = maps:get(pending_requests, State),
Requests = maps:get(GuildId, Pending, []),
Guilds = maps:get(guilds, State),
case Result of
{ok, Data} ->
case start_guild(GuildId, Data, State) of
{ok, Pid, NewState} ->
lists:foreach(fun(From) -> gen_server:reply(From, {ok, Pid}) end, Requests),
NewPending = maps:remove(GuildId, Pending),
NewGuilds = maps:get(guilds, NewState),
CleanGuilds = maps:remove(GuildId, NewGuilds),
{noreply, NewState#{pending_requests => NewPending, guilds => CleanGuilds}};
{error, Reason} ->
logger:error("[guild_manager] Failed to start guild ~p: ~p", [GuildId, Reason]),
lists:foreach(
fun(From) -> gen_server:reply(From, {error, Reason}) end, Requests
),
NewGuilds = maps:remove(GuildId, Guilds),
NewPending = maps:remove(GuildId, Pending),
{noreply, State#{guilds => NewGuilds, pending_requests => NewPending}}
end;
_ ->
lists:foreach(fun(From) -> gen_server:reply(From, {error, not_found}) end, Requests),
NewGuilds = maps:remove(GuildId, Guilds),
NewPending = maps:remove(GuildId, Pending),
{noreply, State#{guilds => NewGuilds, pending_requests => NewPending}}
end;
handle_cast({guild_data_reloaded, _GuildId, Pid, From, Result}, State) ->
case Result of
{ok, Data} ->
gen_server:call(Pid, {reload, Data}, ?GUILD_CALL_TIMEOUT),
gen_server:reply(From, ok),
{noreply, State};
_ ->
gen_server:reply(From, {error, fetch_failed}),
{noreply, State}
end;
handle_cast({all_guilds_reloaded, From, Count}, State) ->
gen_server:reply(From, #{count => Count}),
{noreply, State};
handle_cast(_Unknown, State) ->
{noreply, State}.
-spec handle_info(Info, State) -> {noreply, state()} when
Info :: {'DOWN', reference(), process, pid(), term()} | term(),
State :: state().
handle_info({'DOWN', _Ref, process, Pid, _Reason}, State) ->
Guilds = maps:get(guilds, State),
NewGuilds = process_registry:cleanup_on_down(Pid, Guilds),
{noreply, State#{guilds => NewGuilds}};
handle_info(_Unknown, State) ->
{noreply, State}.
-spec terminate(Reason, State) -> ok when
Reason :: term(),
State :: state().
terminate(_Reason, _State) ->
ok.
-spec code_change(term(), term(), term()) -> {ok, state()}.
code_change(_OldVsn, #state{guilds = Guilds, api_host = ApiHost, api_canary_host = ApiCanaryHost, pending_requests = Pending}, _Extra) ->
{ok, #{
guilds => Guilds,
api_host => ApiHost,
api_canary_host => ApiCanaryHost,
pending_requests => Pending
}};
code_change(_OldVsn, State, _Extra) when is_map(State) ->
{ok, State}.
-spec fetch_guild_data(guild_id(), string()) -> fetch_result().
fetch_guild_data(GuildId, ApiHost) ->
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 = jsx:encode(RpcRequest),
case
hackney:request(post, Url, Headers, Body, [{recv_timeout, 30000}, {connect_timeout, 5000}])
of
{ok, 200, _RespHeaders, ClientRef} ->
case hackney:body(ClientRef) of
{ok, RespBody} ->
hackney:close(ClientRef),
Response = jsx:decode(RespBody, [return_maps]),
Data = maps:get(<<"data">>, Response, #{}),
{ok, Data};
{error, BodyReason} ->
hackney:close(ClientRef),
logger:error("[guild_manager] Failed to read guild response body: ~p", [
BodyReason
]),
{error, fetch_failed}
end;
{ok, StatusCode, _RespHeaders, ClientRef} ->
ErrorBody =
case hackney:body(ClientRef) of
{ok, Body2} -> Body2;
{error, _} -> <<"<unable to read error body>">>
end,
hackney:close(ClientRef),
logger:error(
"[guild_manager] Guild RPC failed with status ~p: ~s",
[StatusCode, ErrorBody]
),
{error, fetch_failed};
{error, Reason} ->
logger:error("[guild_manager] Guild RPC request failed: ~p", [Reason]),
{error, fetch_failed}
end.
-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}, _) ->
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 ->
logger:warning(
"[guild_manager] Canary API request failed for ~p, retrying against stable host",
[GuildId]
),
fetch_guild_data(GuildId, StableHost)
end
end.
-spec start_guild(guild_id(), guild_data(), state()) -> {ok, pid(), state()} | {error, term()}.
start_guild(GuildId, Data, State) ->
GuildName = process_registry:build_process_name(guild, GuildId),
case whereis(GuildName) of
undefined ->
GuildState = #{
id => GuildId,
data => Data,
sessions => #{},
presences => #{}
},
Guilds = maps:get(guilds, State),
case guild: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;
_ExistingPid ->
Guilds = maps:get(guilds, State),
case process_registry:lookup_or_monitor(GuildName, GuildId, Guilds) of
{ok, Pid, _Ref, NewGuilds} ->
{ok, Pid, State#{guilds => NewGuilds}};
{error, not_found} ->
{error, process_died}
end
end.
-spec reload_guilds_in_batches(
[{guild_id(), pid()}],
pid(),
state(),
pos_integer(),
non_neg_integer()
) -> ok.
reload_guilds_in_batches([], _Manager, _State, _BatchSize, _DelayMs) ->
ok;
reload_guilds_in_batches(Guilds, Manager, State, BatchSize, DelayMs) ->
{Batch, Remaining} = lists:split(min(BatchSize, length(Guilds)), Guilds),
lists:foreach(
fun({GuildId, Pid}) ->
ApiHostInfo = select_api_host(State),
spawn(fun() ->
try
case fetch_guild_data_with_fallback(GuildId, ApiHostInfo, State) of
{ok, Data} ->
gen_server:call(Pid, {reload, Data}, ?GUILD_CALL_TIMEOUT);
{error, Reason} ->
logger:error("[guild_manager] Failed to reload guild ~p: ~p", [
GuildId, Reason
])
end
catch
Class:Error:Stacktrace ->
logger:error(
"[guild_manager] Spawned process failed: ~p:~p~n~p",
[Class, Error, Stacktrace]
)
end
end)
end,
Batch
),
case Remaining of
[] ->
ok;
_ ->
timer:sleep(DelayMs),
reload_guilds_in_batches(Remaining, Manager, State, BatchSize, DelayMs)
end.
-spec do_start_or_lookup(guild_id(), gen_server:from(), state()) ->
{reply, {ok, pid()} | {error, term()}, state()} | {noreply, state()}.
do_start_or_lookup(GuildId, From, State) ->
Guilds = maps:get(guilds, State),
case maps:get(GuildId, Guilds, undefined) of
{Pid, _Ref} ->
{reply, {ok, Pid}, State};
loading ->
Pending = maps:get(pending_requests, State),
Requests = maps:get(GuildId, Pending, []),
NewPending = maps:put(GuildId, [From | Requests], Pending),
{noreply, State#{pending_requests => NewPending}};
undefined ->
GuildName = process_registry:build_process_name(guild, GuildId),
case whereis(GuildName) of
undefined ->
NewGuilds = maps:put(GuildId, loading, Guilds),
Pending = maps:get(pending_requests, State),
NewPending = maps:put(GuildId, [From], Pending),
NewState = State#{guilds => NewGuilds, pending_requests => NewPending},
Manager = self(),
ApiHostInfo = select_api_host(State),
spawn(fun() ->
try
Result = fetch_guild_data_with_fallback(GuildId, ApiHostInfo, State),
gen_server:cast(Manager, {guild_data_fetched, GuildId, Result})
catch
Class:Error:Stacktrace ->
logger:error(
"[guild_manager] Spawned process failed: ~p:~p~n~p",
[Class, Error, Stacktrace]
),
gen_server:cast(
Manager, {guild_data_fetched, GuildId, {error, fetch_failed}}
)
end
end),
{noreply, NewState};
_ExistingPid ->
case process_registry:lookup_or_monitor(GuildName, GuildId, Guilds) of
{ok, Pid, _Ref, NewGuilds} ->
{reply, {ok, Pid}, State#{guilds => NewGuilds}};
{error, not_found} ->
{reply, {error, process_died}, State}
end
end
end.
-spec do_stop_guild(guild_id(), state()) -> {reply, ok, state()}.
do_stop_guild(GuildId, State) ->
Guilds = maps:get(guilds, State),
GuildName = process_registry:build_process_name(guild, GuildId),
case maps:get(GuildId, Guilds, undefined) of
{Pid, Ref} ->
demonitor(Ref, [flush]),
gen_server:stop(Pid, normal, ?SHUTDOWN_TIMEOUT),
process_registry:safe_unregister(GuildName),
NewGuilds = maps:remove(GuildId, Guilds),
{reply, ok, State#{guilds => NewGuilds}};
_ ->
case whereis(GuildName) of
undefined ->
{reply, ok, State};
ExistingPid ->
gen_server:stop(ExistingPid, normal, ?SHUTDOWN_TIMEOUT),
process_registry:safe_unregister(GuildName),
{reply, ok, State}
end
end.
-spec do_reload_guild(guild_id(), gen_server:from(), state()) ->
{reply, {error, not_found}, state()} | {noreply, state()}.
do_reload_guild(GuildId, From, State) ->
Guilds = maps:get(guilds, State),
GuildName = process_registry:build_process_name(guild, GuildId),
case maps:get(GuildId, Guilds, undefined) of
{Pid, _Ref} ->
Manager = self(),
ApiHostInfo = select_api_host(State),
spawn(fun() ->
try
Result = fetch_guild_data_with_fallback(GuildId, ApiHostInfo, State),
gen_server:cast(Manager, {guild_data_reloaded, GuildId, Pid, From, Result})
catch
Class:Error:Stacktrace ->
logger:error(
"[guild_manager] Spawned process failed: ~p:~p~n~p",
[Class, Error, Stacktrace]
),
gen_server:cast(
Manager,
{guild_data_reloaded, GuildId, Pid, From, {error, fetch_failed}}
)
end
end),
{noreply, State};
_ ->
case whereis(GuildName) of
undefined ->
{reply, {error, not_found}, State};
_ExistingPid ->
case process_registry:lookup_or_monitor(GuildName, GuildId, Guilds) of
{ok, Pid, _Ref, NewGuilds} ->
NewState = State#{guilds => NewGuilds},
Manager = self(),
ApiHostInfo = select_api_host(NewState),
spawn(fun() ->
try
Result = fetch_guild_data_with_fallback(
GuildId, ApiHostInfo, NewState
),
gen_server:cast(
Manager, {guild_data_reloaded, GuildId, Pid, From, Result}
)
catch
Class:Error:Stacktrace ->
logger:error(
"[guild_manager] Spawned process failed: ~p:~p~n~p",
[Class, Error, Stacktrace]
),
gen_server:cast(
Manager,
{guild_data_reloaded, GuildId, Pid, From,
{error, fetch_failed}}
)
end
end),
{noreply, NewState};
{error, not_found} ->
{reply, {error, not_found}, State}
end
end
end.
-spec do_shutdown_guild(guild_id(), state()) -> {reply, ok, state()}.
do_shutdown_guild(GuildId, State) ->
Guilds = maps:get(guilds, State),
GuildName = process_registry:build_process_name(guild, GuildId),
case maps:get(GuildId, Guilds, undefined) of
{Pid, Ref} ->
demonitor(Ref, [flush]),
gen_server:call(Pid, {terminate}, ?SHUTDOWN_TIMEOUT),
process_registry:safe_unregister(GuildName),
NewGuilds = maps:remove(GuildId, Guilds),
{reply, ok, State#{guilds => NewGuilds}};
_ ->
case whereis(GuildName) of
undefined ->
{reply, ok, State};
ExistingPid ->
catch gen_server:call(ExistingPid, {terminate}, ?SHUTDOWN_TIMEOUT),
process_registry:safe_unregister(GuildName),
{reply, ok, State}
end
end.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,328 @@
%% 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_member_storage).
-export([
new/0,
insert_member/2,
remove_member/2,
get_member/2,
get_members_by_ids/2,
search_members/3,
get_range/3,
count/1,
compute_list_id/1
]).
-record(member_storage, {
members_table :: ets:tid(),
display_name_index :: gb_trees:tree()
}).
-type storage() :: #member_storage{}.
-type user_id() :: integer().
-type member() :: map().
-export_type([storage/0]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec new() -> storage().
new() ->
MembersTable = ets:new(members, [set, private]),
DisplayNameIndex = gb_trees:empty(),
#member_storage{
members_table = MembersTable,
display_name_index = DisplayNameIndex
}.
-spec insert_member(member(), storage()) -> storage().
insert_member(Member, Storage) ->
UserId = extract_user_id(Member),
case UserId of
undefined ->
Storage;
_ ->
OldMember = get_member(UserId, Storage),
Storage1 = remove_from_index(OldMember, Storage),
ets:insert(Storage1#member_storage.members_table, {UserId, Member}),
add_to_index(UserId, Member, Storage1)
end.
-spec remove_member(user_id(), storage()) -> storage().
remove_member(UserId, Storage) ->
case get_member(UserId, Storage) of
undefined ->
Storage;
Member ->
Storage1 = remove_from_index(Member, Storage),
ets:delete(Storage1#member_storage.members_table, UserId),
Storage1
end.
-spec get_member(user_id(), storage()) -> member() | undefined.
get_member(UserId, Storage) ->
case ets:lookup(Storage#member_storage.members_table, UserId) of
[{UserId, Member}] -> Member;
[] -> undefined
end.
-spec get_members_by_ids([user_id()], storage()) -> [member()].
get_members_by_ids(UserIds, Storage) ->
lists:filtermap(
fun(UserId) ->
case get_member(UserId, Storage) of
undefined -> false;
Member -> {true, Member}
end
end,
UserIds
).
-spec search_members(binary(), non_neg_integer(), storage()) -> [member()].
search_members(Query, Limit, Storage) when is_binary(Query), Limit > 0 ->
NormalizedQuery = normalize_display_name(Query),
case NormalizedQuery of
<<>> ->
[];
_ ->
search_by_prefix(NormalizedQuery, Limit, Storage)
end;
search_members(_, _, _) ->
[].
-spec get_range(non_neg_integer(), non_neg_integer(), storage()) -> [member()].
get_range(Offset, Limit, Storage) when is_integer(Offset), is_integer(Limit), Limit > 0 ->
Index = Storage#member_storage.display_name_index,
case gb_trees:size(Index) of
Size when Offset >= Size ->
[];
Size ->
AllKeys = gb_trees:keys(Index),
EndIdx = min(Offset + Limit, Size),
SelectedKeys = lists:sublist(AllKeys, Offset + 1, EndIdx - Offset),
lists:filtermap(
fun(Key) ->
UserId = gb_trees:get(Key, Index),
case get_member(UserId, Storage) of
undefined -> false;
Member -> {true, Member}
end
end,
SelectedKeys
)
end;
get_range(_, _, _) ->
[].
-spec count(storage()) -> non_neg_integer().
count(Storage) ->
ets:info(Storage#member_storage.members_table, size).
-spec compute_list_id([user_id()]) -> integer().
compute_list_id(UserIds) ->
SortedIds = lists:sort(UserIds),
Combined = lists:foldl(
fun(Id, Acc) -> <<Acc/binary, (integer_to_binary(Id))/binary, ",">> end,
<<>>,
SortedIds
),
erlang:phash2(Combined, 16#FFFFFFFF).
-spec extract_user_id(member()) -> user_id() | undefined.
extract_user_id(Member) when is_map(Member) ->
User = maps:get(<<"user">>, Member, #{}),
map_utils:get_integer(User, <<"id">>, undefined);
extract_user_id(_) ->
undefined.
-spec get_display_name(member()) -> binary().
get_display_name(Member) when is_map(Member) ->
Nick = maps:get(<<"nick">>, Member, undefined),
case Nick of
undefined ->
User = maps:get(<<"user">>, Member, #{}),
GlobalName = maps:get(<<"global_name">>, User, undefined),
case GlobalName of
undefined ->
maps:get(<<"username">>, User, <<>>);
_ ->
GlobalName
end;
_ ->
Nick
end.
-spec normalize_display_name(binary()) -> binary().
normalize_display_name(Name) when is_binary(Name) ->
LowerName = string:lowercase(binary_to_list(Name)),
list_to_binary(LowerName).
-spec add_to_index(user_id(), member(), storage()) -> storage().
add_to_index(UserId, Member, Storage) ->
DisplayName = get_display_name(Member),
NormalizedName = normalize_display_name(DisplayName),
Key = make_index_key(NormalizedName, UserId),
Index = Storage#member_storage.display_name_index,
NewIndex = gb_trees:enter(Key, UserId, Index),
Storage#member_storage{display_name_index = NewIndex}.
-spec remove_from_index(member() | undefined, storage()) -> storage().
remove_from_index(undefined, Storage) ->
Storage;
remove_from_index(Member, Storage) ->
UserId = extract_user_id(Member),
DisplayName = get_display_name(Member),
NormalizedName = normalize_display_name(DisplayName),
Key = make_index_key(NormalizedName, UserId),
Index = Storage#member_storage.display_name_index,
case gb_trees:is_defined(Key, Index) of
true ->
NewIndex = gb_trees:delete(Key, Index),
Storage#member_storage{display_name_index = NewIndex};
false ->
Storage
end.
-spec make_index_key(binary(), user_id()) -> {binary(), user_id()}.
make_index_key(NormalizedName, UserId) ->
{NormalizedName, UserId}.
-spec search_by_prefix(binary(), non_neg_integer(), storage()) -> [member()].
search_by_prefix(Prefix, Limit, Storage) ->
Index = Storage#member_storage.display_name_index,
AllKeys = gb_trees:keys(Index),
Matches = lists:filtermap(
fun({Name, UserId}) ->
PrefixLen = byte_size(Prefix),
case Name of
<<Prefix:PrefixLen/binary, _/binary>> ->
case get_member(UserId, Storage) of
undefined -> false;
Member -> {true, Member}
end;
_ ->
false
end
end,
AllKeys
),
lists:sublist(Matches, Limit).
-ifdef(TEST).
new_creates_empty_storage_test() ->
Storage = new(),
?assertEqual(0, count(Storage)).
insert_and_get_member_test() ->
Storage = new(),
Member = #{
<<"user">> => #{
<<"id">> => <<"123">>,
<<"username">> => <<"testuser">>
},
<<"roles">> => []
},
Storage1 = insert_member(Member, Storage),
?assertEqual(1, count(Storage1)),
Retrieved = get_member(123, Storage1),
?assertEqual(Member, Retrieved).
remove_member_test() ->
Storage = new(),
Member = #{
<<"user">> => #{
<<"id">> => <<"123">>,
<<"username">> => <<"testuser">>
}
},
Storage1 = insert_member(Member, Storage),
Storage2 = remove_member(123, Storage1),
?assertEqual(0, count(Storage2)),
?assertEqual(undefined, get_member(123, Storage2)).
get_members_by_ids_test() ->
Storage = new(),
Member1 = #{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>}},
Member2 = #{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"bob">>}},
Storage1 = insert_member(Member1, Storage),
Storage2 = insert_member(Member2, Storage1),
Members = get_members_by_ids([1, 2, 999], Storage2),
?assertEqual(2, length(Members)).
search_members_by_prefix_test() ->
Storage = new(),
Member1 = #{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>}},
Member2 = #{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"bob">>}},
Member3 = #{<<"user">> => #{<<"id">> => <<"3">>, <<"username">> => <<"alicia">>}},
Storage1 = insert_member(Member1, Storage),
Storage2 = insert_member(Member2, Storage1),
Storage3 = insert_member(Member3, Storage2),
Results = search_members(<<"ali">>, 10, Storage3),
?assertEqual(2, length(Results)).
display_name_nick_priority_test() ->
Member = #{
<<"user">> => #{
<<"id">> => <<"1">>,
<<"username">> => <<"user">>,
<<"global_name">> => <<"Global">>
},
<<"nick">> => <<"Nickname">>
},
?assertEqual(<<"Nickname">>, get_display_name(Member)).
display_name_global_name_fallback_test() ->
Member = #{
<<"user">> => #{
<<"id">> => <<"1">>,
<<"username">> => <<"user">>,
<<"global_name">> => <<"Global">>
}
},
?assertEqual(<<"Global">>, get_display_name(Member)).
display_name_username_fallback_test() ->
Member = #{
<<"user">> => #{
<<"id">> => <<"1">>,
<<"username">> => <<"user">>
}
},
?assertEqual(<<"user">>, get_display_name(Member)).
compute_list_id_test() ->
Id1 = compute_list_id([1, 2, 3]),
Id2 = compute_list_id([3, 2, 1]),
?assertEqual(Id1, Id2).
get_range_test() ->
Storage = new(),
Member1 = #{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>}},
Member2 = #{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"bob">>}},
Member3 = #{<<"user">> => #{<<"id">> => <<"3">>, <<"username">> => <<"charlie">>}},
Storage1 = insert_member(Member1, Storage),
Storage2 = insert_member(Member2, Storage1),
Storage3 = insert_member(Member3, Storage2),
Results = get_range(1, 2, Storage3),
?assertEqual(2, length(Results)).
-endif.

View File

@@ -0,0 +1,535 @@
%% 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_members).
-export([get_users_to_mention_by_roles/2]).
-export([get_users_to_mention_by_user_ids/2]).
-export([get_all_users_to_mention/2]).
-export([resolve_all_mentions/2]).
-export([get_members_with_role/2]).
-export([can_manage_roles/2]).
-export([can_manage_role/2]).
-export([get_assignable_roles/2]).
-export([check_target_member/2]).
-export([get_viewable_channels/2]).
-type guild_state() :: map().
-type guild_reply(T) :: {reply, T, guild_state()}.
-type member() :: map().
-type role() :: map().
-type channel() :: map().
-type user_id() :: integer().
-type role_id() :: integer().
-type channel_id() :: integer().
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec get_users_to_mention_by_roles(map(), guild_state()) -> guild_reply(map()).
get_users_to_mention_by_roles(
#{channel_id := ChannelId, role_ids := RoleIds, author_id := AuthorId}, State
) ->
Members = guild_members(State),
RoleIdSet = normalize_int_list(RoleIds),
UserIds = collect_mentions(
Members,
AuthorId,
ChannelId,
State,
fun(Member) -> member_has_any_role(Member, RoleIdSet) end
),
{reply, #{user_ids => UserIds}, State}.
-spec get_users_to_mention_by_user_ids(map(), guild_state()) -> guild_reply(map()).
get_users_to_mention_by_user_ids(
#{channel_id := ChannelId, user_ids := UserIdsReq, author_id := AuthorId}, State
) ->
Members = guild_members(State),
TargetIds = normalize_int_list(UserIdsReq),
UserIds = collect_mentions(
Members,
AuthorId,
ChannelId,
State,
fun(Member) ->
case member_user_id(Member) of
undefined -> false;
Id -> lists:member(Id, TargetIds)
end
end
),
{reply, #{user_ids => UserIds}, State}.
-spec get_all_users_to_mention(map(), guild_state()) -> guild_reply(map()).
get_all_users_to_mention(#{channel_id := ChannelId, author_id := AuthorId}, State) ->
Members = guild_members(State),
UserIds = collect_mentions(Members, AuthorId, ChannelId, State, fun(_) -> true end),
{reply, #{user_ids => UserIds}, State}.
-spec resolve_all_mentions(map(), guild_state()) -> guild_reply(map()).
resolve_all_mentions(
#{
channel_id := ChannelId,
author_id := AuthorId,
mention_everyone := MentionEveryone,
mention_here := MentionHere,
role_ids := RoleIds,
user_ids := DirectUserIds
},
State
) ->
Members = guild_members(State),
Sessions = maps:get(sessions, State, #{}),
RoleIdSet = gb_sets:from_list(normalize_int_list(RoleIds)),
DirectUserIdSet = gb_sets:from_list(normalize_int_list(DirectUserIds)),
HasRoleMentions = not gb_sets:is_empty(RoleIdSet),
HasDirectMentions = not gb_sets:is_empty(DirectUserIdSet),
ConnectedUserIds =
case MentionHere of
true ->
gb_sets:from_list([
maps:get(user_id, S)
|| {_Sid, S} <- maps:to_list(Sessions)
]);
false ->
gb_sets:empty()
end,
UserIds = lists:filtermap(
fun(Member) ->
case member_user_id(Member) of
undefined ->
false;
UserId when UserId =:= AuthorId ->
false;
UserId ->
case is_member_bot(Member) of
true ->
false;
false ->
ShouldMention =
MentionEveryone orelse
(MentionHere andalso
gb_sets:is_member(UserId, ConnectedUserIds)) orelse
(HasRoleMentions andalso
member_has_any_role_set(Member, RoleIdSet)) orelse
(HasDirectMentions andalso
gb_sets:is_member(UserId, DirectUserIdSet)),
case
ShouldMention andalso
member_can_view_channel(UserId, ChannelId, Member, State)
of
true -> {true, UserId};
false -> false
end
end
end
end,
Members
),
{reply, #{user_ids => UserIds}, State}.
-spec get_members_with_role(map(), guild_state()) -> guild_reply(map()).
get_members_with_role(#{role_id := RoleId}, State) ->
Members = guild_members(State),
TargetRoles = [RoleId],
UserIds = lists:filtermap(
fun(Member) ->
case member_user_id(Member) of
undefined ->
false;
UserId ->
case member_has_any_role(Member, TargetRoles) of
true -> {true, UserId};
false -> false
end
end
end,
Members
),
{reply, #{user_ids => UserIds}, State}.
-spec can_manage_roles(map(), guild_state()) -> guild_reply(map()).
can_manage_roles(#{user_id := UserId, role_id := RoleId}, State) ->
Data = guild_data(State),
OwnerId = owner_id(State),
Reply =
if
UserId =:= OwnerId ->
true;
true ->
UserPermissions = guild_permissions:get_member_permissions(
UserId, undefined, State
),
case (UserPermissions band constants:manage_roles_permission()) =/= 0 of
false ->
false;
true ->
Roles = maps:get(<<"roles">>, Data, []),
case find_role_by_id(RoleId, Roles) of
undefined ->
false;
Role ->
UserMax = guild_permissions:get_max_role_position(UserId, State),
UserMax > role_position(Role)
end
end
end,
{reply, #{can_manage => Reply}, State}.
-spec can_manage_role(map(), guild_state()) -> guild_reply(map()).
can_manage_role(#{user_id := UserId, role_id := RoleId}, State) ->
Data = guild_data(State),
Roles = maps:get(<<"roles">>, Data, []),
Reply =
case find_role_by_id(RoleId, Roles) of
undefined ->
false;
Role ->
UserMax = guild_permissions:get_max_role_position(UserId, State),
RolePos = role_position(Role),
UserMax > RolePos orelse
(UserMax =:= RolePos andalso
compare_role_ids_for_equal_position(UserId, RoleId, State))
end,
{reply, #{can_manage => Reply}, State}.
compare_role_ids_for_equal_position(UserId, TargetRoleId, State) ->
case guild_permissions:find_member_by_user_id(UserId, State) of
undefined ->
false;
Member ->
MemberRoles = member_roles(Member),
Data = guild_data(State),
Roles = maps:get(<<"roles">>, Data, []),
UserHighestRole = get_highest_role(MemberRoles, Roles),
case UserHighestRole of
undefined ->
false;
HighestRole ->
HighestRoleId = map_utils:get_integer(HighestRole, <<"id">>, 0),
HighestRoleId < TargetRoleId
end
end.
get_highest_role(MemberRoleIds, Roles) ->
lists:foldl(
fun(RoleId, Acc) ->
case find_role_by_id(RoleId, Roles) of
undefined ->
Acc;
Role ->
case Acc of
undefined ->
Role;
AccRole ->
AccPos = role_position(AccRole),
RolePos = role_position(Role),
if
RolePos > AccPos ->
Role;
RolePos =:= AccPos ->
AccId = map_utils:get_integer(AccRole, <<"id">>, 0),
RId = map_utils:get_integer(Role, <<"id">>, 0),
if
RId < AccId -> Role;
true -> AccRole
end;
true ->
AccRole
end
end
end
end,
undefined,
MemberRoleIds
).
-spec get_assignable_roles(map(), guild_state()) -> guild_reply(map()).
get_assignable_roles(#{user_id := UserId}, State) ->
Roles = guild_roles(State),
OwnerId = owner_id(State),
RoleIds = get_assignable_role_ids(UserId, OwnerId, Roles, State),
{reply, #{role_ids => RoleIds}, State}.
get_assignable_role_ids(OwnerId, OwnerId, Roles, _State) ->
role_ids_from_roles(Roles);
get_assignable_role_ids(UserId, _OwnerId, Roles, State) ->
UserMaxPosition = guild_permissions:get_max_role_position(UserId, State),
lists:filtermap(
fun(Role) -> filter_assignable_role(Role, UserMaxPosition) end,
Roles
).
filter_assignable_role(Role, UserMaxPosition) ->
case role_position(Role) < UserMaxPosition of
true ->
case map_utils:get_integer(Role, <<"id">>, undefined) of
undefined -> false;
RoleId -> {true, RoleId}
end;
false ->
false
end.
-spec check_target_member(map(), guild_state()) -> guild_reply(map()).
check_target_member(#{user_id := UserId, target_user_id := TargetUserId}, State) ->
OwnerId = owner_id(State),
CanManage =
if
UserId =:= OwnerId ->
true;
TargetUserId =:= OwnerId ->
false;
true ->
UserMaxPos = guild_permissions:get_max_role_position(UserId, State),
TargetMaxPos = guild_permissions:get_max_role_position(TargetUserId, State),
UserMaxPos > TargetMaxPos
end,
{reply, #{can_manage => CanManage}, State}.
-spec get_viewable_channels(map(), guild_state()) -> guild_reply(map()).
get_viewable_channels(#{user_id := UserId}, State) ->
Channels = guild_channels(State),
case find_member_by_user_id(UserId, State) of
undefined ->
{reply, #{channel_ids => []}, State};
Member ->
ChannelIds = lists:filtermap(
fun(Channel) ->
ChannelId = map_utils:get_integer(Channel, <<"id">>, undefined),
case ChannelId of
undefined ->
false;
_ ->
case
guild_permissions:can_view_channel(UserId, ChannelId, Member, State)
of
true -> {true, ChannelId};
false -> false
end
end
end,
Channels
),
{reply, #{channel_ids => ChannelIds}, State}
end.
find_member_by_user_id(UserId, State) ->
guild_permissions:find_member_by_user_id(UserId, State).
find_role_by_id(RoleId, Roles) ->
guild_permissions:find_role_by_id(RoleId, Roles).
-spec guild_data(guild_state()) -> map().
guild_data(State) ->
map_utils:ensure_map(map_utils:get_safe(State, data, #{})).
-spec guild_members(guild_state()) -> [member()].
guild_members(State) ->
map_utils:ensure_list(maps:get(<<"members">>, guild_data(State), [])).
-spec guild_roles(guild_state()) -> [role()].
guild_roles(State) ->
map_utils:ensure_list(maps:get(<<"roles">>, guild_data(State), [])).
-spec guild_channels(guild_state()) -> [channel()].
guild_channels(State) ->
map_utils:ensure_list(maps:get(<<"channels">>, guild_data(State), [])).
-spec owner_id(guild_state()) -> user_id().
owner_id(State) ->
Guild = map_utils:ensure_map(maps:get(<<"guild">>, guild_data(State), #{})),
map_utils:get_integer(Guild, <<"owner_id">>, 0).
-spec member_user_id(member()) -> user_id() | undefined.
member_user_id(Member) ->
User = map_utils:ensure_map(maps:get(<<"user">>, Member, #{})),
map_utils:get_integer(User, <<"id">>, undefined).
-spec member_roles(member()) -> [role_id()].
member_roles(Member) ->
normalize_int_list(map_utils:ensure_list(maps:get(<<"roles">>, Member, []))).
-spec member_has_any_role(member(), [role_id()]) -> boolean().
member_has_any_role(Member, RoleIds) ->
MemberRoles = member_roles(Member),
lists:any(fun(RoleId) -> lists:member(RoleId, MemberRoles) end, RoleIds).
-spec member_has_any_role_set(member(), gb_sets:set(role_id())) -> boolean().
member_has_any_role_set(Member, RoleIdSet) ->
MemberRoles = member_roles(Member),
lists:any(fun(RoleId) -> gb_sets:is_member(RoleId, RoleIdSet) end, MemberRoles).
-spec is_member_bot(member()) -> boolean().
is_member_bot(Member) ->
User = map_utils:ensure_map(maps:get(<<"user">>, Member, #{})),
maps:get(<<"bot">>, User, false) =:= true.
-spec member_can_view_channel(user_id(), channel_id(), member(), guild_state()) -> boolean().
member_can_view_channel(UserId, ChannelId, Member, State) when is_integer(ChannelId) ->
guild_permissions:can_view_channel(UserId, ChannelId, Member, State);
member_can_view_channel(_, _, _, _) ->
false.
-spec collect_mentions([member()], user_id(), channel_id(), guild_state(), fun(
(member()) -> boolean()
)) ->
[user_id()].
collect_mentions(Members, AuthorId, ChannelId, State, Predicate) ->
lists:filtermap(
fun(Member) ->
case member_user_id(Member) of
undefined ->
false;
UserId when UserId =:= AuthorId -> false;
UserId ->
case
Predicate(Member) andalso
member_can_view_channel(UserId, ChannelId, Member, State)
of
true -> {true, UserId};
false -> false
end
end
end,
Members
).
-spec normalize_int_list(list()) -> [integer()].
normalize_int_list(List) ->
lists:reverse(
lists:foldl(
fun(Value, Acc) ->
case type_conv:to_integer(Value) of
undefined -> Acc;
Int -> [Int | Acc]
end
end,
[],
map_utils:ensure_list(List)
)
).
-spec role_ids_from_roles([role()]) -> [role_id()].
role_ids_from_roles(Roles) ->
lists:filtermap(
fun(Role) ->
case map_utils:get_integer(Role, <<"id">>, undefined) of
undefined -> false;
RoleId -> {true, RoleId}
end
end,
Roles
).
-spec role_position(role()) -> integer().
role_position(Role) ->
maps:get(<<"position">>, Role, 0).
-ifdef(TEST).
get_users_to_mention_by_roles_basic_test() ->
State = test_state(),
ChannelId = 500,
RoleMod = 200,
Request = #{channel_id => ChannelId, role_ids => [RoleMod], author_id => 1},
{reply, #{user_ids := UserIds}, _} = get_users_to_mention_by_roles(Request, State),
?assertEqual([2], UserIds).
get_assignable_roles_owner_test() ->
State = test_state(),
{reply, #{role_ids := RoleIds}, _} = get_assignable_roles(#{user_id => 1}, State),
?assertEqual(lists:sort([100, 200, 201]), lists:sort(RoleIds)).
get_assignable_roles_member_test() ->
State = test_state(),
{reply, #{role_ids := RoleIds}, _} = get_assignable_roles(#{user_id => 2}, State),
?assertEqual([100], RoleIds).
get_viewable_channels_filters_test() ->
State = test_state(),
{reply, #{channel_ids := ChannelIds}, _} = get_viewable_channels(#{user_id => 2}, State),
?assert(lists:member(500, ChannelIds)).
test_state() ->
GuildId = 100,
OwnerId = 1,
MemberId = 2,
OtherId = 3,
ChannelId = 500,
RoleMod = 200,
RoleHigh = 201,
ViewPerm = constants:view_channel_permission(),
ManageRoles = constants:manage_roles_permission(),
#{
id => GuildId,
data => #{
<<"guild">> => #{<<"owner_id">> => integer_to_binary(OwnerId)},
<<"roles">> => [
#{
<<"id">> => integer_to_binary(GuildId),
<<"permissions">> => integer_to_binary(ViewPerm bor ManageRoles),
<<"position">> => 0
},
#{
<<"id">> => integer_to_binary(RoleMod),
<<"permissions">> => integer_to_binary(ViewPerm),
<<"position">> => 10
},
#{
<<"id">> => integer_to_binary(RoleHigh),
<<"permissions">> => integer_to_binary(ViewPerm),
<<"position">> => 20
}
],
<<"channels">> => [
#{
<<"id">> => integer_to_binary(ChannelId),
<<"type">> => 0,
<<"permission_overwrites">> => []
},
#{
<<"id">> => integer_to_binary(ChannelId + 1),
<<"type">> => 2,
<<"permission_overwrites">> => []
}
],
<<"members">> => [
#{
<<"user">> => #{<<"id">> => integer_to_binary(OwnerId)},
<<"roles">> => [integer_to_binary(GuildId)]
},
#{
<<"user">> => #{<<"id">> => integer_to_binary(MemberId)},
<<"roles">> => [integer_to_binary(RoleMod)],
<<"joined_at">> => <<"2024-01-01T00:00:00Z">>
},
#{
<<"user">> => #{<<"id">> => integer_to_binary(OtherId)},
<<"roles">> => [integer_to_binary(RoleHigh)],
<<"joined_at">> => <<"2024-01-02T00:00:00Z">>
}
]
}
}.
-endif.

View File

@@ -0,0 +1,175 @@
%% 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_passive_sync).
-export([
schedule_passive_sync/1,
handle_passive_sync/1,
send_passive_updates_to_sessions/1,
compute_delta/2
]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-define(PASSIVE_SYNC_INTERVAL, 30000).
schedule_passive_sync(State) ->
erlang:send_after(?PASSIVE_SYNC_INTERVAL, self(), passive_sync),
State.
handle_passive_sync(State) ->
NewState = send_passive_updates_to_sessions(State),
schedule_passive_sync(NewState),
{noreply, NewState}.
send_passive_updates_to_sessions(State) ->
GuildId = maps:get(id, State),
Sessions = maps:get(sessions, State, #{}),
Data = maps:get(data, State, #{}),
Channels = maps:get(<<"channels">>, Data, []),
MemberCount = maps:get(member_count, State, undefined),
IsLargeGuild = case MemberCount of
undefined -> false;
Count when is_integer(Count) -> Count > 250
end,
PassiveSessions = maps:filter(
fun(_SessionId, SessionData) ->
IsLargeGuild andalso session_passive:is_passive(GuildId, SessionData)
end,
Sessions
),
case map_size(PassiveSessions) of
0 ->
State;
_ ->
UpdatedSessions = lists:foldl(
fun({SessionId, SessionData}, AccSessions) ->
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, #{}),
Delta = compute_delta(CurrentLastMessageIds, PreviousLastMessageIds),
case {map_size(Delta), is_pid(Pid)} of
{0, _} ->
AccSessions;
{_, true} ->
EventData = #{
<<"guild_id">> => integer_to_binary(GuildId),
<<"channels">> => Delta
},
gen_server:cast(Pid, {dispatch, passive_updates, EventData}),
MergedLastMessageIds = maps:merge(PreviousLastMessageIds, Delta),
UpdatedSessionData = maps:put(previous_passive_updates, MergedLastMessageIds, SessionData),
maps:put(SessionId, UpdatedSessionData, AccSessions);
_ ->
AccSessions
end
end,
Sessions,
maps:to_list(PassiveSessions)
),
maps:put(sessions, UpdatedSessions, State)
end.
compute_delta(CurrentLastMessageIds, PreviousLastMessageIds) ->
maps:filter(
fun(ChannelId, CurrentValue) ->
case maps:get(ChannelId, PreviousLastMessageIds, undefined) of
undefined -> true;
PreviousValue -> CurrentValue =/= PreviousValue
end
end,
CurrentLastMessageIds
).
build_last_message_ids(Channels, UserId, Member, State) ->
lists:foldl(
fun(Channel, Acc) ->
ChannelIdBin = maps:get(<<"id">>, Channel, undefined),
LastMessageId = maps:get(<<"last_message_id">>, Channel, null),
case {ChannelIdBin, LastMessageId} of
{undefined, _} ->
Acc;
{_, null} ->
Acc;
_ ->
ChannelId = validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0),
case Member of
undefined ->
Acc;
_ ->
case guild_permissions:can_view_channel(UserId, ChannelId, Member, State) of
true ->
maps:put(ChannelIdBin, LastMessageId, Acc);
false ->
Acc
end
end
end
end,
#{},
Channels
).
-ifdef(TEST).
compute_delta_empty_previous_test() ->
Current = #{<<"1">> => <<"100">>, <<"2">> => <<"200">>},
Previous = #{},
Delta = compute_delta(Current, Previous),
?assertEqual(Current, Delta),
ok.
compute_delta_no_changes_test() ->
Current = #{<<"1">> => <<"100">>, <<"2">> => <<"200">>},
Previous = #{<<"1">> => <<"100">>, <<"2">> => <<"200">>},
Delta = compute_delta(Current, Previous),
?assertEqual(#{}, Delta),
ok.
compute_delta_partial_changes_test() ->
Current = #{<<"1">> => <<"101">>, <<"2">> => <<"200">>, <<"3">> => <<"300">>},
Previous = #{<<"1">> => <<"100">>, <<"2">> => <<"200">>},
Delta = compute_delta(Current, Previous),
?assertEqual(#{<<"1">> => <<"101">>, <<"3">> => <<"300">>}, Delta),
ok.
compute_delta_only_new_channels_test() ->
Current = #{<<"1">> => <<"100">>, <<"2">> => <<"200">>, <<"3">> => <<"300">>},
Previous = #{<<"1">> => <<"100">>, <<"2">> => <<"200">>},
Delta = compute_delta(Current, Previous),
?assertEqual(#{<<"3">> => <<"300">>}, Delta),
ok.
compute_delta_ignores_removed_channels_test() ->
Current = #{<<"1">> => <<"100">>},
Previous = #{<<"1">> => <<"100">>, <<"2">> => <<"200">>},
Delta = compute_delta(Current, Previous),
?assertEqual(#{}, Delta),
ok.
-endif.

View File

@@ -0,0 +1,560 @@
%% 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_permissions).
-define(ALL_PERMISSIONS, 16#FFFFFFFFFFFFFFFF).
-export([get_member_permissions/3]).
-export([can_view_channel/4]).
-export([can_view_channel_by_permissions/4]).
-export([can_manage_channel/3]).
-export([apply_channel_overwrites/5]).
-export([get_max_role_position/2]).
-export([find_member_by_user_id/2]).
-export([find_role_by_id/2]).
-export([find_channel_by_id/2]).
-export_type([permission/0]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-type permission() :: non_neg_integer().
-type user_id() :: integer().
-type role_id() :: integer().
-type channel_id() :: integer().
-type maybe_channel_id() :: channel_id() | undefined.
-type guild_state() :: map().
-type guild_data() :: map().
-type member() :: map().
-type role() :: map().
-type channel() :: map().
-type overwrite() :: map().
-type member_roles() :: [role_id()].
-type maybe_member() :: member() | undefined.
-spec get_member_permissions(user_id(), maybe_channel_id(), guild_state()) -> permission().
get_member_permissions(UserId, ChannelId, State) ->
compute_member_permissions(UserId, ChannelId, undefined, State).
-spec can_view_channel(user_id(), channel_id(), maybe_member(), guild_state()) -> boolean().
can_view_channel(UserId, ChannelId, Member, State) ->
guild_virtual_channel_access:has_virtual_access(UserId, ChannelId, State) orelse
can_view_channel_by_permissions(UserId, ChannelId, Member, State).
-spec can_view_channel_by_permissions(user_id(), channel_id(), maybe_member(), guild_state()) ->
boolean().
can_view_channel_by_permissions(UserId, ChannelId, Member, State) ->
(compute_member_permissions(UserId, ChannelId, Member, State) band
constants:view_channel_permission()) =/= 0.
-spec can_manage_channel(user_id(), maybe_channel_id(), guild_state()) -> boolean().
can_manage_channel(UserId, ChannelId, State) ->
(get_member_permissions(UserId, ChannelId, State) band
constants:manage_channels_permission()) =/= 0.
-spec apply_channel_overwrites(permission(), user_id(), member_roles(), channel(), role_id()) ->
permission().
apply_channel_overwrites(BasePerms, UserId, MemberRoles, Channel, EveryoneRoleId) ->
Overwrites = channel_overwrites(Channel),
EveryonePerms = apply_everyone_overwrites(BasePerms, Overwrites, EveryoneRoleId),
{RoleAllow, RoleDeny} = accumulate_role_overwrites(MemberRoles, Overwrites),
RolePerms = (EveryonePerms band bnot RoleDeny) bor RoleAllow,
apply_user_overwrites(RolePerms, Overwrites, UserId).
-spec get_max_role_position(user_id(), guild_state()) -> integer().
get_max_role_position(UserId, State) ->
case {find_member_by_user_id(UserId, State), resolve_data_map(State)} of
{undefined, _} ->
-1;
{_, undefined} ->
-1;
{Member, Data} ->
Roles = ensure_list(maps:get(<<"roles">>, Data, [])),
lists:foldl(
fun(RoleId, MaxPos) ->
case find_role_by_id(RoleId, Roles) of
undefined ->
MaxPos;
Role ->
Position = maps:get(<<"position">>, Role, 0),
max(Position, MaxPos)
end
end,
-1,
member_role_ids(Member)
)
end.
-spec find_member_by_user_id(user_id(), guild_state()) -> member() | undefined.
find_member_by_user_id(UserId, State) when is_integer(UserId) ->
case resolve_data_map(State) of
undefined ->
undefined;
Data ->
Members = ensure_list(maps:get(<<"members">>, Data, [])),
lists:foldl(
fun(Member, Acc) ->
case Acc of
undefined ->
MUser = maps:get(<<"user">>, Member, #{}),
MemberId = to_int(maps:get(<<"id">>, MUser, <<"0">>)),
case MemberId =:= UserId of
true -> Member;
false -> undefined
end;
Found ->
Found
end
end,
undefined,
Members
)
end;
find_member_by_user_id(_, _) ->
undefined.
-spec find_role_by_id(role_id(), list()) -> role() | undefined.
find_role_by_id(RoleId, Roles) ->
TargetId = to_int(RoleId),
lists:foldl(
fun(Role, Acc) ->
case Acc of
undefined ->
case role_id(Role) =:= TargetId of
true -> Role;
false -> undefined
end;
Found ->
Found
end
end,
undefined,
ensure_list(Roles)
).
-spec find_channel_by_id(channel_id(), guild_state()) -> channel() | undefined.
find_channel_by_id(ChannelId, State) when is_integer(ChannelId) ->
case resolve_data_map(State) of
undefined ->
undefined;
Data ->
Channels = ensure_list(maps:get(<<"channels">>, Data, [])),
lists:foldl(
fun(Channel, Acc) ->
case Acc of
undefined ->
ChanId = to_int(maps:get(<<"id">>, Channel, <<"0">>)),
case ChanId =:= ChannelId of
true -> Channel;
false -> undefined
end;
Found ->
Found
end
end,
undefined,
Channels
)
end;
find_channel_by_id(_, _) ->
undefined.
-spec compute_member_permissions(user_id(), maybe_channel_id(), maybe_member(), guild_state()) ->
permission().
compute_member_permissions(UserId, ChannelId, ProvidedMember, State) when is_integer(UserId) ->
case resolve_data_map(State) of
undefined ->
0;
Data ->
OwnerId = guild_owner_id(Data),
case UserId =:= OwnerId of
true ->
?ALL_PERMISSIONS;
false ->
case resolve_member(UserId, ProvidedMember, State) of
undefined ->
0;
Member ->
GuildId = guild_id(State),
Roles = ensure_list(maps:get(<<"roles">>, Data, [])),
BasePermissions = base_role_permissions(GuildId, Roles),
MemberRoles = member_role_ids(Member),
Permissions = aggregate_role_permissions(
MemberRoles, Roles, BasePermissions
),
case (Permissions band constants:administrator_permission()) =/= 0 of
true ->
?ALL_PERMISSIONS;
false ->
maybe_apply_channel_overwrites(
Permissions,
UserId,
MemberRoles,
ChannelId,
GuildId,
State
)
end
end
end
end;
compute_member_permissions(_, _, _, _) ->
0.
-spec resolve_member(user_id(), maybe_member(), guild_state()) -> maybe_member().
resolve_member(_UserId, Member, _State) when is_map(Member) ->
Member;
resolve_member(UserId, _Member, State) ->
find_member_by_user_id(UserId, State).
-spec guild_owner_id(guild_data()) -> user_id().
guild_owner_id(Data) ->
Guild = maps:get(<<"guild">>, Data, #{}),
to_int(maps:get(<<"owner_id">>, Guild, <<"0">>)).
-spec guild_id(guild_state()) -> integer().
guild_id(State) ->
case maps:get(id, State, undefined) of
undefined ->
to_int(maps:get(<<"id">>, State, 0));
GuildId when is_integer(GuildId) ->
GuildId;
GuildId ->
to_int(GuildId)
end.
-spec base_role_permissions(role_id(), list()) -> permission().
base_role_permissions(GuildId, Roles) ->
lists:foldl(
fun(Role, Acc) ->
case role_id(Role) =:= GuildId of
true -> role_permissions(Role);
false -> Acc
end
end,
0,
ensure_list(Roles)
).
-spec aggregate_role_permissions(member_roles(), list(), permission()) -> permission().
aggregate_role_permissions(MemberRoles, Roles, BasePermissions) ->
lists:foldl(
fun(RoleId, Acc) ->
case find_role_by_id(RoleId, Roles) of
undefined ->
Acc;
Role ->
Acc bor role_permissions(Role)
end
end,
BasePermissions,
MemberRoles
).
-spec maybe_apply_channel_overwrites(
permission(), user_id(), member_roles(), maybe_channel_id(), role_id(), guild_state()
) -> permission().
maybe_apply_channel_overwrites(Permissions, _UserId, _MemberRoles, undefined, _GuildId, _State) ->
Permissions;
maybe_apply_channel_overwrites(Permissions, UserId, MemberRoles, ChannelId, GuildId, State) when
is_integer(ChannelId)
->
case find_channel_by_id(ChannelId, State) of
undefined ->
Permissions;
Channel ->
apply_channel_overwrites(Permissions, UserId, MemberRoles, Channel, GuildId)
end;
maybe_apply_channel_overwrites(Permissions, _UserId, _MemberRoles, _ChannelId, _GuildId, _State) ->
Permissions.
-spec member_role_ids(member()) -> member_roles().
member_role_ids(Member) ->
RoleIds = maps:get(<<"roles">>, Member, []),
extract_integer_list(RoleIds).
-spec role_permissions(role()) -> permission().
role_permissions(Role) ->
to_int(maps:get(<<"permissions">>, Role, <<"0">>)).
-spec role_id(role()) -> role_id().
role_id(Role) ->
to_int(maps:get(<<"id">>, Role, <<"0">>)).
-spec channel_overwrites(channel()) -> [overwrite()].
channel_overwrites(Channel) ->
case maps:get(<<"permission_overwrites">>, Channel, []) of
Overwrites when is_list(Overwrites) -> Overwrites;
_ -> []
end.
-spec apply_everyone_overwrites(permission(), [overwrite()], role_id()) -> permission().
apply_everyone_overwrites(BasePerms, Overwrites, EveryoneRoleId) ->
lists:foldl(
fun(Overwrite, Acc) ->
case overwrite_matches_role(Overwrite, EveryoneRoleId) of
true ->
apply_allow_deny(Acc, overwrite_allow(Overwrite), overwrite_deny(Overwrite));
false ->
Acc
end
end,
BasePerms,
Overwrites
).
-spec accumulate_role_overwrites(member_roles(), [overwrite()]) -> {permission(), permission()}.
accumulate_role_overwrites(MemberRoles, Overwrites) ->
lists:foldl(
fun(RoleId, {AllowAcc, DenyAcc}) ->
lists:foldl(
fun(Overwrite, {A, D}) ->
case overwrite_matches_role(Overwrite, RoleId) of
true ->
{A bor overwrite_allow(Overwrite), D bor overwrite_deny(Overwrite)};
false ->
{A, D}
end
end,
{AllowAcc, DenyAcc},
Overwrites
)
end,
{0, 0},
MemberRoles
).
-spec apply_user_overwrites(permission(), [overwrite()], user_id()) -> permission().
apply_user_overwrites(Perms, Overwrites, UserId) ->
lists:foldl(
fun(Overwrite, Acc) ->
case overwrite_matches_user(Overwrite, UserId) of
true ->
apply_allow_deny(Acc, overwrite_allow(Overwrite), overwrite_deny(Overwrite));
false ->
Acc
end
end,
Perms,
Overwrites
).
-spec overwrite_matches_role(overwrite(), role_id()) -> boolean().
overwrite_matches_role(Overwrite, RoleId) when is_map(Overwrite), is_integer(RoleId) ->
overwrite_type(Overwrite) =:= 0 andalso overwrite_id(Overwrite) =:= RoleId;
overwrite_matches_role(_, _) ->
false.
-spec overwrite_matches_user(overwrite(), user_id()) -> boolean().
overwrite_matches_user(Overwrite, UserId) when is_map(Overwrite), is_integer(UserId) ->
overwrite_type(Overwrite) =:= 1 andalso overwrite_id(Overwrite) =:= UserId;
overwrite_matches_user(_, _) ->
false.
-spec overwrite_id(overwrite()) -> integer().
overwrite_id(Overwrite) ->
to_int(maps:get(<<"id">>, Overwrite, <<"0">>)).
-spec overwrite_type(overwrite()) -> integer().
overwrite_type(Overwrite) ->
maps:get(<<"type">>, Overwrite, 0).
-spec overwrite_allow(overwrite()) -> permission().
overwrite_allow(Overwrite) ->
to_int(maps:get(<<"allow">>, Overwrite, <<"0">>)).
-spec overwrite_deny(overwrite()) -> permission().
overwrite_deny(Overwrite) ->
to_int(maps:get(<<"deny">>, Overwrite, <<"0">>)).
-spec apply_allow_deny(permission(), permission(), permission()) -> permission().
apply_allow_deny(Acc, Allow, Deny) ->
(Acc band bnot Deny) bor Allow.
-spec extract_integer_list(list()) -> [integer()].
extract_integer_list(List) when is_list(List) ->
lists:reverse(
lists:foldl(
fun(Value, Acc) ->
case type_conv:to_integer(Value) of
undefined -> Acc;
Int -> [Int | Acc]
end
end,
[],
List
)
);
extract_integer_list(_) ->
[].
-spec ensure_list(term()) -> list().
ensure_list(List) when is_list(List) ->
List;
ensure_list(_) ->
[].
-spec to_int(term()) -> integer().
to_int(Value) ->
case type_conv:to_integer(Value) of
undefined -> 0;
Int -> Int
end.
-spec resolve_data_map(guild_state() | map()) -> guild_data() | 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 ->
Data;
error ->
case State of
#{<<"members">> := _} ->
State;
_ ->
undefined
end
end;
resolve_data_map(_) ->
undefined.
-ifdef(TEST).
owner_receives_full_permissions_test() ->
OwnerId = 1,
GuildId = 100,
State = #{
id => GuildId,
data => #{
<<"guild">> => #{<<"owner_id">> => integer_to_binary(OwnerId)},
<<"roles">> => [#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => <<"0">>}]
}
},
?assertEqual(?ALL_PERMISSIONS, get_member_permissions(OwnerId, undefined, State)).
channel_scope_permissions_test() ->
GuildId = 42,
UserId = 600,
ChannelId = 700,
RoleId = 800,
View = constants:view_channel_permission(),
State = #{
id => GuildId,
data => #{
<<"guild">> => #{<<"owner_id">> => integer_to_binary(GuildId + 1)},
<<"roles">> => [
#{<<"id">> => integer_to_binary(GuildId), <<"permissions">> => <<"0">>},
#{<<"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">> => <<"0">>
},
#{
<<"id">> => integer_to_binary(RoleId),
<<"type">> => 0,
<<"allow">> => integer_to_binary(View),
<<"deny">> => <<"0">>
}
]
}
]
}
},
?assertEqual(0, get_member_permissions(UserId, undefined, State)),
ChannelPerms = get_member_permissions(UserId, ChannelId, State),
?assert((ChannelPerms band View) =/= 0).
apply_channel_overwrites_e2e_test() ->
View = constants:view_channel_permission(),
GuildId = 5,
RoleId = 9,
UserId = 11,
Channel = #{
<<"permission_overwrites">> => [
#{
<<"id">> => integer_to_binary(GuildId),
<<"type">> => 0,
<<"allow">> => <<"0">>,
<<"deny">> => integer_to_binary(View)
},
#{
<<"id">> => integer_to_binary(RoleId),
<<"type">> => 0,
<<"allow">> => integer_to_binary(View),
<<"deny">> => <<"0">>
},
#{
<<"id">> => integer_to_binary(UserId),
<<"type">> => 1,
<<"allow">> => <<"0">>,
<<"deny">> => integer_to_binary(View)
}
]
},
Base = View,
Result = apply_channel_overwrites(Base, UserId, [RoleId], Channel, GuildId),
?assertEqual(0, Result).
administrator_role_grants_all_permissions_test() ->
Admin = constants:administrator_permission(),
GuildId = 100,
UserId = 200,
ChannelId = 300,
OwnerId = 999,
State = #{
id => GuildId,
data => #{
<<"guild">> => #{<<"owner_id">> => integer_to_binary(OwnerId)},
<<"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">> => []
}
]
}
},
?assertEqual(?ALL_PERMISSIONS, get_member_permissions(UserId, undefined, State)),
?assertEqual(?ALL_PERMISSIONS, get_member_permissions(UserId, ChannelId, State)),
?assert(can_view_channel(UserId, ChannelId, undefined, State)).
-endif.

View File

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

View File

@@ -0,0 +1,505 @@
%% 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_request_members).
-export([
handle_request/3
]).
-define(CHUNK_SIZE, 1000).
-define(MAX_USER_IDS, 100).
-define(MAX_NONCE_LENGTH, 32).
-type session_state() :: map().
-type request_data() :: map().
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec handle_request(request_data(), pid(), session_state()) -> ok | {error, atom()}.
handle_request(Data, SocketPid, SessionState) when is_map(Data), is_pid(SocketPid) ->
logger:debug("[guild_request_members] Handling guild members request: ~p", [Data]),
case parse_request(Data) of
{ok, Request} ->
logger:debug("[guild_request_members] Request parsed successfully: ~p", [Request]),
process_request(Request, SocketPid, SessionState);
{error, Reason} ->
logger:warning("[guild_request_members] Failed to parse request: ~p", [Reason]),
{error, Reason}
end;
handle_request(_, _, _) ->
{error, invalid_request}.
-spec parse_request(request_data()) -> {ok, map()} | {error, atom()}.
parse_request(Data) ->
GuildIdRaw = maps:get(<<"guild_id">>, Data, undefined),
Query = maps:get(<<"query">>, Data, <<>>),
Limit = maps:get(<<"limit">>, Data, 0),
UserIdsRaw = maps:get(<<"user_ids">>, Data, []),
Presences = maps:get(<<"presences">>, Data, false),
Nonce = maps:get(<<"nonce">>, Data, null),
NormalizedNonce = normalize_nonce(Nonce),
case validate_guild_id(GuildIdRaw) of
{ok, GuildId} ->
case validate_user_ids(UserIdsRaw) of
{ok, UserIds} ->
{ok, #{
guild_id => GuildId,
query => ensure_binary(Query),
limit => ensure_limit(Limit),
user_ids => UserIds,
presences => Presences =:= true,
nonce => NormalizedNonce
}};
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end.
-spec validate_guild_id(term()) -> {ok, integer()} | {error, atom()}.
validate_guild_id(GuildId) when is_integer(GuildId), GuildId > 0 ->
{ok, GuildId};
validate_guild_id(GuildId) when is_binary(GuildId) ->
case validation:validate_snowflake(<<"guild_id">>, GuildId) of
{ok, Id} -> {ok, Id};
{error, _, _} -> {error, invalid_guild_id}
end;
validate_guild_id(_) ->
{error, invalid_guild_id}.
-spec validate_user_ids(term()) -> {ok, [integer()]} | {error, atom()}.
validate_user_ids(UserIds) when is_list(UserIds) ->
case length(UserIds) > ?MAX_USER_IDS of
true ->
{error, too_many_user_ids};
false ->
ParsedIds = lists:filtermap(
fun(Id) ->
case parse_user_id(Id) of
{ok, ParsedId} -> {true, ParsedId};
error -> false
end
end,
UserIds
),
{ok, ParsedIds}
end;
validate_user_ids(_) ->
{ok, []}.
-spec parse_user_id(term()) -> {ok, integer()} | error.
parse_user_id(Id) when is_integer(Id), Id > 0 ->
{ok, Id};
parse_user_id(Id) when is_binary(Id) ->
case type_conv:to_integer(Id) of
undefined -> error;
ParsedId when ParsedId > 0 -> {ok, ParsedId};
_ -> error
end;
parse_user_id(_) ->
error.
-spec ensure_binary(term()) -> binary().
ensure_binary(Value) when is_binary(Value) -> Value;
ensure_binary(_) -> <<>>.
-spec ensure_limit(term()) -> non_neg_integer().
ensure_limit(Limit) when is_integer(Limit), Limit >= 0 -> Limit;
ensure_limit(_) -> 0.
-spec normalize_nonce(term()) -> binary() | null.
normalize_nonce(Nonce) when is_binary(Nonce), byte_size(Nonce) =< ?MAX_NONCE_LENGTH ->
Nonce;
normalize_nonce(_) ->
null.
-spec process_request(map(), pid(), session_state()) -> ok | {error, atom()}.
process_request(Request, SocketPid, SessionState) ->
#{guild_id := GuildId, query := Query, limit := Limit, user_ids := UserIds} = Request,
UserIdBin = maps:get(user_id, SessionState),
UserId = type_conv:to_integer(UserIdBin),
logger:debug(
"[guild_request_members] Processing request for guild ~p, user ~p, user_ids: ~p",
[GuildId, UserId, UserIds]
),
case check_permission(UserId, GuildId, Query, Limit, UserIds, SessionState) of
ok ->
logger:debug("[guild_request_members] Permission check passed, fetching members"),
fetch_and_send_members(Request, SocketPid, SessionState);
{error, Reason} ->
logger:warning(
"[guild_request_members] Permission check failed: ~p",
[Reason]
),
{error, Reason}
end.
-spec check_permission(integer(), integer(), binary(), non_neg_integer(), [integer()], session_state()) ->
ok | {error, atom()}.
check_permission(UserId, GuildId, Query, Limit, UserIds, SessionState) ->
RequiresPermission = Query =:= <<>> andalso Limit =:= 0 andalso UserIds =:= [],
case RequiresPermission of
false ->
ok;
true ->
case lookup_guild(GuildId, SessionState) of
{ok, GuildPid} ->
check_management_permission(UserId, GuildId, GuildPid);
{error, _} ->
{error, guild_not_found}
end
end.
-spec check_management_permission(integer(), integer(), pid()) -> ok | {error, atom()}.
check_management_permission(UserId, _GuildId, GuildPid) ->
ManageRoles = constants:manage_roles_permission(),
KickMembers = constants:kick_members_permission(),
BanMembers = constants:ban_members_permission(),
RequiredPermission = ManageRoles bor KickMembers bor BanMembers,
PermRequest = #{
user_id => UserId,
permission => RequiredPermission,
channel_id => undefined
},
case gen_server:call(GuildPid, {check_permission, PermRequest}, 5000) of
#{has_permission := true} -> ok;
#{has_permission := false} -> {error, missing_permission};
_ -> {error, permission_check_failed}
end.
-spec lookup_guild(integer(), session_state()) -> {ok, pid()} | {error, not_found}.
lookup_guild(GuildId, SessionState) ->
Guilds = maps:get(guilds, SessionState, #{}),
case maps:get(GuildId, Guilds, undefined) of
{Pid, _Ref} when is_pid(Pid) ->
{ok, Pid};
undefined ->
case gen_server:call(guild_manager, {lookup, GuildId}, 5000) of
{ok, Pid} when is_pid(Pid) -> {ok, Pid};
_ -> {error, not_found}
end;
_ ->
{error, not_found}
end.
-spec fetch_and_send_members(map(), pid(), session_state()) -> ok | {error, atom()}.
fetch_and_send_members(Request, _SocketPid, SessionState) ->
#{
guild_id := GuildId,
query := Query,
limit := Limit,
user_ids := UserIds,
presences := Presences,
nonce := Nonce
} = Request,
SessionId = maps:get(session_id, SessionState),
logger:debug(
"[guild_request_members] Looking up guild ~p for member request",
[GuildId]
),
case lookup_guild(GuildId, SessionState) of
{ok, GuildPid} ->
logger:debug("[guild_request_members] Guild ~p found, fetching members", [GuildId]),
Members = fetch_members(GuildPid, Query, Limit, UserIds),
logger:debug("[guild_request_members] Found ~p members", [length(Members)]),
PresencesList = maybe_fetch_presences(Presences, GuildPid, Members),
send_member_chunks(GuildPid, SessionId, Members, PresencesList, Nonce),
logger:debug(
"[guild_request_members] Sent ~p member chunks for guild ~p with nonce ~p",
[max(1, (length(Members) + ?CHUNK_SIZE - 1) div ?CHUNK_SIZE), GuildId, Nonce]
),
ok;
{error, Reason} ->
logger:warning(
"[guild_request_members] Failed to lookup guild ~p: ~p",
[GuildId, Reason]
),
{error, Reason}
end.
-spec fetch_members(pid(), binary(), non_neg_integer(), [integer()]) -> [map()].
fetch_members(GuildPid, _Query, _Limit, UserIds) when UserIds =/= [] ->
logger:debug("[guild_request_members] Fetching members by user_ids: ~p", [UserIds]),
case gen_server:call(GuildPid, {list_guild_members, #{limit => 100000, offset => 0}}, 10000) of
#{members := AllMembers} ->
logger:debug("[guild_request_members] Got ~p members from guild, filtering by user_ids", [length(AllMembers)]),
Filtered = filter_members_by_ids(AllMembers, UserIds),
logger:debug("[guild_request_members] Filtered to ~p members", [length(Filtered)]),
Filtered;
Other ->
logger:warning("[guild_request_members] Unexpected response from guild: ~p", [Other]),
[]
end;
fetch_members(GuildPid, Query, Limit, []) ->
ActualLimit = case Limit of 0 -> 100000; L -> L end,
logger:debug("[guild_request_members] Fetching members with query '~s', limit ~p", [Query, ActualLimit]),
case gen_server:call(GuildPid, {list_guild_members, #{limit => ActualLimit, offset => 0}}, 10000) of
#{members := AllMembers} ->
logger:debug("[guild_request_members] Got ~p members from guild", [length(AllMembers)]),
Result = case Query of
<<>> ->
lists:sublist(AllMembers, ActualLimit);
_ ->
filter_members_by_query(AllMembers, Query, ActualLimit)
end,
logger:debug("[guild_request_members] Returning ~p members after query/filter", [length(Result)]),
Result;
Other ->
logger:warning("[guild_request_members] Unexpected response from guild: ~p", [Other]),
[]
end.
-spec filter_members_by_ids([map()], [integer()]) -> [map()].
filter_members_by_ids(Members, UserIds) ->
UserIdSet = sets:from_list(UserIds),
lists:filter(
fun(Member) ->
UserId = extract_user_id(Member),
UserId =/= undefined andalso sets:is_element(UserId, UserIdSet)
end,
Members
).
-spec filter_members_by_query([map()], binary(), non_neg_integer()) -> [map()].
filter_members_by_query(Members, Query, Limit) ->
NormalizedQuery = string:lowercase(binary_to_list(Query)),
Matches = lists:filter(
fun(Member) ->
DisplayName = get_display_name(Member),
NormalizedName = string:lowercase(binary_to_list(DisplayName)),
lists:prefix(NormalizedQuery, NormalizedName)
end,
Members
),
lists:sublist(Matches, Limit).
-spec get_display_name(map()) -> binary().
get_display_name(Member) when is_map(Member) ->
Nick = maps:get(<<"nick">>, Member, undefined),
case Nick of
undefined -> nick_isundefined(Member);
null -> nick_isundefined(Member);
_ when is_binary(Nick) -> Nick;
_ -> nick_isundefined(Member)
end;
get_display_name(_) ->
<<>>.
nick_isundefined(Member) ->
User = maps:get(<<"user">>, Member, #{}),
GlobalName = maps:get(<<"global_name">>, User, undefined),
case GlobalName of
undefined ->
Username = maps:get(<<"username">>, User, <<>>),
case Username of
null -> <<>>;
undefined -> <<>>;
_ when is_binary(Username) -> Username;
_ -> <<>>
end;
null ->
Username = maps:get(<<"username">>, User, <<>>),
case Username of
null -> <<>>;
undefined -> <<>>;
_ when is_binary(Username) -> Username;
_ -> <<>>
end;
_ when is_binary(GlobalName) -> GlobalName;
_ -> <<>>
end.
-spec extract_user_id(map()) -> integer() | undefined.
extract_user_id(Member) when is_map(Member) ->
User = maps:get(<<"user">>, Member, #{}),
map_utils:get_integer(User, <<"id">>, undefined);
extract_user_id(_) ->
undefined.
-spec maybe_fetch_presences(boolean(), pid(), [map()]) -> [map()].
maybe_fetch_presences(false, _GuildPid, _Members) ->
[];
maybe_fetch_presences(true, _GuildPid, Members) ->
UserIds = lists:filtermap(
fun(Member) ->
case extract_user_id(Member) of
undefined -> false;
UserId -> {true, UserId}
end
end,
Members
),
case UserIds of
[] ->
[];
_ ->
Cached = presence_cache:bulk_get(UserIds),
[P || P <- Cached, presence_visible(P)]
end.
-spec presence_visible(map()) -> boolean().
presence_visible(P) ->
Status = maps:get(<<"status">>, P, <<"offline">>),
Status =/= <<"offline">> andalso Status =/= <<"invisible">>.
-spec send_member_chunks(pid(), binary(), [map()], [map()], term()) -> ok.
send_member_chunks(GuildPid, SessionId, Members, Presences, Nonce) ->
TotalChunks = max(1, (length(Members) + ?CHUNK_SIZE - 1) div ?CHUNK_SIZE),
MemberChunks = chunk_list(Members, ?CHUNK_SIZE),
PresenceChunks = chunk_presences(Presences, MemberChunks),
logger:debug(
"[guild_request_members] Sending ~p member chunks (total members: ~p, nonce: ~p)",
[TotalChunks, length(Members), Nonce]
),
lists:foldl(
fun({MemberChunk, PresenceChunk}, ChunkIndex) ->
ChunkData = build_chunk_data(
MemberChunk, PresenceChunk, ChunkIndex, TotalChunks, Nonce
),
logger:debug(
"[guild_request_members] Sending chunk ~p/~p with ~p members, nonce: ~p",
[ChunkIndex + 1, TotalChunks, length(MemberChunk), Nonce]
),
gen_server:cast(GuildPid, {send_members_chunk, SessionId, ChunkData}),
ChunkIndex + 1
end,
0,
lists:zip(MemberChunks, PresenceChunks)
),
logger:debug("[guild_request_members] All chunks sent successfully"),
ok.
-spec build_chunk_data([map()], [map()], non_neg_integer(), non_neg_integer(), term()) ->
map().
build_chunk_data(Members, Presences, ChunkIndex, TotalChunks, Nonce) ->
Base = #{
<<"members">> => Members,
<<"chunk_index">> => ChunkIndex,
<<"chunk_count">> => TotalChunks
},
WithPresences = case Presences of
[] -> Base;
_ -> maps:put(<<"presences">>, Presences, Base)
end,
WithNonce = case Nonce of
null -> WithPresences;
_ -> maps:put(<<"nonce">>, Nonce, WithPresences)
end,
WithNonce.
-spec chunk_list([T], pos_integer()) -> [[T]] when T :: term().
chunk_list([], _Size) ->
[[]];
chunk_list(List, Size) ->
chunk_list(List, Size, []).
chunk_list([], _Size, Acc) ->
lists:reverse(Acc);
chunk_list(List, Size, Acc) ->
{Chunk, Rest} = lists:split(min(Size, length(List)), List),
chunk_list(Rest, Size, [Chunk | Acc]).
-spec chunk_presences([map()], [[map()]]) -> [[map()]].
chunk_presences(Presences, MemberChunks) ->
lists:map(
fun(MemberChunk) ->
ChunkUserIds = sets:from_list([extract_user_id(M) || M <- MemberChunk]),
lists:filter(
fun(Presence) ->
User = maps:get(<<"user">>, Presence, #{}),
UserId = map_utils:get_integer(User, <<"id">>, undefined),
UserId =/= undefined andalso sets:is_element(UserId, ChunkUserIds)
end,
Presences
)
end,
MemberChunks
).
-ifdef(TEST).
parse_request_valid_test() ->
Data = #{
<<"guild_id">> => <<"123456789">>,
<<"query">> => <<"test">>,
<<"limit">> => 10,
<<"presences">> => true,
<<"nonce">> => <<"abc123">>
},
{ok, Request} = parse_request(Data),
?assertEqual(123456789, maps:get(guild_id, Request)),
?assertEqual(<<"test">>, maps:get(query, Request)),
?assertEqual(10, maps:get(limit, Request)),
?assertEqual(true, maps:get(presences, Request)),
?assertEqual(<<"abc123">>, maps:get(nonce, Request)).
parse_request_with_user_ids_test() ->
Data = #{
<<"guild_id">> => <<"123">>,
<<"user_ids">> => [<<"1">>, <<"2">>, <<"3">>]
},
{ok, Request} = parse_request(Data),
?assertEqual([1, 2, 3], maps:get(user_ids, Request)).
parse_request_invalid_guild_id_test() ->
Data = #{<<"guild_id">> => <<"invalid">>},
{error, invalid_guild_id} = parse_request(Data).
chunk_list_test() ->
?assertEqual([[1, 2], [3, 4], [5]], chunk_list([1, 2, 3, 4, 5], 2)),
?assertEqual([[1, 2, 3]], chunk_list([1, 2, 3], 5)),
?assertEqual([[]], chunk_list([], 5)).
filter_members_by_query_test() ->
Members = [
#{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>}},
#{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"bob">>}},
#{<<"user">> => #{<<"id">> => <<"3">>, <<"username">> => <<"alicia">>}}
],
Results = filter_members_by_query(Members, <<"ali">>, 10),
?assertEqual(2, length(Results)).
display_name_priority_test() ->
MemberWithNick = #{
<<"user">> => #{<<"username">> => <<"user">>, <<"global_name">> => <<"Global">>},
<<"nick">> => <<"Nick">>
},
?assertEqual(<<"Nick">>, get_display_name(MemberWithNick)),
MemberWithGlobal = #{
<<"user">> => #{<<"username">> => <<"user">>, <<"global_name">> => <<"Global">>}
},
?assertEqual(<<"Global">>, get_display_name(MemberWithGlobal)),
MemberWithUsername = #{
<<"user">> => #{<<"username">> => <<"user">>}
},
?assertEqual(<<"user">>, get_display_name(MemberWithUsername)).
-endif.

View File

@@ -0,0 +1,348 @@
%% 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_sessions).
-export([
handle_session_connect/3,
handle_session_down/2,
filter_sessions_for_channel/4,
filter_sessions_for_manage_channels/4,
filter_sessions_exclude_session/2,
handle_user_offline/2,
set_session_active_guild/3,
set_session_passive_guild/3,
build_initial_last_message_ids/1,
is_session_active/2,
subscribe_to_user_presence/2,
unsubscribe_from_user_presence/2
]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-import(guild_permissions, [can_view_channel/4, can_manage_channel/3, find_member_by_user_id/2]).
-import(guild_data, [get_guild_state/2]).
-import(guild_availability, [is_guild_unavailable_for_user/2]).
handle_session_connect(Request, Pid, State) ->
#{session_id := SessionId, user_id := UserId} = Request,
Sessions = maps:get(sessions, State, #{}),
ActiveGuilds = maps:get(active_guilds, Request, sets:new()),
InitialGuildId = maps:get(initial_guild_id, Request, undefined),
UserRoles = session_passive:get_user_roles_for_guild(UserId, State),
Bot = maps:get(bot, Request, false),
GuildId = maps:get(id, State),
case maps:is_key(SessionId, Sessions) of
true ->
{reply, {ok, get_guild_state(UserId, State)}, State};
false ->
Ref = monitor(process, Pid),
GuildState = get_guild_state(UserId, State),
InitialLastMessageIds = build_initial_last_message_ids(GuildState),
SessionData = #{
session_id => SessionId,
user_id => UserId,
pid => Pid,
mref => Ref,
active_guilds => ActiveGuilds,
user_roles => UserRoles,
bot => Bot,
previous_passive_updates => InitialLastMessageIds
},
NewSessions = maps:put(SessionId, SessionData, Sessions),
State1 = maps:put(sessions, NewSessions, State),
State2 = subscribe_to_user_presence(UserId, State1),
case is_guild_unavailable_for_user(UserId, State2) of
true ->
GuildId = maps:get(id, State2),
UnavailableResponse = #{
<<"id">> => integer_to_binary(GuildId),
<<"unavailable">> => true
},
{reply, {ok, unavailable, UnavailableResponse}, State2};
false ->
SyncedState = maybe_auto_sync_initial_guild(
SessionId,
GuildId,
InitialGuildId,
State2
),
{reply, {ok, GuildState}, SyncedState}
end
end.
build_initial_last_message_ids(GuildState) ->
Channels = maps:get(<<"channels">>, GuildState, []),
lists:foldl(
fun(Channel, Acc) ->
ChannelIdBin = maps:get(<<"id">>, Channel, undefined),
LastMessageId = maps:get(<<"last_message_id">>, Channel, null),
case {ChannelIdBin, LastMessageId} of
{undefined, _} -> Acc;
{_, null} -> Acc;
_ -> maps:put(ChannelIdBin, LastMessageId, Acc)
end
end,
#{},
Channels
).
handle_session_down(Ref, State) ->
Sessions = maps:get(sessions, State, #{}),
DisconnectingSession = maps:fold(
fun(_K, S, Acc) ->
case maps:get(mref, S) =:= Ref of
true -> S;
false -> Acc
end
end,
undefined,
Sessions
),
State1 =
case DisconnectingSession of
undefined ->
State;
Session ->
UserId = maps:get(user_id, Session),
SessionId = maps:get(session_id, Session),
StateAfterPresence = unsubscribe_from_user_presence(UserId, State),
StateAfterMemberList = guild_member_list:unsubscribe_session(
SessionId, StateAfterPresence
),
MemberSubs = maps:get(
member_subscriptions, StateAfterMemberList, guild_subscriptions:init_state()
),
NewMemberSubs = guild_subscriptions:unsubscribe_session(SessionId, MemberSubs),
maps:put(member_subscriptions, NewMemberSubs, StateAfterMemberList)
end,
NewSessions = maps:filter(fun(_K, S) -> maps:get(mref, S) =/= Ref end, Sessions),
NewState = maps:put(sessions, NewSessions, State1),
case map_size(NewSessions) of
0 ->
{stop, normal, NewState};
_ ->
{noreply, NewState}
end.
filter_sessions_for_channel(Sessions, ChannelId, SessionIdOpt, State) ->
GuildId = maps:get(id, State, 0),
lists:filter(
fun({Sid, S}) ->
UserId = maps:get(user_id, S),
Member = find_member_by_user_id(UserId, State),
ExcludeSession =
case SessionIdOpt of
undefined -> false;
SessionId -> Sid =:= SessionId
end,
case {ExcludeSession, Member} of
{true, _} ->
false;
{_, undefined} ->
logger:warning(
"[guild_sessions] Filtering out session with no member: "
"guild_id=~p session_id=~p user_id=~p",
[GuildId, Sid, UserId]
),
false;
{false, _} ->
can_view_channel(UserId, ChannelId, Member, State)
end
end,
maps:to_list(Sessions)
).
filter_sessions_for_manage_channels(Sessions, ChannelId, SessionIdOpt, State) ->
lists:filter(
fun({Sid, S}) ->
UserId = maps:get(user_id, S),
ExcludeSession =
case SessionIdOpt of
undefined -> false;
SessionId -> Sid =:= SessionId
end,
case ExcludeSession of
true ->
false;
false ->
can_manage_channel(UserId, ChannelId, State)
end
end,
maps:to_list(Sessions)
).
filter_sessions_exclude_session(Sessions, SessionIdOpt) ->
case SessionIdOpt of
undefined ->
maps:to_list(Sessions);
SessionId ->
[{Sid, S} || {Sid, S} <- maps:to_list(Sessions), Sid =/= SessionId]
end.
subscribe_to_user_presence(UserId, State) ->
PresenceSubs = maps:get(presence_subscriptions, State, #{}),
CurrentCount = maps:get(UserId, PresenceSubs, 0),
case CurrentCount of
0 ->
presence_bus:subscribe(UserId),
NewSubs = maps:put(UserId, 1, PresenceSubs),
StateWithSubs = maps:put(presence_subscriptions, NewSubs, State),
maybe_send_cached_presence(UserId, StateWithSubs);
_ ->
NewSubs = maps:put(UserId, CurrentCount + 1, PresenceSubs),
maps:put(presence_subscriptions, NewSubs, State)
end.
unsubscribe_from_user_presence(UserId, State) ->
PresenceSubs = maps:get(presence_subscriptions, State, #{}),
CurrentCount = maps:get(UserId, PresenceSubs, 0),
case CurrentCount of
0 ->
State;
1 ->
NewSubs = maps:put(UserId, 0, PresenceSubs),
maps:put(presence_subscriptions, NewSubs, State);
_ ->
NewSubs = maps:put(UserId, CurrentCount - 1, PresenceSubs),
maps:put(presence_subscriptions, NewSubs, State)
end.
handle_user_offline(UserId, State) ->
PresenceSubs = maps:get(presence_subscriptions, State, #{}),
case maps:get(UserId, PresenceSubs, undefined) of
0 ->
presence_bus:unsubscribe(UserId),
NewSubs = maps:remove(UserId, PresenceSubs),
maps:put(presence_subscriptions, NewSubs, State);
undefined ->
State;
_ ->
State
end.
maybe_send_cached_presence(UserId, State) ->
case presence_cache:get(UserId) of
{ok, Payload} ->
case guild_presence:handle_bus_presence(UserId, Payload, State) of
{noreply, UpdatedState} ->
UpdatedState
end;
_ ->
State
end.
set_session_active_guild(SessionId, GuildId, State) ->
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
State;
SessionData ->
NewSessionData = session_passive:set_active(GuildId, SessionData),
NewSessions = maps:put(SessionId, NewSessionData, Sessions),
maps:put(sessions, NewSessions, State)
end.
set_session_passive_guild(SessionId, GuildId, State) ->
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
State;
SessionData ->
NewSessionData = session_passive:set_passive(GuildId, SessionData),
NewSessionData2 = session_passive:clear_guild_synced(GuildId, NewSessionData),
NewSessions = maps:put(SessionId, NewSessionData2, Sessions),
maps:put(sessions, NewSessions, State)
end.
is_session_active(SessionId, State) ->
GuildId = maps:get(id, State, 0),
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
false;
SessionData ->
not session_passive:is_passive(GuildId, SessionData)
end.
maybe_auto_sync_initial_guild(SessionId, GuildId, InitialGuildId, State) ->
case InitialGuildId of
GuildId ->
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
State;
SessionData ->
SyncedSessionData = session_passive:mark_guild_synced(GuildId, SessionData),
NewSessions = maps:put(SessionId, SyncedSessionData, Sessions),
maps:put(sessions, NewSessions, State)
end;
_ ->
State
end.
-ifdef(TEST).
build_initial_last_message_ids_empty_channels_test() ->
GuildState = #{<<"channels">> => []},
Result = build_initial_last_message_ids(GuildState),
?assertEqual(#{}, Result),
ok.
build_initial_last_message_ids_with_channels_test() ->
GuildState = #{
<<"channels">> => [
#{<<"id">> => <<"100">>, <<"last_message_id">> => <<"500">>},
#{<<"id">> => <<"101">>, <<"last_message_id">> => <<"600">>}
]
},
Result = build_initial_last_message_ids(GuildState),
?assertEqual(#{<<"100">> => <<"500">>, <<"101">> => <<"600">>}, Result),
ok.
build_initial_last_message_ids_filters_null_test() ->
GuildState = #{
<<"channels">> => [
#{<<"id">> => <<"100">>, <<"last_message_id">> => <<"500">>},
#{<<"id">> => <<"101">>, <<"last_message_id">> => null},
#{<<"id">> => <<"102">>}
]
},
Result = build_initial_last_message_ids(GuildState),
?assertEqual(#{<<"100">> => <<"500">>}, Result),
ok.
build_initial_last_message_ids_no_channels_key_test() ->
GuildState = #{},
Result = build_initial_last_message_ids(GuildState),
?assertEqual(#{}, Result),
ok.
-endif.

View File

@@ -0,0 +1,466 @@
%% 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_state).
-export([
update_state/3
]).
-import(guild_user_data, [maybe_update_cached_user_data/3]).
-import(guild_availability, [handle_unavailability_transition/2]).
-import(guild_visibility, [compute_and_dispatch_visibility_changes/2]).
-import(guild, [update_counts/1]).
update_state(Event, EventData, State) ->
StateWithUpdatedUser = maybe_update_cached_user_data(Event, EventData, State),
Data = maps:get(data, StateWithUpdatedUser),
UpdatedData = update_data_for_event(Event, EventData, Data, State),
UpdatedState = maps:put(data, UpdatedData, StateWithUpdatedUser),
handle_post_update(Event, StateWithUpdatedUser, UpdatedState).
update_data_for_event(guild_update, EventData, Data, _State) ->
handle_guild_update(EventData, Data);
update_data_for_event(guild_member_add, EventData, Data, _State) ->
handle_member_add(EventData, Data);
update_data_for_event(guild_member_update, EventData, Data, _State) ->
handle_member_update(EventData, Data);
update_data_for_event(guild_member_remove, EventData, Data, State) ->
handle_member_remove(EventData, Data, State);
update_data_for_event(guild_role_create, EventData, Data, _State) ->
handle_role_create(EventData, Data);
update_data_for_event(guild_role_update, EventData, Data, _State) ->
handle_role_update(EventData, Data);
update_data_for_event(guild_role_update_bulk, EventData, Data, _State) ->
handle_role_update_bulk(EventData, Data);
update_data_for_event(guild_role_delete, EventData, Data, _State) ->
handle_role_delete(EventData, Data);
update_data_for_event(channel_create, EventData, Data, _State) ->
handle_channel_create(EventData, Data);
update_data_for_event(channel_update, EventData, Data, _State) ->
handle_channel_update(EventData, Data);
update_data_for_event(channel_update_bulk, EventData, Data, _State) ->
handle_channel_update_bulk(EventData, Data);
update_data_for_event(channel_delete, EventData, Data, _State) ->
handle_channel_delete(EventData, Data);
update_data_for_event(message_create, EventData, Data, _State) ->
handle_message_create(EventData, Data);
update_data_for_event(channel_pins_update, EventData, Data, _State) ->
handle_channel_pins_update(EventData, Data);
update_data_for_event(guild_emojis_update, EventData, Data, _State) ->
handle_emojis_update(EventData, Data);
update_data_for_event(guild_stickers_update, EventData, Data, _State) ->
handle_stickers_update(EventData, Data);
update_data_for_event(_Event, _EventData, Data, _State) ->
Data.
handle_post_update(guild_update, StateWithUpdatedUser, UpdatedState) ->
handle_unavailability_transition(StateWithUpdatedUser, UpdatedState),
UpdatedState;
handle_post_update(guild_member_add, _StateWithUpdatedUser, UpdatedState) ->
update_counts(UpdatedState);
handle_post_update(guild_member_remove, _StateWithUpdatedUser, UpdatedState) ->
State1 = cleanup_removed_member_sessions(UpdatedState),
update_counts(State1);
handle_post_update(Event, StateWithUpdatedUser, UpdatedState) ->
case needs_visibility_check(Event) of
true ->
compute_and_dispatch_visibility_changes(StateWithUpdatedUser, UpdatedState),
UpdatedState;
false ->
UpdatedState
end.
needs_visibility_check(guild_role_create) -> true;
needs_visibility_check(guild_role_update) -> true;
needs_visibility_check(guild_role_update_bulk) -> true;
needs_visibility_check(guild_role_delete) -> true;
needs_visibility_check(guild_member_update) -> true;
needs_visibility_check(channel_update) -> true;
needs_visibility_check(channel_update_bulk) -> true;
needs_visibility_check(_) -> false.
handle_guild_update(EventData, Data) ->
Guild = maps:get(<<"guild">>, Data),
UpdatedGuild = maps:merge(Guild, EventData),
maps:put(<<"guild">>, UpdatedGuild, Data).
handle_member_add(EventData, Data) ->
Members = maps:get(<<"members">>, Data, []),
UpdatedData = maps:put(<<"members">>, Members ++ [EventData], Data),
UpdatedData.
handle_member_update(EventData, Data) ->
Members = maps:get(<<"members">>, Data, []),
UserId = extract_user_id(EventData),
UpdatedMembers = replace_member_by_id(Members, UserId, EventData),
maps:put(<<"members">>, UpdatedMembers, Data).
handle_member_remove(EventData, Data, _State) ->
Members = maps:get(<<"members">>, Data, []),
UserId = extract_user_id(EventData),
FilteredMembers = remove_member_by_id(Members, UserId),
maps:put(<<"members">>, FilteredMembers, Data).
cleanup_removed_member_sessions(State) ->
Data = maps:get(data, State),
Members = maps:get(<<"members">>, Data, []),
MemberUserIds = sets:from_list([extract_user_id_from_member(M) || M <- Members]),
Sessions = maps:get(sessions, State, #{}),
FilteredSessions = maps:filter(
fun(_K, S) ->
UserId = maps:get(user_id, S),
sets:is_element(UserId, MemberUserIds)
end,
Sessions
),
Presences = maps:get(presences, State, #{}),
FilteredPresences = maps:filter(
fun(UserId, _V) ->
sets:is_element(UserId, MemberUserIds)
end,
Presences
),
State1 = maps:put(sessions, FilteredSessions, State),
maps:put(presences, FilteredPresences, State1).
extract_user_id_from_member(Member) when is_map(Member) ->
MUser = maps:get(<<"user">>, Member, #{}),
utils:binary_to_integer_safe(maps:get(<<"id">>, MUser, <<"0">>));
extract_user_id_from_member(_) ->
0.
extract_user_id(EventData) ->
MUser = maps:get(<<"user">>, EventData, #{}),
utils:binary_to_integer_safe(maps:get(<<"id">>, MUser, <<"0">>)).
replace_member_by_id(Members, UserId, NewMember) ->
lists:map(
fun(M) when is_map(M) ->
MMUser = maps:get(<<"user">>, M, #{}),
MUserId = utils:binary_to_integer_safe(maps:get(<<"id">>, MMUser, <<"0">>)),
case MUserId =:= UserId of
true -> NewMember;
false -> M
end
end,
Members
).
remove_member_by_id(Members, UserId) ->
lists:filter(
fun(M) when is_map(M) ->
MMUser = maps:get(<<"user">>, M, #{}),
MUserId = utils:binary_to_integer_safe(maps:get(<<"id">>, MMUser, <<"0">>)),
MUserId =/= UserId
end,
Members
).
handle_role_create(EventData, Data) ->
Roles = maps:get(<<"roles">>, Data, []),
RoleData = maps:get(<<"role">>, EventData),
maps:put(<<"roles">>, Roles ++ [RoleData], Data).
handle_role_update(EventData, Data) ->
Roles = maps:get(<<"roles">>, Data, []),
RoleData = maps:get(<<"role">>, EventData),
RoleId = maps:get(<<"id">>, RoleData),
UpdatedRoles = replace_item_by_id(Roles, RoleId, RoleData),
maps:put(<<"roles">>, UpdatedRoles, Data).
handle_role_update_bulk(EventData, Data) ->
Roles = maps:get(<<"roles">>, Data, []),
BulkRoles = maps:get(<<"roles">>, EventData, []),
UpdatedRoles = bulk_update_items(Roles, BulkRoles),
maps:put(<<"roles">>, UpdatedRoles, Data).
handle_role_delete(EventData, Data) ->
Roles = maps:get(<<"roles">>, Data, []),
RoleId = maps:get(<<"role_id">>, EventData),
FilteredRoles = remove_item_by_id(Roles, RoleId),
Data1 = maps:put(<<"roles">>, FilteredRoles, Data),
Data2 = strip_role_from_members(RoleId, Data1),
strip_role_from_channel_overwrites(RoleId, Data2).
strip_role_from_members(RoleId, Data) ->
Members = maps:get(<<"members">>, Data, []),
UpdatedMembers = lists:map(
fun(Member) when is_map(Member) ->
MemberRoles = maps:get(<<"roles">>, Member, []),
FilteredRoles = lists:filter(
fun(R) ->
RoleIdInt = utils:binary_to_integer_safe(RoleId),
RInt = utils:binary_to_integer_safe(R),
RInt =/= RoleIdInt
end,
MemberRoles
),
maps:put(<<"roles">>, FilteredRoles, Member);
(Member) ->
Member
end,
Members
),
maps:put(<<"members">>, UpdatedMembers, Data).
strip_role_from_channel_overwrites(RoleId, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
RoleIdInt = utils:binary_to_integer_safe(RoleId),
UpdatedChannels = lists:map(
fun(Channel) when is_map(Channel) ->
Overwrites = maps:get(<<"permission_overwrites">>, Channel, []),
FilteredOverwrites = lists:filter(
fun(Overwrite) when is_map(Overwrite) ->
OverwriteType = maps:get(<<"type">>, Overwrite, 0),
OverwriteId = utils:binary_to_integer_safe(maps:get(<<"id">>, Overwrite, <<"0">>)),
not (OverwriteType =:= 0 andalso OverwriteId =:= RoleIdInt);
(_) ->
true
end,
Overwrites
),
maps:put(<<"permission_overwrites">>, FilteredOverwrites, Channel);
(Channel) ->
Channel
end,
Channels
),
maps:put(<<"channels">>, UpdatedChannels, Data).
handle_channel_create(EventData, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
maps:put(<<"channels">>, Channels ++ [EventData], Data).
handle_channel_update(EventData, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
ChannelId = maps:get(<<"id">>, EventData),
UpdatedChannels = replace_item_by_id(Channels, ChannelId, EventData),
maps:put(<<"channels">>, UpdatedChannels, Data).
handle_channel_update_bulk(EventData, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
BulkChannels = maps:get(<<"channels">>, EventData, []),
UpdatedChannels = bulk_update_items(Channels, BulkChannels),
maps:put(<<"channels">>, UpdatedChannels, Data).
handle_channel_delete(EventData, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
ChannelId = maps:get(<<"id">>, EventData),
FilteredChannels = remove_item_by_id(Channels, ChannelId),
maps:put(<<"channels">>, FilteredChannels, Data).
handle_message_create(EventData, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
ChannelId = maps:get(<<"channel_id">>, EventData),
MessageId = maps:get(<<"id">>, EventData),
UpdatedChannels = update_channel_field(Channels, ChannelId, <<"last_message_id">>, MessageId),
maps:put(<<"channels">>, UpdatedChannels, Data).
handle_channel_pins_update(EventData, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
ChannelId = maps:get(<<"channel_id">>, EventData),
LastPin = maps:get(<<"last_pin_timestamp">>, EventData),
UpdatedChannels = update_channel_field(Channels, ChannelId, <<"last_pin_timestamp">>, LastPin),
maps:put(<<"channels">>, UpdatedChannels, Data).
update_channel_field(Channels, ChannelId, Field, Value) ->
lists:map(
fun(C) when is_map(C) ->
case maps:get(<<"id">>, C) =:= ChannelId of
true -> maps:put(Field, Value, C);
false -> C
end
end,
Channels
).
handle_emojis_update(EventData, Data) ->
maps:put(<<"emojis">>, maps:get(<<"emojis">>, EventData, []), Data).
handle_stickers_update(EventData, Data) ->
maps:put(<<"stickers">>, maps:get(<<"stickers">>, EventData, []), Data).
replace_item_by_id(Items, Id, NewItem) ->
lists:map(
fun(Item) when is_map(Item) ->
case maps:get(<<"id">>, Item) of
Id -> NewItem;
_ -> Item
end
end,
Items
).
remove_item_by_id(Items, Id) ->
lists:filter(
fun(Item) when is_map(Item) ->
maps:get(<<"id">>, Item) =/= Id
end,
Items
).
bulk_update_items(Items, BulkItems) ->
BulkMap = lists:foldl(
fun
(Item, Acc) when is_map(Item) ->
case maps:get(<<"id">>, Item, undefined) of
undefined -> Acc;
ItemId -> maps:put(ItemId, Item, Acc)
end;
(_, Acc) ->
Acc
end,
#{},
BulkItems
),
lists:map(
fun
(Item) when is_map(Item) ->
ItemId = maps:get(<<"id">>, Item, undefined),
case maps:get(ItemId, BulkMap, undefined) of
undefined -> Item;
UpdatedItem -> UpdatedItem
end;
(Item) ->
Item
end,
Items
).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
handle_role_delete_strips_from_members_test() ->
RoleIdToDelete = <<"200">>,
Data = #{
<<"roles">> => [
#{<<"id">> => <<"100">>, <<"name">> => <<"Admin">>},
#{<<"id">> => <<"200">>, <<"name">> => <<"Moderator">>}
],
<<"members">> => [
#{
<<"user">> => #{<<"id">> => <<"1">>},
<<"roles">> => [<<"100">>, <<"200">>]
},
#{
<<"user">> => #{<<"id">> => <<"2">>},
<<"roles">> => [<<"200">>]
},
#{
<<"user">> => #{<<"id">> => <<"3">>},
<<"roles">> => [<<"100">>]
}
],
<<"channels">> => []
},
EventData = #{<<"role_id">> => RoleIdToDelete},
Result = handle_role_delete(EventData, Data),
Members = maps:get(<<"members">>, Result),
[M1, M2, M3] = Members,
?assertEqual([<<"100">>], maps:get(<<"roles">>, M1)),
?assertEqual([], maps:get(<<"roles">>, M2)),
?assertEqual([<<"100">>], maps:get(<<"roles">>, M3)).
handle_role_delete_strips_from_channel_overwrites_test() ->
RoleIdToDelete = <<"200">>,
Data = #{
<<"roles">> => [
#{<<"id">> => <<"100">>, <<"name">> => <<"Everyone">>},
#{<<"id">> => <<"200">>, <<"name">> => <<"Moderator">>}
],
<<"members">> => [],
<<"channels">> => [
#{
<<"id">> => <<"500">>,
<<"permission_overwrites">> => [
#{<<"id">> => <<"100">>, <<"type">> => 0, <<"allow">> => <<"0">>, <<"deny">> => <<"1024">>},
#{<<"id">> => <<"200">>, <<"type">> => 0, <<"allow">> => <<"1024">>, <<"deny">> => <<"0">>},
#{<<"id">> => <<"1">>, <<"type">> => 1, <<"allow">> => <<"2048">>, <<"deny">> => <<"0">>}
]
},
#{
<<"id">> => <<"501">>,
<<"permission_overwrites">> => [
#{<<"id">> => <<"200">>, <<"type">> => 0, <<"allow">> => <<"1024">>, <<"deny">> => <<"0">>}
]
}
]
},
EventData = #{<<"role_id">> => RoleIdToDelete},
Result = handle_role_delete(EventData, Data),
Channels = maps:get(<<"channels">>, Result),
[Ch1, Ch2] = Channels,
Ch1Overwrites = maps:get(<<"permission_overwrites">>, Ch1),
Ch2Overwrites = maps:get(<<"permission_overwrites">>, Ch2),
?assertEqual(2, length(Ch1Overwrites)),
?assertEqual(0, length(Ch2Overwrites)),
OverwriteIds = [maps:get(<<"id">>, O) || O <- Ch1Overwrites],
?assert(lists:member(<<"100">>, OverwriteIds)),
?assert(lists:member(<<"1">>, OverwriteIds)),
?assertNot(lists:member(<<"200">>, OverwriteIds)).
handle_role_delete_preserves_user_overwrites_test() ->
RoleIdToDelete = <<"200">>,
Data = #{
<<"roles">> => [
#{<<"id">> => <<"200">>, <<"name">> => <<"Moderator">>}
],
<<"members">> => [],
<<"channels">> => [
#{
<<"id">> => <<"500">>,
<<"permission_overwrites">> => [
#{<<"id">> => <<"200">>, <<"type">> => 0, <<"allow">> => <<"1024">>, <<"deny">> => <<"0">>},
#{<<"id">> => <<"200">>, <<"type">> => 1, <<"allow">> => <<"2048">>, <<"deny">> => <<"0">>}
]
}
]
},
EventData = #{<<"role_id">> => RoleIdToDelete},
Result = handle_role_delete(EventData, Data),
Channels = maps:get(<<"channels">>, Result),
[Ch1] = Channels,
Overwrites = maps:get(<<"permission_overwrites">>, Ch1),
?assertEqual(1, length(Overwrites)),
[RemainingOverwrite] = Overwrites,
?assertEqual(1, maps:get(<<"type">>, RemainingOverwrite)).
handle_role_delete_removes_role_from_roles_list_test() ->
RoleIdToDelete = <<"200">>,
Data = #{
<<"roles">> => [
#{<<"id">> => <<"100">>, <<"name">> => <<"Admin">>},
#{<<"id">> => <<"200">>, <<"name">> => <<"Moderator">>}
],
<<"members">> => [],
<<"channels">> => []
},
EventData = #{<<"role_id">> => RoleIdToDelete},
Result = handle_role_delete(EventData, Data),
Roles = maps:get(<<"roles">>, Result),
?assertEqual(1, length(Roles)),
[RemainingRole] = Roles,
?assertEqual(<<"100">>, maps:get(<<"id">>, RemainingRole)).
-endif.

View File

@@ -0,0 +1,185 @@
%% 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_subscriptions).
-export([
init_state/0,
subscribe/3,
unsubscribe/3,
unsubscribe_session/2,
update_subscriptions/3,
get_subscribed_sessions/2,
is_subscribed/3,
get_user_ids_for_session/2
]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-type session_id() :: binary().
-type user_id() :: integer().
-type subscription_state() :: #{user_id() => sets:set(session_id())}.
-spec init_state() -> subscription_state().
init_state() -> #{}.
-spec subscribe(session_id(), user_id(), subscription_state()) -> subscription_state().
subscribe(SessionId, UserId, State) ->
Subscribers = maps:get(UserId, State, sets:new()),
NewSubscribers = sets:add_element(SessionId, Subscribers),
maps:put(UserId, NewSubscribers, State).
-spec unsubscribe(session_id(), user_id(), subscription_state()) -> subscription_state().
unsubscribe(SessionId, UserId, State) ->
case maps:get(UserId, State, undefined) of
undefined ->
State;
Subscribers ->
NewSubscribers = sets:del_element(SessionId, Subscribers),
case sets:size(NewSubscribers) of
0 -> maps:remove(UserId, State);
_ -> maps:put(UserId, NewSubscribers, State)
end
end.
-spec unsubscribe_session(session_id(), subscription_state()) -> subscription_state().
unsubscribe_session(SessionId, State) ->
maps:fold(
fun(UserId, Subscribers, Acc) ->
NewSubscribers = sets:del_element(SessionId, Subscribers),
case sets:size(NewSubscribers) of
0 -> Acc;
_ -> maps:put(UserId, NewSubscribers, Acc)
end
end,
#{},
State
).
-spec update_subscriptions(session_id(), [user_id()], subscription_state()) ->
subscription_state().
update_subscriptions(SessionId, NewMemberIds, State) ->
CurrentlySubscribed = get_user_ids_for_session(SessionId, State),
NewMemberIdSet = sets:from_list(NewMemberIds),
ToRemove = sets:subtract(CurrentlySubscribed, NewMemberIdSet),
ToAdd = sets:subtract(NewMemberIdSet, CurrentlySubscribed),
State1 = sets:fold(
fun(UserId, AccState) ->
unsubscribe(SessionId, UserId, AccState)
end,
State,
ToRemove
),
sets:fold(
fun(UserId, AccState) ->
subscribe(SessionId, UserId, AccState)
end,
State1,
ToAdd
).
-spec get_subscribed_sessions(user_id(), subscription_state()) -> [session_id()].
get_subscribed_sessions(UserId, State) ->
case maps:get(UserId, State, undefined) of
undefined -> [];
Subscribers -> sets:to_list(Subscribers)
end.
-spec is_subscribed(session_id(), user_id(), subscription_state()) -> boolean().
is_subscribed(SessionId, UserId, State) ->
case maps:get(UserId, State, undefined) of
undefined -> false;
Subscribers -> sets:is_element(SessionId, Subscribers)
end.
-spec get_user_ids_for_session(session_id(), subscription_state()) -> sets:set(user_id()).
get_user_ids_for_session(SessionId, State) ->
maps:fold(
fun(UserId, Subscribers, Acc) ->
case sets:is_element(SessionId, Subscribers) of
true -> sets:add_element(UserId, Acc);
false -> Acc
end
end,
sets:new(),
State
).
-ifdef(TEST).
init_state_test() ->
?assertEqual(#{}, init_state()).
subscribe_test() ->
State0 = init_state(),
State1 = subscribe(<<"session1">>, 123, State0),
?assert(is_subscribed(<<"session1">>, 123, State1)),
?assertNot(is_subscribed(<<"session2">>, 123, State1)).
subscribe_multiple_sessions_test() ->
State0 = init_state(),
State1 = subscribe(<<"session1">>, 123, State0),
State2 = subscribe(<<"session2">>, 123, State1),
?assert(is_subscribed(<<"session1">>, 123, State2)),
?assert(is_subscribed(<<"session2">>, 123, State2)).
unsubscribe_test() ->
State0 = init_state(),
State1 = subscribe(<<"session1">>, 123, State0),
State2 = unsubscribe(<<"session1">>, 123, State1),
?assertNot(is_subscribed(<<"session1">>, 123, State2)).
unsubscribe_one_of_many_test() ->
State0 = init_state(),
State1 = subscribe(<<"session1">>, 123, State0),
State2 = subscribe(<<"session2">>, 123, State1),
State3 = unsubscribe(<<"session1">>, 123, State2),
?assertNot(is_subscribed(<<"session1">>, 123, State3)),
?assert(is_subscribed(<<"session2">>, 123, State3)).
unsubscribe_session_test() ->
State0 = init_state(),
State1 = subscribe(<<"session1">>, 123, State0),
State2 = subscribe(<<"session1">>, 456, State1),
State3 = subscribe(<<"session2">>, 123, State2),
State4 = unsubscribe_session(<<"session1">>, State3),
?assertNot(is_subscribed(<<"session1">>, 123, State4)),
?assertNot(is_subscribed(<<"session1">>, 456, State4)),
?assert(is_subscribed(<<"session2">>, 123, State4)).
get_subscribed_sessions_test() ->
State0 = init_state(),
State1 = subscribe(<<"session1">>, 123, State0),
State2 = subscribe(<<"session2">>, 123, State1),
Sessions = lists:sort(get_subscribed_sessions(123, State2)),
?assertEqual([<<"session1">>, <<"session2">>], Sessions).
update_subscriptions_test() ->
State0 = init_state(),
State1 = subscribe(<<"session1">>, 100, State0),
State2 = subscribe(<<"session1">>, 200, State1),
State3 = update_subscriptions(<<"session1">>, [200, 300], State2),
?assertNot(is_subscribed(<<"session1">>, 100, State3)),
?assert(is_subscribed(<<"session1">>, 200, State3)),
?assert(is_subscribed(<<"session1">>, 300, State3)).
-endif.

View File

@@ -0,0 +1,23 @@
%% 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]).
send_guild_sync(GuildPid, SessionId) ->
gen_server:cast(GuildPid, {send_guild_sync, SessionId}).

View File

@@ -0,0 +1,236 @@
%% 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_unified_subscriptions).
-export([handle_subscriptions/3]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec handle_subscriptions(map(), pid(), map()) -> ok.
handle_subscriptions(Data, SocketPid, SessionState) ->
Subscriptions = maps:get(<<"subscriptions">>, Data, #{}),
Guilds = maps:get(guilds, SessionState, #{}),
SessionId = maps:get(id, SessionState, undefined),
logger:debug("[guild_unified_subscriptions] Processing ~p guild subscriptions for session ~p", [
map_size(Subscriptions), SessionId
]),
maps:foreach(
fun(GuildIdBin, GuildSubData) ->
process_guild_subscription(GuildIdBin, GuildSubData, Guilds, SessionId, SocketPid, SessionState)
end,
Subscriptions
),
ok.
-spec process_guild_subscription(binary(), map(), map(), binary() | undefined, pid(), map()) -> ok.
process_guild_subscription(GuildIdBin, GuildSubData, Guilds, SessionId, SocketPid, SessionState) ->
case validation:validate_snowflake(<<"guild_id">>, GuildIdBin) of
{ok, GuildId} ->
case maps:get(GuildId, Guilds, undefined) of
{GuildPid, _Ref} when is_pid(GuildPid) ->
process_guild_sub_options(GuildId, GuildPid, GuildSubData, SessionId, SocketPid, SessionState);
undefined ->
logger:warning("[guild_unified_subscriptions] Guild ~p not found in session state", [GuildId]),
ok;
_ ->
ok
end;
{error, _, Reason} ->
logger:warning("[guild_unified_subscriptions] Invalid guild_id ~p: ~p", [GuildIdBin, Reason]),
ok
end.
-spec process_guild_sub_options(integer(), pid(), map(), binary() | undefined, pid(), map()) -> ok.
process_guild_sub_options(GuildId, GuildPid, GuildSubData, SessionId, SocketPid, SessionState) ->
WasActive = not session_passive:is_passive(GuildId, SessionState),
ActiveChanged = process_active_flag(GuildSubData, GuildPid, SessionId, WasActive),
process_sync_flag(GuildSubData, GuildId, GuildPid, SessionId, ActiveChanged),
process_member_list_channels(GuildSubData, GuildId, GuildPid, SessionId, SocketPid),
process_member_subscriptions(GuildSubData, GuildPid, SessionId),
process_typing_flag(GuildSubData, GuildPid, SessionId),
ok.
-spec process_active_flag(map(), pid(), binary() | undefined, boolean()) -> boolean().
process_active_flag(GuildSubData, GuildPid, SessionId, WasActive) ->
case maps:get(<<"active">>, GuildSubData, undefined) of
undefined ->
false;
true ->
gen_server:cast(GuildPid, {set_session_active, SessionId}),
logger:debug("[guild_unified_subscriptions] Set session ~p active", [SessionId]),
not WasActive;
false ->
gen_server:cast(GuildPid, {set_session_passive, SessionId}),
logger:debug("[guild_unified_subscriptions] Set session ~p passive", [SessionId]),
WasActive
end.
-spec process_sync_flag(map(), integer(), pid(), binary() | undefined, boolean()) -> ok.
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),
logger:debug("[guild_unified_subscriptions] Sent guild sync for guild ~p", [GuildId]);
false ->
ok
end.
-spec process_member_list_channels(map(), integer(), pid(), binary() | undefined, pid()) -> ok.
process_member_list_channels(GuildSubData, GuildId, GuildPid, SessionId, SocketPid) ->
case maps:get(<<"member_list_channels">>, GuildSubData, undefined) of
undefined ->
ok;
MemberListChannels when is_map(MemberListChannels) ->
logger:debug("[guild_unified_subscriptions] Processing ~p member list channels for guild ~p", [
map_size(MemberListChannels), GuildId
]),
maps:foreach(
fun(ChannelIdBin, Ranges) ->
process_channel_lazy_subscribe(ChannelIdBin, Ranges, GuildId, GuildPid, SessionId, SocketPid)
end,
MemberListChannels
);
_ ->
ok
end.
-spec process_channel_lazy_subscribe(binary(), list(), integer(), pid(), binary() | undefined, pid()) -> ok.
process_channel_lazy_subscribe(ChannelIdBin, Ranges, _GuildId, GuildPid, SessionId, _SocketPid) ->
case validation:validate_snowflake(<<"channel_id">>, ChannelIdBin) of
{ok, ChannelId} ->
ParsedRanges = parse_ranges(Ranges),
logger:debug("[guild_unified_subscriptions] Lazy subscribe channel ~p with ranges ~p", [
ChannelId, ParsedRanges
]),
case gen_server:call(GuildPid, {lazy_subscribe, #{
session_id => SessionId,
channel_id => ChannelId,
ranges => ParsedRanges
}}, 10000) of
ok ->
ok;
Error ->
logger:error("[guild_unified_subscriptions] lazy_subscribe failed for channel ~p: ~p", [
ChannelId, Error
])
end;
{error, _, Reason} ->
logger:warning("[guild_unified_subscriptions] Invalid channel_id ~p: ~p", [ChannelIdBin, Reason])
end,
ok.
-spec parse_ranges(list()) -> [{non_neg_integer(), non_neg_integer()}].
parse_ranges(Ranges) when is_list(Ranges) ->
lists:filtermap(
fun(Range) ->
case Range of
[Start, End] when is_integer(Start), is_integer(End), Start >= 0, End >= Start ->
{true, {Start, End}};
_ ->
false
end
end,
Ranges
);
parse_ranges(_) ->
[].
-spec process_member_subscriptions(map(), pid(), binary() | undefined) -> ok.
process_member_subscriptions(GuildSubData, GuildPid, SessionId) ->
case maps:get(<<"members">>, GuildSubData, undefined) of
undefined ->
ok;
Members when is_list(Members) ->
MemberIds = parse_member_ids(Members),
logger:debug("[guild_unified_subscriptions] Updating member subscriptions with ~p members", [
length(MemberIds)
]),
gen_server:cast(GuildPid, {update_member_subscriptions, SessionId, MemberIds});
_ ->
ok
end.
-spec parse_member_ids(list()) -> [integer()].
parse_member_ids(Members) when is_list(Members) ->
lists:filtermap(
fun(MemberIdRaw) ->
case validation:validate_snowflake(<<"member_id">>, MemberIdRaw) of
{ok, MemberId} -> {true, MemberId};
{error, _, _} -> false
end
end,
Members
);
parse_member_ids(_) ->
[].
-spec process_typing_flag(map(), pid(), binary() | undefined) -> ok.
process_typing_flag(GuildSubData, GuildPid, SessionId) ->
case maps:get(<<"typing">>, GuildSubData, undefined) of
undefined ->
ok;
TypingFlag when is_boolean(TypingFlag) ->
gen_server:cast(GuildPid, {set_session_typing_override, SessionId, TypingFlag}),
logger:debug("[guild_unified_subscriptions] Set typing override to ~p for session ~p", [
TypingFlag, SessionId
]);
_ ->
ok
end.
-ifdef(TEST).
parse_ranges_valid_test() ->
?assertEqual([{0, 99}, {100, 199}], parse_ranges([[0, 99], [100, 199]])).
parse_ranges_invalid_test() ->
?assertEqual([], parse_ranges([[100, 50]])),
?assertEqual([], parse_ranges([[-1, 99]])),
?assertEqual([], parse_ranges([[<<"0">>, 99]])).
parse_ranges_mixed_test() ->
?assertEqual([{0, 99}], parse_ranges([[0, 99], [100, 50], <<"invalid">>])).
parse_ranges_non_list_test() ->
?assertEqual([], parse_ranges(undefined)),
?assertEqual([], parse_ranges(#{})).
parse_member_ids_valid_test() ->
?assertEqual([123, 456], parse_member_ids([<<"123">>, <<"456">>])).
parse_member_ids_invalid_test() ->
?assertEqual([], parse_member_ids([<<"not_a_number">>])).
parse_member_ids_mixed_test() ->
?assertEqual([123], parse_member_ids([<<"123">>, <<"invalid">>])).
parse_member_ids_non_list_test() ->
?assertEqual([], parse_member_ids(undefined)),
?assertEqual([], parse_member_ids(#{})).
-endif.

View File

@@ -0,0 +1,152 @@
%% 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_user_data).
-export([
update_user_data/2,
maybe_update_cached_user_data/3,
handle_user_data_update/3,
check_user_data_differs/2
]).
-import(guild_permissions, [find_member_by_user_id/2]).
update_user_data(EventData, State) ->
UserId = utils:binary_to_integer_safe(maps:get(<<"id">>, EventData)),
Data = maps:get(data, State),
Members = maps:get(<<"members">>, Data, []),
UpdatedMembers = lists:map(
fun(Member) when is_map(Member) ->
MUser = maps:get(<<"user">>, Member, #{}),
MemberId =
case is_map(MUser) of
true ->
utils:binary_to_integer_safe(maps:get(<<"id">>, MUser, <<"0">>));
false ->
undefined
end,
if
MemberId =:= UserId ->
maps:put(<<"user">>, EventData, Member);
true ->
Member
end
end,
Members
),
UpdatedData = maps:put(<<"members">>, UpdatedMembers, Data),
UpdatedState = maps:put(data, UpdatedData, State),
UpdatedMember = find_member_by_user_id(UserId, UpdatedState),
case UpdatedMember of
undefined -> ok;
M -> gen_server:cast(self(), {dispatch, #{event => guild_member_update, data => M}})
end,
{noreply, UpdatedState}.
handle_user_data_update(UserId, UserData, State) ->
Data = maps:get(data, State),
Members = maps:get(<<"members">>, Data, []),
CurrentMember = find_member_by_user_id(UserId, State),
case CurrentMember of
undefined ->
State;
Member ->
CurrentUserData = maps:get(<<"user">>, Member, #{}),
IsDifferent = check_user_data_differs(CurrentUserData, UserData),
if
IsDifferent ->
UpdatedMembers = lists:map(
fun(M) when is_map(M) ->
MUser = maps:get(<<"user">>, M, #{}),
MemberId =
case is_map(MUser) of
true ->
utils:binary_to_integer_safe(
maps:get(<<"id">>, MUser, <<"0">>)
);
false ->
undefined
end,
if
MemberId =:= UserId ->
maps:put(<<"user">>, UserData, M);
true ->
M
end
end,
Members
),
UpdatedData = maps:put(<<"members">>, UpdatedMembers, Data),
UpdatedState = maps:put(data, UpdatedData, State),
UpdatedMember = find_member_by_user_id(UserId, UpdatedState),
case UpdatedMember of
undefined ->
ok;
M ->
GuildId = maps:get(id, UpdatedState),
MemberUpdateData = maps:put(
<<"guild_id">>, integer_to_binary(GuildId), M
),
gen_server:cast(
self(),
{dispatch, #{
event => guild_member_update, data => MemberUpdateData
}}
)
end,
UpdatedState;
true ->
State
end
end.
check_user_data_differs(CurrentUserData, NewUserData) ->
utils:check_user_data_differs(CurrentUserData, NewUserData).
maybe_update_cached_user_data(Event, EventData, State) ->
case Event of
E when E =:= message_create; E =:= message_update ->
case maps:get(<<"author">>, EventData, undefined) of
undefined ->
State;
AuthorData ->
UserId = utils:binary_to_integer_safe(maps:get(<<"id">>, AuthorData, <<"0">>)),
case find_member_by_user_id(UserId, State) of
undefined ->
State;
Member ->
CurrentUserData = maps:get(<<"user">>, Member, #{}),
case check_user_data_differs(CurrentUserData, AuthorData) of
true ->
handle_user_data_update(UserId, AuthorData, State);
false ->
State
end
end
end;
_ ->
State
end.

View File

@@ -0,0 +1,127 @@
%% 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_virtual_channel_access).
-export([
add_virtual_access/3,
remove_virtual_access/3,
has_virtual_access/3,
get_virtual_channels_for_user/2,
get_users_with_virtual_access/2,
dispatch_channel_visibility_change/4
]).
-import(guild_permissions, [find_channel_by_id/2]).
add_virtual_access(UserId, ChannelId, State) ->
VirtualAccess = maps:get(virtual_channel_access, State, #{}),
UserChannels = maps:get(UserId, VirtualAccess, sets:new()),
UpdatedUserChannels = sets:add_element(ChannelId, UserChannels),
UpdatedVirtualAccess = maps:put(UserId, UpdatedUserChannels, VirtualAccess),
maps:put(virtual_channel_access, UpdatedVirtualAccess, State).
remove_virtual_access(UserId, ChannelId, State) ->
VirtualAccess = maps:get(virtual_channel_access, State, #{}),
case maps:get(UserId, VirtualAccess, undefined) of
undefined ->
State;
UserChannels ->
UpdatedUserChannels = sets:del_element(ChannelId, UserChannels),
case sets:size(UpdatedUserChannels) of
0 ->
UpdatedVirtualAccess = maps:remove(UserId, VirtualAccess),
maps:put(virtual_channel_access, UpdatedVirtualAccess, State);
_ ->
UpdatedVirtualAccess = maps:put(UserId, UpdatedUserChannels, VirtualAccess),
maps:put(virtual_channel_access, UpdatedVirtualAccess, State)
end
end.
has_virtual_access(UserId, ChannelId, State) ->
VirtualAccess = maps:get(virtual_channel_access, State, #{}),
case maps:get(UserId, VirtualAccess, undefined) of
undefined ->
false;
UserChannels ->
sets:is_element(ChannelId, UserChannels)
end.
get_virtual_channels_for_user(UserId, State) ->
VirtualAccess = maps:get(virtual_channel_access, State, #{}),
case maps:get(UserId, VirtualAccess, undefined) of
undefined ->
[];
UserChannels ->
sets:to_list(UserChannels)
end.
get_users_with_virtual_access(ChannelId, State) ->
VirtualAccess = maps:get(virtual_channel_access, State, #{}),
maps:fold(
fun(UserId, UserChannels, Acc) ->
case sets:is_element(ChannelId, UserChannels) of
true -> [UserId | Acc];
false -> Acc
end
end,
[],
VirtualAccess
).
dispatch_channel_visibility_change(UserId, ChannelId, Action, State) ->
Channel = find_channel_by_id(ChannelId, State),
case Channel of
undefined ->
ok;
_ ->
Sessions = maps:get(sessions, State, #{}),
GuildId = maps:get(id, State),
UserSessions = maps:filter(
fun(_Sid, SessionData) ->
maps:get(user_id, SessionData) =:= UserId
end,
Sessions
),
case Action of
add ->
ChannelWithGuild = maps:put(
<<"guild_id">>, integer_to_binary(GuildId), Channel
),
maps:foreach(
fun(_Sid, SessionData) ->
Pid = maps:get(pid, SessionData),
gen_server:cast(Pid, {dispatch, channel_create, ChannelWithGuild})
end,
UserSessions
);
remove ->
ChannelDelete = #{
<<"id">> => integer_to_binary(ChannelId),
<<"guild_id">> => integer_to_binary(GuildId)
},
maps:foreach(
fun(_Sid, SessionData) ->
Pid = maps:get(pid, SessionData),
gen_server:cast(Pid, {dispatch, channel_delete, ChannelDelete})
end,
UserSessions
)
end
end.

View File

@@ -0,0 +1,170 @@
%% 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_visibility).
-export([
get_user_viewable_channels/2,
compute_and_dispatch_visibility_changes/2,
viewable_channel_set/2,
have_shared_viewable_channel/3
]).
-import(guild_member_list, [calculate_list_id/2, build_sync_response/4]).
-import(guild_permissions, [can_view_channel/4, find_member_by_user_id/2, find_channel_by_id/2]).
-spec get_user_viewable_channels(integer(), map()) -> [integer()].
get_user_viewable_channels(UserId, State) ->
Data = map_utils:ensure_map(map_utils:get_safe(State, data, #{})),
Channels = map_utils:ensure_list(maps:get(<<"channels">>, Data, [])),
Member = find_member_by_user_id(UserId, State),
case Member of
undefined ->
[];
_ ->
lists:filtermap(
fun(Channel) ->
ChannelId = map_utils:get_integer(Channel, <<"id">>, undefined),
case ChannelId of
undefined ->
false;
_ ->
case can_view_channel(UserId, ChannelId, Member, State) of
true -> {true, ChannelId};
false -> false
end
end
end,
Channels
)
end.
-spec viewable_channel_set(integer(), map()) -> sets:set().
viewable_channel_set(UserId, State) when is_integer(UserId) ->
sets:from_list(get_user_viewable_channels(UserId, State));
viewable_channel_set(_, _) ->
sets:new().
-spec have_shared_viewable_channel(integer(), integer(), map()) -> boolean().
have_shared_viewable_channel(UserId, OtherUserId, State) when is_integer(UserId), is_integer(OtherUserId), UserId =/= OtherUserId ->
SetA = viewable_channel_set(UserId, State),
SetB = viewable_channel_set(OtherUserId, State),
not sets:is_empty(sets:intersection(SetA, SetB));
have_shared_viewable_channel(_, _, _) ->
false.
-spec compute_and_dispatch_visibility_changes(map(), map()) -> ok.
compute_and_dispatch_visibility_changes(OldState, NewState) ->
Sessions = maps:get(sessions, NewState, #{}),
GuildId = maps:get(id, NewState, 0),
lists:foreach(
fun({SessionId, SessionData}) ->
UserId = maps:get(user_id, SessionData),
Pid = maps:get(pid, SessionData),
OldViewable = get_user_viewable_channels(UserId, OldState),
NewViewable = get_user_viewable_channels(UserId, NewState),
OldSet = sets:from_list(OldViewable),
NewSet = sets:from_list(NewViewable),
Removed = sets:subtract(OldSet, NewSet),
Added = sets:subtract(NewSet, OldSet),
lists:foreach(
fun(ChannelId) ->
dispatch_channel_delete(ChannelId, Pid, OldState, GuildId)
end,
sets:to_list(Removed)
),
lists:foreach(
fun(ChannelId) ->
dispatch_channel_create(ChannelId, Pid, NewState, GuildId),
send_member_list_sync(SessionId, SessionData, ChannelId, GuildId, NewState)
end,
sets:to_list(Added)
)
end,
maps:to_list(Sessions)
),
ok.
dispatch_channel_delete(ChannelId, SessionPid, OldState, GuildId) ->
case is_pid(SessionPid) of
true ->
case find_channel_by_id(ChannelId, OldState) of
undefined ->
ok;
_Channel ->
ChannelDelete = #{
<<"id">> => integer_to_binary(ChannelId),
<<"guild_id">> => integer_to_binary(GuildId)
},
gen_server:cast(SessionPid, {dispatch, channel_delete, ChannelDelete})
end;
false ->
ok
end.
dispatch_channel_create(ChannelId, SessionPid, NewState, GuildId) ->
case is_pid(SessionPid) of
true ->
case find_channel_by_id(ChannelId, NewState) of
undefined ->
ok;
Channel ->
ChannelWithGuild = maps:put(
<<"guild_id">>, integer_to_binary(GuildId), Channel
),
gen_server:cast(SessionPid, {dispatch, channel_create, ChannelWithGuild})
end;
false ->
ok
end.
send_member_list_sync(SessionId, SessionData, ChannelId, GuildId, State) ->
SessionPid = maps:get(pid, SessionData),
case is_pid(SessionPid) of
false ->
ok;
true ->
ListId = calculate_list_id(ChannelId, State),
MemberListSubs = maps:get(member_list_subscriptions, State, #{}),
ListSubs = maps:get(ListId, MemberListSubs, #{}),
Ranges = maps:get(SessionId, ListSubs, []),
case Ranges of
[] ->
ok;
_ ->
SessionUserId = maps:get(user_id, SessionData),
case can_send_member_list(SessionUserId, ChannelId, State) of
true ->
SyncResponse = build_sync_response(GuildId, ListId, Ranges, State),
SyncResponseWithChannel = maps:put(<<"channel_id">>, integer_to_binary(ChannelId), SyncResponse),
gen_server:cast(SessionPid, {dispatch, guild_member_list_update, SyncResponseWithChannel});
false ->
ok
end
end
end.
can_send_member_list(UserId, ChannelId, State) ->
is_integer(UserId) andalso
guild_permissions:can_view_channel(UserId, ChannelId, undefined, State).

View File

@@ -0,0 +1,626 @@
%% 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(dm_voice).
-export([voice_state_update/2]).
-export([get_voice_state/2]).
-export([get_voice_token/6]).
-export([disconnect_voice_user/2]).
-export([broadcast_voice_state_update/3]).
-export([join_or_create_call/5, join_or_create_call/6]).
voice_state_update(Request, State) ->
#{
user_id := UserId,
channel_id := ChannelId
} = Request,
ConnectionId = maps:get(connection_id, Request, undefined),
VoiceStates = maps:get(dm_voice_states, State, #{}),
case ChannelId of
null ->
handle_dm_disconnect(ConnectionId, UserId, VoiceStates, State);
ChannelIdValue ->
Channels = maps:get(channels, State, #{}),
UserId = maps:get(user_id, State),
logger:info(
"[dm_voice] Looking up channel ~p for user ~p, channels map has ~p entries",
[ChannelIdValue, UserId, maps:size(Channels)]
),
case maps:get(ChannelIdValue, Channels, undefined) of
undefined ->
logger:info(
"[dm_voice] Channel ~p not found locally for user ~p, trying RPC fallback",
[ChannelIdValue, UserId]
),
case fetch_dm_channel_via_rpc(ChannelIdValue, UserId) of
{ok, Channel} ->
NewChannels = maps:put(ChannelIdValue, Channel, Channels),
NewState = maps:put(channels, NewChannels, State),
logger:info(
"[dm_voice] RPC fallback found channel ~p for user ~p, added to local map",
[ChannelIdValue, UserId]
),
handle_dm_voice_with_channel(
Channel, ChannelIdValue, UserId, Request, NewState
);
{error, Reason} ->
logger:warning(
"[dm_voice] Channel ~p not found for user ~p via RPC: ~p",
[ChannelIdValue, UserId, Reason]
),
{reply, gateway_errors:error(dm_channel_not_found), State}
end;
Channel ->
logger:info(
"[dm_voice] Found channel ~p for user ~p, type: ~p",
[ChannelIdValue, UserId, maps:get(<<"type">>, Channel, 0)]
),
handle_dm_voice_with_channel(Channel, ChannelIdValue, UserId, Request, State)
end
end.
handle_dm_voice_with_channel(Channel, ChannelIdValue, UserId, Request, State) ->
#{
session_id := SessionId,
self_mute := SelfMute,
self_deaf := SelfDeaf,
self_video := SelfVideo
} = Request,
SelfStream = maps:get(self_stream, Request, false),
ConnectionId = maps:get(connection_id, Request, undefined),
IsMobile = maps:get(is_mobile, Request, false),
ViewerStreamKey = maps:get(viewer_stream_key, Request, undefined),
Latitude = maps:get(latitude, Request, null),
Longitude = maps:get(longitude, Request, null),
VoiceStates = maps:get(dm_voice_states, State, #{}),
ChannelType = maps:get(<<"type">>, Channel, 0),
case is_dm_channel_type(ChannelType) of
false ->
{reply, gateway_errors:error(dm_invalid_channel_type), State};
true ->
case check_recipient(UserId, ChannelIdValue, State) of
false ->
{reply, gateway_errors:error(dm_not_recipient), State};
true ->
handle_dm_connect_or_update(
ConnectionId,
ChannelIdValue,
UserId,
SessionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
Latitude,
Longitude,
VoiceStates,
State
)
end
end.
handle_dm_disconnect(undefined, _UserId, _VoiceStates, State) ->
{reply, gateway_errors:error(voice_missing_connection_id), State};
handle_dm_disconnect(ConnectionId, _UserId, VoiceStates, State) ->
case maps:get(ConnectionId, VoiceStates, undefined) of
undefined ->
{reply, #{success => true}, State};
OldVoiceState ->
NewVoiceStates = maps:remove(ConnectionId, VoiceStates),
NewState = maps:put(dm_voice_states, NewVoiceStates, State),
OldChannelId = maps:get(<<"channel_id">>, OldVoiceState, null),
DisconnectVoiceState = maps:put(
<<"channel_id">>, null, maps:put(<<"connection_id">>, ConnectionId, OldVoiceState)
),
SessionId = maps:get(id, State),
case OldChannelId of
null ->
ok;
ChannelIdValue ->
SessionPid = maps:get(session_pid, State),
gen_server:cast(SessionPid, {call_unmonitor, ChannelIdValue}),
spawn(fun() ->
try
case gen_server:call(call_manager, {lookup, ChannelIdValue}, 5000) of
{ok, CallPid} ->
gen_server:call(CallPid, {leave, SessionId}, 5000);
_ ->
ok
end
catch
_:_ -> ok
end
end)
end,
case OldChannelId of
null ->
ok;
_ ->
case validation:validate_snowflake(<<"channel_id">>, OldChannelId) of
{ok, OldChannelIdInt} ->
broadcast_voice_state_update(
OldChannelIdInt, DisconnectVoiceState, NewState
);
{error, _, Reason} ->
logger:warning("[dm_voice] Invalid channel_id: ~p", [Reason]),
ok
end
end,
{reply, #{success => true}, NewState}
end.
handle_dm_connect_or_update(
ConnectionId,
ChannelIdValue,
UserId,
SessionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
Latitude,
Longitude,
_VoiceStates,
State
) when ConnectionId =:= undefined; ConnectionId =:= null ->
VoiceStates = maps:get(dm_voice_states, State, #{}),
case validate_dm_viewer_stream_key(ViewerStreamKey, ChannelIdValue, VoiceStates) of
{error, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
{ok, ParsedViewerKey} ->
get_dm_voice_token_and_create_state(
UserId,
ChannelIdValue,
SessionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ParsedViewerKey,
IsMobile,
Latitude,
Longitude,
State
)
end;
handle_dm_connect_or_update(
ConnectionId,
ChannelIdValue,
UserId,
SessionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
_Latitude,
_Longitude,
VoiceStates,
State
) ->
case maps:get(ConnectionId, VoiceStates, undefined) of
undefined ->
{reply, gateway_errors:error(voice_connection_not_found), State};
ExistingVoiceState ->
ExistingSessionId = maps:get(<<"session_id">>, ExistingVoiceState, undefined),
EffectiveSessionId = resolve_effective_session_id(ExistingSessionId, SessionId),
ValidViewerKey = validate_dm_viewer_stream_key(
ViewerStreamKey, ChannelIdValue, VoiceStates
),
case ValidViewerKey of
{error, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
{ok, ParsedViewerKey} ->
UpdatedVoiceState = ExistingVoiceState#{
<<"channel_id">> => integer_to_binary(ChannelIdValue),
<<"session_id">> => EffectiveSessionId,
<<"self_mute">> => SelfMute,
<<"self_deaf">> => SelfDeaf,
<<"self_video">> => SelfVideo,
<<"self_stream">> => SelfStream,
<<"is_mobile">> => IsMobile,
<<"viewer_stream_key">> => ParsedViewerKey
},
NewVoiceStates = maps:put(ConnectionId, UpdatedVoiceState, VoiceStates),
NewState = maps:put(dm_voice_states, NewVoiceStates, State),
broadcast_voice_state_update(ChannelIdValue, UpdatedVoiceState, NewState),
OldChannelId = maps:get(<<"channel_id">>, ExistingVoiceState, null),
NewChannelIdBin = integer_to_binary(ChannelIdValue),
NeedsToken = OldChannelId =/= NewChannelIdBin,
maybe_spawn_join_call(
NeedsToken, ChannelIdValue, UserId, UpdatedVoiceState, SessionId
),
{reply, #{success => true, needs_token => NeedsToken}, NewState}
end
end.
normalize_session_id(undefined) ->
undefined;
normalize_session_id(SessionId) when is_binary(SessionId) ->
SessionId;
normalize_session_id(SessionId) when is_integer(SessionId) ->
integer_to_binary(SessionId);
normalize_session_id(SessionId) when is_list(SessionId) ->
list_to_binary(SessionId);
normalize_session_id(SessionId) ->
try
erlang:iolist_to_binary(SessionId)
catch
_:_ -> SessionId
end.
validate_dm_viewer_stream_key(RawKey, ChannelIdValue, VoiceStates) ->
case RawKey of
undefined ->
{ok, null};
null ->
{ok, null};
_ when not is_binary(RawKey) -> {error, voice_invalid_state};
_ ->
case voice_state_utils:parse_stream_key(RawKey) of
{ok, #{scope := dm, channel_id := ParsedChannelId, connection_id := ConnId}} when
ParsedChannelId =:= ChannelIdValue
->
case maps:get(ConnId, VoiceStates, undefined) of
undefined ->
{error, voice_connection_not_found};
StreamVS ->
case map_utils:get_integer(StreamVS, <<"channel_id">>, undefined) of
ChannelIdValue -> {ok, RawKey};
_ -> {error, voice_invalid_state}
end
end;
_ ->
{error, voice_invalid_state}
end
end.
resolve_effective_session_id(ExistingSessionId, RequestSessionId) ->
ExistingNormalized = normalize_session_id(ExistingSessionId),
RequestNormalized = normalize_session_id(RequestSessionId),
case ExistingNormalized of
undefined -> RequestNormalized;
RequestNormalized -> RequestNormalized;
_ -> ExistingNormalized
end.
maybe_spawn_join_call(false, _ChannelId, _UserId, _VoiceState, _SessionId) ->
ok;
maybe_spawn_join_call(true, ChannelId, UserId, VoiceState, SessionId) ->
spawn(fun() ->
try
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, self())
catch
_:_ -> ok
end
end).
get_voice_token(ChannelId, UserId, _SessionId, SessionPid, Latitude, Longitude) ->
Req = voice_utils:build_voice_token_rpc_request(
null, ChannelId, UserId, null, Latitude, Longitude
),
case rpc_client:call(Req) of
{ok, Data} ->
Token = maps:get(<<"token">>, Data),
Endpoint = maps:get(<<"endpoint">>, Data),
ConnectionId = maps:get(<<"connectionId">>, Data),
SessionPid !
{voice_server_update, #{
channel_id => integer_to_binary(ChannelId),
endpoint => Endpoint,
token => Token,
connection_id => ConnectionId
}},
ok;
{error, _Reason} ->
error
end.
get_dm_voice_token_and_create_state(
UserId,
ChannelId,
SessionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
Latitude,
Longitude,
State
) ->
Req = voice_utils:build_voice_token_rpc_request(
null, ChannelId, UserId, null, Latitude, Longitude
),
case rpc_client:call(Req) of
{ok, Data} ->
handle_dm_token_success(
Data,
UserId,
ChannelId,
SessionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
State
);
{error, _Reason} ->
{reply, gateway_errors:error(voice_token_failed), State}
end.
handle_dm_token_success(
Data,
UserId,
ChannelId,
SessionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
State
) ->
Token = maps:get(<<"token">>, Data),
Endpoint = maps:get(<<"endpoint">>, Data),
ConnectionId = maps:get(<<"connectionId">>, Data),
VoiceState = #{
<<"user_id">> => integer_to_binary(UserId),
<<"channel_id">> => integer_to_binary(ChannelId),
<<"connection_id">> => ConnectionId,
<<"is_mobile">> => IsMobile,
<<"session_id">> => SessionId,
<<"self_mute">> => SelfMute,
<<"self_deaf">> => SelfDeaf,
<<"self_video">> => SelfVideo,
<<"self_stream">> => SelfStream,
<<"viewer_stream_key">> => ViewerStreamKey
},
VoiceStates = maps:get(dm_voice_states, State, #{}),
NewVoiceStates = maps:put(ConnectionId, VoiceState, VoiceStates),
NewState = maps:put(dm_voice_states, NewVoiceStates, State),
broadcast_voice_state_update(ChannelId, VoiceState, NewState),
SessionPid = maps:get(session_pid, State),
VoiceServerUpdate = #{
<<"token">> => Token,
<<"endpoint">> => Endpoint,
<<"channel_id">> => integer_to_binary(ChannelId),
<<"connection_id">> => ConnectionId
},
gen_server:cast(SessionPid, {dispatch, voice_server_update, VoiceServerUpdate}),
GatewaySessionId = maps:get(id, State),
spawn(fun() ->
try
join_or_create_call(ChannelId, UserId, VoiceState, GatewaySessionId, SessionPid)
catch
_:_ -> ok
end
end),
{reply, #{success => true, needs_token => false, connection_id => ConnectionId}, NewState}.
get_voice_state(ConnectionId, State) ->
VoiceStates = maps:get(dm_voice_states, State, #{}),
maps:get(ConnectionId, VoiceStates, undefined).
disconnect_voice_user(UserId, State) ->
VoiceStates = maps:get(dm_voice_states, State, #{}),
UserVoiceStates = maps:filter(
fun(_ConnectionId, VoiceState) ->
maps:get(<<"user_id">>, VoiceState) =:= integer_to_binary(UserId)
end,
VoiceStates
),
case maps:size(UserVoiceStates) of
0 ->
{reply, #{success => true}, State};
_ ->
NewVoiceStates = maps:fold(
fun(ConnectionId, _VoiceState, Acc) ->
maps:remove(ConnectionId, Acc)
end,
VoiceStates,
UserVoiceStates
),
NewState = maps:put(dm_voice_states, NewVoiceStates, State),
maps:foreach(
fun(_ConnectionId, VoiceState) ->
ChannelId = maps:get(<<"channel_id">>, VoiceState, null),
DisconnectVoiceState = maps:put(
<<"channel_id">>,
null,
maps:put(<<"connection_id">>, _ConnectionId, VoiceState)
),
case ChannelId of
null ->
ok;
_ ->
case validation:validate_snowflake(<<"channel_id">>, ChannelId) of
{ok, ChannelIdInt} ->
broadcast_voice_state_update(
ChannelIdInt, DisconnectVoiceState, NewState
);
{error, _, Reason} ->
logger:warning(
"[dm_voice] Invalid channel_id in voice state: ~p", [Reason]
),
ok
end
end
end,
UserVoiceStates
),
{reply, #{success => true}, NewState}
end.
broadcast_voice_state_update(ChannelId, VoiceState, State) ->
Channels = maps:get(channels, State, #{}),
case maps:get(ChannelId, Channels, undefined) of
undefined ->
ok;
Channel ->
Recipients = maps:get(<<"recipient_ids">>, Channel, []),
UserId = maps:get(user_id, State),
AllRecipients = lists:usort([UserId | Recipients]),
Event = voice_state_update,
lists:foreach(
fun(RecipientId) ->
presence_manager:dispatch_to_user(RecipientId, Event, VoiceState)
end,
AllRecipients
)
end.
check_recipient(UserId, ChannelId, State) ->
Channels = maps:get(channels, State, #{}),
case maps:get(ChannelId, Channels, undefined) of
undefined ->
false;
Channel ->
ChannelType = maps:get(<<"type">>, Channel, 0),
is_dm_channel_type(ChannelType) andalso is_channel_recipient(UserId, Channel, State)
end.
is_dm_channel_type(1) -> true;
is_dm_channel_type(3) -> true;
is_dm_channel_type(_) -> false.
is_channel_recipient(UserId, Channel, State) ->
Recipients = maps:get(<<"recipient_ids">>, Channel, []),
CurrentUserId = maps:get(user_id, State),
lists:member(UserId, [CurrentUserId | Recipients]).
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid) ->
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, 10).
join_or_create_call(_ChannelId, UserId, _VoiceState, _SessionId, _SessionPid, 0) ->
logger:warning("[dm_voice] Failed to join call after retries, user ~p could not join", [UserId]),
ok;
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, Retries) ->
ConnectionId = maps:get(<<"connection_id">>, VoiceState, undefined),
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
{ok, CallPid} ->
JoinMsg =
case ConnectionId of
undefined ->
{join, UserId, VoiceState, SessionId, SessionPid};
_ ->
{join, UserId, VoiceState, SessionId, SessionPid, ConnectionId}
end,
case gen_server:call(CallPid, JoinMsg, 5000) of
ok ->
gen_server:cast(SessionPid, {call_monitor, ChannelId, CallPid}),
ok;
Error ->
Error
end;
{error, not_found} ->
timer:sleep(300),
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, Retries - 1);
not_found ->
timer:sleep(300),
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, Retries - 1)
end.
fetch_dm_channel_via_rpc(ChannelId, UserId) ->
Req = #{
<<"type">> => <<"get_dm_channel">>,
<<"channel_id">> => ChannelId,
<<"user_id">> => UserId
},
case rpc_client:call(Req) of
{ok, #{<<"channel">> := null}} ->
{error, not_found};
{ok, #{<<"channel">> := Channel}} when is_map(Channel) ->
{ok, convert_api_channel_to_gateway_format(Channel, UserId)};
{ok, _} ->
{error, not_found};
{error, Reason} ->
{error, Reason}
end.
convert_api_channel_to_gateway_format(Channel, CurrentUserId) ->
ChannelType = maps:get(<<"type">>, Channel, 0),
Recipients = maps:get(<<"recipients">>, Channel, []),
RecipientIds = lists:filtermap(
fun(R) -> extract_recipient_id(R, CurrentUserId) end,
Recipients
),
#{
<<"id">> => maps:get(<<"id">>, Channel),
<<"type">> => ChannelType,
<<"recipient_ids">> => RecipientIds
}.
extract_recipient_id(Recipient, CurrentUserId) when is_map(Recipient) ->
case maps:get(<<"id">>, Recipient, undefined) of
undefined -> false;
Id -> filter_recipient_id(parse_id(Id), CurrentUserId)
end;
extract_recipient_id(Id, CurrentUserId) ->
filter_recipient_id(parse_id(Id), CurrentUserId).
parse_id(Id) when is_integer(Id) -> Id;
parse_id(Id) when is_binary(Id) ->
case validation:validate_snowflake(<<"id">>, Id) of
{ok, IntId} -> IntId;
{error, _, _} -> null
end;
parse_id(_) ->
null.
filter_recipient_id(null, _CurrentUserId) -> false;
filter_recipient_id(Id, Id) -> false;
filter_recipient_id(Id, _CurrentUserId) -> {true, Id}.

View File

@@ -0,0 +1,125 @@
%% 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).
-export([voice_state_update/2]).
-export([get_voice_state/2]).
-export([update_member_voice/2]).
-export([disconnect_voice_user/2]).
-export([disconnect_voice_user_if_in_channel/2]).
-export([disconnect_all_voice_users_in_channel/2]).
-export([confirm_voice_connection_from_livekit/2]).
-export([move_member/2]).
-export([broadcast_voice_state_update/3]).
-export([broadcast_voice_server_update_to_session/6]).
-export([send_voice_server_update_for_move/5]).
-export([send_voice_server_updates_for_move/4]).
-export([switch_voice_region_handler/2]).
-export([switch_voice_region/3]).
-export([get_voice_states_list/1]).
-export([handle_virtual_channel_access_for_move/4]).
-export([cleanup_virtual_access_on_disconnect/2]).
voice_state_update(Request, State) ->
case guild_voice_connection:voice_state_update(Request, State) of
{reply, Response, NewState} ->
{reply, Response, NewState};
{error, Category, Message} ->
{reply, {error, Category, Message}, State}
end.
get_voice_state(Request, State) ->
guild_voice_state:get_voice_state(Request, State).
get_voice_states_list(State) ->
guild_voice_state:get_voice_states_list(State).
update_member_voice(Request, State) ->
guild_voice_member:update_member_voice(Request, State).
disconnect_voice_user(Request, State) ->
guild_voice_disconnect:disconnect_voice_user(Request, State).
disconnect_voice_user_if_in_channel(Request, State) ->
guild_voice_disconnect:disconnect_voice_user_if_in_channel(Request, State).
disconnect_all_voice_users_in_channel(Request, State) ->
guild_voice_disconnect:disconnect_all_voice_users_in_channel(Request, State).
confirm_voice_connection_from_livekit(Request, State) ->
case guild_voice_connection:confirm_voice_connection_from_livekit(Request, State) of
{reply, Response, NewState} ->
{reply, Response, NewState};
{error, Category, Message} ->
{reply, {error, Category, Message}, State}
end.
move_member(Request, State) ->
guild_voice_move:move_member(Request, State).
send_voice_server_update_for_move(GuildId, ChannelId, UserId, SessionId, GuildPid) ->
guild_voice_move:send_voice_server_update_for_move(
GuildId, ChannelId, UserId, SessionId, GuildPid
).
send_voice_server_updates_for_move(GuildId, ChannelId, SessionDataList, GuildPid) ->
guild_voice_move:send_voice_server_updates_for_move(
GuildId, ChannelId, SessionDataList, GuildPid
).
broadcast_voice_state_update(VoiceState, State, OldChannelIdBin) ->
guild_voice_broadcast:broadcast_voice_state_update(VoiceState, State, OldChannelIdBin).
broadcast_voice_server_update_to_session(GuildId, SessionId, Token, Endpoint, ConnectionId, State) ->
guild_voice_broadcast:broadcast_voice_server_update_to_session(
GuildId, SessionId, Token, Endpoint, ConnectionId, State
).
switch_voice_region_handler(Request, State) ->
guild_voice_region:switch_voice_region_handler(Request, State).
switch_voice_region(GuildId, ChannelId, GuildPid) ->
guild_voice_region:switch_voice_region(GuildId, ChannelId, GuildPid).
handle_virtual_channel_access_for_move(UserId, ChannelId, _ConnectionsToMove, GuildPid) ->
case gen_server:call(GuildPid, {get_sessions}, 10000) of
State when is_map(State) ->
Member = guild_permissions:find_member_by_user_id(UserId, State),
case Member of
undefined ->
ok;
_ ->
HasViewPermission = guild_permissions:can_view_channel_by_permissions(
UserId, ChannelId, Member, State
),
case HasViewPermission of
true ->
ok;
false ->
gen_server:cast(
GuildPid,
{add_virtual_channel_access, UserId, ChannelId}
)
end
end;
_ ->
ok
end.
cleanup_virtual_access_on_disconnect(UserId, GuildPid) ->
gen_server:cast(GuildPid, {cleanup_virtual_access_for_user, UserId}).

View File

@@ -0,0 +1,117 @@
%% 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_broadcast).
-export([broadcast_voice_state_update/3]).
-export([broadcast_voice_server_update_to_session/6]).
-ifdef(TEST).
-define(WARN_MISSING_CONN(_VoiceState), ok).
-else.
-define(WARN_MISSING_CONN(VoiceState),
logger:warning(
"[guild_voice_broadcast] Skipping VOICE_STATE_UPDATE broadcast - missing connection_id: ~p",
[VoiceState]
)
).
-endif.
broadcast_voice_state_update(VoiceState, State, OldChannelIdBin) ->
case maps:get(<<"connection_id">>, VoiceState, undefined) of
undefined ->
?WARN_MISSING_CONN(VoiceState),
ok;
ConnectionId ->
Sessions = maps:get(sessions, State, #{}),
ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
FilterChannelIdBin =
case ChannelIdBin of
null ->
OldChannelIdBin;
_ ->
ChannelIdBin
end,
FilterChannelId = utils:binary_to_integer_safe(FilterChannelIdBin),
UserId = maps:get(<<"user_id">>, VoiceState, <<"unknown">>),
GuildId = maps:get(id, State, 0),
AllSessionDetails = [{Sid, maps:get(user_id, S)} || {Sid, S} <- maps:to_list(Sessions)],
logger:info(
"[guild_voice_broadcast] Broadcasting voice state update: "
"guild_id=~p user_id=~p channel_id=~p connection_id=~p "
"total_sessions=~p all_sessions=~p filter_channel_id=~p",
[
GuildId,
UserId,
ChannelIdBin,
ConnectionId,
maps:size(Sessions),
AllSessionDetails,
FilterChannelId
]
),
FilteredSessions = guild_sessions:filter_sessions_for_channel(
Sessions, FilterChannelId, undefined, State
),
SessionDetails = [{Sid, maps:get(user_id, S)} || {Sid, S} <- FilteredSessions],
Pids = [maps:get(pid, S) || {_Sid, S} <- FilteredSessions],
logger:info(
"[guild_voice_broadcast] Filtered sessions: "
"guild_id=~p user_id=~p filtered_count=~p session_details=~p pids=~p",
[GuildId, UserId, length(FilteredSessions), SessionDetails, Pids]
),
lists:foreach(
fun(Pid) when is_pid(Pid) ->
logger:info(
"[guild_voice_broadcast] Sending voice_state_update to session pid ~p",
[Pid]
),
gen_server:cast(Pid, {dispatch, voice_state_update, VoiceState})
end,
Pids
)
end.
broadcast_voice_server_update_to_session(GuildId, SessionId, Token, Endpoint, ConnectionId, State) ->
VoiceServerUpdate = #{
<<"token">> => Token,
<<"endpoint">> => Endpoint,
<<"guild_id">> => integer_to_binary(GuildId),
<<"connection_id">> => ConnectionId
},
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
ok;
SessionData ->
SessionPid = maps:get(pid, SessionData, null),
case SessionPid of
Pid when is_pid(Pid) ->
gen_server:cast(Pid, {dispatch, voice_server_update, VoiceServerUpdate});
_ ->
ok
end
end.

View File

@@ -0,0 +1,699 @@
%% 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_connection).
-include_lib("fluxer_gateway/include/voice_state.hrl").
-export([voice_state_update/2]).
-export([confirm_voice_connection_from_livekit/2]).
-export([request_voice_token/4]).
-type guild_state() :: map().
-type voice_state() :: map().
-type voice_state_map() :: #{binary() => voice_state()}.
-type pending_voice_connections() :: #{binary() => map()}.
-type context() :: #{
user_id := integer() | undefined,
channel_id := integer() | null | undefined,
session_id := term(),
connection_id := binary() | undefined,
raw_connection_id := term(),
self_mute := boolean(),
self_deaf := boolean(),
self_video := boolean(),
self_stream := boolean(),
is_mobile := boolean(),
viewer_stream_key := term()
}.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-ifdef(TEST).
-define(LOG_INVALID_GUILD_ID(_Value), ok).
-else.
-define(LOG_INVALID_GUILD_ID(Value),
logger:warning(
"[guild_voice_connection] Invalid guild_id value: ~p", [Value]
)
).
-endif.
voice_state_update(Request, State) ->
Context = build_context(Request),
case maps:get(user_id, Context) of
undefined ->
gateway_errors:error(voice_invalid_user_id);
UserId ->
logger:debug(
"[guild_voice_connection] Processing voice state update: UserId=~p, ChannelId=~p, "
"ConnectionId=~p",
[UserId, maps:get(channel_id, Context), maps:get(connection_id, Context)]
),
VoiceStates = voice_state_utils:voice_states(State),
case guild_voice_member:find_member_by_user_id(UserId, State) of
undefined ->
logger:warning("[guild_voice_connection] Member not found for UserId: ~p", [
UserId
]),
gateway_errors:error(voice_member_not_found);
Member ->
handle_member_voice(Context, Member, VoiceStates, State)
end
end.
-spec handle_member_voice(context(), map(), voice_state_map(), guild_state()) ->
{reply, map(), guild_state()} | {error, atom(), binary()}.
handle_member_voice(Context, Member, VoiceStates, State) ->
case maps:get(channel_id, Context) of
undefined ->
gateway_errors:error(voice_invalid_channel_id);
null ->
handle_disconnect(Context, VoiceStates, State);
ChannelIdValue ->
handle_voice_connect_or_update(Context, ChannelIdValue, Member, VoiceStates, State)
end.
handle_disconnect(Context, VoiceStates, State) ->
guild_voice_disconnect:handle_voice_disconnect(
maps:get(raw_connection_id, Context),
maps:get(session_id, Context),
maps:get(user_id, Context),
VoiceStates,
State
).
handle_voice_connect_or_update(Context, ChannelIdValue, Member, VoiceStates, State) ->
ConnectionId = maps:get(connection_id, Context),
Channel = guild_voice_member:find_channel_by_id(ChannelIdValue, State),
case Channel of
undefined ->
logger:warning("[guild_voice_connection] Channel not found: ~p", [ChannelIdValue]),
gateway_errors:error(voice_channel_not_found);
_ ->
case ConnectionId of
undefined ->
handle_new_connection(Context, Member, Channel, VoiceStates, State);
_ ->
handle_update_connection(
Context,
ChannelIdValue,
Member,
Channel,
VoiceStates,
State
)
end
end.
handle_update_connection(Context, ChannelIdValue, Member, Channel, VoiceStates, State) ->
ConnectionId = maps:get(connection_id, Context),
case maps:get(ConnectionId, VoiceStates, undefined) of
undefined ->
gateway_errors:error(voice_connection_not_found);
ExistingVoiceState ->
ExistingChannelIdBin = maps:get(<<"channel_id">>, ExistingVoiceState, null),
NewChannelIdBin = integer_to_binary(ChannelIdValue),
IsChannelChange = ExistingChannelIdBin =/= NewChannelIdBin,
UserId = maps:get(user_id, Context),
GuildId = map_utils:get_integer(State, id, undefined),
ViewerKeyResult =
resolve_viewer_stream_key(
Context, GuildId, ChannelIdValue, VoiceStates, ExistingVoiceState
),
PermCheck =
case IsChannelChange of
true ->
guild_voice_permissions:check_voice_permissions_and_limits(
UserId, ChannelIdValue, Channel, VoiceStates, State, false
);
false ->
{ok, allowed}
end,
case PermCheck of
{error, _Category, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
{ok, allowed} ->
case ViewerKeyResult of
{error, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
{ok, ParsedViewerKey} ->
Flags = voice_state_utils:voice_flags_from_context(Context),
guild_voice_state:update_voice_state_data(
ConnectionId,
NewChannelIdBin,
Flags,
Member,
ExistingVoiceState,
VoiceStates,
State,
false,
ParsedViewerKey
)
end
end
end.
handle_new_connection(Context, Member, Channel, VoiceStates, State) ->
UserId = maps:get(user_id, Context),
ChannelIdValue = maps:get(channel_id, Context),
GuildId = map_utils:get_integer(State, id, undefined),
ViewerKeyResult = resolve_viewer_stream_key(Context, GuildId, ChannelIdValue, VoiceStates, #{}),
PermCheck = guild_voice_permissions:check_voice_permissions_and_limits(
UserId, ChannelIdValue, Channel, VoiceStates, State, false
),
case {PermCheck, ViewerKeyResult} of
{{error, _Category, ErrorAtom}, _} ->
{reply, gateway_errors:error(ErrorAtom), State};
{{ok, allowed}, {error, ErrorAtom}} ->
{reply, gateway_errors:error(ErrorAtom), State};
{{ok, allowed}, {ok, ParsedViewerKey}} ->
get_voice_token_and_create_state(Context, Member, ParsedViewerKey, State)
end.
get_voice_token_and_create_state(Context, Member, ParsedViewerStreamKey, State) ->
ChannelIdValue = maps:get(channel_id, Context),
UserId = maps:get(user_id, Context),
SessionId = maps:get(session_id, Context),
case resolve_guild_identity(State) of
{error, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
{ok, GuildId, GuildIdBin} ->
logger:info(
"[guild_voice_connection] Requesting voice token for GuildId=~p, ChannelId=~p, UserId=~p",
[GuildId, ChannelIdValue, UserId]
),
VoicePermissions = voice_utils:compute_voice_permissions(UserId, ChannelIdValue, State),
logger:debug("[guild_voice_connection] Computed voice permissions: ~p", [
VoicePermissions
]),
case request_voice_token(GuildId, ChannelIdValue, UserId, VoicePermissions) of
{ok, TokenData} ->
logger:debug("[guild_voice_connection] Voice token request succeeded"),
Token = maps:get(token, TokenData),
Endpoint = maps:get(endpoint, TokenData),
ConnectionId = maps:get(connection_id, TokenData),
ChannelIdBin = integer_to_binary(ChannelIdValue),
UserIdBin = integer_to_binary(UserId),
ServerMute = maps:get(<<"mute">>, Member, false),
ServerDeaf = maps:get(<<"deaf">>, Member, false),
Flags = voice_state_utils:voice_flags_from_context(Context),
#voice_flags{
self_mute = SelfMuteFlag,
self_deaf = SelfDeafFlag,
self_video = SelfVideoFlag,
self_stream = SelfStreamFlag,
is_mobile = IsMobileFlag
} = Flags,
SessionIdValue = maps:get(session_id, Context, undefined),
SessionIdBin = normalize_session_id(SessionIdValue),
VoiceState0 = guild_voice_state:create_voice_state(
GuildIdBin,
ChannelIdBin,
UserIdBin,
ConnectionId,
ServerMute,
ServerDeaf,
Flags,
ParsedViewerStreamKey
),
VoiceState1 = maybe_attach_session_id(VoiceState0, SessionIdBin),
VoiceState = maybe_attach_member(VoiceState1, Member),
VoiceStates = voice_state_utils:voice_states(State),
NewVoiceStates = maps:put(ConnectionId, VoiceState, VoiceStates),
StateWithVoiceStates = maps:put(voice_states, NewVoiceStates, State),
guild_voice_broadcast:broadcast_voice_state_update(
VoiceState, StateWithVoiceStates, ChannelIdBin
),
PendingMetadata = #{
user_id => UserId,
guild_id => GuildId,
channel_id => ChannelIdValue,
session_id => SessionIdBin,
self_mute => SelfMuteFlag,
self_deaf => SelfDeafFlag,
self_video => SelfVideoFlag,
self_stream => SelfStreamFlag,
is_mobile => IsMobileFlag,
server_mute => ServerMute,
server_deaf => ServerDeaf,
member => Member,
viewer_stream_key => ParsedViewerStreamKey
},
PendingConnections = pending_voice_connections(StateWithVoiceStates),
NewPendingConnections = maps:put(
ConnectionId,
PendingMetadata,
PendingConnections
),
NewState = maps:put(
pending_voice_connections, NewPendingConnections, StateWithVoiceStates
),
maybe_broadcast_voice_server_update(
SessionId, GuildId, Token, Endpoint, ConnectionId, NewState
),
{reply,
#{
success => true,
token => Token,
endpoint => Endpoint,
connection_id => ConnectionId,
voice_state => VoiceState
},
NewState};
{error, Reason} ->
logger:error("[guild_voice_connection] Failed to request voice token: ~p", [
Reason
]),
{reply, gateway_errors:error(voice_token_failed), State}
end
end.
-spec build_context(map()) -> context().
build_context(Request0) ->
Request = map_utils:ensure_map(Request0),
RawConnectionId = maps:get(connection_id, Request, undefined),
#{
user_id => normalize_user_id(maps:get(user_id, Request, undefined)),
channel_id => normalize_channel_id_value(maps:get(channel_id, Request, null)),
session_id => maps:get(session_id, Request, undefined),
connection_id => normalize_connection_id(RawConnectionId),
raw_connection_id => RawConnectionId,
self_mute => normalize_boolean(maps:get(self_mute, Request, false)),
self_deaf => normalize_boolean(maps:get(self_deaf, Request, false)),
self_video => normalize_boolean(maps:get(self_video, Request, false)),
self_stream => normalize_boolean(maps:get(self_stream, Request, false)),
is_mobile => normalize_boolean(maps:get(is_mobile, Request, false)),
viewer_stream_key => maps:get(viewer_stream_key, Request, undefined)
}.
normalize_connection_id(undefined) ->
undefined;
normalize_connection_id(null) ->
undefined;
normalize_connection_id(ConnectionId) ->
ConnectionId.
-spec normalize_user_id(term()) -> integer() | undefined.
normalize_user_id(Value) ->
type_conv:to_integer(Value).
-spec normalize_channel_id_value(term()) -> integer() | null | undefined.
normalize_channel_id_value(null) ->
null;
normalize_channel_id_value(Value) ->
type_conv:to_integer(Value).
-spec normalize_boolean(term()) -> boolean().
normalize_boolean(true) -> true;
normalize_boolean(<<"true">>) -> true;
normalize_boolean(false) -> false;
normalize_boolean(<<"false">>) -> false;
normalize_boolean(_) -> false.
maybe_attach_session_id(VoiceState, undefined) ->
VoiceState;
maybe_attach_session_id(VoiceState, SessionId) when is_binary(SessionId) ->
maps:put(<<"session_id">>, SessionId, VoiceState).
maybe_attach_member(VoiceState, Member) when is_map(Member) ->
case maps:size(Member) of
0 -> VoiceState;
_ -> maps:put(<<"member">>, Member, VoiceState)
end.
normalize_session_id(undefined) ->
undefined;
normalize_session_id(null) ->
undefined;
normalize_session_id(SessionId) when is_binary(SessionId) ->
SessionId;
normalize_session_id(SessionId) when is_integer(SessionId) ->
integer_to_binary(SessionId);
normalize_session_id(SessionId) when is_list(SessionId) ->
list_to_binary(SessionId);
normalize_session_id(SessionId) ->
try
erlang:iolist_to_binary(SessionId)
catch
_:_ -> undefined
end.
resolve_viewer_stream_key(Context, GuildId, ChannelIdValue, VoiceStates, ExistingVoiceState) ->
RawKey = maps:get(viewer_stream_key, Context, undefined),
case RawKey of
undefined ->
{ok, maps:get(<<"viewer_stream_key">>, ExistingVoiceState, null)};
null ->
{ok, null};
_ when not is_binary(RawKey) ->
{error, voice_invalid_state};
_ ->
case voice_state_utils:parse_stream_key(RawKey) of
{error, _} ->
{error, voice_invalid_state};
{ok, #{
scope := guild,
guild_id := ParsedGuildId,
channel_id := ParsedChannelId,
connection_id := StreamConnId
}} when
is_integer(ChannelIdValue), ParsedChannelId =:= ChannelIdValue
->
GuildScopeCheck =
case GuildId of
undefined -> ok;
ParsedGuildId -> ok;
_ -> error
end,
case GuildScopeCheck of
ok ->
case maps:get(StreamConnId, VoiceStates, undefined) of
undefined ->
{error, voice_connection_not_found};
StreamVS ->
case
map_utils:get_integer(StreamVS, <<"channel_id">>, undefined)
of
ChannelIdValue -> {ok, RawKey};
_ -> {error, voice_invalid_state}
end
end;
error ->
{error, voice_invalid_state}
end;
{ok, #{scope := dm, channel_id := ParsedChannelId}} when
is_integer(ChannelIdValue), ParsedChannelId =:= ChannelIdValue
->
{ok, RawKey};
_ ->
{error, voice_invalid_state}
end
end.
resolve_voice_state_from_pending(ConnectionId, PendingData, State, VoiceStates) ->
case maps:get(ConnectionId, VoiceStates, undefined) of
VoiceState when is_map(VoiceState) ->
VoiceState;
_ ->
case maps:get(voice_state, PendingData, undefined) of
VoiceState when is_map(VoiceState) ->
VoiceState;
_ ->
build_voice_state_from_pending(PendingData, ConnectionId, State)
end
end.
build_voice_state_from_pending(PendingData, ConnectionId, State) ->
GuildIdState = map_utils:get_integer(State, id, undefined),
GuildId0 = pending_get_integer(PendingData, guild_id),
GuildId =
case GuildId0 of
undefined -> GuildIdState;
_ -> GuildId0
end,
ChannelId = pending_get_integer(PendingData, channel_id),
UserId = pending_get_integer(PendingData, user_id),
case {GuildId, ChannelId, UserId} of
{undefined, _, _} ->
undefined;
{_, undefined, _} ->
undefined;
{_, _, undefined} ->
undefined;
{GId, ChId, UId} ->
GuildIdBin = integer_to_binary(GId),
ChannelIdBin = integer_to_binary(ChId),
UserIdBin = integer_to_binary(UId),
Flags = #voice_flags{
self_mute = pending_get_boolean(PendingData, self_mute),
self_deaf = pending_get_boolean(PendingData, self_deaf),
self_video = pending_get_boolean(PendingData, self_video),
self_stream = pending_get_boolean(PendingData, self_stream),
is_mobile = pending_get_boolean(PendingData, is_mobile)
},
ServerMute = pending_get_boolean(PendingData, server_mute),
ServerDeaf = pending_get_boolean(PendingData, server_deaf),
ViewerStreamKey = pending_get_value(PendingData, viewer_stream_key),
VoiceState0 = guild_voice_state:create_voice_state(
GuildIdBin,
ChannelIdBin,
UserIdBin,
ConnectionId,
ServerMute,
ServerDeaf,
Flags,
ViewerStreamKey
),
SessionId = pending_get_binary(PendingData, session_id),
Member = pending_get_map(PendingData, member),
VoiceState1 = maybe_attach_session_id(VoiceState0, SessionId),
maybe_attach_member(VoiceState1, Member)
end.
pending_get_value(PendingData, Key) ->
case maps:get(Key, PendingData, undefined) of
undefined ->
BinKey = atom_to_binary(Key, utf8),
maps:get(BinKey, PendingData, undefined);
Value ->
Value
end.
pending_get_integer(PendingData, Key) ->
case pending_get_value(PendingData, Key) of
undefined -> undefined;
Value -> type_conv:to_integer(Value)
end.
pending_get_boolean(PendingData, Key) ->
case pending_get_value(PendingData, Key) of
true -> true;
false -> false;
_ -> false
end.
pending_get_binary(PendingData, Key) ->
case pending_get_value(PendingData, Key) of
undefined -> undefined;
Value -> type_conv:to_binary(Value)
end.
pending_get_map(PendingData, Key) ->
case pending_get_value(PendingData, Key) of
Map when is_map(Map) -> Map;
_ -> #{}
end.
resolve_guild_identity(State) ->
Data = guild_data(State),
DataGuildIdBin = maps:get(<<"id">>, Data, undefined),
StateGuildId = map_utils:get_integer(State, id, undefined),
GuildMeta = map_utils:ensure_map(maps:get(<<"guild">>, Data, #{})),
GuildMetaIdBin = maps:get(<<"id">>, GuildMeta, undefined),
resolve_guild_id_priority([
{DataGuildIdBin, fun normalize_guild_id/1},
{StateGuildId, fun normalize_guild_id/1},
{GuildMetaIdBin, fun normalize_guild_id/1}
]).
resolve_guild_id_priority([]) ->
logger:error("[guild_voice_connection] Missing guild id in state"),
{error, voice_guild_id_missing};
resolve_guild_id_priority([{undefined, _} | Rest]) ->
resolve_guild_id_priority(Rest);
resolve_guild_id_priority([{Value, NormalizeFun} | _]) ->
NormalizeFun(Value).
normalize_guild_id(Value) ->
case type_conv:to_integer(Value) of
undefined ->
?LOG_INVALID_GUILD_ID(Value),
{error, voice_invalid_guild_id};
Int ->
{ok, Int, guild_id_binary(Value, Int)}
end.
guild_id_binary(Value, Int) ->
case type_conv:to_binary(Value) of
undefined -> integer_to_binary(Int);
Bin -> Bin
end.
maybe_broadcast_voice_server_update(undefined, _GuildId, _Token, _Endpoint, _ConnectionId, _State) ->
ok;
maybe_broadcast_voice_server_update(null, _GuildId, _Token, _Endpoint, _ConnectionId, _State) ->
ok;
maybe_broadcast_voice_server_update(SessionId, GuildId, Token, Endpoint, ConnectionId, State) ->
guild_voice_broadcast:broadcast_voice_server_update_to_session(
GuildId, SessionId, Token, Endpoint, ConnectionId, State
).
guild_data(State) ->
map_utils:ensure_map(maps:get(data, State, #{})).
confirm_voice_connection_from_livekit(Request, State) ->
ConnectionId = maps:get(connection_id, Request, undefined),
logger:info(
"[guild_voice_connection] confirm_voice_connection_from_livekit connection_id=~p pending_count=~p",
[ConnectionId, maps:size(pending_voice_connections(State))]
),
case ConnectionId of
undefined ->
gateway_errors:error(voice_missing_connection_id);
_ ->
PendingConnections = pending_voice_connections(State),
case maps:get(ConnectionId, PendingConnections, undefined) of
undefined ->
logger:warning(
"[guild_voice_connection] confirm_voice_connection_from_livekit missing pending connection_id=~p",
[ConnectionId]
),
gateway_errors:error(voice_connection_not_found);
PendingData ->
logger:info(
"[guild_voice_connection] Found pending connection_id=~p for guild=~p",
[ConnectionId, map_utils:get_integer(State, id, 0)]
),
VoiceStates = voice_state_utils:voice_states(State),
VoiceState = resolve_voice_state_from_pending(
ConnectionId, PendingData, State, VoiceStates
),
NewPendingConnections = maps:remove(ConnectionId, PendingConnections),
StateWithoutPending = maps:put(
pending_voice_connections, NewPendingConnections, State
),
case VoiceState of
undefined ->
logger:warning(
"[guild_voice_connection] Missing voice_state for confirmed connection ~p",
[ConnectionId]
),
{reply, #{success => true}, StateWithoutPending};
_ ->
UpdatedVoiceStates = maps:put(ConnectionId, VoiceState, VoiceStates),
StateWithVoiceStates = maps:put(
voice_states, UpdatedVoiceStates, StateWithoutPending
),
ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
UserId = maps:get(<<"user_id">>, VoiceState, <<"unknown">>),
GuildId = maps:get(id, StateWithVoiceStates, 0),
Sessions = maps:get(sessions, StateWithVoiceStates, #{}),
logger:info(
"[guild_voice_connection] confirm_voice_connection_from_livekit: "
"guild_id=~p user_id=~p channel_id=~p connection_id=~p sessions_count=~p",
[GuildId, UserId, ChannelIdBin, ConnectionId, maps:size(Sessions)]
),
guild_voice_broadcast:broadcast_voice_state_update(
VoiceState, StateWithVoiceStates, ChannelIdBin
),
{reply, #{success => true}, StateWithVoiceStates}
end
end
end.
-spec request_voice_token(integer(), integer(), integer(), map()) ->
{ok, map()} | {error, term()}.
request_voice_token(GuildId, ChannelId, UserId, VoicePermissions) ->
Req = voice_utils:build_voice_token_rpc_request(
GuildId, ChannelId, UserId, null, null, null, VoicePermissions
),
case rpc_client:call(Req) of
{ok, Data} ->
{ok, #{
token => maps:get(<<"token">>, Data),
endpoint => maps:get(<<"endpoint">>, Data),
connection_id => maps:get(<<"connectionId">>, Data)
}};
{error, Reason} ->
logger:error("[guild_voice_connection] RPC request failed: ~p", [Reason]),
{error, Reason}
end.
-spec pending_voice_connections(guild_state()) -> pending_voice_connections().
pending_voice_connections(State) ->
case maps:get(pending_voice_connections, State, undefined) of
Map when is_map(Map) -> Map;
_ -> #{}
end.
-ifdef(TEST).
build_context_normalizes_fields_test() ->
Request = #{
user_id => <<"42">>,
channel_id => <<"99">>,
connection_id => <<"conn">>,
self_mute => true,
self_deaf => <<"nope">>,
self_video => true,
self_stream => false,
is_mobile => <<"yes">>
},
Context = build_context(Request),
?assertEqual(42, maps:get(user_id, Context)),
?assertEqual(99, maps:get(channel_id, Context)),
?assertEqual(<<"conn">>, maps:get(connection_id, Context)),
?assertEqual(true, maps:get(self_mute, Context)),
?assertEqual(false, maps:get(self_deaf, Context)),
?assertEqual(true, maps:get(self_video, Context)),
?assertEqual(false, maps:get(self_stream, Context)),
?assertEqual(false, maps:get(is_mobile, Context)).
resolve_guild_identity_prefers_data_test() ->
State = #{
id => 7,
data => #{
<<"id">> => <<"555">>,
<<"guild">> => #{<<"id">> => <<"111">>}
}
},
?assertMatch({ok, 555, <<"555">>}, resolve_guild_identity(State)).
normalize_guild_id_invalid_test() ->
?assertMatch({error, voice_invalid_guild_id}, normalize_guild_id(foo)).
voice_state_update_invalid_user_id_test() ->
{error, validation_error, voice_invalid_user_id} =
voice_state_update(#{channel_id => null}, #{}).
-endif.

View File

@@ -0,0 +1,319 @@
%% 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_disconnect).
-export([handle_voice_disconnect/5]).
-export([force_disconnect_participant/4]).
-export([disconnect_voice_user/2]).
-export([disconnect_voice_user_if_in_channel/2]).
-export([disconnect_all_voice_users_in_channel/2]).
-export([cleanup_virtual_channel_access_for_user/2]).
-type guild_state() :: map().
-type voice_state() :: map().
-type voice_state_map() :: #{binary() => voice_state()}.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec handle_voice_disconnect(
binary() | undefined,
term(),
integer(),
voice_state_map() | term(),
guild_state()
) -> {reply, map(), guild_state()}.
handle_voice_disconnect(undefined, _SessionId, _UserId, _VoiceStates, State) ->
{reply, gateway_errors:error(voice_missing_connection_id), State};
handle_voice_disconnect(ConnectionId, _SessionId, UserId, VoiceStates0, State) ->
VoiceStates = voice_state_utils:ensure_voice_states(VoiceStates0),
case maps:get(ConnectionId, VoiceStates, undefined) of
undefined ->
{reply, #{success => true}, State};
OldVoiceState ->
case guild_voice_state:user_matches_voice_state(OldVoiceState, UserId) of
false ->
{reply, gateway_errors:error(voice_user_mismatch), State};
true ->
case
{
voice_state_utils:voice_state_guild_id(OldVoiceState),
voice_state_utils:voice_state_channel_id(OldVoiceState)
}
of
{undefined, _} ->
{reply, gateway_errors:error(voice_invalid_state), State};
{_, undefined} ->
{reply, gateway_errors:error(voice_invalid_state), State};
{GuildId, ChannelId} ->
maybe_force_disconnect(GuildId, ChannelId, UserId, ConnectionId, State),
NewVoiceStates = maps:remove(ConnectionId, VoiceStates),
NewState = maps:put(voice_states, NewVoiceStates, State),
voice_state_utils:broadcast_disconnects(
#{ConnectionId => OldVoiceState}, NewState
),
FinalState = cleanup_virtual_channel_access_for_user(UserId, NewState),
{reply, #{success => true}, FinalState}
end
end
end.
-spec disconnect_voice_user(map(), guild_state()) -> {reply, map(), guild_state()}.
disconnect_voice_user(#{user_id := UserId} = Request, State) ->
ConnectionId = maps:get(connection_id, Request, null),
VoiceStates = voice_state_utils:voice_states(State),
case ConnectionId of
null ->
UserVoiceStates = voice_state_utils:filter_voice_states(VoiceStates, fun(_, V) ->
voice_state_utils:voice_state_user_id(V) =:= UserId
end),
case maps:size(UserVoiceStates) of
0 ->
{reply, #{success => true}, State};
_ ->
NewVoiceStates = voice_state_utils:drop_voice_states(
UserVoiceStates, VoiceStates
),
NewState = maps:put(voice_states, NewVoiceStates, State),
voice_state_utils:broadcast_disconnects(UserVoiceStates, NewState),
FinalState = cleanup_virtual_channel_access_for_user(UserId, NewState),
{reply, #{success => true}, FinalState}
end;
SpecificConnection ->
case maps:get(SpecificConnection, VoiceStates, undefined) of
undefined ->
{reply, #{success => true}, State};
VoiceState ->
case voice_state_utils:voice_state_user_id(VoiceState) of
undefined ->
{reply, gateway_errors:error(voice_invalid_state), State};
VoiceStateUserId when VoiceStateUserId =:= UserId ->
NewVoiceStates = maps:remove(SpecificConnection, VoiceStates),
NewState = maps:put(voice_states, NewVoiceStates, State),
voice_state_utils:broadcast_disconnects(
#{SpecificConnection => VoiceState}, NewState
),
FinalState = cleanup_virtual_channel_access_for_user(UserId, NewState),
{reply, #{success => true}, FinalState};
_ ->
{reply, gateway_errors:error(voice_user_mismatch), State}
end
end
end.
disconnect_voice_user_if_in_channel(
#{user_id := UserId, expected_channel_id := ExpectedChannelId} = Request,
State
) ->
ConnectionId = maps:get(connection_id, Request, undefined),
VoiceStates = voice_state_utils:voice_states(State),
case ConnectionId of
undefined ->
UserVoiceStates = voice_state_utils:filter_voice_states(VoiceStates, fun(_, V) ->
voice_state_utils:voice_state_user_id(V) =:= UserId andalso
voice_state_utils:voice_state_channel_id(V) =:= ExpectedChannelId
end),
case maps:size(UserVoiceStates) of
0 ->
{reply,
#{
success => true,
ignored => true,
reason => <<"not_in_expected_channel">>
},
State};
_ ->
NewVoiceStates = voice_state_utils:drop_voice_states(
UserVoiceStates, VoiceStates
),
NewState = maps:put(voice_states, NewVoiceStates, State),
voice_state_utils:broadcast_disconnects(UserVoiceStates, NewState),
{reply, #{success => true}, NewState}
end;
ConnId ->
case maps:get(ConnId, VoiceStates, undefined) of
undefined ->
{reply,
#{success => true, ignored => true, reason => <<"connection_not_found">>},
State};
VoiceState ->
case
{
voice_state_utils:voice_state_user_id(VoiceState),
voice_state_utils:voice_state_channel_id(VoiceState)
}
of
{UserId, ExpectedChannelId} ->
NewVoiceStates = maps:remove(ConnId, VoiceStates),
NewState = maps:put(voice_states, NewVoiceStates, State),
voice_state_utils:broadcast_disconnects(
#{ConnId => VoiceState}, NewState
),
{reply, #{success => true}, NewState};
_ ->
{reply,
#{
success => true,
ignored => true,
reason => <<"user_or_channel_mismatch">>
},
State}
end
end
end.
-spec disconnect_all_voice_users_in_channel(map(), guild_state()) -> {reply, map(), guild_state()}.
disconnect_all_voice_users_in_channel(#{channel_id := ChannelId}, State) ->
VoiceStates = voice_state_utils:voice_states(State),
ChannelVoiceStates = voice_state_utils:filter_voice_states(VoiceStates, fun(_, V) ->
voice_state_utils:voice_state_channel_id(V) =:= ChannelId
end),
case maps:size(ChannelVoiceStates) of
0 ->
{reply, #{success => true, disconnected_count => 0}, State};
Count ->
NewVoiceStates = voice_state_utils:drop_voice_states(ChannelVoiceStates, VoiceStates),
NewState = maps:put(voice_states, NewVoiceStates, State),
voice_state_utils:broadcast_disconnects(ChannelVoiceStates, NewState),
{reply, #{success => true, disconnected_count => Count}, NewState}
end.
-spec force_disconnect_participant(integer(), integer(), integer(), binary()) ->
{ok, map()} | {error, term()}.
force_disconnect_participant(GuildId, ChannelId, UserId, ConnectionId) ->
Req = voice_utils:build_force_disconnect_rpc_request(GuildId, ChannelId, UserId, ConnectionId),
case rpc_client:call(Req) of
{ok, _Data} ->
logger:debug(
"[guild_voice_disconnect] Force disconnected participant via RPC ~p",
[
[
{guildId, GuildId},
{channelId, ChannelId},
{userId, UserId},
{connectionId, ConnectionId}
]
]
),
{ok, #{success => true}};
{error, Reason} ->
logger:error(
"[guild_voice_disconnect] Failed to force disconnect participant via RPC ~p",
[
[
{guildId, GuildId},
{channelId, ChannelId},
{userId, UserId},
{connectionId, ConnectionId},
{error, Reason}
]
]
),
{error, Reason}
end.
cleanup_virtual_channel_access_for_user(UserId, State) ->
VoiceStates = voice_state_utils:voice_states(State),
HasVoiceConnection = maps:fold(
fun(_ConnId, VoiceState, Acc) ->
case Acc of
true -> true;
false -> voice_state_utils:voice_state_user_id(VoiceState) =:= UserId
end
end,
false,
VoiceStates
),
case HasVoiceConnection of
true ->
State;
false ->
VirtualChannels = guild_virtual_channel_access:get_virtual_channels_for_user(
UserId, State
),
lists:foldl(
fun(ChannelId, AccState) ->
Member = guild_permissions:find_member_by_user_id(UserId, AccState),
case Member of
undefined ->
AccState;
_ ->
HasViewPermission = guild_permissions:can_view_channel_by_permissions(
UserId, ChannelId, Member, AccState
),
case HasViewPermission of
true ->
guild_virtual_channel_access:remove_virtual_access(
UserId, ChannelId, AccState
);
false ->
guild_virtual_channel_access:dispatch_channel_visibility_change(
UserId, ChannelId, remove, AccState
),
guild_virtual_channel_access:remove_virtual_access(
UserId, ChannelId, AccState
)
end
end
end,
State,
VirtualChannels
)
end.
maybe_force_disconnect(GuildId, ChannelId, UserId, ConnectionId, State) ->
case maps:get(test_force_disconnect_fun, State, undefined) of
Fun when is_function(Fun, 4) ->
Fun(GuildId, ChannelId, UserId, ConnectionId);
_ ->
force_disconnect_participant(GuildId, ChannelId, UserId, ConnectionId)
end.
-ifdef(TEST).
disconnect_voice_user_removes_all_connections_test() ->
VoiceStates = #{
<<"a">> => voice_state_fixture(5, 10, 20),
<<"b">> => voice_state_fixture(5, 10, 21)
},
State = #{voice_states => VoiceStates},
{reply, #{success := true}, #{voice_states := #{}}} =
disconnect_voice_user(#{user_id => 5, connection_id => null}, State).
handle_voice_disconnect_invalid_state_test() ->
VoiceState = #{<<"user_id">> => <<"5">>},
VoiceStates = #{<<"conn">> => VoiceState},
State = #{voice_states => VoiceStates},
{reply, {error, validation_error, _}, _} =
handle_voice_disconnect(<<"conn">>, undefined, 5, VoiceStates, State).
disconnect_voice_user_if_in_channel_ignored_test() ->
VoiceStates = #{},
State = #{voice_states => VoiceStates},
{reply, #{ignored := true}, _} =
disconnect_voice_user_if_in_channel(#{user_id => 5, expected_channel_id => 99}, State).
voice_state_fixture(UserId, GuildId, ChannelId) ->
#{
<<"user_id">> => integer_to_binary(UserId),
<<"guild_id">> => integer_to_binary(GuildId),
<<"channel_id">> => integer_to_binary(ChannelId)
}.
-endif.

View 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_voice_member).
-export([update_member_voice/2]).
-export([find_member_by_user_id/2]).
-export([find_channel_by_id/2]).
-type guild_state() :: map().
-type guild_reply(T) :: {reply, T, guild_state()}.
-type member() :: map().
-type voice_state() :: map().
-type request() :: #{
user_id := integer(),
mute := boolean(),
deaf := boolean()
}.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec update_member_voice(request(), guild_state()) -> guild_reply(map()).
update_member_voice(Request, State) ->
#{user_id := UserId, mute := Mute, deaf := Deaf} = Request,
VoiceStates = voice_state_utils:voice_states(State),
GuildId = map_utils:get_integer(State, id, 0),
case find_member_by_user_id(UserId, State) of
undefined ->
{reply, gateway_errors:error(voice_member_not_found), State};
Member ->
UpdatedMember = set_member_voice_flags(Member, Mute, Deaf),
StateWithUpdatedMember = store_member(UpdatedMember, State),
UserVoiceStates = user_voice_states(UserId, VoiceStates),
case maps:size(UserVoiceStates) of
0 ->
{reply, #{success => true}, StateWithUpdatedMember};
_ ->
maybe_enforce_voice_states(
GuildId, UserId, Mute, Deaf, UserVoiceStates, State
),
{NewVoiceStates, UpdatedStates} =
update_voice_states(UserVoiceStates, VoiceStates, Mute, Deaf),
FinalState = maps:put(voice_states, NewVoiceStates, StateWithUpdatedMember),
broadcast_voice_state_updates(UpdatedStates, FinalState),
{reply, #{success => true}, FinalState}
end
end.
find_member_by_user_id(UserId, State) ->
guild_permissions:find_member_by_user_id(UserId, State).
find_channel_by_id(ChannelId, State) ->
guild_permissions:find_channel_by_id(ChannelId, State).
enforce_participant_state_in_livekit(GuildId, ChannelId, UserId, Mute, Deaf) ->
Req = voice_utils:build_update_participant_rpc_request(GuildId, ChannelId, UserId, Mute, Deaf),
case rpc_client:call(Req) of
{ok, _Data} ->
logger:debug(
"[guild_voice_member] Enforced participant state in LiveKit ~p",
[
[
{guildId, GuildId},
{channelId, ChannelId},
{userId, UserId},
{mute, Mute},
{deaf, Deaf}
]
]
),
ok;
{error, Reason} ->
logger:warning(
"[guild_voice_member] Failed to enforce participant state in LiveKit ~p",
[
[
{guildId, GuildId},
{channelId, ChannelId},
{userId, UserId},
{mute, Mute},
{deaf, Deaf},
{error, Reason}
]
]
),
ok
end.
-spec guild_data(guild_state()) -> map().
guild_data(State) ->
map_utils:ensure_map(map_utils:get_safe(State, data, #{})).
-spec guild_members(guild_state()) -> [member()].
guild_members(State) ->
map_utils:ensure_list(maps:get(<<"members">>, guild_data(State), [])).
-spec member_user_id(member()) -> integer() | undefined.
member_user_id(Member) when is_map(Member) ->
User = map_utils:ensure_map(maps:get(<<"user">>, Member, #{})),
map_utils:get_integer(User, <<"id">>, undefined);
member_user_id(_) ->
undefined.
-spec set_member_voice_flags(member(), boolean(), boolean()) -> member().
set_member_voice_flags(Member, Mute, Deaf) ->
Member#{<<"mute">> => Mute, <<"deaf">> => Deaf}.
-spec store_member(member(), guild_state()) -> guild_state().
store_member(Member, State) ->
case member_user_id(Member) of
undefined ->
State;
TargetId ->
Data = guild_data(State),
Members = guild_members(State),
UpdatedMembers = lists:map(
fun(Current) ->
case member_user_id(Current) of
TargetId -> Member;
_ -> Current
end
end,
Members
),
UpdatedData = maps:put(<<"members">>, UpdatedMembers, Data),
maps:put(data, UpdatedData, State)
end.
-spec user_voice_states(integer(), map()) -> map().
user_voice_states(UserId, VoiceStates) when is_integer(UserId), is_map(VoiceStates) ->
maps:filter(
fun(_ConnId, VoiceState) ->
voice_state_utils:voice_state_user_id(VoiceState) =:= UserId
end,
VoiceStates
);
user_voice_states(_UserId, _VoiceStates) ->
#{}.
-spec update_voice_states(map(), map(), boolean(), boolean()) -> {map(), [voice_state()]}.
update_voice_states(UserVoiceStates, VoiceStates, Mute, Deaf) ->
maps:fold(
fun(ConnId, VoiceState, {AccVoiceStates, AccUpdated}) ->
UpdatedVoiceState = update_voice_state_flags(VoiceState, Mute, Deaf),
{maps:put(ConnId, UpdatedVoiceState, AccVoiceStates), [UpdatedVoiceState | AccUpdated]}
end,
{VoiceStates, []},
UserVoiceStates
).
-spec update_voice_state_flags(voice_state(), boolean(), boolean()) -> voice_state().
update_voice_state_flags(VoiceState, Mute, Deaf) ->
OldVersion = maps:get(<<"version">>, VoiceState, 0),
VoiceState#{<<"mute">> => Mute, <<"deaf">> => Deaf, <<"version">> => OldVersion + 1}.
-spec maybe_enforce_voice_states(integer(), integer(), boolean(), boolean(), map(), guild_state()) ->
ok.
maybe_enforce_voice_states(GuildId, UserId, Mute, Deaf, VoiceStates, State) ->
maps:foreach(
fun(_ConnId, VoiceState) ->
case voice_state_utils:voice_state_channel_id(VoiceState) of
ChannelId when is_integer(ChannelId) ->
dispatch_livekit_enforcement(GuildId, ChannelId, UserId, Mute, Deaf, State);
_ ->
ok
end
end,
VoiceStates
).
-spec dispatch_livekit_enforcement(
integer(), integer(), integer(), boolean(), boolean(), guild_state()
) -> ok.
dispatch_livekit_enforcement(GuildId, ChannelId, UserId, Mute, Deaf, State) ->
case maps:get(test_livekit_fun, State, undefined) of
Fun when is_function(Fun, 5) ->
Fun(GuildId, ChannelId, UserId, Mute, Deaf);
_ ->
spawn(fun() ->
enforce_participant_state_in_livekit(GuildId, ChannelId, UserId, Mute, Deaf)
end)
end.
-spec broadcast_voice_state_updates([voice_state()], guild_state()) -> ok.
broadcast_voice_state_updates([], _State) ->
ok;
broadcast_voice_state_updates(UpdatedStates, State) ->
lists:foreach(
fun(UpdatedVoiceState) ->
ChannelIdBin = maps:get(<<"channel_id">>, UpdatedVoiceState, null),
guild_voice_broadcast:broadcast_voice_state_update(
UpdatedVoiceState, State, ChannelIdBin
)
end,
UpdatedStates
).
-ifdef(TEST).
update_member_voice_updates_member_flags_test() ->
State = voice_member_test_state(#{}),
Request = #{user_id => 10, mute => true, deaf => false},
{reply, #{success := true}, UpdatedState} = update_member_voice(Request, State),
Member = find_member_by_user_id(10, UpdatedState),
?assertEqual(true, maps:get(<<"mute">>, Member)),
?assertEqual(false, maps:get(<<"deaf">>, Member)).
update_member_voice_updates_voice_states_test() ->
Self = self(),
VoiceState = voice_state_fixture(10, 500),
TestFun = fun(GuildId, ChannelId, UserId, Mute, Deaf) ->
Self ! {enforced, GuildId, ChannelId, UserId, Mute, Deaf}
end,
State = voice_member_test_state(#{
voice_states => #{<<"conn">> => VoiceState},
test_livekit_fun => TestFun
}),
Request = #{user_id => 10, mute => true, deaf => true},
{reply, #{success := true}, UpdatedState} = update_member_voice(Request, State),
UpdatedVoiceStates = maps:get(voice_states, UpdatedState),
UpdatedVoiceState = maps:get(<<"conn">>, UpdatedVoiceStates),
?assertEqual(true, maps:get(<<"mute">>, UpdatedVoiceState)),
?assertEqual(true, maps:get(<<"deaf">>, UpdatedVoiceState)),
?assertEqual(1, maps:get(<<"version">>, UpdatedVoiceState)),
receive
{enforced, 42, 500, 10, true, true} -> ok
after 100 ->
?assert(false)
end.
voice_member_test_state(Overrides) ->
BaseData = #{
<<"members">> => [member_fixture(10)]
},
BaseState = #{
id => 42,
data => BaseData,
voice_states => #{}
},
maps:merge(BaseState, Overrides).
member_fixture(UserId) ->
#{
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
<<"mute">> => false,
<<"deaf">> => false
}.
voice_state_fixture(UserId, ChannelId) ->
#{
<<"user_id">> => integer_to_binary(UserId),
<<"channel_id">> => integer_to_binary(ChannelId),
<<"connection_id">> => <<"test-conn">>,
<<"mute">> => false,
<<"deaf">> => false,
<<"version">> => 0,
<<"member">> => member_fixture(UserId)
}.
-endif.

View File

@@ -0,0 +1,378 @@
%% 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_move).
-export([move_member/2]).
-export([send_voice_server_update_for_move/5]).
-export([send_voice_server_updates_for_move/4]).
-type guild_state() :: map().
-type move_request() :: #{
user_id := integer(),
moderator_id := integer(),
channel_id := integer() | null,
connection_id => binary() | null,
mute := boolean(),
deaf := boolean()
}.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec move_member(move_request(), guild_state()) -> {reply, map(), guild_state()}.
move_member(Request, State) ->
#{
user_id := UserId,
moderator_id := ModeratorId,
channel_id := ChannelIdRaw
} = Request,
ConnectionId = maps:get(connection_id, Request, null),
ChannelId = normalize_channel_id(ChannelIdRaw),
VoiceStates = voice_state_utils:voice_states(State),
UserVoiceStates = find_user_voice_states(UserId, VoiceStates),
case maps:size(UserVoiceStates) of
0 ->
{reply, gateway_errors:error(voice_user_not_in_voice), State};
_ ->
ConnectionsToMove = select_connections_to_move(
ConnectionId, UserId, VoiceStates, UserVoiceStates
),
handle_move(
ConnectionsToMove, ChannelId, UserId, ModeratorId, ConnectionId, VoiceStates, State
)
end.
find_user_voice_states(UserId, VoiceStates) ->
maps:filter(
fun(_ConnId, VoiceState) ->
voice_state_utils:voice_state_user_id(VoiceState) =:= UserId
end,
VoiceStates
).
select_connections_to_move(null, _UserId, _VoiceStates, UserVoiceStates) ->
UserVoiceStates;
select_connections_to_move(ConnectionId, UserId, VoiceStates, _UserVoiceStates) ->
case maps:get(ConnectionId, VoiceStates, undefined) of
undefined ->
#{};
VoiceState ->
case voice_state_utils:voice_state_user_id(VoiceState) of
UserId ->
#{ConnectionId => VoiceState};
_ ->
#{}
end
end.
handle_move(ConnectionsToMove, ChannelId, UserId, ModeratorId, ConnectionId, VoiceStates, State) ->
logger:info(
"[guild_voice_move] handle_move user_id=~p moderator_id=~p channel_id=~p connection_id=~p connections=~p",
[UserId, ModeratorId, ChannelId, ConnectionId, maps:keys(ConnectionsToMove)]
),
case maps:size(ConnectionsToMove) of
0 ->
Error =
case ConnectionId of
null -> gateway_errors:error(voice_user_not_in_voice);
_ -> gateway_errors:error(voice_connection_not_found)
end,
{reply, Error, State};
_ ->
case ChannelId of
null ->
handle_disconnect_move(ConnectionsToMove, UserId, VoiceStates, State);
ChannelIdValue ->
handle_channel_move(
ConnectionsToMove, ChannelIdValue, UserId, ModeratorId, VoiceStates, State
)
end
end.
handle_disconnect_move(ConnectionsToMove, UserId, VoiceStates, State) ->
NewVoiceStates = maps:fold(
fun(ConnId, _VoiceState, Acc) -> maps:remove(ConnId, Acc) end,
VoiceStates,
ConnectionsToMove
),
NewState = maps:put(voice_states, NewVoiceStates, State),
maps:foreach(
fun(_ConnId, VoiceState) ->
OldChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
DisconnectVoiceState = maps:put(<<"channel_id">>, null, VoiceState),
guild_voice_broadcast:broadcast_voice_state_update(
DisconnectVoiceState, NewState, OldChannelIdBin
)
end,
ConnectionsToMove
),
{reply, #{success => true, user_id => UserId, connections_moved => ConnectionsToMove},
NewState}.
handle_channel_move(ConnectionsToMove, ChannelIdValue, UserId, ModeratorId, VoiceStates, State) ->
logger:info(
"[guild_voice_move] handle_channel_move user_id=~p moderator_id=~p target_channel_id=~p connections=~p",
[UserId, ModeratorId, ChannelIdValue, maps:keys(ConnectionsToMove)]
),
Channel = guild_voice_member:find_channel_by_id(ChannelIdValue, State),
case Channel of
undefined ->
{reply, gateway_errors:error(voice_channel_not_found), State};
_ ->
ChannelType = maps:get(<<"type">>, Channel, 0),
case ChannelType of
2 ->
check_move_permissions_and_execute(
ConnectionsToMove, ChannelIdValue, UserId, ModeratorId, VoiceStates, State
);
_ ->
{reply, gateway_errors:error(voice_channel_not_voice), State}
end
end.
check_move_permissions_and_execute(
ConnectionsToMove, ChannelIdValue, _UserId, ModeratorId, VoiceStates, State
) ->
ViewPerm = constants:view_channel_permission(),
ConnectPerm = constants:connect_permission(),
ModPerms = guild_permissions:get_member_permissions(ModeratorId, ChannelIdValue, State),
ModHasConnect = (ModPerms band ConnectPerm) =:= ConnectPerm,
ModHasView = (ModPerms band ViewPerm) =:= ViewPerm,
case ModHasConnect andalso ModHasView of
false ->
{reply, gateway_errors:error(voice_moderator_missing_connect), State};
true ->
execute_move(ConnectionsToMove, VoiceStates, State)
end.
execute_move(ConnectionsToMove, VoiceStates, State) ->
NewVoiceStates = maps:fold(
fun(ConnId, _VoiceState, Acc) -> maps:remove(ConnId, Acc) end,
VoiceStates,
ConnectionsToMove
),
StateAfterDisconnect = maps:put(voice_states, NewVoiceStates, State),
maps:foreach(
fun(_ConnId, VoiceState) ->
OldChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
DisconnectVoiceState = maps:put(<<"channel_id">>, null, VoiceState),
guild_voice_broadcast:broadcast_voice_state_update(
DisconnectVoiceState, StateAfterDisconnect, OldChannelIdBin
)
end,
ConnectionsToMove
),
SessionData = extract_session_data(ConnectionsToMove),
{reply,
#{
success => true,
needs_token => true,
session_data => SessionData,
connections_to_move => ConnectionsToMove
},
StateAfterDisconnect}.
extract_session_data(ConnectionsToMove) ->
{_ConnectionIds, SessionData} = maps:fold(
fun(ConnId, VoiceState, {AccConnIds, AccSessionData}) ->
SessionInfo = guild_voice_state:extract_session_info_from_voice_state(
ConnId, VoiceState
),
{[ConnId | AccConnIds], [SessionInfo | AccSessionData]}
end,
{[], []},
ConnectionsToMove
),
SessionData.
-spec normalize_channel_id(term()) -> integer() | null.
normalize_channel_id(null) ->
null;
normalize_channel_id(Value) ->
case type_conv:to_integer(Value) of
undefined -> null;
Int -> Int
end.
-spec member_user_id(map()) -> integer() | undefined.
member_user_id(Member) ->
User = map_utils:ensure_map(maps:get(<<"user">>, map_utils:ensure_map(Member), #{})),
map_utils:get_integer(User, <<"id">>, undefined).
-ifdef(TEST).
move_member_user_not_in_voice_test() ->
Request = #{
user_id => 10,
moderator_id => 20,
channel_id => null,
mute => false,
deaf => false
},
State = test_state(#{}),
{reply, {error, not_found, voice_user_not_in_voice}, _} = move_member(Request, State).
find_user_voice_states_filters_test() ->
VoiceStates = #{
<<"conn-a">> => voice_state_fixture(10, 100, <<"conn-a">>),
<<"conn-b">> => voice_state_fixture(11, 101, <<"conn-b">>)
},
Result = find_user_voice_states(10, VoiceStates),
?assertEqual(#{<<"conn-a">> => maps:get(<<"conn-a">>, VoiceStates)}, Result).
select_connections_to_move_specific_connection_test() ->
VoiceStates = #{
<<"conn-a">> => voice_state_fixture(10, 100, <<"conn-a">>),
<<"conn-b">> => voice_state_fixture(11, 101, <<"conn-b">>)
},
Selected = select_connections_to_move(<<"conn-b">>, 11, VoiceStates, #{}),
?assertEqual(#{<<"conn-b">> => maps:get(<<"conn-b">>, VoiceStates)}, Selected),
?assertEqual(#{}, select_connections_to_move(<<"conn-b">>, 10, VoiceStates, #{})).
test_state(VoiceStates) ->
#{
id => 1,
data => #{
<<"members">> => [],
<<"channels">> => []
},
voice_states => VoiceStates
}.
voice_state_fixture(UserId, ChannelId, ConnId) ->
#{
<<"user_id">> => integer_to_binary(UserId),
<<"channel_id">> => integer_to_binary(ChannelId),
<<"connection_id">> => ConnId,
<<"member">> => #{
<<"user">> => #{<<"id">> => integer_to_binary(UserId)}
}
}.
-endif.
send_voice_server_update_for_move(GuildId, ChannelId, UserId, SessionId, GuildPid) ->
case SessionId of
undefined ->
ok;
_ ->
case gen_server:call(GuildPid, {get_sessions}, 10000) of
State when is_map(State) ->
VoicePermissions = voice_utils:compute_voice_permissions(
UserId, ChannelId, State
),
case
guild_voice_connection:request_voice_token(
GuildId, ChannelId, UserId, VoicePermissions
)
of
{ok, TokenData} ->
Token = maps:get(token, TokenData),
Endpoint = maps:get(endpoint, TokenData),
ConnectionId = maps:get(connection_id, TokenData),
guild_voice_broadcast:broadcast_voice_server_update_to_session(
GuildId, SessionId, Token, Endpoint, ConnectionId, State
);
{error, _Reason} ->
ok
end;
_ ->
ok
end
end.
send_voice_server_updates_for_move(GuildId, ChannelId, SessionDataList, GuildPid) ->
lists:foreach(
fun(SessionInfo) ->
send_single_voice_server_update(GuildId, ChannelId, SessionInfo, GuildPid)
end,
SessionDataList
).
send_single_voice_server_update(GuildId, ChannelId, SessionInfo, GuildPid) ->
SessionId = maps:get(session_id, SessionInfo),
SelfMute = maps:get(self_mute, SessionInfo),
SelfDeaf = maps:get(self_deaf, SessionInfo),
SelfVideo = maps:get(self_video, SessionInfo),
SelfStream = maps:get(self_stream, SessionInfo),
IsMobile = maps:get(is_mobile, SessionInfo),
Member = maps:get(member, SessionInfo),
ServerMute = maps:get(<<"mute">>, Member, false),
ServerDeaf = maps:get(<<"deaf">>, Member, false),
case member_user_id(Member) of
undefined ->
logger:warning(
"[guild_voice_move] Missing user_id in member while sending voice server update: ~p",
[SessionInfo]
),
ok;
UserId ->
case gen_server:call(GuildPid, {get_sessions}, 10000) of
StateData when is_map(StateData) ->
VoicePermissions = voice_utils:compute_voice_permissions(
UserId, ChannelId, StateData
),
case
guild_voice_connection:request_voice_token(
GuildId, ChannelId, UserId, VoicePermissions
)
of
{ok, TokenData} ->
Token = maps:get(token, TokenData),
Endpoint = maps:get(endpoint, TokenData),
NewConnectionId = maps:get(connection_id, TokenData),
PendingMetadata = #{
<<"user_id">> => UserId,
<<"guild_id">> => GuildId,
<<"channel_id">> => ChannelId,
<<"connection_id">> => NewConnectionId,
<<"session_id">> => SessionId,
<<"self_mute">> => SelfMute,
<<"self_deaf">> => SelfDeaf,
<<"self_video">> => SelfVideo,
<<"self_stream">> => SelfStream,
<<"is_mobile">> => IsMobile,
<<"server_mute">> => ServerMute,
<<"server_deaf">> => ServerDeaf,
<<"member">> => Member
},
gen_server:cast(
GuildPid,
{store_pending_connection, NewConnectionId, PendingMetadata}
),
guild_voice_broadcast:broadcast_voice_server_update_to_session(
GuildId, SessionId, Token, Endpoint, NewConnectionId, StateData
);
{error, _Reason} ->
ok
end;
_ ->
ok
end
end.

View File

@@ -0,0 +1,304 @@
%% 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_permission_sync).
-export([
sync_user_voice_permissions/2,
sync_all_voice_permissions_for_channel/2,
maybe_sync_permissions_on_role_update/2,
maybe_sync_permissions_on_member_update/2
]).
-type guild_state() :: map().
-type voice_state() :: map().
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec sync_user_voice_permissions(integer(), guild_state()) -> ok.
sync_user_voice_permissions(UserId, State) ->
VoiceStates = voice_state_utils:voice_states(State),
GuildId = map_utils:get_integer(State, id, 0),
UserVoiceStates = maps:filter(
fun(_ConnId, VoiceState) ->
voice_state_utils:voice_state_user_id(VoiceState) =:= UserId
end,
VoiceStates
),
maps:foreach(
fun(_ConnId, VoiceState) ->
sync_voice_state_permissions(GuildId, UserId, VoiceState, State)
end,
UserVoiceStates
),
ok.
-spec sync_all_voice_permissions_for_channel(integer(), guild_state()) -> ok.
sync_all_voice_permissions_for_channel(ChannelId, State) ->
VoiceStates = voice_state_utils:voice_states(State),
GuildId = map_utils:get_integer(State, id, 0),
ChannelVoiceStates = maps:filter(
fun(_ConnId, VoiceState) ->
voice_state_utils:voice_state_channel_id(VoiceState) =:= ChannelId
end,
VoiceStates
),
maps:foreach(
fun(_ConnId, VoiceState) ->
UserId = voice_state_utils:voice_state_user_id(VoiceState),
case UserId of
undefined -> ok;
_ -> sync_voice_state_permissions(GuildId, UserId, VoiceState, State)
end
end,
ChannelVoiceStates
),
ok.
-spec maybe_sync_permissions_on_role_update(map(), guild_state()) -> ok.
maybe_sync_permissions_on_role_update(RoleUpdate, State) ->
RoleId = maps:get(<<"id">>, RoleUpdate, undefined),
case RoleId of
undefined ->
ok;
_ ->
OldPermissions = maps:get(<<"old_permissions">>, RoleUpdate, 0),
NewPermissions = maps:get(<<"permissions">>, RoleUpdate, 0),
AdminPerm = constants:administrator_permission(),
SpeakPerm = constants:speak_permission(),
StreamPerm = constants:stream_permission(),
VoicePerms = AdminPerm bor SpeakPerm bor StreamPerm,
OldVoicePerms = OldPermissions band VoicePerms,
NewVoicePerms = NewPermissions band VoicePerms,
case OldVoicePerms =/= NewVoicePerms of
true ->
sync_users_with_role(RoleId, State);
false ->
ok
end
end.
-spec maybe_sync_permissions_on_member_update(map(), guild_state()) -> ok.
maybe_sync_permissions_on_member_update(MemberUpdate, State) ->
UserId = get_member_user_id(MemberUpdate),
case UserId of
undefined ->
ok;
_ ->
OldRoles = maps:get(<<"old_roles">>, MemberUpdate, []),
NewRoles = maps:get(<<"roles">>, MemberUpdate, []),
case OldRoles =/= NewRoles of
true ->
sync_user_voice_permissions(UserId, State);
false ->
ok
end
end.
-spec sync_voice_state_permissions(integer(), integer(), voice_state(), guild_state()) -> ok.
sync_voice_state_permissions(GuildId, UserId, VoiceState, State) ->
ChannelId = voice_state_utils:voice_state_channel_id(VoiceState),
ConnectionId = maps:get(<<"connection_id">>, VoiceState, undefined),
case {ChannelId, ConnectionId} of
{undefined, _} ->
ok;
{_, undefined} ->
ok;
{ChId, ConnId} when is_integer(ChId), is_binary(ConnId) ->
VoicePermissions = voice_utils:compute_voice_permissions(UserId, ChId, State),
dispatch_permission_update(GuildId, ChId, UserId, ConnId, VoicePermissions, State)
end.
-spec dispatch_permission_update(integer(), integer(), integer(), binary(), map(), guild_state()) ->
ok.
dispatch_permission_update(GuildId, ChannelId, UserId, ConnectionId, VoicePermissions, State) ->
case maps:get(test_permission_sync_fun, State, undefined) of
Fun when is_function(Fun, 5) ->
Fun(GuildId, ChannelId, UserId, ConnectionId, VoicePermissions);
_ ->
spawn(fun() ->
enforce_voice_permissions_in_livekit(
GuildId, ChannelId, UserId, ConnectionId, VoicePermissions
)
end)
end.
-spec enforce_voice_permissions_in_livekit(
integer(), integer(), integer(), binary(), map()
) -> ok.
enforce_voice_permissions_in_livekit(GuildId, ChannelId, UserId, ConnectionId, VoicePermissions) ->
Req = voice_utils:build_update_participant_permissions_rpc_request(
GuildId, ChannelId, UserId, ConnectionId, VoicePermissions
),
case rpc_client:call(Req) of
{ok, _Data} ->
logger:debug(
"[guild_voice_permission_sync] Synced voice permissions ~p",
[
[
{guildId, GuildId},
{channelId, ChannelId},
{userId, UserId},
{connectionId, ConnectionId},
{permissions, VoicePermissions}
]
]
),
ok;
{error, Reason} ->
logger:warning(
"[guild_voice_permission_sync] Failed to sync voice permissions ~p",
[
[
{guildId, GuildId},
{channelId, ChannelId},
{userId, UserId},
{connectionId, ConnectionId},
{permissions, VoicePermissions},
{error, Reason}
]
]
),
ok
end.
-spec sync_users_with_role(binary() | integer(), guild_state()) -> ok.
sync_users_with_role(RoleId, State) ->
RoleIdBin = ensure_binary(RoleId),
VoiceStates = voice_state_utils:voice_states(State),
GuildId = map_utils:get_integer(State, id, 0),
maps:foreach(
fun(_ConnId, VoiceState) ->
UserId = voice_state_utils:voice_state_user_id(VoiceState),
case UserId of
undefined ->
ok;
_ ->
case user_has_role(UserId, RoleIdBin, State) of
true ->
sync_voice_state_permissions(GuildId, UserId, VoiceState, State);
false ->
ok
end
end
end,
VoiceStates
),
ok.
-spec user_has_role(integer(), binary(), guild_state()) -> boolean().
user_has_role(UserId, RoleIdBin, State) ->
case guild_voice_member:find_member_by_user_id(UserId, State) of
undefined ->
false;
Member ->
Roles = maps:get(<<"roles">>, Member, []),
lists:member(RoleIdBin, Roles)
end.
-spec get_member_user_id(map()) -> integer() | undefined.
get_member_user_id(MemberUpdate) ->
User = maps:get(<<"user">>, MemberUpdate, #{}),
map_utils:get_integer(User, <<"id">>, undefined).
-spec ensure_binary(binary() | integer()) -> binary().
ensure_binary(Value) when is_binary(Value) -> Value;
ensure_binary(Value) when is_integer(Value) -> integer_to_binary(Value).
-ifdef(TEST).
sync_user_voice_permissions_syncs_connected_user_test() ->
Self = self(),
TestFun = fun(GuildId, ChannelId, UserId, ConnectionId, Permissions) ->
Self ! {synced, GuildId, ChannelId, UserId, ConnectionId, Permissions}
end,
UserId = 10,
ChannelId = 500,
GuildId = 42,
RoleId = 999,
VoiceState = #{
<<"user_id">> => integer_to_binary(UserId),
<<"channel_id">> => integer_to_binary(ChannelId),
<<"connection_id">> => <<"test-conn">>
},
Permissions =
constants:view_channel_permission() bor
constants:connect_permission() bor
constants:speak_permission() bor
constants:stream_permission(),
State = #{
id => GuildId,
voice_states => #{<<"conn">> => VoiceState},
test_permission_sync_fun => TestFun,
data => #{
<<"guild">> => #{<<"owner_id">> => <<"1">>},
<<"roles">> => [
#{
<<"id">> => integer_to_binary(RoleId),
<<"permissions">> => integer_to_binary(Permissions)
},
#{
<<"id">> => integer_to_binary(GuildId),
<<"permissions">> => <<"0">>
}
],
<<"members">> => [
#{
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
<<"roles">> => [integer_to_binary(RoleId)]
}
],
<<"channels">> => [
#{
<<"id">> => integer_to_binary(ChannelId),
<<"permission_overwrites">> => []
}
]
}
},
ok = sync_user_voice_permissions(UserId, State),
receive
{synced, GuildId, ChannelId, UserId, <<"test-conn">>, Perms} ->
?assertEqual(true, maps:get(can_speak, Perms)),
?assertEqual(true, maps:get(can_stream, Perms))
after 100 ->
?assert(false)
end.
sync_user_voice_permissions_no_voice_state_test() ->
State = #{
id => 42,
voice_states => #{}
},
ok = sync_user_voice_permissions(10, State).
-endif.

View File

@@ -0,0 +1,170 @@
%% 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_permissions).
-export([check_voice_permissions_and_limits/6]).
-type guild_state() :: map().
-type voice_state_map() :: #{binary() => map()}.
-type channel() :: map().
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-import(utils, [parse_iso8601_to_unix_ms/1]).
-spec check_voice_permissions_and_limits(
integer(), integer(), channel(), voice_state_map(), guild_state(), boolean()
) ->
{ok, allowed} | {error, atom(), atom()}.
check_voice_permissions_and_limits(UserId, ChannelIdValue, Channel, VoiceStates, State, IsUpdate) ->
case is_member_timed_out(UserId, State) of
true ->
gateway_errors:error(voice_member_timed_out);
false ->
case has_view_and_connect_perms(UserId, ChannelIdValue, State) of
false ->
gateway_errors:error(voice_permission_denied);
true ->
case
channel_has_capacity(UserId, ChannelIdValue, Channel, VoiceStates, IsUpdate)
of
true -> {ok, allowed};
false -> gateway_errors:error(voice_channel_full)
end
end
end.
-spec has_view_and_connect_perms(integer(), integer(), guild_state()) -> boolean().
has_view_and_connect_perms(UserId, ChannelIdValue, State) ->
case guild_virtual_channel_access:has_virtual_access(UserId, ChannelIdValue, State) of
true ->
true;
false ->
Permissions = resolve_permissions(UserId, ChannelIdValue, State),
ViewPerm = constants:view_channel_permission(),
ConnectPerm = constants:connect_permission(),
(Permissions band ViewPerm) =:= ViewPerm andalso
(Permissions band ConnectPerm) =:= ConnectPerm
end.
-spec channel_has_capacity(integer(), integer(), channel(), voice_state_map(), boolean()) ->
boolean().
channel_has_capacity(UserId, ChannelIdValue, Channel, VoiceStates, IsUpdate) ->
UserLimit = maps:get(<<"user_limit">>, Channel, 0),
case UserLimit of
0 ->
true;
Limit when Limit > 0 ->
UsersInChannel = users_in_channel(ChannelIdValue, VoiceStates),
CurrentCount = sets:size(UsersInChannel),
AlreadyPresent = sets:is_element(UserId, UsersInChannel),
AdjustedCount =
case AlreadyPresent orelse IsUpdate of
true -> CurrentCount - 1;
false -> CurrentCount
end,
AdjustedCount < Limit;
_ ->
true
end.
-spec is_member_timed_out(integer(), guild_state()) -> boolean().
is_member_timed_out(UserId, State) ->
case guild_permissions:find_member_by_user_id(UserId, State) of
undefined ->
false;
Member ->
TimeoutMs = parse_iso8601_to_unix_ms(
maps:get(<<"communication_disabled_until">>, Member, undefined)
),
case TimeoutMs of
undefined ->
false;
Value when is_integer(Value) ->
Value > erlang:system_time(millisecond);
_ ->
false
end
end.
-spec users_in_channel(integer(), voice_state_map()) -> sets:set().
users_in_channel(ChannelIdValue, VoiceStates0) ->
VoiceStates = voice_state_utils:ensure_voice_states(VoiceStates0),
maps:fold(
fun(_ConnId, VState, Acc) ->
case voice_state_utils:voice_state_channel_id(VState) of
ChannelIdValue ->
case voice_state_utils:voice_state_user_id(VState) of
undefined -> Acc;
UserId -> sets:add_element(UserId, Acc)
end;
_ ->
Acc
end
end,
sets:new(),
VoiceStates
).
-spec resolve_permissions(integer(), integer(), guild_state()) -> integer().
resolve_permissions(UserId, ChannelIdValue, State) ->
case State of
#{test_perm_fun := Fun} when is_function(Fun, 1) ->
Fun(UserId);
_ ->
guild_permissions:get_member_permissions(UserId, ChannelIdValue, State)
end.
-ifdef(TEST).
voice_permissions_missing_view_test() ->
State = permission_test_state(0, fun(_) -> constants:view_channel_permission() end),
Result = check_voice_permissions_and_limits(1, 10, #{<<"user_limit">> => 0}, #{}, State, false),
?assertMatch({error, permission_denied, voice_permission_denied}, Result).
voice_permissions_full_channel_test() ->
State = permission_test_state(2, fun(_) -> required_voice_perms() end),
VoiceStates = #{
<<"conn1">> => #{<<"channel_id">> => <<"10">>, <<"user_id">> => <<"1">>},
<<"conn2">> => #{<<"channel_id">> => <<"10">>, <<"user_id">> => <<"2">>}
},
Result = check_voice_permissions_and_limits(
3, 10, #{<<"user_limit">> => 2}, VoiceStates, State, false
),
?assertMatch({error, permission_denied, voice_channel_full}, Result).
voice_permissions_existing_user_update_test() ->
State = permission_test_state(2, fun(_) -> required_voice_perms() end),
VoiceStates = #{
<<"conn1">> => #{<<"channel_id">> => <<"10">>, <<"user_id">> => <<"1">>},
<<"conn2">> => #{<<"channel_id">> => <<"10">>, <<"user_id">> => <<"2">>}
},
Result = check_voice_permissions_and_limits(
1, 10, #{<<"user_limit">> => 2}, VoiceStates, State, true
),
?assertEqual({ok, allowed}, Result).
required_voice_perms() ->
constants:view_channel_permission() bor constants:connect_permission().
permission_test_state(GuildId, PermFun) ->
#{id => GuildId, test_perm_fun => PermFun}.
-endif.

View File

@@ -0,0 +1,127 @@
%% 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_region).
-export([switch_voice_region_handler/2]).
-export([switch_voice_region/3]).
switch_voice_region_handler(Request, State) ->
#{channel_id := ChannelId} = Request,
Channel = guild_voice_member:find_channel_by_id(ChannelId, State),
case Channel of
undefined ->
{reply, gateway_errors:error(voice_channel_not_found), State};
_ ->
ChannelType = maps:get(<<"type">>, Channel, 0),
case ChannelType of
2 ->
{reply, #{success => true}, State};
_ ->
{reply, gateway_errors:error(voice_channel_not_voice), State}
end
end.
switch_voice_region(GuildId, ChannelId, GuildPid) ->
case gen_server:call(GuildPid, {get_sessions}, 10000) of
State when is_map(State) ->
VoiceStates = voice_state_utils:voice_states(State),
UsersInChannel = maps:fold(
fun(ConnectionId, VoiceState, Acc) ->
case voice_state_utils:voice_state_channel_id(VoiceState) of
ChannelId ->
case voice_state_utils:voice_state_user_id(VoiceState) of
undefined ->
logger:warning(
"[guild_voice_region] Missing user_id for connection ~p",
[ConnectionId]
),
Acc;
UserId ->
SessionId = maps:get(<<"session_id">>, VoiceState, undefined),
[{UserId, SessionId, VoiceState} | Acc]
end;
_ ->
Acc
end
end,
[],
VoiceStates
),
lists:foreach(
fun({UserId, SessionId, VoiceState}) ->
case SessionId of
undefined ->
ok;
_ ->
send_voice_server_update_for_region_switch(
GuildId, ChannelId, UserId, SessionId, VoiceState, GuildPid
)
end
end,
UsersInChannel
);
_ ->
ok
end.
send_voice_server_update_for_region_switch(
GuildId, ChannelId, UserId, SessionId, ExistingVoiceState, GuildPid
) ->
case gen_server:call(GuildPid, {get_sessions}, 10000) of
State when is_map(State) ->
VoicePermissions = voice_utils:compute_voice_permissions(UserId, ChannelId, State),
case
guild_voice_connection:request_voice_token(
GuildId, ChannelId, UserId, VoicePermissions
)
of
{ok, TokenData} ->
Token = maps:get(token, TokenData),
Endpoint = maps:get(endpoint, TokenData),
ConnectionId = maps:get(connection_id, TokenData),
PendingMetadata = #{
user_id => UserId,
guild_id => GuildId,
channel_id => ChannelId,
session_id => SessionId,
self_mute => maps:get(<<"self_mute">>, ExistingVoiceState, false),
self_deaf => maps:get(<<"self_deaf">>, ExistingVoiceState, false),
self_video => maps:get(<<"self_video">>, ExistingVoiceState, false),
self_stream => maps:get(<<"self_stream">>, ExistingVoiceState, false),
is_mobile => maps:get(<<"is_mobile">>, ExistingVoiceState, false),
server_mute => maps:get(<<"mute">>, ExistingVoiceState, false),
server_deaf => maps:get(<<"deaf">>, ExistingVoiceState, false),
member => maps:get(<<"member">>, ExistingVoiceState, #{})
},
gen_server:cast(
GuildPid, {store_pending_connection, ConnectionId, PendingMetadata}
),
guild_voice_broadcast:broadcast_voice_server_update_to_session(
GuildId, SessionId, Token, Endpoint, ConnectionId, State
);
{error, _Reason} ->
ok
end;
_ ->
ok
end.

View File

@@ -0,0 +1,205 @@
%% 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_state).
-include_lib("fluxer_gateway/include/voice_state.hrl").
-export([get_voice_state/2]).
-export([get_voice_states_list/1]).
-export([update_voice_state_data/9]).
-export([user_matches_voice_state/2]).
-export([create_voice_state/8]).
-export([extract_session_info_from_voice_state/2]).
-type guild_state() :: map().
-type voice_state() :: map().
-type voice_state_map() :: #{binary() => voice_state()}.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec get_voice_state(map(), guild_state()) -> {reply, map(), guild_state()}.
get_voice_state(Request, State) ->
case maps:get(connection_id, Request, null) of
null ->
{reply, #{voice_state => null}, State};
ConnectionId ->
VoiceStates = voice_state_utils:voice_states(State),
VoiceState = maps:get(ConnectionId, VoiceStates, null),
{reply, #{voice_state => VoiceState}, State}
end.
-spec get_voice_states_list(guild_state()) -> [voice_state()].
get_voice_states_list(State) ->
maps:values(voice_state_utils:voice_states(State)).
-spec update_voice_state_data(
binary(),
binary(),
voice_flags(),
map(),
voice_state(),
voice_state_map(),
guild_state(),
boolean(),
term()
) -> {reply, map(), guild_state()}.
update_voice_state_data(
ConnectionId,
ChannelIdBin,
Flags,
Member,
ExistingVoiceState,
VoiceStates,
State,
NeedsToken,
ViewerStreamKey
) ->
#voice_flags{
self_mute = SelfMute,
self_deaf = SelfDeaf,
self_video = SelfVideo,
self_stream = SelfStream,
is_mobile = IsMobile
} = Flags,
ServerMute = maps:get(<<"mute">>, Member, false),
ServerDeaf = maps:get(<<"deaf">>, Member, false),
OldVersion = maps:get(<<"version">>, ExistingVoiceState, 0),
UpdatedVoiceState = ExistingVoiceState#{
<<"channel_id">> => ChannelIdBin,
<<"mute">> => ServerMute,
<<"deaf">> => ServerDeaf,
<<"self_mute">> => SelfMute,
<<"self_deaf">> => SelfDeaf,
<<"self_video">> => SelfVideo,
<<"self_stream">> => SelfStream,
<<"is_mobile">> => IsMobile,
<<"viewer_stream_key">> => ViewerStreamKey,
<<"version">> => OldVersion + 1
},
NewVoiceStates = maps:put(ConnectionId, UpdatedVoiceState, VoiceStates),
NewState = maps:put(voice_states, NewVoiceStates, State),
guild_voice_broadcast:broadcast_voice_state_update(UpdatedVoiceState, NewState, ChannelIdBin),
Reply =
case NeedsToken of
true -> #{success => true, voice_state => UpdatedVoiceState, needs_token => true};
false -> #{success => true, voice_state => UpdatedVoiceState}
end,
{reply, Reply, NewState}.
-spec user_matches_voice_state(voice_state(), integer() | binary()) -> boolean().
user_matches_voice_state(VoiceState, UserId) when is_integer(UserId) ->
case map_utils:get_integer(VoiceState, <<"user_id">>, undefined) of
undefined -> false;
VoiceUserId -> VoiceUserId =:= UserId
end;
user_matches_voice_state(VoiceState, UserId) when is_binary(UserId) ->
type_conv:to_binary(map_utils:get_binary(VoiceState, <<"user_id">>, undefined)) =:= UserId;
user_matches_voice_state(_VoiceState, _UserId) ->
false.
-spec create_voice_state(
binary(),
binary(),
binary(),
binary(),
boolean(),
boolean(),
voice_flags(),
term()
) -> voice_state().
create_voice_state(
GuildIdBin,
ChannelIdBin,
UserIdBin,
ConnectionId,
ServerMute,
ServerDeaf,
Flags,
ViewerStreamKey
) ->
#voice_flags{
self_mute = SelfMute,
self_deaf = SelfDeaf,
self_video = SelfVideo,
self_stream = SelfStream,
is_mobile = IsMobile
} = Flags,
#{
<<"guild_id">> => GuildIdBin,
<<"channel_id">> => ChannelIdBin,
<<"user_id">> => UserIdBin,
<<"connection_id">> => ConnectionId,
<<"mute">> => ServerMute,
<<"deaf">> => ServerDeaf,
<<"self_mute">> => SelfMute,
<<"self_deaf">> => SelfDeaf,
<<"self_video">> => SelfVideo,
<<"self_stream">> => SelfStream,
<<"is_mobile">> => IsMobile,
<<"viewer_stream_key">> => ViewerStreamKey,
<<"version">> => 0
}.
-spec extract_session_info_from_voice_state(binary(), voice_state()) -> map().
extract_session_info_from_voice_state(ConnId, VoiceState) ->
#{
connection_id => ConnId,
session_id => maps:get(<<"session_id">>, VoiceState, undefined),
self_mute => maps:get(<<"self_mute">>, VoiceState, false),
self_deaf => maps:get(<<"self_deaf">>, VoiceState, false),
self_video => maps:get(<<"self_video">>, VoiceState, false),
self_stream => maps:get(<<"self_stream">>, VoiceState, false),
is_mobile => maps:get(<<"is_mobile">>, VoiceState, false),
member => maps:get(<<"member">>, VoiceState, #{})
}.
-ifdef(TEST).
user_matches_voice_state_integer_test() ->
VoiceState = #{<<"user_id">> => <<"10">>},
?assert(user_matches_voice_state(VoiceState, 10)),
?assertNot(user_matches_voice_state(VoiceState, 11)).
update_voice_state_data_updates_version_test() ->
VoiceState = #{<<"version">> => 1, <<"channel_id">> => <<"1">>},
Member = #{<<"mute">> => true, <<"deaf">> => false},
Flags = #voice_flags{
self_mute = true,
self_deaf = false,
self_video = false,
self_stream = false,
is_mobile = false
},
{reply, #{voice_state := Updated}, _} =
update_voice_state_data(
<<"conn">>,
<<"2">>,
Flags,
Member,
VoiceState,
#{<<"conn">> => VoiceState},
#{voice_states => #{}},
false,
null
),
?assertEqual(2, maps:get(<<"version">>, Updated)),
?assertEqual(<<"2">>, maps:get(<<"channel_id">>, Updated)).
-endif.

View File

@@ -0,0 +1,105 @@
%% 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(voice_disconnect_common).
-export([
find_session_by_user_id/2,
disconnect_user/4,
disconnect_user_if_in_channel/5,
channel_has_capacity/3
]).
-type user_id() :: integer().
-type session_id() :: binary().
-type session_pid() :: pid().
-type monitor_ref() :: reference().
-type session_tuple() :: {user_id(), session_pid(), monitor_ref()}.
-type sessions_map() :: #{session_id() => session_tuple()}.
-type voice_states_map() :: #{user_id() => map()}.
-type cleanup_fun() :: fun((user_id(), session_id()) -> ok).
-spec find_session_by_user_id(user_id(), sessions_map()) ->
{ok, session_id(), session_pid(), monitor_ref()} | not_found.
find_session_by_user_id(UserId, Sessions) ->
maps:fold(
fun
(SessionId, {U, Pid, Ref}, _) when U =:= UserId ->
{ok, SessionId, Pid, Ref};
(_, _, Acc) ->
Acc
end,
not_found,
Sessions
).
-spec disconnect_user(user_id(), voice_states_map(), sessions_map(), cleanup_fun()) ->
{ok, voice_states_map(), sessions_map()} | {not_found, voice_states_map(), sessions_map()}.
disconnect_user(UserId, VoiceStates, Sessions, CleanupFun) ->
case find_session_by_user_id(UserId, Sessions) of
not_found ->
{not_found, VoiceStates, Sessions};
{ok, SessionId, _Pid, Ref} ->
demonitor(Ref, [flush]),
CleanupFun(UserId, SessionId),
NewVoiceStates = maps:remove(UserId, VoiceStates),
NewSessions = maps:remove(SessionId, Sessions),
{ok, NewVoiceStates, NewSessions}
end.
-spec disconnect_user_if_in_channel(
user_id(), integer(), voice_states_map(), sessions_map(), cleanup_fun()
) ->
{ok, voice_states_map(), sessions_map()}
| {not_found, voice_states_map(), sessions_map()}
| {channel_mismatch, voice_states_map(), sessions_map()}.
disconnect_user_if_in_channel(UserId, ExpectedChannelId, VoiceStates, Sessions, CleanupFun) ->
case maps:get(UserId, VoiceStates, undefined) of
undefined ->
{not_found, VoiceStates, Sessions};
VoiceState ->
ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, undefined),
ExpectedBin = integer_to_binary(ExpectedChannelId),
case ChannelIdBin =:= ExpectedBin of
false ->
{channel_mismatch, VoiceStates, Sessions};
true ->
disconnect_user(UserId, VoiceStates, Sessions, CleanupFun)
end
end.
-spec channel_has_capacity(binary() | integer(), non_neg_integer(), voice_states_map()) ->
boolean().
channel_has_capacity(_ChannelId, 0, _VoiceStates) ->
true;
channel_has_capacity(ChannelId, UserLimit, VoiceStates) ->
ChannelIdBin = ensure_binary(ChannelId),
UsersInChannel = maps:fold(
fun(_UserId, VoiceState, Count) ->
case maps:get(<<"channel_id">>, VoiceState, undefined) of
ChannelIdBin -> Count + 1;
_ -> Count
end
end,
0,
VoiceStates
),
UsersInChannel < UserLimit.
-spec ensure_binary(binary() | integer()) -> binary().
ensure_binary(Value) when is_binary(Value) -> Value;
ensure_binary(Value) when is_integer(Value) -> integer_to_binary(Value).

View File

@@ -0,0 +1,58 @@
%% 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(voice_pending_common).
-export([
add_pending_connection/3,
remove_pending_connection/2,
get_pending_connection/2,
confirm_pending_connection/2
]).
-type connection_id() :: binary().
-type pending_metadata() :: map().
-type pending_map() :: #{connection_id() => pending_metadata()}.
-spec add_pending_connection(connection_id(), pending_metadata(), pending_map()) -> pending_map().
add_pending_connection(ConnectionId, Metadata, PendingMap) ->
maps:put(ConnectionId, Metadata#{joined_at => erlang:system_time(millisecond)}, PendingMap).
-spec remove_pending_connection(connection_id() | undefined, pending_map()) -> pending_map().
remove_pending_connection(undefined, PendingMap) ->
PendingMap;
remove_pending_connection(ConnectionId, PendingMap) ->
maps:remove(ConnectionId, PendingMap).
-spec get_pending_connection(connection_id() | undefined, pending_map()) ->
pending_metadata() | undefined.
get_pending_connection(undefined, _PendingMap) ->
undefined;
get_pending_connection(ConnectionId, PendingMap) ->
maps:get(ConnectionId, PendingMap, undefined).
-spec confirm_pending_connection(connection_id() | undefined, pending_map()) ->
{confirmed, pending_map()} | {not_found, pending_map()}.
confirm_pending_connection(undefined, PendingMap) ->
{not_found, PendingMap};
confirm_pending_connection(ConnectionId, PendingMap) ->
case maps:get(ConnectionId, PendingMap, undefined) of
undefined ->
{not_found, PendingMap};
_Metadata ->
{confirmed, maps:remove(ConnectionId, PendingMap)}
end.

View File

@@ -0,0 +1,164 @@
%% 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(voice_state_utils).
-include_lib("fluxer_gateway/include/voice_state.hrl").
-export([
voice_states/1,
ensure_voice_states/1,
voice_state_user_id/1,
voice_state_channel_id/1,
voice_state_guild_id/1,
filter_voice_states/2,
drop_voice_states/2,
broadcast_disconnects/2,
voice_flags_from_context/1,
parse_stream_key/1,
build_stream_key/3
]).
voice_states(State) when is_map(State) ->
case maps:get(voice_states, State, undefined) of
Map when is_map(Map) -> Map;
_ -> #{}
end.
ensure_voice_states(Map) when is_map(Map) ->
Map;
ensure_voice_states(_) ->
#{}.
voice_state_user_id(VoiceState) ->
map_utils:get_integer(VoiceState, <<"user_id">>, undefined).
voice_state_channel_id(VoiceState) ->
map_utils:get_integer(VoiceState, <<"channel_id">>, undefined).
voice_state_guild_id(VoiceState) ->
map_utils:get_integer(VoiceState, <<"guild_id">>, undefined).
filter_voice_states(VoiceStates, Predicate) when is_map(VoiceStates) ->
maps:filter(Predicate, VoiceStates);
filter_voice_states(_, _) ->
#{}.
drop_voice_states(ToDrop, VoiceStates) ->
maps:fold(fun(ConnId, _VoiceState, Acc) -> maps:remove(ConnId, Acc) end, VoiceStates, ToDrop).
broadcast_disconnects(VoiceStates, State) ->
maps:foreach(
fun(ConnId, VoiceState) ->
OldChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
DisconnectVoiceState = VoiceState#{
<<"channel_id">> => null,
<<"connection_id">> => ConnId
},
guild_voice_broadcast:broadcast_voice_state_update(
DisconnectVoiceState, State, OldChannelIdBin
)
end,
VoiceStates
).
voice_flags_from_context(Context) ->
#voice_flags{
self_mute = maps:get(self_mute, Context, false),
self_deaf = maps:get(self_deaf, Context, false),
self_video = maps:get(self_video, Context, false),
self_stream = maps:get(self_stream, Context, false),
is_mobile = maps:get(is_mobile, Context, false)
}.
-spec parse_stream_key(term()) ->
{ok, #{
scope := guild | dm,
guild_id := integer() | undefined,
channel_id := integer(),
connection_id := binary()
}}
| {error, invalid_stream_key}.
parse_stream_key(StreamKey) when is_binary(StreamKey) ->
Parts = binary:split(StreamKey, <<":">>, [global]),
case Parts of
[ScopeBin, ChannelBin, ConnId] when byte_size(ChannelBin) > 0, byte_size(ConnId) > 0 ->
try
Scope = parse_scope_bin(ScopeBin),
ChannelId = parse_channel_bin(ChannelBin),
build_stream_key_result(Scope, ChannelId, ConnId)
catch
_:_ ->
{error, invalid_stream_key}
end;
_ ->
{error, invalid_stream_key}
end;
parse_stream_key(_) ->
{error, invalid_stream_key}.
-spec parse_scope_bin(binary()) -> {dm, undefined} | {guild, integer()}.
parse_scope_bin(<<"dm">>) ->
{dm, undefined};
parse_scope_bin(ScopeBin) ->
GuildId = type_conv:to_integer(ScopeBin),
true = is_integer(GuildId),
{guild, GuildId}.
-spec parse_channel_bin(binary()) -> integer().
parse_channel_bin(ChannelBin) ->
Chan = type_conv:to_integer(ChannelBin),
true = is_integer(Chan),
Chan.
-spec build_stream_key_result({dm, undefined} | {guild, integer()}, integer(), binary()) ->
{ok, #{
scope := guild | dm,
guild_id := integer() | undefined,
channel_id := integer(),
connection_id := binary()
}}.
build_stream_key_result({dm, _}, ChannelId, ConnId) ->
{ok, #{
scope => dm,
guild_id => undefined,
channel_id => ChannelId,
connection_id => ConnId
}};
build_stream_key_result({guild, GuildId}, ChannelId, ConnId) ->
{ok, #{
scope => guild,
guild_id => GuildId,
channel_id => ChannelId,
connection_id => ConnId
}}.
-spec build_stream_key(integer() | undefined, integer(), binary()) -> binary().
build_stream_key(undefined, ChannelId, ConnectionId) when
is_integer(ChannelId), is_binary(ConnectionId)
->
<<"dm:", (integer_to_binary(ChannelId))/binary, ":", ConnectionId/binary>>;
build_stream_key(GuildId, ChannelId, ConnectionId) when
is_integer(GuildId), is_integer(ChannelId), is_binary(ConnectionId)
->
<<
(integer_to_binary(GuildId))/binary,
":",
(integer_to_binary(ChannelId))/binary,
":",
ConnectionId/binary
>>.

View File

@@ -0,0 +1,150 @@
%% 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(voice_utils).
-export([
build_voice_token_rpc_request/6,
build_voice_token_rpc_request/7,
build_force_disconnect_rpc_request/4,
build_update_participant_rpc_request/5,
build_update_participant_permissions_rpc_request/5,
add_geolocation_to_request/3,
compute_voice_permissions/3
]).
build_voice_token_rpc_request(GuildId, ChannelId, UserId, ConnectionId, Latitude, Longitude) ->
BaseReq =
case GuildId of
null ->
#{
<<"type">> => <<"voice_get_token">>,
<<"channel_id">> => integer_to_binary(ChannelId),
<<"user_id">> => integer_to_binary(UserId)
};
_ ->
BaseMap = #{
<<"type">> => <<"voice_get_token">>,
<<"guild_id">> => integer_to_binary(GuildId),
<<"channel_id">> => integer_to_binary(ChannelId),
<<"user_id">> => integer_to_binary(UserId)
},
case ConnectionId of
null ->
BaseMap;
ConnectionId when is_binary(ConnectionId) ->
maps:put(<<"connection_id">>, ConnectionId, BaseMap);
ConnectionId when is_integer(ConnectionId) ->
maps:put(<<"connection_id">>, integer_to_binary(ConnectionId), BaseMap);
_ ->
BaseMap
end
end,
add_geolocation_to_request(BaseReq, Latitude, Longitude).
add_geolocation_to_request(RequestMap, Latitude, Longitude) ->
case {Latitude, Longitude} of
{Lat, Long} when is_binary(Lat) andalso is_binary(Long) ->
maps:merge(RequestMap, #{
<<"latitude">> => Lat,
<<"longitude">> => Long
});
_ ->
RequestMap
end.
build_force_disconnect_rpc_request(GuildId, ChannelId, UserId, ConnectionId) ->
BaseReq = #{
<<"type">> => <<"voice_force_disconnect_participant">>,
<<"channel_id">> => integer_to_binary(ChannelId),
<<"user_id">> => integer_to_binary(UserId),
<<"connection_id">> => ConnectionId
},
case GuildId of
null ->
BaseReq;
_ ->
maps:put(<<"guild_id">>, integer_to_binary(GuildId), BaseReq)
end.
build_update_participant_rpc_request(GuildId, ChannelId, UserId, Mute, Deaf) ->
BaseReq = #{
<<"type">> => <<"voice_update_participant">>,
<<"channel_id">> => integer_to_binary(ChannelId),
<<"user_id">> => integer_to_binary(UserId),
<<"mute">> => Mute,
<<"deaf">> => Deaf
},
case GuildId of
null ->
BaseReq;
_ ->
maps:put(<<"guild_id">>, integer_to_binary(GuildId), BaseReq)
end.
build_update_participant_permissions_rpc_request(
GuildId, ChannelId, UserId, ConnectionId, VoicePermissions
) ->
BaseReq = #{
<<"type">> => <<"voice_update_participant_permissions">>,
<<"channel_id">> => integer_to_binary(ChannelId),
<<"user_id">> => integer_to_binary(UserId),
<<"connection_id">> => ConnectionId,
<<"can_speak">> => maps:get(can_speak, VoicePermissions, true),
<<"can_stream">> => maps:get(can_stream, VoicePermissions, true),
<<"can_video">> => maps:get(can_video, VoicePermissions, true)
},
case GuildId of
null ->
BaseReq;
_ ->
maps:put(<<"guild_id">>, integer_to_binary(GuildId), BaseReq)
end.
-spec compute_voice_permissions(integer(), integer(), map()) -> map().
compute_voice_permissions(UserId, ChannelId, State) ->
Permissions = guild_permissions:get_member_permissions(UserId, ChannelId, State),
SpeakPerm = constants:speak_permission(),
StreamPerm = constants:stream_permission(),
AdminPerm = constants:administrator_permission(),
IsAdmin = (Permissions band AdminPerm) =:= AdminPerm,
CanSpeak = IsAdmin orelse ((Permissions band SpeakPerm) =:= SpeakPerm),
CanStream = IsAdmin orelse ((Permissions band StreamPerm) =:= StreamPerm),
HasVirtualAccess = guild_virtual_channel_access:has_virtual_access(UserId, ChannelId, State),
FinalCanSpeak = CanSpeak orelse HasVirtualAccess,
FinalCanStream = CanStream orelse HasVirtualAccess,
#{
can_speak => FinalCanSpeak,
can_stream => FinalCanStream,
can_video => FinalCanStream
}.
build_voice_token_rpc_request(
GuildId, ChannelId, UserId, ConnectionId, Latitude, Longitude, VoicePermissions
) ->
BaseReq = build_voice_token_rpc_request(
GuildId, ChannelId, UserId, ConnectionId, Latitude, Longitude
),
maps:merge(BaseReq, #{
<<"can_speak">> => maps:get(can_speak, VoicePermissions, true),
<<"can_stream">> => maps:get(can_stream, VoicePermissions, true),
<<"can_video">> => maps:get(can_video, VoicePermissions, true)
}).

View File

@@ -0,0 +1,874 @@
%% 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(presence).
-behaviour(gen_server).
-export([start_link/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
start_link(PresenceData) ->
gen_server:start_link(?MODULE, PresenceData, []).
init(PresenceData) ->
process_flag(trap_exit, true),
UserId = maps:get(user_id, PresenceData),
UserData = maps:get(user_data, PresenceData),
Status = maps:get(status, PresenceData),
IsBot0 = maps:get(<<"bot">>, UserData, false),
IsBot =
case IsBot0 of
true -> true;
_ -> false
end,
GuildIds0 = maps:get(guild_ids, PresenceData, []),
FriendIds0 = maps:get(friend_ids, PresenceData, []),
GroupDmRecipients0 = maps:get(group_dm_recipients, PresenceData, #{}),
CustomStatus = maps:get(custom_status, PresenceData, null),
FriendIds =
case IsBot of
true -> [];
false -> FriendIds0
end,
GroupDmRecipients = normalize_group_dm_recipients(GroupDmRecipients0, UserId, IsBot),
State = #{
user_id => UserId,
user_data => UserData,
sessions => #{},
custom_status => CustomStatus,
status => Status,
guild_ids => map_from_ids(GuildIds0),
temporary_guild_ids => #{},
friends => map_from_ids(FriendIds),
group_dm_recipients => GroupDmRecipients,
subscriptions => #{},
is_bot => IsBot,
initial_presences_sent => false,
last_published_presence => undefined
},
StateWithSubs = ensure_initial_global_subscriptions(State),
PresencePid = self(),
spawn(fun() -> fetch_initial_presences(PresencePid, StateWithSubs) end),
{ok, StateWithSubs}.
handle_call({session_connect, Request}, {Pid, _}, State) ->
Result = presence_session:handle_session_connect(Request, Pid, State),
publish_global_if_needed(Result);
handle_call({terminate_session, SessionIdHashes}, _From, State) ->
Sessions = maps:get(sessions, State),
SessionPids = [maps:get(pid, S) || S <- maps:values(Sessions)],
lists:foreach(
fun(Pid) when is_pid(Pid) ->
gen_server:cast(Pid, {terminate, SessionIdHashes})
end,
SessionPids
),
{reply, ok, State};
handle_call({dispatch, EventAtom, Data}, _From, State) ->
Sessions = maps:get(sessions, State),
UserId = maps:get(user_id, State),
SessionPids = [maps:get(pid, S) || S <- maps:values(Sessions)],
lists:foreach(
fun(Pid) when is_pid(Pid) ->
case erlang:is_process_alive(Pid) of
true ->
gen_server:cast(Pid, {dispatch, EventAtom, Data});
false ->
ok
end
end,
SessionPids
),
case EventAtom of
user_update ->
CurrentUserData = maps:get(user_data, State, #{}),
case utils:check_user_data_differs(CurrentUserData, Data) of
true ->
publish_user_update_to_bus(UserId, Data, State),
NewState = maps:put(user_data, Data, State),
{reply, ok, NewState};
false ->
{reply, ok, State}
end;
message_create ->
HasMobile = lists:any(
fun(Session) ->
maps:get(mobile, Session, false)
end,
maps:values(Sessions)
),
AllAfk = lists:all(
fun(Session) ->
maps:get(afk, Session, false)
end,
maps:values(Sessions)
),
ShouldSendPush =
(map_size(Sessions) =:= 0) orelse ((not HasMobile) andalso AllAfk),
case ShouldSendPush of
true ->
AuthorIdBin = maps:get(<<"id">>, maps:get(<<"author">>, Data, #{}), <<"0">>),
AuthorId = validation:snowflake_or_default(AuthorIdBin, 0),
push:handle_message_create(#{
message_data => Data,
user_ids => [UserId],
guild_id => 0,
author_id => AuthorId
});
false ->
ok
end,
{reply, ok, State};
_ ->
{reply, ok, State}
end;
handle_call({join_guild, GuildId}, _From, State) ->
handle_join_guild(GuildId, State);
handle_call({leave_guild, GuildId}, _From, State) ->
handle_leave_guild(GuildId, State);
handle_call({add_temporary_guild, GuildId}, _From, State) ->
{reply, JoinReply, JoinedState} = handle_join_guild(GuildId, State),
TemporaryGuildIds = maps:get(temporary_guild_ids, JoinedState, #{}),
NewTemporaryGuildIds = maps:put(GuildId, true, TemporaryGuildIds),
NewState = maps:put(temporary_guild_ids, NewTemporaryGuildIds, JoinedState),
{reply, JoinReply, NewState};
handle_call({remove_temporary_guild, GuildId}, _From, State) ->
{reply, LeaveReply, LeftState} = handle_leave_guild(GuildId, State),
TemporaryGuildIds = maps:get(temporary_guild_ids, LeftState, #{}),
NewTemporaryGuildIds = maps:remove(GuildId, TemporaryGuildIds),
NewState = maps:put(temporary_guild_ids, NewTemporaryGuildIds, LeftState),
{reply, LeaveReply, NewState};
handle_call({terminate, SessionIdHashes}, _From, State) ->
Sessions = maps:get(sessions, State),
SessionPids = [maps:get(pid, S) || S <- maps:values(Sessions)],
lists:foreach(
fun(Pid) when is_pid(Pid) ->
gen_server:cast(Pid, {terminate, SessionIdHashes})
end,
SessionPids
),
{stop, normal, ok, State};
handle_call(_, _From, State) ->
{reply, ok, State}.
handle_cast({dispatch, Event, Data}, State) ->
Sessions = maps:get(sessions, State),
UserId = maps:get(user_id, State),
SessionPids = [maps:get(pid, S) || S <- maps:values(Sessions)],
lists:foreach(
fun(Pid) when is_pid(Pid) ->
gen_server:cast(Pid, {dispatch, Event, Data})
end,
SessionPids
),
case Event of
user_update ->
CurrentUserData = maps:get(user_data, State, #{}),
case utils:check_user_data_differs(CurrentUserData, Data) of
true ->
publish_user_update_to_bus(UserId, Data, State),
NewState = maps:put(user_data, Data, State),
{noreply, NewState};
false ->
{noreply, State}
end;
message_create ->
HasMobile = lists:any(
fun(Session) ->
maps:get(mobile, Session, false)
end,
maps:values(Sessions)
),
AllAfk = lists:all(
fun(Session) ->
maps:get(afk, Session, false)
end,
maps:values(Sessions)
),
ShouldSendPush =
(map_size(Sessions) =:= 0) orelse ((not HasMobile) andalso AllAfk),
case ShouldSendPush of
true ->
AuthorIdBin = maps:get(<<"id">>, maps:get(<<"author">>, Data, #{}), <<"0">>),
AuthorId = validation:snowflake_or_default(AuthorIdBin, 0),
push:handle_message_create(#{
message_data => Data,
user_ids => [UserId],
guild_id => 0,
author_id => AuthorId
});
false ->
ok
end,
{noreply, State};
_ ->
{noreply, State}
end;
handle_cast({presence_update, Request}, State) ->
{UpdatedRequest, StateWithCustomStatus} = maybe_handle_custom_status(Request, State),
Result = presence_session:handle_presence_update(UpdatedRequest, StateWithCustomStatus),
publish_global_if_needed(Result);
handle_cast({terminate_session, SessionIdHashes}, State) ->
Sessions = maps:get(sessions, State),
SessionPids = [maps:get(pid, S) || S <- maps:values(Sessions)],
lists:foreach(
fun(Pid) when is_pid(Pid) ->
gen_server:cast(Pid, {terminate, SessionIdHashes})
end,
SessionPids
),
{noreply, State};
handle_cast({terminate_all_sessions}, State) ->
Sessions = maps:get(sessions, State),
SessionPids = [maps:get(pid, S) || S <- maps:values(Sessions)],
lists:foreach(
fun(Pid) when is_pid(Pid) ->
gen_server:cast(Pid, {terminate_force})
end,
SessionPids
),
{noreply, State};
handle_cast({sync_friends, FriendIds}, State) ->
NewState = sync_friend_subscriptions(FriendIds, State),
{noreply, NewState};
handle_cast({sync_group_dm_recipients, RecipientsByChannel}, State) ->
NewState = sync_group_dm_subscriptions(RecipientsByChannel, State),
{noreply, NewState};
handle_cast(_, State) ->
{noreply, State}.
handle_info({presence, TargetId, Payload}, State) ->
dispatch_global_presence(TargetId, Payload, State);
handle_info({initial_presences, Presences}, State) ->
Sessions = maps:get(sessions, State),
SessionPids = [maps:get(pid, S) || S <- maps:values(Sessions)],
lists:foreach(
fun(Pid) when is_pid(Pid) ->
gen_server:cast(Pid, {initial_global_presences, Presences})
end,
SessionPids
),
{noreply, State};
handle_info({'DOWN', Ref, process, _Pid, Reason}, State) ->
handle_process_down(Ref, Reason, State);
handle_info(_, State) ->
{noreply, State}.
terminate(_Reason, State) when not is_map(State) ->
ok;
terminate(_Reason, State) ->
UserId = maps:get(user_id, State),
presence_cache:delete(UserId),
publish_offline_on_terminate(UserId, State),
kick_temporary_members_on_terminate(UserId, State),
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
kick_temporary_members_on_terminate(UserId, State) ->
TemporaryGuildIds = maps:get(temporary_guild_ids, State, #{}),
case map_size(TemporaryGuildIds) of
0 ->
ok;
_ ->
GuildIdsList = maps:keys(TemporaryGuildIds),
spawn(fun() ->
Request = #{
<<"type">> => <<"kick_temporary_member">>,
<<"user_id">> => type_conv:to_binary(UserId),
<<"guild_ids">> => [type_conv:to_binary(Gid) || Gid <- GuildIdsList]
},
case rpc_client:call(Request) of
{ok, _} ->
ok;
{error, Reason} ->
logger:warning(
"[presence] Failed to kick temporary member ~p from guilds ~p: ~p",
[UserId, GuildIdsList, Reason]
)
end
end)
end.
publish_offline_on_terminate(UserId, State) ->
LastPublished = maps:get(last_published_presence, State, undefined),
WasVisible =
case LastPublished of
undefined ->
false;
#{status := Status} when
Status =:= <<"online">>;
Status =:= <<"idle">>;
Status =:= <<"dnd">>
->
true;
_ ->
false
end,
case WasVisible of
true ->
UserData = user_utils:normalize_user(maps:get(user_data, State, #{})),
Payload = #{
<<"user">> => UserData,
<<"status">> => <<"offline">>,
<<"mobile">> => false,
<<"afk">> => false,
<<"custom_status">> => null
},
presence_bus:publish(UserId, Payload);
false ->
ok
end.
handle_process_down(Ref, _Reason, State) ->
Sessions = maps:get(sessions, State),
case presence_session:find_session_by_ref(Ref, Sessions) of
{ok, SessionId} ->
NewSessions = maps:remove(SessionId, Sessions),
NewState0 = maps:put(sessions, NewSessions, State),
NewState = publish_global_presence(NewSessions, NewState0),
if
map_size(NewSessions) =:= 0 ->
{stop, normal, NewState};
true ->
presence_session:dispatch_sessions_replace(NewState),
{noreply, NewState}
end;
not_found ->
{noreply, State}
end.
ensure_initial_global_subscriptions(State) ->
case maps:get(is_bot, State, false) of
true ->
State;
false ->
FriendIds = maps:keys(maps:get(friends, State, #{})),
GroupDm = maps:get(group_dm_recipients, State, #{}),
State1 =
lists:foldl(
fun(FriendId, Acc) ->
ensure_subscription(FriendId, friend, undefined, Acc)
end,
State,
FriendIds
),
lists:foldl(
fun({ChannelId, Recipients}, AccState) ->
RecipientIds = maps:keys(Recipients),
lists:foldl(
fun(RId, A) -> ensure_subscription(RId, gdm, ChannelId, A) end,
AccState,
RecipientIds
)
end,
State1,
maps:to_list(GroupDm)
)
end.
publish_global_if_needed({reply, Reply, NewState}) ->
FinalState = publish_global_presence(maps:get(sessions, NewState), NewState),
{reply, Reply, FinalState};
publish_global_if_needed({noreply, NewState}) ->
FinalState = publish_global_presence(maps:get(sessions, NewState), NewState),
{noreply, FinalState}.
publish_global_presence(_Sessions, State) ->
UserId = maps:get(user_id, State),
Payload = build_presence_payload(State),
ExternalStatus = maps:get(<<"status">>, Payload),
Mobile = maps:get(<<"mobile">>, Payload),
Afk = maps:get(<<"afk">>, Payload),
CustomStatus = maps:get(<<"custom_status">>, Payload, null),
CurrentExternal = #{
status => ExternalStatus,
mobile => Mobile,
afk => Afk,
custom_status => CustomStatus
},
LastPublished = maps:get(last_published_presence, State, undefined),
case presence_changed(LastPublished, CurrentExternal) of
true ->
case ExternalStatus of
<<"offline">> ->
presence_cache:delete(UserId);
_ ->
presence_cache:put(UserId, Payload)
end,
presence_bus:publish(UserId, Payload),
maps:put(last_published_presence, CurrentExternal, State);
false ->
State
end.
presence_changed(undefined, _Current) ->
true;
presence_changed(Last, Current) ->
Last =/= Current.
publish_user_update_to_bus(UserId, UserData, State) ->
LastPublished = maps:get(last_published_presence, State, undefined),
WasVisible = is_last_published_visible(LastPublished),
case WasVisible of
true ->
NormalizedUserData = user_utils:normalize_user(UserData),
Payload = #{
<<"user">> => NormalizedUserData,
<<"user_update">> => true
},
presence_bus:publish(UserId, Payload);
false ->
ok
end.
is_last_published_visible(undefined) ->
false;
is_last_published_visible(#{status := Status}) when
Status =:= <<"online">>;
Status =:= <<"idle">>;
Status =:= <<"dnd">>
->
true;
is_last_published_visible(_) ->
false.
dispatch_global_presence(TargetId, Payload, State) ->
UserId = maps:get(user_id, State),
case TargetId =:= UserId of
true ->
{noreply, State};
false ->
cache_if_visible(TargetId, Payload),
Sessions = maps:get(sessions, State),
SessionPids = [maps:get(pid, S) || S <- maps:values(Sessions)],
lists:foreach(
fun(Pid) when is_pid(Pid) ->
gen_server:cast(Pid, {dispatch, presence_update, Payload})
end,
SessionPids
),
{noreply, State}
end.
sync_friend_subscriptions(FriendIds, State) ->
case maps:get(is_bot, State, false) of
true ->
State;
false ->
ExistingFriends = maps:get(friends, State, #{}),
ExistingIds = maps:keys(ExistingFriends),
Additions = lists:subtract(FriendIds, ExistingIds),
Removals = lists:subtract(ExistingIds, FriendIds),
State1 =
lists:foldl(
fun(FId, Acc) ->
ensure_subscription(FId, friend, undefined, Acc)
end,
State,
Additions
),
State2 =
lists:foldl(
fun(FId, Acc) ->
remove_subscription_reason(FId, friend, undefined, Acc)
end,
State1,
Removals
),
State3 = maps:put(friends, map_from_ids(FriendIds), State2),
State4 = maybe_send_cached_presences(Additions, State3),
maybe_force_offline(Removals, State4)
end.
sync_group_dm_subscriptions(RecipientsByChannel, State) ->
case maps:get(is_bot, State, false) of
true ->
State;
false ->
Current = maps:get(group_dm_recipients, State, #{}),
Normalized = normalize_group_dm_recipients(
RecipientsByChannel, maps:get(user_id, State), false
),
{ToAdd, ToRemove} = diff_group_dm_recipients(Current, Normalized),
State1 =
lists:foldl(
fun({UserId, ChannelId}, Acc) ->
ensure_subscription(UserId, gdm, ChannelId, Acc)
end,
State,
ToAdd
),
State2 =
lists:foldl(
fun({UserId, ChannelId}, Acc) ->
remove_subscription_reason(UserId, gdm, ChannelId, Acc)
end,
State1,
ToRemove
),
AddedUsers = lists:usort([UserId || {UserId, _} <- ToAdd]),
State3 = maybe_send_cached_presences(AddedUsers, State2),
RemovedUsers = lists:usort([UserId || {UserId, _} <- ToRemove]),
State4 = maybe_force_offline(RemovedUsers, State3),
maps:put(group_dm_recipients, Normalized, State4)
end.
diff_group_dm_recipients(Old, New) ->
OldPairs =
lists:append(
[
[{UserId, ChannelId} || UserId <- maps:keys(Recipients)]
|| {ChannelId, Recipients} <- maps:to_list(Old)
]
),
NewPairs =
lists:append(
[
[{UserId, ChannelId} || UserId <- maps:keys(Recipients)]
|| {ChannelId, Recipients} <- maps:to_list(New)
]
),
{
lists:subtract(NewPairs, OldPairs),
lists:subtract(OldPairs, NewPairs)
}.
ensure_subscription(UserId, Reason, ChannelId, State) ->
case UserId =:= maps:get(user_id, State) of
true ->
State;
false ->
Subscriptions = maps:get(subscriptions, State, #{}),
Entry0 = maps:get(UserId, Subscriptions, #{friend => false, gdm_channels => #{}}),
Entry1 =
case Reason of
friend ->
Entry0#{friend => true};
gdm ->
Channels = maps:get(gdm_channels, Entry0, #{}),
Entry0#{gdm_channels => maps:put(ChannelId, true, Channels)}
end,
WasEmpty = not has_subscription(Entry0),
NewSubscriptions = maps:put(UserId, Entry1, Subscriptions),
case WasEmpty andalso has_subscription(Entry1) of
true -> presence_bus:subscribe(UserId);
false -> ok
end,
maps:put(subscriptions, NewSubscriptions, State)
end.
remove_subscription_reason(UserId, Reason, ChannelId, State) ->
Subscriptions = maps:get(subscriptions, State, #{}),
Entry0 = maps:get(UserId, Subscriptions, #{friend => false, gdm_channels => #{}}),
Entry1 =
case Reason of
friend ->
Entry0#{friend => false};
gdm ->
Channels = maps:get(gdm_channels, Entry0, #{}),
Entry0#{gdm_channels => maps:remove(ChannelId, Channels)}
end,
ShouldRemove = not has_subscription(Entry1),
NewSubscriptions =
case ShouldRemove of
true -> maps:remove(UserId, Subscriptions);
false -> maps:put(UserId, Entry1, Subscriptions)
end,
case ShouldRemove of
true -> presence_bus:unsubscribe(UserId);
false -> ok
end,
maps:put(subscriptions, NewSubscriptions, State).
has_subscription(Entry) ->
(maps:get(friend, Entry, false) =:= true) orelse
(map_size(maps:get(gdm_channels, Entry, #{})) > 0).
normalize_group_dm_recipients(RecipientsByChannel, UserId, IsBot) ->
case IsBot of
true ->
#{};
false ->
maps:from_list(
[
{ChannelId,
map_from_ids([
Rid
|| Rid <- recipient_list(RecipientIds), Rid =/= UserId
])}
|| {ChannelId, RecipientIds} <- maps:to_list(RecipientsByChannel)
]
)
end.
handle_join_guild(GuildId, State) ->
Guilds = maps:get(guild_ids, State, #{}),
case maps:is_key(GuildId, Guilds) of
true ->
{reply, ok, State};
false ->
NewGuilds = maps:put(GuildId, true, Guilds),
NewState = maps:put(guild_ids, NewGuilds, State),
presence_session:notify_sessions_guild_join(GuildId, NewState),
{reply, ok, NewState}
end.
handle_leave_guild(GuildId, State) ->
Guilds = maps:get(guild_ids, State, #{}),
case maps:is_key(GuildId, Guilds) of
false ->
{reply, ok, State};
true ->
NewGuilds = maps:remove(GuildId, Guilds),
TemporaryGuildIds = maps:get(temporary_guild_ids, State, #{}),
NewTemporaryGuildIds = maps:remove(GuildId, TemporaryGuildIds),
State1 = maps:put(guild_ids, NewGuilds, State),
NewState = maps:put(temporary_guild_ids, NewTemporaryGuildIds, State1),
presence_session:notify_sessions_guild_leave(GuildId, NewState),
{reply, ok, NewState}
end.
map_from_ids(Ids) when is_list(Ids) ->
maps:from_list([{Id, true} || Id <- Ids]).
cache_if_visible(UserId, Payload) when is_integer(UserId), is_map(Payload) ->
Status = maps:get(<<"status">>, Payload, <<"offline">>),
case Status of
<<"offline">> -> ok;
<<"invisible">> -> ok;
_ -> presence_cache:put(UserId, Payload)
end;
cache_if_visible(_, _) ->
ok.
build_presence_payload(State) ->
Sessions = maps:get(sessions, State),
Status = presence_status:get_current_status(Sessions),
Mobile = presence_status:get_flattened_mobile(Sessions),
Afk = presence_status:get_flattened_afk(Sessions),
UserData = maps:get(user_data, State, #{}),
CustomStatus = maps:get(custom_status, State, null),
presence_payload:build(UserData, Status, Mobile, Afk, CustomStatus).
maybe_handle_custom_status(Request, State) ->
case maps:find(<<"custom_status">>, Request) of
error ->
{Request, State};
{ok, null} ->
{maps:put(<<"custom_status">>, null, Request), maps:put(custom_status, null, State)};
{ok, CustomStatus} when is_map(CustomStatus) ->
PreviousCustomStatus = maps:get(custom_status, State, null),
case
custom_status_comparator(PreviousCustomStatus) =:=
custom_status_comparator(CustomStatus)
of
true ->
{maps:put(<<"custom_status">>, PreviousCustomStatus, Request), State};
false ->
validate_custom_status(CustomStatus, Request, State)
end;
_ ->
{Request, State}
end.
validate_custom_status(CustomStatus, Request, State) ->
UserId = maps:get(user_id, State),
case custom_status_validation:validate(UserId, CustomStatus) of
{ok, #{<<"custom_status">> := Validated}} ->
UpdatedRequest = maps:put(<<"custom_status">>, Validated, Request),
{UpdatedRequest, maps:put(custom_status, Validated, State)};
{ok, _} ->
UpdatedRequest = maps:put(<<"custom_status">>, null, Request),
{UpdatedRequest, maps:put(custom_status, null, State)};
{error, Reason} ->
logger:warning(
"[presence] Custom status validation failed for user ~p: ~p",
[UserId, Reason]
),
{Request, State}
end.
custom_status_comparator(null) ->
null;
custom_status_comparator(Map) when is_map(Map) ->
#{
<<"text">> => field_or_null(Map, <<"text">>),
<<"expires_at">> => field_or_null(Map, <<"expires_at">>),
<<"emoji_id">> => field_or_null(Map, <<"emoji_id">>),
<<"emoji_name">> => field_or_null(Map, <<"emoji_name">>)
}.
field_or_null(Map, Key) ->
case maps:get(Key, Map, undefined) of
undefined -> null;
Value -> Value
end.
maybe_send_cached_presences(UserIds, State) ->
case UserIds of
[] ->
State;
_ ->
lists:foreach(
fun(Uid) ->
case presence_cache:get(Uid) of
{ok, Presence} ->
notify_sessions_presence(Presence, State);
_ ->
ok
end
end,
UserIds
),
State
end.
maybe_force_offline(UserIds, State) ->
Subscriptions = maps:get(subscriptions, State, #{}),
lists:foldl(
fun(Uid, Acc) ->
case maps:is_key(Uid, Subscriptions) of
true ->
Acc;
false ->
presence_cache:delete(Uid),
Offline = #{
<<"user">> => #{<<"id">> => integer_to_binary(Uid)},
<<"status">> => <<"offline">>,
<<"mobile">> => false,
<<"afk">> => false,
<<"custom_status">> => null
},
notify_sessions_presence(Offline, Acc)
end
end,
State,
UserIds
).
notify_sessions_presence(Payload, State) ->
Sessions = maps:get(sessions, State, #{}),
SessionPids = [maps:get(pid, S) || S <- maps:values(Sessions)],
lists:foreach(
fun(Pid) when is_pid(Pid) ->
gen_server:cast(Pid, {dispatch, presence_update, Payload})
end,
SessionPids
),
State.
fetch_initial_presences(PresencePid, State) ->
case maps:get(is_bot, State, false) of
true ->
ok;
false ->
FriendIds = maps:keys(maps:get(friends, State, #{})),
GdmIds =
lists:append([
maps:keys(Recipients)
|| {_, Recipients} <- maps:to_list(
maps:get(group_dm_recipients, State, #{})
)
]),
Targets = lists:usort(FriendIds ++ GdmIds),
case Targets of
[] ->
ok;
_ ->
Presences = presence_cache:bulk_get(Targets),
Visible = [
P
|| P <- Presences, maps:get(<<"status">>, P, <<"offline">>) =/= <<"offline">>
],
case Visible of
[] ->
ok;
_ ->
PresencePid ! {initial_presences, Visible}
end
end
end.
recipient_list(Value) when is_list(Value) ->
Value;
recipient_list(Value) when is_map(Value) ->
maps:keys(Value);
recipient_list(_) ->
[].
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
gdm_subscription_add_remove_test() ->
maybe_start_presence_bus(),
maybe_start_presence_cache(),
BaseState = #{
user_id => 1,
is_bot => false,
sessions => #{},
user_data => #{},
subscriptions => #{},
friends => #{},
group_dm_recipients => #{}
},
State1 = sync_group_dm_subscriptions(#{1 => [10]}, BaseState),
Subscriptions1 = maps:get(subscriptions, State1),
Entry1 = maps:get(10, Subscriptions1),
GdmChannels1 = maps:get(gdm_channels, Entry1, #{}),
?assertEqual(true, maps:get(1, GdmChannels1)),
State2 = sync_group_dm_subscriptions(#{}, State1),
Subscriptions2 = maps:get(subscriptions, State2, #{}),
?assertEqual(false, maps:is_key(10, Subscriptions2)),
ok.
maybe_start_presence_bus() ->
case whereis(presence_bus) of
undefined ->
case presence_bus:start_link() of
{ok, _Pid} -> ok;
{error, {already_started, _Pid}} -> ok;
Other -> Other
end;
_ ->
ok
end.
maybe_start_presence_cache() ->
case whereis(presence_cache) of
undefined ->
case presence_cache:start_link() of
{ok, _Pid} -> ok;
{error, {already_started, _Pid}} -> ok;
Other -> Other
end;
_ ->
ok
end.
-endif.

View File

@@ -0,0 +1,292 @@
%% 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(presence_bus).
-behaviour(gen_server).
-include_lib("fluxer_gateway/include/timeout_config.hrl").
-export([start_link/0, subscribe/1, unsubscribe/1, publish/2]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-type shard() :: #{pid := pid(), ref := reference()}.
-type state() :: #{shards := #{non_neg_integer() => shard()}, shard_count := pos_integer()}.
-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-spec subscribe(integer()) -> ok.
subscribe(UserId) when is_integer(UserId) ->
gen_server:call(?MODULE, {subscribe, UserId, self()}, ?DEFAULT_GEN_SERVER_TIMEOUT).
-spec unsubscribe(integer()) -> ok.
unsubscribe(UserId) when is_integer(UserId) ->
gen_server:call(?MODULE, {unsubscribe, UserId, self()}, ?DEFAULT_GEN_SERVER_TIMEOUT).
-spec publish(integer(), term()) -> ok.
publish(UserId, Payload) when is_integer(UserId) ->
gen_server:call(?MODULE, {publish, UserId, Payload}, ?DEFAULT_GEN_SERVER_TIMEOUT).
-spec init(list()) -> {ok, state()}.
init([]) ->
process_flag(trap_exit, true),
{ShardCount, Source} = determine_shard_count(presence_bus_shards),
Shards = start_shards(ShardCount, #{}),
maybe_log_shard_source(presence_bus, ShardCount, Source),
{ok, #{shards => Shards, shard_count => ShardCount}}.
-spec handle_call(term(), gen_server:from(), state()) -> {reply, term(), state()}.
handle_call({subscribe, UserId, Pid}, _From, State) ->
{Reply, NewState} = forward_call(UserId, {subscribe, UserId, Pid}, State),
{reply, Reply, NewState};
handle_call({unsubscribe, UserId, Pid}, _From, State) ->
{Reply, NewState} = forward_call(UserId, {unsubscribe, UserId, Pid}, State),
{reply, Reply, NewState};
handle_call({publish, UserId, Payload}, _From, State) ->
{Reply, NewState} = forward_call(UserId, {publish, UserId, Payload}, State),
{reply, Reply, NewState};
handle_call(Request, _From, State) ->
logger:warning("[presence_bus] unknown request ~p", [Request]),
{reply, ok, State}.
-spec handle_cast(term(), state()) -> {noreply, state()}.
handle_cast(_Msg, State) ->
{noreply, State}.
-spec handle_info(term(), state()) -> {noreply, state()}.
handle_info({'DOWN', Ref, process, _Pid, Reason}, State) ->
Shards = maps:get(shards, State),
case find_shard_by_ref(Ref, Shards) of
{ok, Index} ->
logger:warning("[presence_bus] shard ~p crashed: ~p", [Index, Reason]),
{_Shard, NewState} = restart_shard(Index, State),
{noreply, NewState};
not_found ->
{noreply, State}
end;
handle_info({'EXIT', Pid, Reason}, State) ->
Shards = maps:get(shards, State),
case find_shard_by_pid(Pid, Shards) of
{ok, Index} ->
logger:warning("[presence_bus] shard ~p exited: ~p", [Index, Reason]),
{_Shard, NewState} = restart_shard(Index, State),
{noreply, NewState};
not_found ->
{noreply, State}
end;
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(term(), state()) -> ok.
terminate(_Reason, State) ->
Shards = maps:get(shards, State),
lists:foreach(
fun(Shard) ->
Pid = maps:get(pid, Shard),
catch gen_server:stop(Pid, shutdown, 5000)
end,
maps:values(Shards)
),
ok.
-spec code_change(term(), term(), term()) -> {ok, state()}.
code_change(_OldVsn, State, _Extra) when is_map(State) ->
{ok, State};
code_change(_OldVsn, {state, Shards, ShardCount}, _Extra) ->
ConvertedShards = maps:map(
fun(_Index, {shard, Pid, Ref}) ->
#{pid => Pid, ref => Ref}
end,
Shards
),
{ok, #{shards => ConvertedShards, shard_count => ShardCount}}.
-spec determine_shard_count(atom()) -> {pos_integer(), configured | auto}.
determine_shard_count(ConfigKey) ->
case fluxer_gateway_env:get(ConfigKey) of
Value when is_integer(Value), Value > 0 ->
{Value, configured};
_ ->
{default_shard_count(), auto}
end.
-spec default_shard_count() -> pos_integer().
default_shard_count() ->
Candidates = [
erlang:system_info(logical_processors_available), erlang:system_info(schedulers_online)
],
lists:max([C || C <- Candidates, is_integer(C), C > 0] ++ [1]).
-spec maybe_log_shard_source(atom(), pos_integer(), configured | auto) -> ok.
maybe_log_shard_source(Name, Count, configured) ->
logger:info("[~p] starting with ~p shards (configured)", [Name, Count]),
ok;
maybe_log_shard_source(Name, Count, auto) ->
logger:info(
"[~p] starting with ~p shards (auto, set FLUXER_GATEWAY_PRESENCE_BUS_SHARDS for cross-node consistency)",
[Name, Count]
),
ok.
-spec start_shards(pos_integer(), #{}) -> #{non_neg_integer() => shard()}.
start_shards(Count, Acc) ->
lists:foldl(
fun(Index, MapAcc) ->
case start_shard(Index) of
{ok, Shard} ->
maps:put(Index, Shard, MapAcc);
{error, Reason} ->
logger:warning("[presence_bus] failed to start shard ~p: ~p", [Index, Reason]),
MapAcc
end
end,
Acc,
lists:seq(0, Count - 1)
).
-spec start_shard(non_neg_integer()) -> {ok, shard()} | {error, term()}.
start_shard(Index) ->
case presence_bus_shard:start_link(Index) of
{ok, Pid} ->
Ref = erlang:monitor(process, Pid),
{ok, #{pid => Pid, ref => Ref}};
Error ->
Error
end.
-spec restart_shard(non_neg_integer(), state()) -> {shard(), state()}.
restart_shard(Index, State) ->
case start_shard(Index) of
{ok, Shard} ->
Shards = maps:get(shards, State),
Updated = State#{shards := maps:put(Index, Shard, Shards)},
{Shard, Updated};
{error, Reason} ->
logger:error("[presence_bus] failed to restart shard ~p: ~p", [Index, Reason]),
Dummy = #{pid => spawn(fun() -> exit(normal) end), ref => make_ref()},
{Dummy, State}
end.
-spec forward_call(term(), term(), state()) -> {term(), state()}.
forward_call(Key, Request, State) ->
{Index, State1} = ensure_shard(Key, State),
call_shard(Index, Request, State1).
-spec call_shard(non_neg_integer(), term(), state()) -> {term(), state()}.
call_shard(Index, Request, State) ->
Shards = maps:get(shards, State),
Shard = maps:get(Index, Shards),
Pid = maps:get(pid, Shard),
case catch gen_server:call(Pid, Request, ?DEFAULT_GEN_SERVER_TIMEOUT) of
{'EXIT', _} ->
{_Shard, State1} = restart_shard(Index, State),
call_shard(Index, Request, State1);
Reply ->
{Reply, State}
end.
-spec ensure_shard(term(), state()) -> {non_neg_integer(), state()}.
ensure_shard(Key, State) ->
Count = maps:get(shard_count, State),
Index = select_shard(Key, Count),
ensure_shard_for_index(Index, State).
-spec ensure_shard_for_index(non_neg_integer(), state()) -> {non_neg_integer(), state()}.
ensure_shard_for_index(Index, State) ->
Shards = maps:get(shards, State),
case maps:get(Index, Shards, undefined) of
undefined ->
{_Shard, NewState} = restart_shard(Index, State),
{Index, NewState};
#{pid := Pid} when is_pid(Pid) ->
case erlang:is_process_alive(Pid) of
true ->
{Index, State};
false ->
{_Shard, NewState} = restart_shard(Index, State),
{Index, NewState}
end
end.
-spec select_shard(term(), pos_integer()) -> non_neg_integer().
select_shard(Key, Count) when Count > 0 ->
rendezvous_router:select(Key, Count).
-spec find_shard_by_ref(reference(), #{non_neg_integer() => shard()}) ->
{ok, non_neg_integer()} | not_found.
find_shard_by_ref(Ref, Shards) ->
maps:fold(
fun
(Index, #{ref := R}, _) when R =:= Ref -> {ok, Index};
(_, _, Acc) -> Acc
end,
not_found,
Shards
).
-spec find_shard_by_pid(pid(), #{non_neg_integer() => shard()}) ->
{ok, non_neg_integer()} | not_found.
find_shard_by_pid(Pid, Shards) ->
maps:fold(
fun
(Index, #{pid := P}, _) when P =:= Pid -> {ok, Index};
(_, _, Acc) -> Acc
end,
not_found,
Shards
).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
subscribe_publish_roundtrip_test() ->
{ok, Pid} = maybe_start_for_test(),
UserId = 99999,
Payload = #{<<"status">> => <<"online">>},
?assertEqual(ok, subscribe(UserId)),
?assertEqual(ok, publish(UserId, Payload)),
receive
{presence, UserId, Payload} ->
ok
after 1000 ->
?assert(false)
end,
?assertEqual(ok, unsubscribe(UserId)),
?assertEqual(ok, gen_server:stop(Pid)).
unsubscribe_stops_delivery_test() ->
{ok, Pid} = maybe_start_for_test(),
UserId = 88888,
Payload = #{<<"status">> => <<"idle">>},
subscribe(UserId),
?assertEqual(ok, unsubscribe(UserId)),
?assertEqual(ok, publish(UserId, Payload)),
receive
{presence, UserId, Payload} ->
?assert(false)
after 300 ->
ok
end,
?assertEqual(ok, gen_server:stop(Pid)).
maybe_start_for_test() ->
case whereis(?MODULE) of
undefined -> start_link();
Existing when is_pid(Existing) -> {ok, Existing}
end.
-endif.

View 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(presence_bus_shard).
-behaviour(gen_server).
-include_lib("fluxer_gateway/include/timeout_config.hrl").
-export([start_link/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-type state() :: #{scope := atom(), pg_pid := pid(), shard_index := non_neg_integer()}.
-define(SCOPE_PREFIX, presence_bus).
-spec start_link(non_neg_integer()) -> {ok, pid()} | {error, term()}.
start_link(ShardIndex) ->
gen_server:start_link(?MODULE, #{shard_index => ShardIndex}, []).
-spec init(map()) -> {ok, state()} | {stop, term()}.
init(#{shard_index := ShardIndex}) ->
process_flag(trap_exit, true),
Scope = scope_name(ShardIndex),
case ensure_pg_scope(Scope) of
{ok, PgPid} ->
{ok, #{scope => Scope, pg_pid => PgPid, shard_index => ShardIndex}};
{error, Reason} ->
{stop, Reason}
end.
-spec handle_call(term(), gen_server:from(), state()) -> {reply, term(), state()}.
handle_call({subscribe, UserId, Pid}, _From, State) ->
Scope = maps:get(scope, State),
{reply, do_subscribe(Scope, UserId, Pid), State};
handle_call({unsubscribe, UserId, Pid}, _From, State) ->
Scope = maps:get(scope, State),
{reply, do_unsubscribe(Scope, UserId, Pid), State};
handle_call({publish, UserId, Payload}, _From, State) ->
Scope = maps:get(scope, State),
{reply, do_publish(Scope, UserId, Payload), State};
handle_call(_Request, _From, State) ->
{reply, ok, State}.
-spec handle_cast(term(), state()) -> {noreply, state()}.
handle_cast(_Msg, State) ->
{noreply, State}.
-spec handle_info(term(), state()) -> {noreply, state()}.
handle_info({'EXIT', PgPid, Reason}, State) ->
StoredPgPid = maps:get(pg_pid, State),
case PgPid =:= StoredPgPid of
true ->
Scope = maps:get(scope, State),
ShardIndex = maps:get(shard_index, State),
logger:warning(
"[presence_bus_shard ~p] pg process exited: ~p; restarting scope",
[ShardIndex, Reason]
),
case ensure_pg_scope(Scope) of
{ok, NewPgPid} ->
{noreply, State#{pg_pid := NewPgPid}};
{error, _} ->
{noreply, State}
end;
false ->
{noreply, State}
end;
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(term(), state()) -> ok.
terminate(_Reason, _State) ->
ok.
-spec code_change(term(), term(), term()) -> {ok, state()}.
code_change(_OldVsn, State, _Extra) when is_map(State) ->
{ok, State};
code_change(_OldVsn, {state, Scope, PgPid, ShardIndex}, _Extra) ->
{ok, #{scope => Scope, pg_pid => PgPid, shard_index => ShardIndex}}.
-spec do_subscribe(atom(), integer(), pid()) -> ok.
do_subscribe(Scope, UserId, Pid) ->
Group = {presence, UserId},
case catch pg:join(Scope, Group, Pid) of
ok ->
ok;
{'EXIT', Reason} ->
logger:warning("[presence_bus_shard] failed to join group ~p: ~p", [Group, Reason]),
ok;
_ ->
ok
end.
-spec do_unsubscribe(atom(), integer(), pid()) -> ok.
do_unsubscribe(Scope, UserId, Pid) ->
Group = {presence, UserId},
case catch pg:leave(Scope, Group, Pid) of
ok ->
ok;
{'EXIT', Reason} ->
logger:warning("[presence_bus_shard] failed to leave group ~p: ~p", [Group, Reason]),
ok;
_ ->
ok
end.
-spec do_publish(atom(), integer(), term()) -> ok.
do_publish(Scope, UserId, Payload) ->
Group = {presence, UserId},
Members =
case catch pg:get_members(Scope, Group) of
{'EXIT', _} -> [];
List when is_list(List) -> List;
_ -> []
end,
case Members of
[] ->
ok;
_ ->
lists:foreach(
fun(TargetPid) ->
catch TargetPid ! {presence, UserId, Payload}
end,
Members
),
ok
end.
-spec ensure_pg_scope(atom()) -> {ok, pid()} | {error, term()}.
ensure_pg_scope(Scope) ->
case catch pg:start_link(Scope) of
{ok, PgPid} ->
{ok, PgPid};
{error, {already_started, PgPid}} ->
link(PgPid),
{ok, PgPid};
{'EXIT', Reason} ->
{error, Reason};
Error ->
Error
end.
-spec scope_name(non_neg_integer()) -> atom().
scope_name(Index) ->
list_to_atom(atom_to_list(?SCOPE_PREFIX) ++ "_" ++ integer_to_list(Index)).

View File

@@ -0,0 +1,334 @@
%% 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(presence_cache).
-behaviour(gen_server).
-include_lib("fluxer_gateway/include/timeout_config.hrl").
-compile({no_auto_import, [get/1, put/2]}).
-export([start_link/0, put/2, delete/1, get/1, bulk_get/1, get_memory_stats/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-type shard() :: #{pid := pid(), ref := reference()}.
-type state() :: #{shards := #{non_neg_integer() => shard()}, shard_count := pos_integer()}.
-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-spec put(integer(), map()) -> ok.
put(UserId, Presence) when is_integer(UserId), is_map(Presence) ->
gen_server:call(?MODULE, {put, UserId, Presence}, ?DEFAULT_GEN_SERVER_TIMEOUT).
-spec delete(integer()) -> ok.
delete(UserId) when is_integer(UserId) ->
gen_server:call(?MODULE, {delete, UserId}, ?DEFAULT_GEN_SERVER_TIMEOUT).
-spec get(integer()) -> {ok, map()} | not_found.
get(UserId) when is_integer(UserId) ->
gen_server:call(?MODULE, {get, UserId}, ?DEFAULT_GEN_SERVER_TIMEOUT).
-spec bulk_get([term()]) -> [map()].
bulk_get(UserIds) when is_list(UserIds) ->
gen_server:call(?MODULE, {bulk_get, UserIds}, ?DEFAULT_GEN_SERVER_TIMEOUT).
-spec get_memory_stats() -> {ok, map()} | {error, term()}.
get_memory_stats() ->
gen_server:call(?MODULE, get_memory_stats, ?DEFAULT_GEN_SERVER_TIMEOUT).
-spec init(list()) -> {ok, state()}.
init([]) ->
process_flag(trap_exit, true),
{ShardCount, Source} = determine_shard_count(presence_cache_shards),
Shards = start_shards(ShardCount, #{}),
maybe_log_shard_source(presence_cache, ShardCount, Source),
{ok, #{shards => Shards, shard_count => ShardCount}}.
-spec handle_call(term(), gen_server:from(), state()) -> {reply, term(), state()}.
handle_call({put, UserId, Presence}, _From, State) ->
{Reply, NewState} = forward_call(UserId, {put, UserId, Presence}, State),
{reply, Reply, NewState};
handle_call({delete, UserId}, _From, State) ->
{Reply, NewState} = forward_call(UserId, {delete, UserId}, State),
{reply, Reply, NewState};
handle_call({get, UserId}, _From, State) ->
{Reply, NewState} = forward_call(UserId, {get, UserId}, State),
{reply, Reply, NewState};
handle_call({bulk_get, UserIds}, _From, State) ->
{Reply, NewState} = forward_bulk_get(UserIds, State),
{reply, Reply, NewState};
handle_call(get_memory_stats, _From, State) ->
Count = maps:get(shard_count, State),
WordSize = erlang:system_info(wordsize),
TotalMemory = lists:foldl(fun(Index, Acc) ->
TableName = presence_cache_shard:table_name(Index),
case ets:info(TableName, memory) of
undefined -> Acc;
Words -> Acc + (Words * WordSize)
end
end, 0, lists:seq(0, Count - 1)),
TotalEntries = lists:foldl(fun(Index, Acc) ->
TableName = presence_cache_shard:table_name(Index),
case ets:info(TableName, size) of
undefined -> Acc;
Size -> Acc + Size
end
end, 0, lists:seq(0, Count - 1)),
{reply, {ok, #{memory_bytes => TotalMemory, entry_count => TotalEntries}}, State};
handle_call(Request, _From, State) ->
logger:warning("[presence_cache] unknown request ~p", [Request]),
{reply, ok, State}.
-spec handle_cast(term(), state()) -> {noreply, state()}.
handle_cast(_Msg, State) ->
{noreply, State}.
-spec handle_info(term(), state()) -> {noreply, state()}.
handle_info({'DOWN', Ref, process, _Pid, Reason}, State) ->
Shards = maps:get(shards, State),
case find_shard_by_ref(Ref, Shards) of
{ok, Index} ->
logger:warning("[presence_cache] shard ~p crashed: ~p", [Index, Reason]),
{_Shard, NewState} = restart_shard(Index, State),
{noreply, NewState};
not_found ->
{noreply, State}
end;
handle_info({'EXIT', Pid, Reason}, State) ->
Shards = maps:get(shards, State),
case find_shard_by_pid(Pid, Shards) of
{ok, Index} ->
logger:warning("[presence_cache] shard ~p exited: ~p", [Index, Reason]),
{_Shard, NewState} = restart_shard(Index, State),
{noreply, NewState};
not_found ->
{noreply, State}
end;
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(term(), state()) -> ok.
terminate(_Reason, State) ->
Shards = maps:get(shards, State),
lists:foreach(
fun(Shard) ->
Pid = maps:get(pid, Shard),
catch gen_server:stop(Pid, shutdown, 5000)
end,
maps:values(Shards)
),
ok.
-spec code_change(term(), term(), term()) -> {ok, state()}.
code_change(_OldVsn, State, _Extra) when is_map(State) ->
{ok, State};
code_change(_OldVsn, {state, Shards, ShardCount}, _Extra) ->
ConvertedShards = maps:map(
fun(_Index, {shard, Pid, Ref}) ->
#{pid => Pid, ref => Ref}
end,
Shards
),
{ok, #{shards => ConvertedShards, shard_count => ShardCount}}.
-spec determine_shard_count(atom()) -> {pos_integer(), configured | auto}.
determine_shard_count(ConfigKey) ->
case fluxer_gateway_env:get(ConfigKey) of
Value when is_integer(Value), Value > 0 ->
{Value, configured};
_ ->
{default_shard_count(), auto}
end.
-spec default_shard_count() -> pos_integer().
default_shard_count() ->
Candidates = [
erlang:system_info(logical_processors_available), erlang:system_info(schedulers_online)
],
lists:max([C || C <- Candidates, is_integer(C), C > 0] ++ [1]).
-spec maybe_log_shard_source(atom(), pos_integer(), configured | auto) -> ok.
maybe_log_shard_source(Name, Count, configured) ->
logger:info("[~p] starting with ~p shards (configured)", [Name, Count]),
ok;
maybe_log_shard_source(Name, Count, auto) ->
logger:info("[~p] starting with ~p shards (auto)", [Name, Count]),
ok.
-spec start_shards(pos_integer(), #{}) -> #{non_neg_integer() => shard()}.
start_shards(Count, Acc) ->
lists:foldl(
fun(Index, MapAcc) ->
case start_shard(Index) of
{ok, Shard} ->
maps:put(Index, Shard, MapAcc);
{error, Reason} ->
logger:warning("[presence_cache] failed to start shard ~p: ~p", [Index, Reason]),
MapAcc
end
end,
Acc,
lists:seq(0, Count - 1)
).
-spec start_shard(non_neg_integer()) -> {ok, shard()} | {error, term()}.
start_shard(Index) ->
case presence_cache_shard:start_link(Index) of
{ok, Pid} ->
Ref = erlang:monitor(process, Pid),
{ok, #{pid => Pid, ref => Ref}};
Error ->
Error
end.
-spec restart_shard(non_neg_integer(), state()) -> {shard(), state()}.
restart_shard(Index, State) ->
case start_shard(Index) of
{ok, Shard} ->
Shards = maps:get(shards, State),
Updated = State#{shards := maps:put(Index, Shard, Shards)},
{Shard, Updated};
{error, Reason} ->
logger:error("[presence_cache] failed to restart shard ~p: ~p", [Index, Reason]),
Dummy = #{pid => spawn(fun() -> exit(normal) end), ref => make_ref()},
{Dummy, State}
end.
-spec forward_call(term(), term(), state()) -> {term(), state()}.
forward_call(Key, Request, State) ->
{Index, State1} = ensure_shard(Key, State),
call_shard(Index, Request, State1).
-spec forward_bulk_get([term()], state()) -> {term(), state()}.
forward_bulk_get(UserIds, State) ->
Count = maps:get(shard_count, State),
Unique = lists:usort(UserIds),
Groups = rendezvous_router:group_keys(Unique, Count),
{Results, FinalState} =
lists:foldl(
fun({Index, Ids}, {AccResults, AccState}) ->
{Reply, State1} = call_shard(Index, {bulk_get, Ids}, AccState),
case Reply of
List when is_list(List) ->
{Reply ++ AccResults, State1};
_ ->
{AccResults, State1}
end
end,
{[], State},
Groups
),
{lists:reverse(Results), FinalState}.
-spec call_shard(non_neg_integer(), term(), state()) -> {term(), state()}.
call_shard(Index, Request, State) ->
Shards = maps:get(shards, State),
Shard = maps:get(Index, Shards),
Pid = maps:get(pid, Shard),
case catch gen_server:call(Pid, Request, ?DEFAULT_GEN_SERVER_TIMEOUT) of
{'EXIT', _} ->
{_Shard, State1} = restart_shard(Index, State),
call_shard(Index, Request, State1);
Reply ->
{Reply, State}
end.
-spec ensure_shard(term(), state()) -> {non_neg_integer(), state()}.
ensure_shard(Key, State) ->
Count = maps:get(shard_count, State),
Index = select_shard(Key, Count),
ensure_shard_for_index(Index, State).
-spec ensure_shard_for_index(non_neg_integer(), state()) -> {non_neg_integer(), state()}.
ensure_shard_for_index(Index, State) ->
Shards = maps:get(shards, State),
case maps:get(Index, Shards, undefined) of
undefined ->
{_Shard, NewState} = restart_shard(Index, State),
{Index, NewState};
#{pid := Pid} when is_pid(Pid) ->
case erlang:is_process_alive(Pid) of
true ->
{Index, State};
false ->
{_Shard, NewState} = restart_shard(Index, State),
{Index, NewState}
end
end.
-spec select_shard(term(), pos_integer()) -> non_neg_integer().
select_shard(Key, Count) when Count > 0 ->
rendezvous_router:select(Key, Count).
-spec find_shard_by_ref(reference(), #{non_neg_integer() => shard()}) ->
{ok, non_neg_integer()} | not_found.
find_shard_by_ref(Ref, Shards) ->
maps:fold(
fun
(Index, #{ref := R}, _) when R =:= Ref -> {ok, Index};
(_, _, Acc) -> Acc
end,
not_found,
Shards
).
-spec find_shard_by_pid(pid(), #{non_neg_integer() => shard()}) ->
{ok, non_neg_integer()} | not_found.
find_shard_by_pid(Pid, Shards) ->
maps:fold(
fun
(Index, #{pid := P}, _) when P =:= Pid -> {ok, Index};
(_, _, Acc) -> Acc
end,
not_found,
Shards
).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
put_and_get_visible_status_test() ->
{ok, Pid} = maybe_start_for_test(),
Presence = #{<<"status">> => <<"online">>},
?assertEqual(ok, put(1, Presence)),
?assertMatch({ok, _}, get(1)),
?assertEqual(ok, gen_server:stop(Pid)).
put_offline_evicted_test() ->
{ok, Pid} = maybe_start_for_test(),
Presence = #{<<"status">> => <<"offline">>},
?assertEqual(ok, put(2, Presence)),
?assertEqual(not_found, get(2)),
?assertEqual(ok, gen_server:stop(Pid)).
bulk_get_across_shards_test() ->
{ok, Pid} = maybe_start_for_test(),
Visible = #{<<"status">> => <<"online">>, <<"user">> => #{<<"id">> => <<"3">>}},
put(3, Visible),
put(4, Visible),
Results = bulk_get([3, 4, 3]),
?assertEqual(2, length(Results)),
?assertEqual(ok, gen_server:stop(Pid)).
maybe_start_for_test() ->
case whereis(?MODULE) of
undefined -> start_link();
Existing when is_pid(Existing) -> {ok, Existing}
end.
-endif.

View File

@@ -0,0 +1,118 @@
%% 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(presence_cache_shard).
-behaviour(gen_server).
-include_lib("fluxer_gateway/include/timeout_config.hrl").
-export([start_link/1, table_name/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-type state() :: #{table := atom(), shard_index := non_neg_integer()}.
-define(TABLE_PREFIX, presence_cache).
-spec start_link(non_neg_integer()) -> {ok, pid()} | {error, term()}.
start_link(ShardIndex) ->
gen_server:start_link(?MODULE, #{shard_index => ShardIndex}, []).
-spec init(map()) -> {ok, state()}.
init(#{shard_index := ShardIndex}) ->
process_flag(trap_exit, true),
TableName = table_name(ShardIndex),
ensure_table(TableName),
{ok, #{table => TableName, shard_index => ShardIndex}}.
-spec handle_call(term(), gen_server:from(), state()) -> {reply, term(), state()}.
handle_call({put, UserId, Presence}, _From, State) ->
Table = maps:get(table, State),
{reply, do_put(Table, UserId, Presence), State};
handle_call({delete, UserId}, _From, State) ->
Table = maps:get(table, State),
ets:delete(Table, UserId),
{reply, ok, State};
handle_call({get, UserId}, _From, State) ->
Table = maps:get(table, State),
Reply =
case catch ets:lookup(Table, UserId) of
[{_, Presence}] -> {ok, Presence};
_ -> not_found
end,
{reply, Reply, State};
handle_call({bulk_get, UserIds}, _From, State) ->
Table = maps:get(table, State),
Presences =
lists:filtermap(
fun(Uid) ->
case catch ets:lookup(Table, Uid) of
[{_, Presence}] -> {true, Presence};
_ -> false
end
end,
lists:usort(UserIds)
),
{reply, Presences, State};
handle_call(_Request, _From, State) ->
{reply, ok, State}.
-spec handle_cast(term(), state()) -> {noreply, state()}.
handle_cast(_Msg, State) ->
{noreply, State}.
-spec handle_info(term(), state()) -> {noreply, state()}.
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(term(), state()) -> ok.
terminate(_Reason, _State) ->
ok.
-spec code_change(term(), term(), term()) -> {ok, state()}.
code_change(_OldVsn, State, _Extra) when is_map(State) ->
{ok, State};
code_change(_OldVsn, {state, Table, ShardIndex}, _Extra) ->
{ok, #{table => Table, shard_index => ShardIndex}}.
-spec do_put(atom(), integer(), map()) -> ok.
do_put(Table, UserId, Presence) ->
Status = maps:get(<<"status">>, Presence, <<"offline">>),
case Status of
<<"invisible">> ->
ets:delete(Table, UserId),
ok;
<<"offline">> ->
ets:delete(Table, UserId),
ok;
_ ->
ets:insert(Table, {UserId, Presence}),
ok
end.
-spec ensure_table(atom()) -> ok.
ensure_table(Table) ->
case ets:info(Table) of
undefined ->
ets:new(Table, [named_table, public, set, {read_concurrency, true}]),
ok;
_ ->
ok
end.
-spec table_name(non_neg_integer()) -> atom().
table_name(Index) ->
list_to_atom(atom_to_list(?TABLE_PREFIX) ++ "_" ++ integer_to_list(Index)).

View File

@@ -0,0 +1,298 @@
%% 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(presence_manager).
-behaviour(gen_server).
-include_lib("fluxer_gateway/include/timeout_config.hrl").
-export([start_link/0, lookup/1, dispatch_to_user/3, terminate_all_sessions/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-type user_id() :: integer().
-type event_type() :: atom() | binary().
-type shard() :: #{pid := pid(), ref := reference()}.
-type state() :: #{shards := #{non_neg_integer() => shard()}, shard_count := pos_integer()}.
-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-spec lookup(user_id()) -> {ok, pid()} | {error, not_found}.
lookup(UserId) ->
gen_server:call(?MODULE, {lookup, UserId}, ?DEFAULT_GEN_SERVER_TIMEOUT).
-spec terminate_all_sessions(user_id()) -> ok | {error, term()}.
terminate_all_sessions(UserId) ->
gen_server:call(?MODULE, {terminate_all_sessions, UserId}, ?DEFAULT_GEN_SERVER_TIMEOUT).
-spec dispatch_to_user(user_id(), event_type(), term()) -> ok | {error, not_found}.
dispatch_to_user(UserId, Event, Data) ->
gen_server:call(?MODULE, {dispatch, UserId, Event, Data}, ?DEFAULT_GEN_SERVER_TIMEOUT).
-spec init(list()) -> {ok, state()}.
init([]) ->
process_flag(trap_exit, true),
{ShardCount, Source} = determine_shard_count(),
{ShardMap, _} = lists:foldl(
fun(Index, {Acc, Counter}) ->
case start_shard(Index) of
{ok, Shard} ->
{maps:put(Index, Shard, Acc), Counter + 1};
{error, Reason} ->
logger:warning("[presence_manager] failed to start shard ~p: ~p", [
Index, Reason
]),
{Acc, Counter}
end
end,
{#{}, 0},
lists:seq(0, ShardCount - 1)
),
maybe_log_shard_source(presence_manager, ShardCount, Source),
{ok, #{shards => ShardMap, shard_count => ShardCount}}.
-spec handle_call(term(), gen_server:from(), state()) -> {reply, term(), state()}.
handle_call({lookup, UserId}, _From, State) ->
{Reply, NewState} = forward_call(UserId, {lookup, UserId}, State),
{reply, Reply, NewState};
handle_call({dispatch, UserId, Event, Data}, _From, State) ->
{Reply, NewState} = forward_call(UserId, {dispatch, UserId, Event, Data}, State),
{reply, Reply, NewState};
handle_call({terminate_all_sessions, UserId}, _From, State) ->
{Reply, NewState} = forward_call(UserId, {terminate_all_sessions, UserId}, State),
{reply, Reply, NewState};
handle_call({start_or_lookup, _} = Request, _From, State) ->
Key = extract_user_id(Request),
{Reply, NewState} = forward_call(Key, Request, State),
{reply, Reply, NewState};
handle_call(get_local_count, _From, State) ->
{Count, NewState} = aggregate_counts(get_local_count, State),
{reply, {ok, Count}, NewState};
handle_call(get_global_count, _From, State) ->
{Count, NewState} = aggregate_counts(get_global_count, State),
{reply, {ok, Count}, NewState};
handle_call(Request, _From, State) ->
logger:warning("[presence_manager] unknown request ~p", [Request]),
{reply, ok, State}.
-spec handle_cast(term(), state()) -> {noreply, state()}.
handle_cast(_Msg, State) ->
{noreply, State}.
-spec handle_info(term(), state()) -> {noreply, state()}.
handle_info({'DOWN', Ref, process, _Pid, Reason}, State) ->
Shards = maps:get(shards, State),
case find_shard_by_ref(Ref, Shards) of
{ok, Index} ->
logger:warning("[presence_manager] shard ~p crashed: ~p", [Index, Reason]),
{_ShardEntry, UpdatedState} = restart_shard(Index, State),
{noreply, UpdatedState};
not_found ->
{noreply, State}
end;
handle_info({'EXIT', Pid, Reason}, State) ->
Shards = maps:get(shards, State),
case find_shard_by_pid(Pid, Shards) of
{ok, Index} ->
logger:warning("[presence_manager] shard ~p exited: ~p", [Index, Reason]),
{_ShardEntry, UpdatedState} = restart_shard(Index, State),
{noreply, UpdatedState};
not_found ->
{noreply, State}
end;
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(term(), state()) -> ok.
terminate(_Reason, State) ->
Shards = maps:get(shards, State),
lists:foreach(
fun(Shard) ->
Pid = maps:get(pid, Shard),
catch gen_server:stop(Pid, shutdown, 5000)
end,
maps:values(Shards)
),
ok.
-spec code_change(term(), term(), term()) -> {ok, state()}.
code_change(_OldVsn, State, _Extra) when is_map(State) ->
{ok, State};
code_change(_OldVsn, {state, Shards, ShardCount}, _Extra) ->
ConvertedShards = maps:map(
fun(_Index, {shard, Pid, Ref}) ->
#{pid => Pid, ref => Ref}
end,
Shards
),
{ok, #{shards => ConvertedShards, shard_count => ShardCount}}.
-spec determine_shard_count() -> {pos_integer(), configured | auto}.
determine_shard_count() ->
case fluxer_gateway_env:get(presence_shards) of
Value when is_integer(Value), Value > 0 ->
{Value, configured};
_ ->
{default_shard_count(), auto}
end.
-spec start_shard(non_neg_integer()) -> {ok, shard()} | {error, term()}.
start_shard(Index) ->
case presence_manager_shard:start_link(Index) of
{ok, Pid} ->
Ref = erlang:monitor(process, Pid),
{ok, #{pid => Pid, ref => Ref}};
Error ->
Error
end.
-spec restart_shard(non_neg_integer(), state()) -> {shard(), state()}.
restart_shard(Index, State) ->
case start_shard(Index) of
{ok, Shard} ->
Shards = maps:get(shards, State),
Updated = State#{shards := maps:put(Index, Shard, Shards)},
{Shard, Updated};
{error, Reason} ->
logger:error("[presence_manager] failed to restart shard ~p: ~p", [Index, Reason]),
Dummy = #{pid => spawn(fun() -> exit(normal) end), ref => make_ref()},
{Dummy, State}
end.
-spec forward_call(user_id(), term(), state()) -> {term(), state()}.
forward_call(Key, Request, State) ->
{ShardIndex, State1} = ensure_shard(Key, State),
Shards = maps:get(shards, State1),
Shard = maps:get(ShardIndex, Shards),
Pid = maps:get(pid, Shard),
case catch gen_server:call(Pid, Request, ?DEFAULT_GEN_SERVER_TIMEOUT) of
{'EXIT', _} ->
{_ShardEntry, State2} = restart_shard(ShardIndex, State1),
forward_call(Key, Request, State2);
Reply ->
{Reply, State1}
end.
-spec aggregate_counts(term(), state()) -> {non_neg_integer(), state()}.
aggregate_counts(Request, State) ->
Shards = maps:get(shards, State),
Results =
[
begin
Pid = maps:get(pid, Shard),
case catch gen_server:call(Pid, Request, ?DEFAULT_GEN_SERVER_TIMEOUT) of
{ok, Count} -> Count;
_ -> 0
end
end
|| Shard <- maps:values(Shards)
],
{lists:sum(Results), State}.
-spec ensure_shard(user_id(), state()) -> {non_neg_integer(), state()}.
ensure_shard(Key, State) ->
Count = maps:get(shard_count, State),
Shards = maps:get(shards, State),
Index = select_shard(Key, Count),
case maps:get(Index, Shards, undefined) of
undefined ->
{_ShardEntry, NewState} = restart_shard(Index, State),
{Index, NewState};
#{pid := Pid} when is_pid(Pid) ->
case erlang:is_process_alive(Pid) of
true ->
{Index, State};
false ->
{_ShardEntry, NewState} = restart_shard(Index, State),
{Index, NewState}
end
end.
-spec select_shard(user_id(), pos_integer()) -> non_neg_integer().
select_shard(Key, Count) when Count > 0 ->
rendezvous_router:select(Key, Count).
-spec extract_user_id(term()) -> user_id().
extract_user_id({start_or_lookup, #{user_id := UserId}}) -> UserId;
extract_user_id(_) -> 0.
-spec find_shard_by_ref(reference(), #{non_neg_integer() => shard()}) ->
{ok, non_neg_integer()} | not_found.
find_shard_by_ref(Ref, Shards) ->
maps:fold(
fun
(Index, #{ref := R}, _) when R =:= Ref -> {ok, Index};
(_, _, Acc) -> Acc
end,
not_found,
Shards
).
-spec find_shard_by_pid(pid(), #{non_neg_integer() => shard()}) ->
{ok, non_neg_integer()} | not_found.
find_shard_by_pid(Pid, Shards) ->
maps:fold(
fun
(Index, #{pid := P}, _) when P =:= Pid -> {ok, Index};
(_, _, Acc) -> Acc
end,
not_found,
Shards
).
-spec default_shard_count() -> pos_integer().
default_shard_count() ->
Candidates = [
erlang:system_info(logical_processors_available), erlang:system_info(schedulers_online)
],
lists:max([C || C <- Candidates, is_integer(C), C > 0] ++ [1]).
-spec maybe_log_shard_source(atom(), pos_integer(), configured | auto) -> ok.
maybe_log_shard_source(Name, Count, configured) ->
logger:info("[~p] starting with ~p shards (configured)", [Name, Count]),
ok;
maybe_log_shard_source(Name, Count, auto) ->
logger:info("[~p] starting with ~p shards (auto)", [Name, Count]),
ok.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
determine_shard_count_configured_test() ->
with_runtime_config(presence_shards, 5, fun() ->
?assertMatch({5, configured}, determine_shard_count())
end).
determine_shard_count_auto_test() ->
with_runtime_config(presence_shards, undefined, fun() ->
{Count, auto} = determine_shard_count(),
?assert(Count > 0)
end).
with_runtime_config(Key, Value, Fun) ->
Original = fluxer_gateway_env:get(Key),
fluxer_gateway_env:patch(#{Key => Value}),
Result = Fun(),
fluxer_gateway_env:update(fun(Map) ->
case Original of
undefined -> maps:remove(Key, Map);
Val -> maps:put(Key, Val, Map)
end
end),
Result.
-endif.

View File

@@ -0,0 +1,227 @@
%% 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(presence_manager_shard).
-behaviour(gen_server).
-include_lib("fluxer_gateway/include/timeout_config.hrl").
-export([start_link/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-type user_id() :: integer().
-type presence_ref() :: {pid(), reference()}.
-type status() :: online | offline | idle | dnd.
-type event_type() :: atom() | binary().
-type start_or_lookup_request() :: #{
user_id := user_id(),
user_data := map(),
guild_ids := [integer()],
status := status(),
friend_ids := [user_id()],
group_dm_recipients := map()
}.
-type state() :: #{presences := #{user_id() => presence_ref()}}.
-spec start_link(non_neg_integer()) -> {ok, pid()} | {error, term()}.
start_link(ShardIndex) ->
gen_server:start_link(?MODULE, #{shard_index => ShardIndex}, []).
-spec init(map()) -> {ok, state()}.
init(_Args) ->
process_flag(trap_exit, true),
{ok, #{presences => #{}}}.
-spec handle_call(Request, From, State) -> Result when
Request ::
{lookup, user_id()}
| {start_or_lookup, start_or_lookup_request()}
| {dispatch, user_id(), event_type(), term()}
| get_local_count
| get_global_count
| term(),
From :: gen_server:from(),
State :: state(),
Result :: {reply, Reply, state()},
Reply ::
{ok, pid()}
| {error, not_found}
| {error, registration_failed}
| {error, process_disappeared}
| {error, term()}
| {ok, non_neg_integer()}
| ok.
handle_call({lookup, UserId}, _From, State) ->
do_lookup(UserId, State);
handle_call({dispatch, UserId, Event, Data}, _From, State) ->
case lookup_presence(UserId, State) of
{ok, PresencePid, NewState} ->
gen_server:cast(PresencePid, {dispatch, Event, Data}),
{reply, ok, NewState};
{error, not_found, NewState} ->
{reply, {error, not_found}, NewState}
end;
handle_call({start_or_lookup, Request}, _From, State) ->
do_start_or_lookup(Request, State);
handle_call({terminate_all_sessions, UserId}, _From, State) ->
case terminate_sessions_for_user(UserId, State) of
{Result, NewState} ->
{reply, Result, NewState}
end;
handle_call(get_local_count, _From, State) ->
Presences = maps:get(presences, State),
Count = process_registry:get_count(Presences),
{reply, {ok, Count}, State};
handle_call(get_global_count, _From, State) ->
Presences = maps:get(presences, State),
Count = process_registry:get_count(Presences),
{reply, {ok, Count}, State};
handle_call(_Unknown, _From, State) ->
{reply, ok, State}.
-spec handle_cast(term(), state()) -> {noreply, state()}.
handle_cast(_Unknown, State) ->
{noreply, State}.
-spec handle_info(Info, State) -> {noreply, state()} when
Info :: {'DOWN', reference(), process, pid(), term()} | term(),
State :: state().
handle_info({'DOWN', _Ref, process, Pid, _Reason}, State) ->
Presences = maps:get(presences, State),
NewPresences = process_registry:cleanup_on_down(Pid, Presences),
{noreply, State#{presences := NewPresences}};
handle_info(_Unknown, State) ->
{noreply, State}.
-spec terminate(Reason, State) -> ok when
Reason :: term(),
State :: state().
terminate(_Reason, _State) ->
ok.
-spec code_change(term(), term(), term()) -> {ok, state()}.
code_change(_OldVsn, State, _Extra) when is_map(State) ->
{ok, State};
code_change(_OldVsn, {state, Presences}, _Extra) ->
{ok, #{presences => Presences}}.
-spec do_lookup(user_id(), state()) -> {reply, {ok, pid()} | {error, not_found}, state()}.
do_lookup(UserId, State) ->
case lookup_presence(UserId, State) of
{ok, Pid, NewState} ->
{reply, {ok, Pid}, NewState};
{error, not_found, NewState} ->
{reply, {error, not_found}, NewState}
end.
-spec do_start_or_lookup(start_or_lookup_request(), state()) ->
{reply, {ok, pid()} | {error, registration_failed | process_disappeared | term()}, state()}.
do_start_or_lookup(Request, State) ->
Presences = maps:get(presences, State),
#{
user_id := UserId,
user_data := UserData,
guild_ids := GuildIds,
status := Status
} = Request,
case maps:get(UserId, Presences, undefined) of
{Pid, _Ref} ->
{reply, {ok, Pid}, State};
undefined ->
PresenceName = process_registry:build_process_name(presence, UserId),
case whereis(PresenceName) of
undefined ->
FriendIds = maps:get(friend_ids, Request, []),
GroupDmRecipients = maps:get(group_dm_recipients, Request, #{}),
PresenceData = #{
user_id => UserId,
user_data => UserData,
guild_ids => GuildIds,
status => Status,
friend_ids => FriendIds,
group_dm_recipients => GroupDmRecipients,
custom_status => maps:get(custom_status, Request, null)
},
case presence:start_link(PresenceData) of
{ok, Pid} ->
case
process_registry:register_and_monitor(PresenceName, Pid, Presences)
of
{ok, RegisteredPid, Ref, NewPresences0} ->
CleanPresences = maps:remove(PresenceName, NewPresences0),
NewPresences = maps:put(
UserId, {RegisteredPid, Ref}, CleanPresences
),
{reply, {ok, RegisteredPid}, State#{
presences := NewPresences
}};
{error, registration_race_condition} ->
{reply, {error, registration_failed}, State};
{error, _Reason} = Error ->
{reply, Error, State}
end;
Error ->
{reply, Error, State}
end;
_ExistingPid ->
case process_registry:lookup_or_monitor(PresenceName, UserId, Presences) of
{ok, Pid, _Ref, NewPresences} ->
{reply, {ok, Pid}, State#{presences := NewPresences}};
{error, not_found} ->
{reply, {error, process_disappeared}, State}
end
end
end.
-spec lookup_presence(user_id(), state()) -> {ok, pid(), state()} | {error, not_found, state()}.
lookup_presence(UserId, State) ->
Presences = maps:get(presences, State),
case maps:get(UserId, Presences, undefined) of
{Pid, _Ref} ->
{ok, Pid, State};
undefined ->
PresenceName = process_registry:build_process_name(presence, UserId),
case process_registry:lookup_or_monitor(PresenceName, UserId, Presences) of
{ok, Pid, Ref, NewPresences0} ->
CleanPresences = maps:remove(PresenceName, NewPresences0),
FinalPresences = maps:put(UserId, {Pid, Ref}, CleanPresences),
{ok, Pid, State#{presences := FinalPresences}};
{error, not_found} ->
{error, not_found, State}
end
end.
terminate_sessions_for_user(UserId, State) ->
Presences = maps:get(presences, State),
case maps:get(UserId, Presences, undefined) of
{Pid, _Ref} ->
gen_server:cast(Pid, {terminate_all_sessions}),
{ok, State};
undefined ->
PresenceName = process_registry:build_process_name(presence, UserId),
case process_registry:lookup_or_monitor(PresenceName, UserId, Presences) of
{ok, Pid, Ref, NewPresences0} ->
CleanPresences = maps:remove(PresenceName, NewPresences0),
FinalPresences = maps:put(UserId, {Pid, Ref}, CleanPresences),
gen_server:cast(Pid, {terminate_all_sessions}),
{ok, State#{presences := FinalPresences}};
{error, not_found} ->
{ok, State}
end
end.

View File

@@ -0,0 +1,54 @@
%% 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(presence_payload).
-export([build/5]).
build(UserData, Status, Mobile, Afk, CustomStatus) ->
StatusBin = ensure_status_binary(Status),
#{
<<"user">> => user_utils:normalize_user(UserData),
<<"status">> => StatusBin,
<<"mobile">> => Mobile,
<<"afk">> => Afk,
<<"custom_status">> => custom_status_for(StatusBin, CustomStatus)
}.
ensure_status_binary(Status) when is_atom(Status) ->
constants:status_type_atom(Status);
ensure_status_binary(Status) when is_binary(Status) ->
Status;
ensure_status_binary(_) ->
<<"offline">>.
custom_status_for(StatusBin, CustomStatus) ->
case StatusBin of
<<"offline">> ->
null;
<<"invisible">> ->
null;
_ ->
normalize_custom_status(CustomStatus)
end.
normalize_custom_status(null) ->
null;
normalize_custom_status(CustomStatus) when is_map(CustomStatus) ->
CustomStatus;
normalize_custom_status(_) ->
null.

View File

@@ -0,0 +1,116 @@
%% 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(presence_session).
-export([
handle_session_connect/3,
handle_presence_update/2,
dispatch_sessions_replace/1,
notify_sessions_guild_join/2,
notify_sessions_guild_leave/2,
find_session_by_ref/2
]).
handle_session_connect(Request, Pid, State) ->
#{session_id := SessionId, status := Status} = Request,
Afk = maps:get(afk, Request, false),
Mobile = maps:get(mobile, Request, false),
SocketPid = maps:get(socket_pid, Request, undefined),
Sessions = maps:get(sessions, State),
case maps:is_key(SessionId, Sessions) of
true ->
SessionsData = presence_status:collect_sessions_for_replace(Sessions),
{reply, {ok, SessionsData}, State};
false ->
Ref = monitor(process, Pid),
SessionEntry = #{
session_id => SessionId,
status => Status,
afk => Afk,
mobile => Mobile,
pid => Pid,
mref => Ref,
socket_pid => SocketPid
},
NewSessions = maps:put(SessionId, SessionEntry, Sessions),
NewState = maps:put(sessions, NewSessions, State),
SessionsData = presence_status:collect_sessions_for_replace(NewSessions),
{reply, {ok, SessionsData}, NewState}
end.
handle_presence_update(Request, State) ->
#{session_id := SessionId, status := Status} = Request,
Afk = maps:get(afk, Request, false),
Sessions = maps:get(sessions, State),
case maps:get(SessionId, Sessions, undefined) of
undefined ->
{noreply, State};
Session ->
UpdatedSession = Session#{status => Status, afk => Afk},
NewSessions = maps:put(SessionId, UpdatedSession, Sessions),
NewState = maps:put(sessions, NewSessions, State),
dispatch_sessions_replace(NewState),
{noreply, NewState}
end.
dispatch_sessions_replace(State) ->
Sessions = maps:get(sessions, State),
SessionsData = presence_status:collect_sessions_for_replace(Sessions),
SessionPids = [maps:get(pid, S) || S <- maps:values(Sessions)],
lists:foreach(
fun(Pid) when is_pid(Pid) ->
gen_server:cast(Pid, {dispatch, sessions_replace, SessionsData})
end,
SessionPids
).
notify_sessions_guild_join(GuildId, State) ->
Sessions = maps:get(sessions, State),
SessionPids = [maps:get(pid, S) || S <- maps:values(Sessions)],
lists:foreach(
fun(Pid) when is_pid(Pid) ->
gen_server:cast(Pid, {guild_join, GuildId})
end,
SessionPids
).
notify_sessions_guild_leave(GuildId, State) ->
Sessions = maps:get(sessions, State),
SessionPids = [maps:get(pid, S) || S <- maps:values(Sessions)],
lists:foreach(
fun(Pid) when is_pid(Pid) ->
gen_server:cast(Pid, {guild_leave, GuildId})
end,
SessionPids
).
find_session_by_ref(Ref, Sessions) ->
maps:fold(
fun(SessionId, S, Acc) ->
case maps:get(mref, S) of
Ref -> {ok, SessionId};
_ -> Acc
end
end,
not_found,
Sessions
).

View File

@@ -0,0 +1,111 @@
%% 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(presence_status).
-export([
get_current_status/1,
get_flattened_mobile/1,
get_flattened_afk/1,
collect_sessions_for_replace/1
]).
get_current_status(Sessions) ->
AllStatuses = [maps:get(status, S) || S <- maps:values(Sessions)],
case lists:member(invisible, AllStatuses) of
true ->
invisible;
false ->
StatusPrecedence = [online, dnd, idle],
lists:foldl(
fun(Status, Acc) ->
case Acc of
offline ->
case lists:member(Status, AllStatuses) of
true -> Status;
false -> Acc
end;
_ ->
Acc
end
end,
offline,
StatusPrecedence
)
end.
get_flattened_mobile(Sessions) ->
lists:any(
fun(Session) ->
maps:get(mobile, Session, false)
end,
maps:values(Sessions)
).
get_flattened_afk(Sessions) ->
HasMobile = lists:any(
fun(Session) ->
maps:get(mobile, Session, false)
end,
maps:values(Sessions)
),
case HasMobile of
true ->
false;
false ->
case maps:size(Sessions) of
0 ->
false;
_ ->
lists:all(
fun(Session) ->
maps:get(afk, Session, false)
end,
maps:values(Sessions)
)
end
end.
collect_sessions_for_replace(Sessions) ->
Status = get_current_status(Sessions),
Mobile = get_flattened_mobile(Sessions),
Afk = get_flattened_afk(Sessions),
BaseSessions = [
#{
<<"session_id">> => <<"all">>,
<<"status">> => constants:status_type_atom(Status),
<<"mobile">> => Mobile,
<<"afk">> => Afk
}
],
SessionEntries = lists:map(
fun({SessionId, Session}) ->
#{
<<"session_id">> => SessionId,
<<"status">> => constants:status_type_atom(maps:get(status, Session)),
<<"afk">> => maps:get(afk, Session, false),
<<"mobile">> => maps:get(mobile, Session, false)
}
end,
maps:to_list(Sessions)
),
BaseSessions ++ SessionEntries.

View File

@@ -0,0 +1,97 @@
%% 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(presence_targets).
-export([
friend_ids_from_state/1,
group_dm_recipients_from_state/1
]).
friend_ids_from_state(State) ->
Relationships = maps:get(relationships, State, #{}),
[
UserId
|| {UserId, Type} <- maps:to_list(Relationships),
Type =:= 1 orelse Type =:= 3
].
group_dm_recipients_from_state(State) ->
UserId = maps:get(user_id, State),
Channels = maps:get(channels, State, #{}),
maps:from_list(
[
{ChannelId, map_from_ids([Rid || Rid <- RecipientIds, Rid =/= UserId])}
|| {ChannelId, Channel} <- maps:to_list(Channels),
maps:get(<<"type">>, Channel, 0) =:= 3,
RecipientIds <- [extract_recipient_ids(Channel)]
]
).
extract_recipient_ids(Channel) ->
Recipients = maps:get(<<"recipients">>, Channel, maps:get(<<"recipient_ids">>, Channel, [])),
Unique =
lists:foldl(
fun(Entry, Acc) ->
case extract_recipient_id(Entry) of
undefined ->
Acc;
Value ->
case lists:member(Value, Acc) of
true -> Acc;
false -> [Value | Acc]
end
end
end,
[],
Recipients
),
lists:reverse(Unique).
extract_recipient_id(Entry) when is_map(Entry) ->
type_conv:extract_id(Entry, <<"id">>);
extract_recipient_id(Entry) ->
case Entry of
Bin when is_binary(Bin) ->
type_conv:extract_id(#{<<"id">> => Bin}, <<"id">>);
Int when is_integer(Int) ->
Int;
_ ->
undefined
end.
map_from_ids(Ids) when is_list(Ids) ->
maps:from_list([{Id, true} || Id <- Ids]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
friend_ids_from_state_filters_relationship_types_test() ->
State = #{
relationships =>
#{
10 => 1,
11 => 3,
12 => 4,
13 => 2
}
},
Ids = lists:sort(friend_ids_from_state(State)),
?assertEqual([10, 11], Ids),
ok.
-endif.

View File

@@ -0,0 +1,187 @@
%% 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(presence_utils).
-export([
collect_guild_member_presences/1,
collect_guild_member_ids/1,
filter_self_presence/2,
is_visible_presence/1,
batch_presences/1,
send_presence_bulk/4
]).
-define(PRESENCE_BATCH_SIZE, 500).
-spec collect_guild_member_presences(map()) -> [map()].
collect_guild_member_presences(GuildState) ->
MemberIds = collect_guild_member_ids(GuildState),
case MemberIds of
[] ->
[];
_ ->
Presences = presence_cache:bulk_get(MemberIds),
[P || P <- Presences, is_visible_presence(P)]
end.
-spec collect_guild_member_ids(map()) -> [integer()].
collect_guild_member_ids(GuildState) ->
Members = get_members_from_guild_state(GuildState),
MemberIds = [member_user_id(M) || M <- Members],
[Id || Id <- MemberIds, Id =/= undefined].
-spec filter_self_presence(integer(), [map()]) -> [map()].
filter_self_presence(UserId, Presences) ->
[P || P <- Presences, presence_user_id(P) =/= UserId].
-spec is_visible_presence(map()) -> boolean().
is_visible_presence(Presence) ->
Status = maps:get(<<"status">>, Presence, <<"offline">>),
Status =/= <<"offline">> andalso Status =/= <<"invisible">>.
-spec batch_presences([map()]) -> [[map()]].
batch_presences([]) ->
[];
batch_presences(Presences) ->
batch_presences(Presences, []).
batch_presences([], Acc) ->
lists:reverse(Acc);
batch_presences(Presences, Acc) ->
{Batch, Rest} = take_batch(Presences, ?PRESENCE_BATCH_SIZE),
batch_presences(Rest, [Batch | Acc]).
-spec send_presence_bulk(pid(), integer(), integer(), [map()]) -> ok.
send_presence_bulk(_Pid, _GuildId, _UserId, []) ->
ok;
send_presence_bulk(Pid, GuildId, UserId, Presences) ->
FilteredPresences = filter_self_presence(UserId, Presences),
case FilteredPresences of
[] ->
ok;
_ ->
Batches = batch_presences(FilteredPresences),
lists:foreach(
fun(Batch) ->
BulkPayload = #{
<<"guild_id">> => integer_to_binary(GuildId),
<<"presences">> => Batch
},
gen_server:cast(Pid, {dispatch, presence_update_bulk, BulkPayload})
end,
Batches
)
end.
get_members_from_guild_state(GuildState) ->
case maps:get(data, GuildState, undefined) of
undefined ->
map_utils:ensure_list(maps:get(<<"members">>, GuildState, []));
Data ->
map_utils:ensure_list(maps:get(<<"members">>, Data, []))
end.
member_user_id(Member) ->
User = maps:get(<<"user">>, Member, #{}),
map_utils:get_integer(User, <<"id">>, undefined).
presence_user_id(P) when is_map(P) ->
User = maps:get(<<"user">>, P, #{}),
map_utils:get_integer(User, <<"id">>, undefined);
presence_user_id(_) ->
undefined.
take_batch(List, N) when length(List) =< N ->
{List, []};
take_batch(List, N) ->
{lists:sublist(List, N), lists:nthtail(N, List)}.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
batch_presences_empty_test() ->
?assertEqual([], batch_presences([])).
batch_presences_small_list_test() ->
Presences = [#{<<"user">> => #{<<"id">> => I}} || I <- lists:seq(1, 10)],
Batches = batch_presences(Presences),
?assertEqual(1, length(Batches)),
?assertEqual(10, length(hd(Batches))).
batch_presences_exact_batch_size_test() ->
Presences = [#{<<"user">> => #{<<"id">> => I}} || I <- lists:seq(1, 500)],
Batches = batch_presences(Presences),
?assertEqual(1, length(Batches)),
?assertEqual(500, length(hd(Batches))).
batch_presences_multiple_batches_test() ->
Presences = [#{<<"user">> => #{<<"id">> => I}} || I <- lists:seq(1, 1250)],
Batches = batch_presences(Presences),
?assertEqual(3, length(Batches)),
?assertEqual(500, length(lists:nth(1, Batches))),
?assertEqual(500, length(lists:nth(2, Batches))),
?assertEqual(250, length(lists:nth(3, Batches))).
filter_self_presence_test() ->
Presences = [
#{<<"user">> => #{<<"id">> => <<"1">>}},
#{<<"user">> => #{<<"id">> => <<"2">>}},
#{<<"user">> => #{<<"id">> => <<"3">>}}
],
Filtered = filter_self_presence(2, Presences),
?assertEqual(2, length(Filtered)),
?assert(
not lists:any(
fun(P) -> presence_user_id(P) =:= 2 end,
Filtered
)
).
is_visible_presence_online_test() ->
?assert(is_visible_presence(#{<<"status">> => <<"online">>})),
?assert(is_visible_presence(#{<<"status">> => <<"idle">>})),
?assert(is_visible_presence(#{<<"status">> => <<"dnd">>})).
is_visible_presence_offline_test() ->
?assertNot(is_visible_presence(#{<<"status">> => <<"offline">>})),
?assertNot(is_visible_presence(#{<<"status">> => <<"invisible">>})),
?assertNot(is_visible_presence(#{})).
collect_guild_member_ids_internal_format_test() ->
GuildState = #{
data => #{
<<"members">> => [
#{<<"user">> => #{<<"id">> => <<"100">>}},
#{<<"user">> => #{<<"id">> => <<"200">>}}
]
}
},
Ids = collect_guild_member_ids(GuildState),
?assertEqual([100, 200], lists:sort(Ids)).
collect_guild_member_ids_external_format_test() ->
GuildState = #{
<<"members">> => [
#{<<"user">> => #{<<"id">> => <<"100">>}},
#{<<"user">> => #{<<"id">> => <<"200">>}}
]
},
Ids = collect_guild_member_ids(GuildState),
?assertEqual([100, 200], lists:sort(Ids)).
-endif.

View File

@@ -0,0 +1,268 @@
%% 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(push).
-behaviour(gen_server).
-export([start_link/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-export([
handle_message_create/1,
sync_user_guild_settings/3,
sync_user_blocked_ids/2,
invalidate_user_badge_count/1
]).
-export([get_cache_stats/0]).
-export_type([state/0]).
-import(push_eligibility, [is_eligible_for_push/8]).
-import(push_cache, [
update_lru/2,
get_user_push_subscriptions/2,
cache_user_subscriptions/3,
invalidate_user_badge_count/2
]).
-import(push_sender, [send_push_notifications/8]).
-import(push_logger_filter, [install_progress_filter/0]).
-type state() :: #{
user_guild_settings_cache := map(),
user_guild_settings_lru := list(),
user_guild_settings_size := non_neg_integer(),
user_guild_settings_max_mb := non_neg_integer() | undefined,
push_subscriptions_cache := map(),
push_subscriptions_lru := list(),
push_subscriptions_size := non_neg_integer(),
push_subscriptions_max_mb := non_neg_integer() | undefined,
blocked_ids_cache := map(),
blocked_ids_lru := list(),
blocked_ids_size := non_neg_integer(),
blocked_ids_max_mb := non_neg_integer() | undefined,
badge_counts_cache := map(),
badge_counts_lru := list(),
badge_counts_size := non_neg_integer(),
badge_counts_max_mb := non_neg_integer() | undefined,
badge_counts_ttl_seconds := non_neg_integer()
}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
init([]) ->
install_progress_filter(),
PushEnabled = fluxer_gateway_env:get(push_enabled),
BaseState = #{
user_guild_settings_cache => #{},
user_guild_settings_lru => [],
user_guild_settings_size => 0,
user_guild_settings_max_mb => undefined,
push_subscriptions_cache => #{},
push_subscriptions_lru => [],
push_subscriptions_size => 0,
push_subscriptions_max_mb => undefined,
blocked_ids_cache => #{},
blocked_ids_lru => [],
blocked_ids_size => 0,
blocked_ids_max_mb => undefined,
badge_counts_cache => #{},
badge_counts_lru => [],
badge_counts_size => 0,
badge_counts_max_mb => undefined,
badge_counts_ttl_seconds => 0
},
case PushEnabled of
true ->
UgsMaxMb = fluxer_gateway_env:get(push_user_guild_settings_cache_mb),
PsMaxMb = fluxer_gateway_env:get(push_subscriptions_cache_mb),
BiMaxMb = fluxer_gateway_env:get(push_blocked_ids_cache_mb),
BcMaxMb = fluxer_gateway_env:get(push_badge_counts_cache_mb),
BcTtl = fluxer_gateway_env:get(push_badge_counts_cache_ttl_seconds),
{ok, BaseState#{
user_guild_settings_max_mb := UgsMaxMb,
push_subscriptions_max_mb := PsMaxMb,
blocked_ids_max_mb := BiMaxMb,
badge_counts_max_mb := BcMaxMb,
badge_counts_ttl_seconds := BcTtl
}};
false ->
{ok, BaseState}
end.
handle_call(get_cache_stats, _From, State) ->
#{
user_guild_settings_cache := UgsCache,
push_subscriptions_cache := PsCache,
blocked_ids_cache := BiCache,
badge_counts_cache := BcCache
} = State,
Stats = #{
user_guild_settings_size => maps:size(UgsCache),
push_subscriptions_size => maps:size(PsCache),
blocked_ids_size => maps:size(BiCache),
badge_counts_size => maps:size(BcCache)
},
{reply, {ok, Stats}, State};
handle_call(_Request, _From, State) ->
{reply, ok, State}.
handle_cast({handle_message_create, Params}, State) ->
ParamMap = case Params of Maps when is_map(Maps) -> Maps; _ -> #{} end,
GuildId = maps:get(<<"guild_id">>, ParamMap, undefined),
ChannelId = maps:get(<<"channel_id">>, ParamMap, undefined),
MessageId = maps:get(<<"id">>, ParamMap, undefined),
{noreply, do_handle_message_create(Params, State)};
handle_cast({sync_user_guild_settings, UserId, GuildId, UserGuildSettings}, State) ->
#{
user_guild_settings_cache := UgsCache,
user_guild_settings_lru := UgsLru
} = State,
Key = {settings, UserId, GuildId},
NewCache = maps:put(Key, UserGuildSettings, UgsCache),
NewLru = update_lru(Key, UgsLru),
{noreply, State#{
user_guild_settings_cache := NewCache,
user_guild_settings_lru := NewLru
}};
handle_cast({sync_user_blocked_ids, UserId, BlockedIds}, State) ->
#{
blocked_ids_cache := BiCache,
blocked_ids_lru := BiLru
} = State,
Key = {blocked, UserId},
NewCache = maps:put(Key, BlockedIds, BiCache),
NewLru = update_lru(Key, BiLru),
{noreply, State#{
blocked_ids_cache := NewCache,
blocked_ids_lru := NewLru
}};
handle_cast({cache_user_guild_settings, UserId, GuildId, Settings}, State) ->
#{
user_guild_settings_cache := UgsCache,
user_guild_settings_lru := UgsLru
} = State,
Key = {settings, UserId, GuildId},
NewCache = maps:put(Key, Settings, UgsCache),
NewLru = update_lru(Key, UgsLru),
{noreply, State#{
user_guild_settings_cache := NewCache,
user_guild_settings_lru := NewLru
}};
handle_cast({invalidate_user_badge_count, UserId}, State) ->
{noreply, invalidate_user_badge_count(UserId, State)};
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, _State) ->
ok.
code_change(_OldVsn, {state, UgsCache, UgsLru, UgsSize, UgsMaxMb, PsCache, PsLru, PsSize, PsMaxMb,
BiCache, BiLru, BiSize, BiMaxMb, BcCache, BcLru, BcSize, BcMaxMb, BcTtl}, _Extra) ->
{ok, #{
user_guild_settings_cache => UgsCache,
user_guild_settings_lru => UgsLru,
user_guild_settings_size => UgsSize,
user_guild_settings_max_mb => UgsMaxMb,
push_subscriptions_cache => PsCache,
push_subscriptions_lru => PsLru,
push_subscriptions_size => PsSize,
push_subscriptions_max_mb => PsMaxMb,
blocked_ids_cache => BiCache,
blocked_ids_lru => BiLru,
blocked_ids_size => BiSize,
blocked_ids_max_mb => BiMaxMb,
badge_counts_cache => BcCache,
badge_counts_lru => BcLru,
badge_counts_size => BcSize,
badge_counts_max_mb => BcMaxMb,
badge_counts_ttl_seconds => BcTtl
}};
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
handle_message_create(Params) ->
PushEnabled = fluxer_gateway_env:get(push_enabled),
case PushEnabled of
true -> gen_server:cast(?MODULE, {handle_message_create, Params});
false ->
ok
end.
sync_user_guild_settings(UserId, GuildId, UserGuildSettings) ->
gen_server:cast(?MODULE, {sync_user_guild_settings, UserId, GuildId, UserGuildSettings}).
sync_user_blocked_ids(UserId, BlockedIds) ->
gen_server:cast(?MODULE, {sync_user_blocked_ids, UserId, BlockedIds}).
invalidate_user_badge_count(UserId) ->
gen_server:cast(?MODULE, {invalidate_user_badge_count, UserId}).
get_cache_stats() ->
gen_server:call(?MODULE, get_cache_stats, 5000).
do_handle_message_create(Params, State) ->
MessageData = maps:get(message_data, Params),
UserIds = maps:get(user_ids, Params),
GuildId = maps:get(guild_id, Params),
AuthorId = maps:get(author_id, Params),
UserRolesMap = maps:get(user_roles, Params, #{}),
ChannelId = binary_to_integer(maps:get(<<"channel_id">>, MessageData)),
MessageId = binary_to_integer(maps:get(<<"id">>, MessageData)),
GuildDefaultNotifications = maps:get(guild_default_notifications, Params, 0),
GuildName = maps:get(guild_name, Params, undefined),
ChannelName = maps:get(channel_name, Params, undefined),
logger:debug(
"[push] Processing message ~p in channel ~p, guild ~p for users ~p (author ~p, defaults ~p)",
[MessageId, ChannelId, GuildId, UserIds, AuthorId, GuildDefaultNotifications]
),
EligibleUsers = lists:filter(
fun(UserId) ->
Eligible = is_eligible_for_push(
UserId,
AuthorId,
GuildId,
ChannelId,
MessageData,
GuildDefaultNotifications,
UserRolesMap,
State
),
logger:debug("[push] User ~p eligible: ~p", [UserId, Eligible]),
Eligible
end,
UserIds
),
logger:debug("[push] Eligible users: ~p", [EligibleUsers]),
case EligibleUsers of
[] ->
State;
_ ->
send_push_notifications(
EligibleUsers,
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
State
)
end.

View File

@@ -0,0 +1,161 @@
%% 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(push_cache).
-export([update_lru/2]).
-export([get_user_push_subscriptions/2]).
-export([cache_user_subscriptions/3]).
-export([get_user_badge_count/2]).
-export([cache_user_badge_count/4]).
-export([estimate_subscriptions_size/1]).
-export([evict_if_needed/4]).
-export([invalidate_user_badge_count/2]).
update_lru(Key, Lru) ->
NewLru = lists:delete(Key, Lru),
[Key | NewLru].
get_user_push_subscriptions(UserId, State) ->
Key = {subscriptions, UserId},
PushSubscriptionsCache = maps:get(push_subscriptions_cache, State, #{}),
case maps:get(Key, PushSubscriptionsCache, undefined) of
undefined ->
[];
Subs ->
Subs
end.
cache_user_subscriptions(UserId, Subscriptions, State) ->
Key = {subscriptions, UserId},
NewSubsSize = estimate_subscriptions_size(Subscriptions),
OldSubsSize =
case maps:get(Key, maps:get(push_subscriptions_cache, State, #{}), undefined) of
undefined -> 0;
OldSubs -> estimate_subscriptions_size(OldSubs)
end,
SizeDelta = NewSubsSize - OldSubsSize,
PushSubscriptionsLru = maps:get(push_subscriptions_lru, State, []),
NewLru = update_lru(Key, PushSubscriptionsLru),
PushSubscriptionsCache = maps:get(push_subscriptions_cache, State, #{}),
NewCache = maps:put(Key, Subscriptions, PushSubscriptionsCache),
PushSubscriptionsSize = maps:get(push_subscriptions_size, State, 0),
NewSize = PushSubscriptionsSize + SizeDelta,
MaxBytes =
case maps:get(push_subscriptions_max_mb, State, undefined) of
undefined -> NewSize;
Mb -> Mb * 1024 * 1024
end,
{FinalCache, FinalLru, FinalSize} = evict_if_needed(
NewCache, NewLru, NewSize, MaxBytes
),
State#{
push_subscriptions_cache => FinalCache,
push_subscriptions_lru => FinalLru,
push_subscriptions_size => FinalSize
}.
get_user_badge_count(UserId, State) ->
Key = {badge_count, UserId},
BadgeCountsCache = maps:get(badge_counts_cache, State, #{}),
case maps:get(Key, BadgeCountsCache, undefined) of
undefined ->
undefined;
Badge ->
Badge
end.
cache_user_badge_count(UserId, BadgeCount, CachedAt, State) ->
Key = {badge_count, UserId},
NewBadge = {BadgeCount, CachedAt},
OldBadgeSize =
case maps:get(Key, maps:get(badge_counts_cache, State, #{}), undefined) of
undefined -> 0;
OldBadge -> estimate_badge_count_size(OldBadge)
end,
NewBadgeSize = estimate_badge_count_size(NewBadge),
SizeDelta = NewBadgeSize - OldBadgeSize,
BadgeCountsLru = maps:get(badge_counts_lru, State, []),
NewLru = update_lru(Key, BadgeCountsLru),
BadgeCountsCache = maps:get(badge_counts_cache, State, #{}),
NewCache = maps:put(Key, NewBadge, BadgeCountsCache),
BadgeCountsSize = maps:get(badge_counts_size, State, 0),
NewSize = BadgeCountsSize + SizeDelta,
MaxBytes =
case maps:get(badge_counts_max_mb, State, undefined) of
undefined -> NewSize;
Mb -> Mb * 1024 * 1024
end,
{FinalCache, FinalLru, FinalSize} = evict_if_needed(
NewCache, NewLru, NewSize, MaxBytes
),
State#{
badge_counts_cache => FinalCache,
badge_counts_lru => FinalLru,
badge_counts_size => FinalSize
}.
estimate_subscriptions_size(Subscriptions) ->
length(Subscriptions) * 200.
estimate_badge_count_size({_Count, _Timestamp}) ->
64.
evict_if_needed(Cache, Lru, Size, MaxBytes) when Size > MaxBytes ->
evict_oldest(Cache, Lru, Size, MaxBytes, lists:reverse(Lru));
evict_if_needed(Cache, Lru, Size, _MaxBytes) ->
{Cache, Lru, Size}.
evict_oldest(Cache, Lru, Size, _MaxBytes, []) ->
{Cache, Lru, Size};
evict_oldest(Cache, Lru, Size, MaxBytes, [OldestKey | Remaining]) ->
case maps:get(OldestKey, Cache, undefined) of
undefined ->
evict_oldest(Cache, Lru, Size, MaxBytes, Remaining);
OldSubs ->
NewCache = maps:remove(OldestKey, Cache),
NewSize = Size - estimate_subscriptions_size(OldSubs),
NewLru = lists:delete(OldestKey, Lru),
evict_if_needed(NewCache, NewLru, NewSize, MaxBytes)
end.
invalidate_user_badge_count(UserId, State) ->
Key = {badge_count, UserId},
BadgeCountsCache = maps:get(badge_counts_cache, State, #{}),
case maps:get(Key, BadgeCountsCache, undefined) of
undefined ->
State;
Badge ->
NewCache = maps:remove(Key, BadgeCountsCache),
BadgeCountsLru = lists:delete(Key, maps:get(badge_counts_lru, State, [])),
BadgeCountsSize = maps:get(badge_counts_size, State, 0),
NewSize = max(0, BadgeCountsSize - estimate_badge_count_size(Badge)),
State#{
badge_counts_cache => NewCache,
badge_counts_lru => BadgeCountsLru,
badge_counts_size => NewSize
}
end.

View File

@@ -0,0 +1,37 @@
%% 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(push_core).
-export([handle_message_create/1]).
-export([sync_user_guild_settings/3]).
-export([sync_user_blocked_ids/2]).
handle_message_create(Params) ->
PushEnabled = fluxer_gateway_env:get(push_enabled),
case PushEnabled of
true ->
gen_server:cast(push, {handle_message_create, Params});
false ->
ok
end.
sync_user_guild_settings(UserId, GuildId, UserGuildSettings) ->
gen_server:cast(push, {sync_user_guild_settings, UserId, GuildId, UserGuildSettings}).
sync_user_blocked_ids(UserId, BlockedIds) ->
gen_server:cast(push, {sync_user_blocked_ids, UserId, BlockedIds}).

View File

@@ -0,0 +1,312 @@
%% 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(push_eligibility).
-export([is_eligible_for_push/8]).
-export([is_user_blocked/3]).
-export([check_user_guild_settings/7]).
-export([should_allow_notification/5]).
-export([is_user_mentioned/4]).
-define(LARGE_GUILD_THRESHOLD, 250).
-define(LARGE_GUILD_OVERRIDE_FEATURE, <<"LARGE_GUILD_OVERRIDE">>).
-define(MESSAGE_NOTIFICATIONS_NULL, -1).
-define(MESSAGE_NOTIFICATIONS_ALL, 0).
-define(MESSAGE_NOTIFICATIONS_ONLY_MENTIONS, 1).
-define(MESSAGE_NOTIFICATIONS_NO_MESSAGES, 2).
-define(MESSAGE_NOTIFICATIONS_INHERIT, 3).
-define(CHANNEL_TYPE_DM, 1).
-define(CHANNEL_TYPE_GROUP_DM, 3).
is_eligible_for_push(
UserId, UserId, _GuildId, _ChannelId, _MessageData, _GuildDefaultNotifications, _UserRoles, _State
) ->
false;
is_eligible_for_push(
UserId,
AuthorId,
GuildId,
ChannelId,
MessageData,
GuildDefaultNotifications,
UserRolesMap,
State
) ->
Blocked = is_user_blocked(UserId, AuthorId, State),
SettingsOk = check_user_guild_settings(
UserId,
GuildId,
ChannelId,
MessageData,
GuildDefaultNotifications,
UserRolesMap,
State
),
not Blocked andalso SettingsOk.
is_user_blocked(UserId, AuthorId, State) ->
BlockedIdsCache = maps:get(blocked_ids_cache, State, #{}),
case maps:get({blocked, UserId}, BlockedIdsCache, undefined) of
undefined ->
false;
BlockedIds ->
Blocked = lists:member(AuthorId, BlockedIds),
Blocked
end.
check_user_guild_settings(
_UserId, 0, _ChannelId, _MessageData, _GuildDefaultNotifications, _UserRolesMap, _State
) ->
true;
check_user_guild_settings(
UserId,
GuildId,
ChannelId,
MessageData,
GuildDefaultNotifications,
UserRolesMap,
State
) ->
UserGuildSettingsCache = maps:get(user_guild_settings_cache, State, #{}),
Settings =
case maps:get({settings, UserId, GuildId}, UserGuildSettingsCache, undefined) of
undefined ->
FetchedSettings = push_subscriptions:fetch_and_cache_user_guild_settings(
UserId, GuildId, State
),
case FetchedSettings of
null -> #{};
S -> S
end;
S ->
S
end,
MobilePush = maps:get(mobile_push, Settings, true),
case MobilePush of
false ->
false;
true ->
Muted = maps:get(muted, Settings, false),
ChannelOverrides = maps:get(channel_overrides, Settings, #{}),
ChannelKey = integer_to_binary(ChannelId),
ChannelOverride = maps:get(ChannelKey, ChannelOverrides, #{}),
ChannelMuted = maps:get(muted, ChannelOverride, undefined),
ActualMuted =
case ChannelMuted of
undefined -> Muted;
_ -> ChannelMuted
end,
MuteConfig = maps:get(mute_config, Settings, undefined),
IsTempMuted =
case MuteConfig of
undefined ->
false;
#{<<"end_time">> := EndTimeStr} ->
case push_utils:parse_timestamp(EndTimeStr) of
undefined ->
false;
EndTime ->
Now = erlang:system_time(millisecond),
Now < EndTime
end;
_ ->
false
end,
case ActualMuted orelse IsTempMuted of
true ->
false;
false ->
Level = resolve_message_notifications(
ChannelId,
Settings,
GuildDefaultNotifications
),
EffectiveLevel = override_for_large_guild(GuildId, Level, State),
should_allow_notification(
EffectiveLevel,
MessageData,
UserId,
Settings,
UserRolesMap
)
end
end.
should_allow_notification(Level, MessageData, UserId, Settings, UserRolesMap) ->
case Level of
?MESSAGE_NOTIFICATIONS_NO_MESSAGES ->
false;
?MESSAGE_NOTIFICATIONS_ONLY_MENTIONS ->
case is_private_channel(MessageData) of
true ->
true;
false ->
is_user_mentioned(UserId, MessageData, Settings, UserRolesMap)
end;
_ ->
true
end.
is_private_channel(MessageData) ->
ChannelType = maps:get(<<"channel_type">>, MessageData, ?CHANNEL_TYPE_DM),
ChannelType =:= ?CHANNEL_TYPE_DM orelse ChannelType =:= ?CHANNEL_TYPE_GROUP_DM.
is_user_mentioned(UserId, MessageData, Settings, UserRolesMap) ->
MentionEveryone = maps:get(<<"mention_everyone">>, MessageData, false),
SuppressEveryone = maps:get(suppress_everyone, Settings, false),
SuppressRoles = maps:get(suppress_roles, Settings, false),
case {MentionEveryone, SuppressEveryone} of
{true, false} ->
true;
{true, true} ->
false;
_ ->
Mentions = maps:get(<<"mentions">>, MessageData, []),
MentionRoles = maps:get(<<"mention_roles">>, MessageData, []),
UserRoles = maps:get(UserId, UserRolesMap, []),
is_user_in_mentions(UserId, Mentions) orelse
case SuppressRoles of
true -> false;
false -> has_mentioned_role(UserRoles, MentionRoles)
end
end.
is_user_in_mentions(UserId, Mentions) ->
lists:any(fun(Mention) -> mention_matches_user(UserId, Mention) end, Mentions).
mention_matches_user(UserId, Mention) ->
case maps:get(<<"id">>, Mention, undefined) of
undefined ->
false;
Id when is_integer(Id) ->
Id =:= UserId;
Id when is_binary(Id) ->
case validation:validate_snowflake(<<"mention.id">>, Id) of
{ok, ParsedId} -> ParsedId =:= UserId;
_ -> false
end;
_ -> false
end.
has_mentioned_role([], _) ->
false;
has_mentioned_role([RoleId | Rest], MentionRoles) ->
case role_in_mentions(RoleId, MentionRoles) of
true -> true;
false -> has_mentioned_role(Rest, MentionRoles)
end.
role_in_mentions(RoleId, MentionRoles) ->
RoleBin = integer_to_binary(RoleId),
lists:any(
fun(MentionRole) ->
case MentionRole of
Value when is_integer(Value) ->
Value =:= RoleId;
Value when is_binary(Value) ->
Value =:= RoleBin;
_ ->
false
end
end,
MentionRoles
).
resolve_message_notifications(ChannelId, Settings, GuildDefaultNotifications) ->
ChannelOverrides = maps:get(channel_overrides, Settings, #{}),
ChannelKey = integer_to_binary(ChannelId),
Level =
case maps:get(ChannelKey, ChannelOverrides, undefined) of
undefined ->
undefined;
Override ->
maps:get(message_notifications, Override, ?MESSAGE_NOTIFICATIONS_NULL)
end,
case Level of
?MESSAGE_NOTIFICATIONS_NULL -> resolve_guild_notification(Settings, GuildDefaultNotifications);
?MESSAGE_NOTIFICATIONS_INHERIT -> resolve_guild_notification(Settings, GuildDefaultNotifications);
undefined -> resolve_guild_notification(Settings, GuildDefaultNotifications);
Valid -> normalize_notification_level(Valid)
end.
resolve_guild_notification(Settings, GuildDefaultNotifications) ->
Level = maps:get(message_notifications, Settings, ?MESSAGE_NOTIFICATIONS_NULL),
case Level of
?MESSAGE_NOTIFICATIONS_NULL -> normalize_notification_level(GuildDefaultNotifications);
?MESSAGE_NOTIFICATIONS_INHERIT -> normalize_notification_level(GuildDefaultNotifications);
Valid -> normalize_notification_level(Valid)
end.
normalize_notification_level(Level) when Level == ?MESSAGE_NOTIFICATIONS_ALL ->
Level;
normalize_notification_level(Level) when Level == ?MESSAGE_NOTIFICATIONS_ONLY_MENTIONS ->
Level;
normalize_notification_level(Level) when Level == ?MESSAGE_NOTIFICATIONS_NO_MESSAGES ->
Level;
normalize_notification_level(_) ->
?MESSAGE_NOTIFICATIONS_ALL.
override_for_large_guild(GuildId, CurrentLevel, _State) ->
case get_guild_large_metadata(GuildId) of
undefined ->
CurrentLevel;
#{member_count := Count, features := Features} ->
case is_large_guild(Count, Features) of
true -> enforce_only_mentions(CurrentLevel);
false -> CurrentLevel
end
end.
enforce_only_mentions(CurrentLevel) ->
case CurrentLevel of
0 -> 1;
_ -> CurrentLevel
end.
is_large_guild(Count, Features) when is_integer(Count) ->
Count > ?LARGE_GUILD_THRESHOLD orelse has_large_guild_override(Features);
is_large_guild(_, Features) ->
has_large_guild_override(Features).
has_large_guild_override(Features) when is_list(Features) ->
lists:member(?LARGE_GUILD_OVERRIDE_FEATURE, Features);
has_large_guild_override(_) ->
false.
get_guild_large_metadata(GuildId) ->
GuildName = process_registry:build_process_name(guild, GuildId),
try
case whereis(GuildName) of
undefined ->
undefined;
Pid when is_pid(Pid) ->
case gen_server:call(Pid, {get_large_guild_metadata}, 500) of
#{member_count := Count, features := Features} ->
#{member_count => Count, features => Features};
_ ->
undefined
end
end
catch
_:_ -> undefined
end.

View File

@@ -0,0 +1,35 @@
%% 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(push_logger_filter).
-export([install_progress_filter/0]).
install_progress_filter() ->
Filter = {fun logger_filters:progress/2, stop},
case logger:add_handler_filter(default, push_progress_filter, Filter) of
ok ->
ok;
{error, already_exists} ->
ok;
{error, Reason} ->
logger:error(
"[push] failed to install progress filter: ~p",
[Reason]
),
{error, Reason}
end.

View File

@@ -0,0 +1,131 @@
%% 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(push_notification).
-export([sanitize_mentions/2, build_notification_title/5, build_notification_payload/10]).
sanitize_mentions(Content, Mentions) ->
lists:foldl(
fun(Mention, Acc) ->
case
{
maps:get(<<"id">>, Mention, undefined),
maps:get(<<"username">>, Mention, undefined)
}
of
{undefined, _} ->
Acc;
{_, undefined} ->
Acc;
{Id, Username} ->
Pattern = <<"<@", Id/binary, ">">>,
Replacement = <<"@", Username/binary>>,
binary:replace(Acc, Pattern, Replacement, [global])
end
end,
Content,
Mentions
).
build_notification_title(AuthorUsername, MessageData, GuildId, GuildName, ChannelName) ->
ChannelType = maps:get(<<"channel_type">>, MessageData, 1),
case GuildId of
0 ->
case ChannelType of
3 ->
iolist_to_binary([AuthorUsername, <<" (Group DM)">>]);
_ ->
AuthorUsername
end;
_ ->
case {ChannelName, GuildName} of
{undefined, _} ->
AuthorUsername;
{_, undefined} ->
AuthorUsername;
{ChanName, GName} ->
iolist_to_binary([
AuthorUsername,
<<" (#">>,
ChanName,
<<", ">>,
GName,
<<")">>
])
end
end.
build_notification_payload(
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
AuthorUsername,
AuthorAvatarUrl,
TargetUserId,
BadgeCount
) ->
Content = maps:get(<<"content">>, MessageData, <<"">>),
Mentions = maps:get(<<"mentions">>, MessageData, []),
SanitizedContent = sanitize_mentions(Content, Mentions),
ContentPreview =
case byte_size(SanitizedContent) > 100 of
true -> binary:part(SanitizedContent, 0, 100);
false -> SanitizedContent
end,
Title = build_notification_title(AuthorUsername, MessageData, GuildId, GuildName, ChannelName),
BadgeValue = max(0, BadgeCount),
#{
<<"title">> => Title,
<<"body">> => ContentPreview,
<<"icon">> => AuthorAvatarUrl,
<<"badge">> => <<"https://fluxerstatic.com/web/apple-touch-icon.png">>,
<<"data">> =>
#{
<<"channel_id">> => integer_to_binary(ChannelId),
<<"message_id">> => integer_to_binary(MessageId),
<<"guild_id">> =>
case GuildId of
0 -> null;
_ -> integer_to_binary(GuildId)
end,
<<"url">> =>
case GuildId of
0 ->
iolist_to_binary([
<<"/channels/@me/">>,
integer_to_binary(ChannelId),
<<"/">>,
integer_to_binary(MessageId)
]);
_ ->
iolist_to_binary([
<<"/channels/">>,
integer_to_binary(GuildId),
<<"/">>,
integer_to_binary(ChannelId),
<<"/">>,
integer_to_binary(MessageId)
])
end,
<<"badge_count">> => BadgeValue,
<<"target_user_id">> => integer_to_binary(TargetUserId)
}
}.

View File

@@ -0,0 +1,320 @@
%% 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(push_sender).
-import(push_cache, [
get_user_badge_count/2,
cache_user_badge_count/4
]).
-import(rpc_client, [call/1]).
-export([send_to_user_subscriptions/9, send_push_notifications/8]).
send_to_user_subscriptions(
UserId,
Subscriptions,
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
BadgeCount
) ->
AuthorData = maps:get(<<"author">>, MessageData, #{}),
AuthorUsername = maps:get(<<"username">>, AuthorData, <<"Unknown">>),
AuthorAvatar = maps:get(<<"avatar">>, AuthorData, null),
AuthorAvatarUrl =
case AuthorAvatar of
null -> push_utils:get_default_avatar_url(maps:get(<<"id">>, AuthorData, <<"0">>));
Hash -> push_utils:construct_avatar_url(maps:get(<<"id">>, AuthorData, <<"0">>), Hash)
end,
NotificationPayload = push_notification:build_notification_payload(
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
AuthorUsername,
AuthorAvatarUrl,
UserId,
BadgeCount
),
case ensure_vapid_credentials() of
{ok, VapidEmail, VapidPublicKey, VapidPrivateKey} ->
FailedSubscriptions = lists:filtermap(
fun(Sub) ->
send_notification_to_subscription(
UserId,
Sub,
NotificationPayload,
VapidEmail,
VapidPublicKey,
VapidPrivateKey
)
end,
Subscriptions
),
case FailedSubscriptions of
[] ->
ok;
_ ->
logger:debug("[push] Deleting ~p failed subscriptions", [
length(FailedSubscriptions)
]),
push_subscriptions:delete_failed_subscriptions(FailedSubscriptions)
end;
{error, Reason} ->
logger:warning("[push] %s - skipping push send", [Reason])
end.
send_push_notifications(
UserIds, MessageData, GuildId, ChannelId, MessageId, GuildName, ChannelName, State
) ->
{BadgeCounts, StateWithBadgeCounts} = ensure_badge_counts(UserIds, State),
{UncachedUsers, CachedState} = lists:foldl(
fun(UserId, {Uncached, S}) ->
Key = {subscriptions, UserId},
PushSubscriptionsCache = maps:get(push_subscriptions_cache, S, #{}),
case maps:is_key(Key, PushSubscriptionsCache) of
true ->
Subscriptions = push_cache:get_user_push_subscriptions(UserId, S),
logger:debug(
"[push] Using cached subscriptions for user ~p (~p subs)",
[UserId, length(Subscriptions)]
),
BadgeCount = maps:get(UserId, BadgeCounts, 0),
case Subscriptions of
[] ->
ok;
_ ->
send_to_user_subscriptions(
UserId,
Subscriptions,
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
BadgeCount
)
end,
{Uncached, S};
false ->
{[UserId | Uncached], S}
end
end,
{[], StateWithBadgeCounts},
UserIds
),
case UncachedUsers of
[] ->
CachedState;
_ ->
push_subscriptions:fetch_and_send_subscriptions(
UncachedUsers,
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
CachedState,
BadgeCounts
)
end.
ensure_badge_counts(UserIds, State) ->
Now = erlang:system_time(second),
TTL = maps:get(badge_counts_ttl_seconds, State, 0),
{CachedCounts, Missing} =
lists:foldl(
fun(UserId, {Acc, MissingAcc}) ->
case get_user_badge_count(UserId, State) of
{Count, Timestamp} when TTL > 0, Now - Timestamp < TTL ->
{maps:put(UserId, Count, Acc), MissingAcc};
_ ->
{Acc, [UserId | MissingAcc]}
end
end,
{#{}, []},
UserIds
),
UniqueMissing = lists:usort(Missing),
case UniqueMissing of
[] ->
{CachedCounts, State};
_ ->
fetch_badge_counts(UniqueMissing, CachedCounts, State, Now)
end.
fetch_badge_counts(UserIds, Counts, State, CachedAt) ->
Request = #{
<<"type">> => <<"get_badge_counts">>,
<<"user_ids">> => [integer_to_binary(UserId) || UserId <- UserIds]
},
case call(Request) of
{ok, Data} ->
BadgeData = maps:get(<<"badge_counts">>, Data, #{}),
lists:foldl(
fun(UserId, {Acc, S}) ->
UserIdBin = integer_to_binary(UserId),
Count = normalize_badge_count(maps:get(UserIdBin, BadgeData, 0)),
NewState = cache_user_badge_count(UserId, Count, CachedAt, S),
{maps:put(UserId, Count, Acc), NewState}
end,
{Counts, State},
UserIds
);
{error, Reason} ->
logger:error("[push] Failed to fetch badge counts: ~p", [Reason]),
{Counts, State}
end.
normalize_badge_count(Value) when is_integer(Value), Value >= 0 ->
Value;
normalize_badge_count(_) ->
0.
-define(PUSH_TTL, <<"86400">>).
ensure_vapid_credentials() ->
Email = fluxer_gateway_env:get(vapid_email),
Public = fluxer_gateway_env:get(vapid_public_key),
Private = fluxer_gateway_env:get(vapid_private_key),
case {Email, Public, Private} of
{Email0, Public0, Private0}
when is_binary(Email0) andalso is_binary(Public0) andalso is_binary(Private0) andalso
byte_size(Public0) > 0 andalso byte_size(Private0) > 0 ->
{ok, Email0, Public0, Private0};
_ ->
{error, "Missing VAPID credentials"}
end.
send_notification_to_subscription(
UserId,
Subscription,
Payload,
VapidEmail,
VapidPublicKey,
VapidPrivateKey
) ->
case extract_subscription_fields(Subscription) of
{ok, Endpoint, P256dhKey, AuthKey, SubscriptionId} ->
logger:debug("[push] Sending to endpoint ~p for user ~p", [Endpoint, UserId]),
VapidClaims = #{
<<"sub">> => <<"mailto:", VapidEmail/binary>>,
<<"aud">> => push_utils:extract_origin(Endpoint),
<<"exp">> => erlang:system_time(second) + 43200
},
VapidTokenResult =
try
{ok, push_utils:generate_vapid_token(VapidClaims, VapidPublicKey, VapidPrivateKey)}
catch
C:R ->
logger:error("[push] VAPID token generation failed: ~p:~p", [C, R]),
{error, {C, R}}
end,
case VapidTokenResult of
{ok, VapidToken} ->
case push_utils:encrypt_payload(jsx:encode(Payload), P256dhKey, AuthKey, 4096) of
{ok, EncryptedBody} ->
Headers = build_push_headers(VapidToken, VapidPublicKey),
handle_push_response(UserId, SubscriptionId, Endpoint, Headers, EncryptedBody);
{error, EncryptError} ->
logger:error("[push] Failed to encrypt payload: ~p", [EncryptError]),
metrics_client:counter(<<"push.failure">>, #{<<"reason">> => <<"encryption_error">>}),
false
end;
{error, _} ->
metrics_client:counter(<<"push.failure">>, #{<<"reason">> => <<"vapid_error">>}),
false
end;
{error, Reason} ->
logger:error("[push] Invalid subscription for user ~p: ~s", [UserId, Reason]),
metrics_client:counter(<<"push.failure">>, #{<<"reason">> => <<"invalid_subscription">>}),
false
end.
extract_subscription_fields(Subscription) ->
case
{
maps:get(<<"endpoint">>, Subscription, undefined),
maps:get(<<"p256dh_key">>, Subscription, undefined),
maps:get(<<"auth_key">>, Subscription, undefined),
maps:get(<<"subscription_id">>, Subscription, undefined)
}
of
{Endpoint, P256dhKey, AuthKey, SubscriptionId}
when is_binary(Endpoint) andalso is_binary(P256dhKey)
andalso is_binary(AuthKey) andalso is_binary(SubscriptionId) ->
{ok, Endpoint, P256dhKey, AuthKey, SubscriptionId};
_ ->
{error, "missing keys"}
end.
build_push_headers(VapidToken, VapidPublicKey) ->
[
{<<"TTL">>, ?PUSH_TTL},
{<<"Content-Type">>, <<"application/octet-stream">>},
{<<"Content-Encoding">>, <<"aes128gcm">>},
{<<"Authorization">>,
<<"vapid t=", VapidToken/binary, ", k=", VapidPublicKey/binary>>}
].
handle_push_response(UserId, SubscriptionId, Endpoint, Headers, Body) ->
case hackney:request(post, binary_to_list(Endpoint), Headers, Body, []) of
{ok, Status, _, _} when Status >= 200, Status < 300 ->
logger:debug("[push] Push sent successfully (%p) for user %p", [Status, UserId]),
metrics_client:counter(<<"push.success">>),
false;
{ok, 410, _, _} ->
logger:debug("[push] Subscription expired (410) for user ~p", [UserId]),
metrics_client:counter(<<"push.failure">>, #{<<"reason">> => <<"expired">>}),
{true, delete_payload(UserId, SubscriptionId)};
{ok, 404, _, _} ->
logger:debug("[push] Subscription not found (404) for user ~p", [UserId]),
metrics_client:counter(<<"push.failure">>, #{<<"reason">> => <<"not_found">>}),
{true, delete_payload(UserId, SubscriptionId)};
{ok, Status, _, ClientRef} ->
{ok, ErrorBody} = hackney:body(ClientRef),
logger:error("[push] Push failed with status ~p for user ~p (%s)", [
Status,
UserId,
ErrorBody
]),
metrics_client:counter(<<"push.failure">>, #{<<"reason">> => <<"http_error">>}),
false;
{error, Reason} ->
logger:error("[push] Failed to send push for user ~p: ~p", [UserId, Reason]),
metrics_client:counter(<<"push.failure">>, #{<<"reason">> => <<"network_error">>}),
false
end.
delete_payload(UserId, SubscriptionId) ->
#{
<<"user_id">> => integer_to_binary(UserId),
<<"subscription_id">> => SubscriptionId
}.

View File

@@ -0,0 +1,102 @@
%% 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(push_subscriptions).
-export([fetch_and_send_subscriptions/9]).
-export([fetch_and_cache_user_guild_settings/3]).
-export([delete_failed_subscriptions/1]).
fetch_and_send_subscriptions(
UserIds,
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
State,
BadgeCounts
) ->
SubscriptionsReq = #{
<<"type">> => <<"get_push_subscriptions">>,
<<"user_ids">> => [integer_to_binary(UserId) || UserId <- UserIds]
},
case rpc_client:call(SubscriptionsReq) of
{ok, SubscriptionsData} ->
NewState = lists:foldl(
fun(UserId, S) ->
UserIdBin = integer_to_binary(UserId),
case maps:get(UserIdBin, SubscriptionsData, []) of
[] ->
push_cache:cache_user_subscriptions(UserId, [], S);
Subscriptions ->
BadgeCount = maps:get(UserId, BadgeCounts, 0),
push_sender:send_to_user_subscriptions(
UserId,
Subscriptions,
MessageData,
GuildId,
ChannelId,
MessageId,
GuildName,
ChannelName,
BadgeCount
),
push_cache:cache_user_subscriptions(UserId, Subscriptions, S)
end
end,
State,
UserIds
),
NewState;
{error, _Reason} ->
State
end.
fetch_and_cache_user_guild_settings(UserId, GuildId, _State) ->
Req = #{
<<"type">> => <<"get_user_guild_settings">>,
<<"user_ids">> => [integer_to_binary(UserId)],
<<"guild_id">> => integer_to_binary(GuildId)
},
case rpc_client:call(Req) of
{ok, Data} ->
UserGuildSettings = maps:get(<<"user_guild_settings">>, Data, [null]),
[SettingsData | _] = UserGuildSettings,
case SettingsData of
null ->
null;
Settings ->
gen_server:cast(
push, {cache_user_guild_settings, UserId, GuildId, Settings}
),
Settings
end;
{error, Reason} ->
null
end.
delete_failed_subscriptions(FailedSubscriptions) ->
DeleteReq = #{
<<"type">> => <<"delete_push_subscriptions">>,
<<"subscriptions">> => FailedSubscriptions
},
rpc_client:call(DeleteReq).

View File

@@ -0,0 +1,262 @@
%% 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(push_utils).
-export([
construct_avatar_url/2,
get_default_avatar_url/1,
extract_origin/1,
generate_vapid_token/3,
base64url_encode/1,
base64url_decode/1,
encrypt_payload/4,
decode_subscription_key/1,
hkdf_expand/4,
hkdf_expand_loop/6,
parse_timestamp/1
]).
construct_avatar_url(UserId, Hash) ->
MediaProxyBin = media_proxy_endpoint_binary(),
iolist_to_binary([
MediaProxyBin,
<<"/avatars/">>,
UserId,
<<"/">>,
Hash,
<<".png">>
]).
get_default_avatar_url(UserId) ->
Index = avatar_index(UserId),
iolist_to_binary([
<<"https://fluxerstatic.com/avatars/">>,
integer_to_binary(Index),
<<".png">>
]).
avatar_index(UserId) ->
case catch binary_to_integer(UserId) of
{'EXIT', _} -> 0;
Value -> wrap_avatar_index(Value)
end.
wrap_avatar_index(Value) ->
Rem = Value rem 6,
case Rem < 0 of
true -> Rem + 6;
false -> Rem
end.
media_proxy_endpoint_binary() ->
case fluxer_gateway_env:get(media_proxy_endpoint) of
undefined ->
erlang:error({missing_config, media_proxy_endpoint});
Endpoint ->
value_to_binary(Endpoint)
end.
value_to_binary(Value) when is_binary(Value) ->
Value;
value_to_binary(Value) when is_list(Value) ->
list_to_binary(Value).
extract_origin(Url) ->
case binary:split(Url, <<"://">>) of
[Protocol, Rest] ->
case binary:split(Rest, <<"/">>) of
[Host | _] -> <<Protocol/binary, "://", Host/binary>>;
_ -> Url
end;
_ ->
Url
end.
generate_vapid_token(Claims, PublicKeyB64Url, PrivateKeyB64Url) ->
logger:debug("[push] Generating VAPID token", []),
try
application:ensure_all_started(crypto),
application:ensure_all_started(public_key),
application:ensure_all_started(jose),
PrivRaw =
case base64url_decode(PrivateKeyB64Url) of
error ->
logger:error("[push] Failed to decode private key: ~p", [PrivateKeyB64Url]),
erlang:error(invalid_private_key);
PrivDecoded ->
PrivDecoded
end,
PubRaw =
case base64url_decode(PublicKeyB64Url) of
error ->
logger:error("[push] Failed to decode public key: ~p", [PublicKeyB64Url]),
erlang:error(invalid_public_key);
PubDecoded ->
PubDecoded
end,
<<4, X:32/binary, Y:32/binary>> = PubRaw,
B64 = fun(Bin) -> base64url_encode(Bin) end,
JWKMap = #{
<<"kty">> => <<"EC">>,
<<"crv">> => <<"P-256">>,
<<"d">> => B64(PrivRaw),
<<"x">> => B64(X),
<<"y">> => B64(Y)
},
JWK0 = jose_jwk:from_map(JWKMap),
JWK =
case JWK0 of
{JW, _Fields} -> JW;
JW -> JW
end,
Header = #{<<"alg">> => <<"ES256">>, <<"typ">> => <<"JWT">>},
JWS = jose_jwt:sign(JWK, Header, Claims),
Compact0 = jose_jws:compact(JWS),
CompactBin =
case Compact0 of
{_Meta, Bin} when is_binary(Bin) -> Bin;
Other ->
logger:error("[push] Unexpected compact return: ~p", [Other]),
erlang:error({unexpected_compact_return, Other})
end,
logger:debug("[push] Generated VAPID token successfully"),
CompactBin
catch
C:R:Stack ->
logger:error("[push] VAPID token generation failed: ~p:~p~n~p", [C, R, Stack]),
erlang:error({vapid_token_generation_failed, C, R})
end.
base64url_encode(Data) ->
jose_base64url:encode(Data).
base64url_decode(Data) ->
case jose_base64url:decode(Data) of
{ok, Decoded} -> Decoded;
error -> error
end.
encrypt_payload(Message, PeerPubB64, AuthSecretB64, RecordSize0) ->
try
logger:debug(
"[push] Encrypting payload with p256dh key size: ~p, auth key size: ~p",
[byte_size(PeerPubB64), byte_size(AuthSecretB64)]
),
PeerPub = decode_subscription_key(PeerPubB64),
AuthSecret = decode_subscription_key(AuthSecretB64),
RecordSize =
case RecordSize0 of
0 -> 4096;
_ -> RecordSize0
end,
RecordLen = RecordSize - 16,
Salt = crypto:strong_rand_bytes(16),
{LocalPub, LocalPriv} = crypto:generate_key(ecdh, prime256v1),
<<4, _/binary>> = PeerPub,
Secret = crypto:compute_key(ecdh, PeerPub, LocalPriv, prime256v1),
PRKInfo = <<"WebPush: info", 0, PeerPub/binary, LocalPub/binary>>,
IKM = hkdf_expand(Secret, AuthSecret, PRKInfo, 32),
CEKInfo = <<"Content-Encoding: aes128gcm", 0>>,
NonceInfo = <<"Content-Encoding: nonce", 0>>,
CEK = hkdf_expand(IKM, Salt, CEKInfo, 16),
Nonce = hkdf_expand(IKM, Salt, NonceInfo, 12),
HeaderLen = 16 + 4 + 1 + byte_size(LocalPub),
Data0 = <<Message/binary, 16#02>>,
Required = RecordLen - HeaderLen,
Data0Len = byte_size(Data0),
case Data0Len =< Required of
false ->
{error, max_pad_exceeded};
true ->
PadLen = Required - Data0Len,
Padding =
case PadLen of
0 -> <<>>;
_ -> binary:copy(<<0>>, PadLen)
end,
Data = <<Data0/binary, Padding/binary>>,
{Cipher, Tag} = crypto:crypto_one_time_aead(
aes_gcm, CEK, Nonce, Data, <<>>, 16, true
),
Ciphertext = <<Cipher/binary, Tag/binary>>,
Body = <<
Salt/binary,
RecordSize:32/big-unsigned-integer,
(byte_size(LocalPub)):8,
LocalPub/binary,
Ciphertext/binary
>>,
{ok, Body}
end
catch
C:R:Stack ->
logger:error("[push] Encryption failed: ~p:~p~nStack: ~p", [C, R, Stack]),
{error, encryption_failed}
end.
decode_subscription_key(B64) when is_binary(B64) ->
Padded =
case byte_size(B64) rem 4 of
0 -> B64;
Rem -> <<B64/binary, (binary:copy(<<"=">>, 4 - Rem))/binary>>
end,
case jose_base64url:decode(Padded) of
{ok, Decoded} ->
Decoded;
_ ->
try base64:decode(Padded) of
Decoded when is_binary(Decoded) -> Decoded
catch
_:_ -> erlang:error(decode_key_error)
end
end.
hkdf_expand(IKM, Salt, Info, Length) ->
PRK = crypto:mac(hmac, sha256, Salt, IKM),
hkdf_expand_loop(PRK, Info, Length, 1, <<>>, <<>>).
hkdf_expand_loop(_PRK, _Info, Length, _I, _Tprev, Acc) when byte_size(Acc) >= Length ->
binary:part(Acc, 0, Length);
hkdf_expand_loop(PRK, Info, Length, I, Tprev, Acc) ->
T = crypto:mac(hmac, sha256, PRK, <<Tprev/binary, Info/binary, I:8/integer>>),
hkdf_expand_loop(PRK, Info, Length, I + 1, T, <<Acc/binary, T/binary>>).
parse_timestamp(Str) when is_binary(Str) ->
try
binary_to_integer(Str)
catch
_:_ -> undefined
end;
parse_timestamp(_) ->
undefined.

View File

@@ -0,0 +1,370 @@
%% 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(session).
-behaviour(gen_server).
-export([start_link/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
start_link(SessionData) ->
gen_server:start_link(?MODULE, SessionData, []).
init(SessionData) ->
process_flag(trap_exit, true),
Id = maps:get(id, SessionData),
UserId = maps:get(user_id, SessionData),
UserData = maps:get(user_data, SessionData),
Version = maps:get(version, SessionData),
TokenHash = maps:get(token_hash, SessionData),
AuthSessionIdHash = maps:get(auth_session_id_hash, SessionData),
Properties = maps:get(properties, SessionData),
Status = maps:get(status, SessionData),
Afk = maps:get(afk, SessionData, false),
Mobile = maps:get(mobile, SessionData, false),
SocketPid = maps:get(socket_pid, SessionData),
GuildIds = maps:get(guilds, SessionData),
Ready0 = maps:get(ready, SessionData),
Bot = maps:get(bot, SessionData, false),
InitialGuildId = maps:get(initial_guild_id, SessionData, undefined),
Ready =
case Bot of
true -> ensure_bot_ready_map(Ready0);
false -> Ready0
end,
IgnoredEvents = build_ignored_events_map(maps:get(ignored_events, SessionData, [])),
Channels = load_private_channels(Ready),
logger:debug("[session] Loaded ~p private channels into session state for user ~p", [
maps:size(Channels),
UserId
]),
State = #{
id => Id,
user_id => UserId,
user_data => UserData,
custom_status => maps:get(custom_status, SessionData, null),
version => Version,
token_hash => TokenHash,
auth_session_id_hash => AuthSessionIdHash,
buffer => [],
seq => 0,
ack_seq => 0,
properties => Properties,
status => Status,
afk => Afk,
mobile => Mobile,
presence_pid => undefined,
presence_mref => undefined,
socket_pid => SocketPid,
socket_mref => monitor(process, SocketPid),
guilds => maps:from_list([{Gid, undefined} || Gid <- GuildIds]),
calls => #{},
channels => Channels,
ready => Ready,
bot => Bot,
ignored_events => IgnoredEvents,
initial_guild_id => InitialGuildId,
collected_guild_states => [],
collected_sessions => [],
collected_presences => [],
relationships => load_relationships(Ready),
suppress_presence_updates => true,
pending_presences => [],
guild_connect_inflight => #{}
},
self() ! {presence_connect, 0},
case Bot of
true -> self() ! bot_initial_ready;
false -> ok
end,
lists:foreach(fun(Gid) -> self() ! {guild_connect, Gid, 0} end, GuildIds),
erlang:send_after(3000, self(), premature_readiness),
erlang:send_after(200, self(), enable_presence_updates),
{ok, State}.
handle_call({token_verify, Token}, _From, State) ->
TokenHash = maps:get(token_hash, State),
HashedInput = utils:hash_token(Token),
IsValid = HashedInput =:= TokenHash,
{reply, IsValid, State};
handle_call({heartbeat_ack, Seq}, _From, State) ->
AckSeq = maps:get(ack_seq, State),
Buffer = maps:get(buffer, State),
if
Seq < AckSeq ->
{reply, false, State};
true ->
NewBuffer = [Event || Event <- Buffer, maps:get(seq, Event) > Seq],
{reply, true, maps:merge(State, #{ack_seq => Seq, buffer => NewBuffer})}
end;
handle_call({resume, Seq, SocketPid}, _From, State) ->
CurrentSeq = maps:get(seq, State),
Buffer = maps:get(buffer, State),
PresencePid = maps:get(presence_pid, State, undefined),
SessionId = maps:get(id, State),
Status = maps:get(status, State),
Afk = maps:get(afk, State),
Mobile = maps:get(mobile, State),
if
Seq > CurrentSeq ->
{reply, invalid_seq, State};
true ->
MissedEvents = [Event || Event <- Buffer, maps:get(seq, Event) > Seq],
NewState = maps:merge(State, #{
socket_pid => SocketPid,
socket_mref => monitor(process, SocketPid)
}),
case PresencePid of
undefined ->
ok;
Pid when is_pid(Pid) ->
gen_server:call(
Pid,
{session_connect, #{
session_id => SessionId,
status => Status,
afk => Afk,
mobile => Mobile
}},
10000
)
end,
{reply, {ok, MissedEvents}, NewState}
end;
handle_call({get_state}, _From, State) ->
SerializedState = serialize_state(State),
{reply, SerializedState, State};
handle_call({voice_state_update, Data}, _From, State) ->
session_voice:handle_voice_state_update(Data, State);
handle_call(_, _From, State) ->
{reply, ok, State}.
handle_cast({presence_update, Update}, State) ->
PresencePid = maps:get(presence_pid, State, undefined),
SessionId = maps:get(id, State),
Status = maps:get(status, State),
Afk = maps:get(afk, State),
Mobile = maps:get(mobile, State),
NewStatus = maps:get(status, Update, Status),
NewAfk = maps:get(afk, Update, Afk),
NewMobile = maps:get(mobile, Update, Mobile),
NewState = maps:merge(State, #{status => NewStatus, afk => NewAfk, mobile => NewMobile}),
case PresencePid of
undefined ->
ok;
Pid when is_pid(Pid) ->
gen_server:cast(
Pid,
{presence_update, #{
session_id => SessionId, status => NewStatus, afk => NewAfk, mobile => NewMobile
}}
)
end,
{noreply, NewState};
handle_cast({dispatch, Event, Data}, State) ->
session_dispatch:handle_dispatch(Event, Data, State);
handle_cast({initial_global_presences, Presences}, State) ->
NewState =
lists:foldl(
fun(Presence, AccState) ->
{noreply, UpdatedState} = session_dispatch:handle_dispatch(
presence_update, Presence, AccState
),
UpdatedState
end,
State,
Presences
),
{noreply, NewState};
handle_cast({guild_join, GuildId}, State) ->
self() ! {guild_connect, GuildId, 0},
{noreply, State};
handle_cast({guild_leave, GuildId}, State) ->
Guilds = maps:get(guilds, State),
case maps:get(GuildId, Guilds, undefined) of
{Pid, Ref} when is_pid(Pid) ->
demonitor(Ref),
NewGuilds = maps:put(GuildId, undefined, Guilds),
session_dispatch:handle_dispatch(
guild_delete, #{<<"id">> => integer_to_binary(GuildId)}, State
),
{noreply, maps:put(guilds, NewGuilds, State)};
_ ->
{noreply, State}
end;
handle_cast({terminate, SessionIdHashes}, State) ->
AuthHash = maps:get(auth_session_id_hash, State),
DecodedHashes = [base64url:decode(Hash) || Hash <- SessionIdHashes],
case lists:member(AuthHash, DecodedHashes) of
true -> {stop, normal, State};
false -> {noreply, State}
end;
handle_cast({terminate_force}, State) ->
{stop, normal, State};
handle_cast({call_connect, ChannelIdBin}, State) ->
case validation:validate_snowflake(<<"channel_id">>, ChannelIdBin) of
{ok, ChannelId} ->
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
{ok, CallPid} ->
case gen_server:call(CallPid, {get_state}, 5000) of
{ok, CallData} ->
session_dispatch:handle_dispatch(call_create, CallData, State);
_ ->
{noreply, State}
end;
not_found ->
{noreply, State}
end;
{error, _, Reason} ->
logger:warning("[session] Invalid channel_id for call_connect: ~p", [Reason]),
{noreply, State}
end;
handle_cast({call_monitor, ChannelId, CallPid}, State) ->
Calls = maps:get(calls, State, #{}),
case maps:get(ChannelId, Calls, undefined) of
undefined ->
Ref = monitor(process, CallPid),
NewCalls = maps:put(ChannelId, {CallPid, Ref}, Calls),
{noreply, maps:put(calls, NewCalls, State)};
{OldPid, OldRef} when OldPid =/= CallPid ->
demonitor(OldRef, [flush]),
Ref = monitor(process, CallPid),
NewCalls = maps:put(ChannelId, {CallPid, Ref}, Calls),
{noreply, maps:put(calls, NewCalls, State)};
_ ->
{noreply, State}
end;
handle_cast({call_unmonitor, ChannelId}, State) ->
Calls = maps:get(calls, State, #{}),
case maps:get(ChannelId, Calls, undefined) of
{_Pid, Ref} ->
demonitor(Ref, [flush]),
NewCalls = maps:remove(ChannelId, Calls),
{noreply, maps:put(calls, NewCalls, State)};
undefined ->
{noreply, State}
end;
handle_cast(_, State) ->
{noreply, State}.
handle_info({presence_connect, Attempt}, State) ->
PresencePid = maps:get(presence_pid, State, undefined),
case PresencePid of
undefined -> session_connection:handle_presence_connect(Attempt, State);
_ -> {noreply, State}
end;
handle_info({guild_connect, GuildId, Attempt}, State) ->
session_connection:handle_guild_connect(GuildId, Attempt, State);
handle_info({guild_connect_result, GuildId, Attempt, Result}, State) ->
session_connection:handle_guild_connect_result(GuildId, Attempt, Result, State);
handle_info({call_reconnect, ChannelId, Attempt}, State) ->
session_connection:handle_call_reconnect(ChannelId, Attempt, State);
handle_info(enable_presence_updates, State) ->
FlushedState = session_dispatch:flush_all_pending_presences(State),
{noreply, maps:put(suppress_presence_updates, false, FlushedState)};
handle_info(premature_readiness, State) ->
Ready = maps:get(ready, State),
case Ready of
undefined -> {noreply, State};
_ -> session_ready:dispatch_ready_data(State)
end;
handle_info(bot_initial_ready, State) ->
Ready = maps:get(ready, State, undefined),
case Ready of
undefined -> {noreply, State};
_ -> session_ready:dispatch_ready_data(State)
end;
handle_info(resume_timeout, State) ->
SocketPid = maps:get(socket_pid, State, undefined),
case SocketPid of
undefined -> {stop, normal, State};
_ -> {noreply, State}
end;
handle_info({'DOWN', Ref, process, _Pid, Reason}, State) ->
session_monitor:handle_process_down(Ref, Reason, State);
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, _State) ->
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
serialize_state(State) ->
#{
id => maps:get(id, State),
session_id => maps:get(id, State),
user_id => integer_to_binary(maps:get(user_id, State)),
user_data => maps:get(user_data, State),
version => maps:get(version, State),
seq => maps:get(seq, State),
ack_seq => maps:get(ack_seq, State),
properties => maps:get(properties, State),
status => maps:get(status, State),
afk => maps:get(afk, State),
mobile => maps:get(mobile, State),
buffer => maps:get(buffer, State),
ready => maps:get(ready, State),
guilds => maps:get(guilds, State, #{}),
collected_guild_states => maps:get(collected_guild_states, State),
collected_sessions => maps:get(collected_sessions, State),
collected_presences => maps:get(collected_presences, State, [])
}.
build_ignored_events_map(Events) when is_list(Events) ->
maps:from_list([{Event, true} || Event <- Events]);
build_ignored_events_map(_) ->
#{}.
load_private_channels(Ready) when is_map(Ready) ->
PrivateChannels = maps:get(<<"private_channels">>, Ready, []),
maps:from_list([
{type_conv:extract_id(Channel, <<"id">>), Channel}
|| Channel <- PrivateChannels
]);
load_private_channels(_) ->
#{}.
load_relationships(Ready) when is_map(Ready) ->
Relationships = maps:get(<<"relationships">>, Ready, []),
maps:from_list(
[
{type_conv:extract_id(Rel, <<"id">>), maps:get(<<"type">>, Rel, 0)}
|| Rel <- Relationships, type_conv:extract_id(Rel, <<"id">>) =/= undefined
]
);
load_relationships(_) ->
#{}.
ensure_bot_ready_map(undefined) ->
#{<<"guilds">> => []};
ensure_bot_ready_map(Ready) when is_map(Ready) ->
maps:merge(Ready, #{<<"guilds">> => []});
ensure_bot_ready_map(_) ->
#{<<"guilds">> => []}.

View File

@@ -0,0 +1,301 @@
%% 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(session_connection).
-export([
handle_presence_connect/2,
handle_guild_connect/3,
handle_guild_connect_result/4,
handle_call_reconnect/3
]).
-define(GUILD_CONNECT_MAX_INFLIGHT, 8).
handle_presence_connect(Attempt, State) ->
UserId = maps:get(user_id, State),
UserData = maps:get(user_data, State),
Guilds = maps:get(guilds, State),
Status = maps:get(status, State),
SessionId = maps:get(id, State),
Afk = maps:get(afk, State),
Mobile = maps:get(mobile, State),
SocketPid = maps:get(socket_pid, State, undefined),
FriendIds = presence_targets:friend_ids_from_state(State),
GroupDmRecipients = presence_targets:group_dm_recipients_from_state(State),
Message =
{start_or_lookup, #{
user_id => UserId,
user_data => UserData,
guild_ids => maps:keys(Guilds),
status => Status,
friend_ids => FriendIds,
group_dm_recipients => GroupDmRecipients,
custom_status => maps:get(custom_status, State, null)
}},
case gen_server:call(presence_manager, Message, 5000) of
{ok, Pid} ->
try
case
gen_server:call(
Pid,
{session_connect, #{
session_id => SessionId,
status => Status,
afk => Afk,
mobile => Mobile,
socket_pid => SocketPid
}},
10000
)
of
{ok, Sessions} ->
gen_server:cast(Pid, {sync_friends, FriendIds}),
gen_server:cast(Pid, {sync_group_dm_recipients, GroupDmRecipients}),
NewState = maps:merge(State, #{
presence_pid => Pid,
presence_mref => monitor(process, Pid),
collected_sessions => Sessions
}),
session_ready:check_readiness(NewState);
_ ->
case Attempt < 25 of
true ->
erlang:send_after(
backoff_utils:calculate(Attempt),
self(),
{presence_connect, Attempt + 1}
),
{noreply, State};
false ->
{noreply, State}
end
end
catch
exit:{noproc, _} when Attempt < 25 ->
erlang:send_after(
backoff_utils:calculate(Attempt), self(), {presence_connect, Attempt + 1}
),
{noreply, State};
exit:{normal, _} when Attempt < 25 ->
erlang:send_after(
backoff_utils:calculate(Attempt), self(), {presence_connect, Attempt + 1}
),
{noreply, State};
_:_ ->
{noreply, State}
end;
_ ->
case Attempt < 25 of
true ->
erlang:send_after(
backoff_utils:calculate(Attempt), self(), {presence_connect, Attempt + 1}
),
{noreply, State};
false ->
{noreply, State}
end
end.
handle_guild_connect(GuildId, Attempt, State) ->
Guilds = maps:get(guilds, State),
SessionId = maps:get(id, State),
UserId = maps:get(user_id, State),
case maps:get(GuildId, Guilds, undefined) of
{_Pid, _Ref} ->
{noreply, State};
_ ->
maybe_spawn_guild_connect(GuildId, Attempt, SessionId, UserId, State)
end.
handle_guild_connect_result(GuildId, Attempt, Result, State) ->
Inflight = maps:get(guild_connect_inflight, State, #{}),
case maps:get(GuildId, Inflight, undefined) of
Attempt ->
NewInflight = maps:remove(GuildId, Inflight),
State1 = maps:put(guild_connect_inflight, NewInflight, State),
handle_guild_connect_result_internal(GuildId, Attempt, Result, State1);
_ ->
{noreply, State}
end.
handle_call_reconnect(ChannelId, Attempt, State) ->
Calls = maps:get(calls, State, #{}),
SessionId = maps:get(id, State),
case maps:get(ChannelId, Calls, undefined) of
{_Pid, _Ref} ->
{noreply, State};
_ ->
attempt_call_reconnect(ChannelId, Attempt, SessionId, State)
end.
maybe_spawn_guild_connect(GuildId, Attempt, SessionId, UserId, State) ->
Inflight0 = maps:get(guild_connect_inflight, State, #{}),
AlreadyInflight = maps:is_key(GuildId, Inflight0),
TooManyInflight = map_size(Inflight0) >= ?GUILD_CONNECT_MAX_INFLIGHT,
Bot = maps:get(bot, State, false),
case {AlreadyInflight, TooManyInflight} of
{true, _} ->
{noreply, State};
{false, true} ->
erlang:send_after(50, self(), {guild_connect, GuildId, Attempt}),
{noreply, State};
{false, false} ->
Inflight = maps:put(GuildId, Attempt, Inflight0),
State1 = maps:put(guild_connect_inflight, Inflight, State),
SessionPid = self(),
InitialGuildId = maps:get(initial_guild_id, State, undefined),
spawn(fun() ->
do_guild_connect(SessionPid, GuildId, Attempt, SessionId, UserId, Bot, InitialGuildId)
end),
{noreply, State1}
end.
do_guild_connect(SessionPid, GuildId, Attempt, SessionId, UserId, Bot, InitialGuildId) ->
Result =
try
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 5000) of
{ok, GuildPid} ->
ActiveGuilds = build_initial_active_guilds(InitialGuildId, GuildId),
Request = #{
session_id => SessionId,
user_id => UserId,
session_pid => SessionPid,
bot => Bot,
initial_guild_id => InitialGuildId,
active_guilds => ActiveGuilds
},
case gen_server:call(GuildPid, {session_connect, Request}, 10000) of
{ok, unavailable, UnavailableResponse} ->
{ok_unavailable, GuildPid, UnavailableResponse};
{ok, GuildState} ->
{ok, GuildPid, GuildState};
Error ->
{error, {session_connect_failed, Error}}
end;
Error ->
{error, {guild_manager_failed, Error}}
end
catch
exit:{noproc, _} ->
{error, {guild_died, noproc}};
exit:{normal, _} ->
{error, {guild_died, normal}};
_:Reason ->
{error, {exception, Reason}}
end,
SessionPid ! {guild_connect_result, GuildId, Attempt, Result},
ok.
handle_guild_connect_result_internal(
GuildId, _Attempt, {ok_unavailable, GuildPid, UnavailableResponse}, State
) ->
finalize_guild_connection(GuildId, GuildPid, State, fun(St) ->
session_ready:process_guild_state(UnavailableResponse, St)
end);
handle_guild_connect_result_internal(GuildId, _Attempt, {ok, GuildPid, GuildState}, State) ->
finalize_guild_connection(GuildId, GuildPid, State, fun(St) ->
session_ready:process_guild_state(GuildState, St)
end);
handle_guild_connect_result_internal(GuildId, Attempt, {error, {session_connect_failed, _}}, State) ->
retry_or_fail(GuildId, Attempt, State, fun(_GId, St) -> {noreply, St} end);
handle_guild_connect_result_internal(GuildId, Attempt, {error, _Reason}, State) ->
retry_or_fail(GuildId, Attempt, State, fun(GId, St) ->
session_ready:mark_guild_unavailable(GId, St)
end).
finalize_guild_connection(GuildId, GuildPid, State, ReadyFun) ->
Guilds0 = maps:get(guilds, State),
case maps:get(GuildId, Guilds0, undefined) of
{Pid, _Ref} when is_pid(Pid) ->
{noreply, State};
_ ->
MonitorRef = monitor(process, GuildPid),
Guilds = maps:put(GuildId, {GuildPid, MonitorRef}, Guilds0),
State1 = maps:put(guilds, Guilds, State),
ReadyFun(State1)
end.
retry_or_fail(GuildId, Attempt, State, FailureFun) ->
case Attempt < 25 of
true ->
BackoffMs = backoff_utils:calculate(Attempt),
erlang:send_after(BackoffMs, self(), {guild_connect, GuildId, Attempt + 1}),
{noreply, State};
false ->
logger:error(
"[session_connection] Guild ~p connect failed after ~p attempts",
[GuildId, Attempt]
),
FailureFun(GuildId, State)
end.
attempt_call_reconnect(ChannelId, Attempt, _SessionId, State) ->
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
{ok, CallPid} ->
connect_to_call_process(CallPid, ChannelId, State);
not_found ->
handle_call_not_found(ChannelId, Attempt, State);
_Error ->
handle_call_lookup_error(ChannelId, Attempt, State)
end.
connect_to_call_process(CallPid, ChannelId, State) ->
Calls = maps:get(calls, State, #{}),
MonitorRef = monitor(process, CallPid),
NewCalls = maps:put(ChannelId, {CallPid, MonitorRef}, Calls),
StateWithCall = maps:put(calls, NewCalls, State),
case gen_server:call(CallPid, {get_state}, 5000) of
{ok, CallData} ->
session_dispatch:handle_dispatch(call_create, CallData, StateWithCall);
_Error ->
demonitor(MonitorRef, [flush]),
{noreply, State}
end.
handle_call_not_found(ChannelId, Attempt, State) ->
retry_call_or_remove(ChannelId, Attempt, State).
handle_call_lookup_error(ChannelId, Attempt, State) ->
retry_call_or_remove(ChannelId, Attempt, State).
retry_call_or_remove(ChannelId, Attempt, State) ->
case Attempt < 15 of
true ->
erlang:send_after(
backoff_utils:calculate(Attempt),
self(),
{call_reconnect, ChannelId, Attempt + 1}
),
{noreply, State};
false ->
Calls = maps:get(calls, State, #{}),
NewCalls = maps:remove(ChannelId, Calls),
{noreply, maps:put(calls, NewCalls, State)}
end.
build_initial_active_guilds(undefined, _GuildId) ->
sets:new();
build_initial_active_guilds(GuildId, GuildId) ->
sets:from_list([GuildId]);
build_initial_active_guilds(_, _) ->
sets:new().

View File

@@ -0,0 +1,486 @@
%% 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(session_dispatch).
-export([
handle_dispatch/3,
flush_all_pending_presences/1
]).
handle_dispatch(Event, Data, State) ->
case should_ignore_event(Event, State) of
true ->
{noreply, State};
false ->
case should_buffer_presence(Event, Data, State) of
true ->
{noreply, buffer_presence(Event, Data, State)};
false ->
Seq = maps:get(seq, State),
Buffer = maps:get(buffer, State),
SocketPid = maps:get(socket_pid, State, undefined),
NewSeq = Seq + 1,
Request = #{event => Event, data => Data, seq => NewSeq},
NewBuffer =
case Event of
message_reaction_add ->
Buffer;
message_reaction_remove ->
Buffer;
_ ->
Buffer ++ [Request]
end,
case SocketPid of
undefined ->
ok;
Pid when is_pid(Pid) ->
case erlang:is_process_alive(Pid) of
true ->
Pid ! {dispatch, Event, Data, NewSeq},
ok;
false ->
ok
end
end,
StateWithChannels = update_channels_map(Event, Data, State),
StateWithRelationships0 = update_relationships_map(
Event, Data, StateWithChannels
),
StateAfterMain = maps:merge(StateWithRelationships0, #{
seq => NewSeq, buffer => NewBuffer
}),
StateWithPending = maybe_flush_pending_presences(Event, Data, StateAfterMain),
FinalState = sync_presence_targets(StateWithPending),
{noreply, FinalState}
end
end.
should_buffer_presence(presence_update, Data, State) ->
case maps:get(suppress_presence_updates, State, true) of
true ->
true;
false ->
HasGuildId =
is_map(Data) andalso (maps:get(<<"guild_id">>, Data, undefined) =/= undefined),
case HasGuildId of
true ->
false;
false ->
UserId = presence_user_id(Data),
Relationships = maps:get(relationships, State, #{}),
case UserId of
undefined ->
false;
_ ->
IsRelationship = relationship_allows_presence(UserId, Relationships),
IsGroupDmRecipient = is_group_dm_recipient(UserId, State),
not (IsRelationship orelse IsGroupDmRecipient)
end
end
end;
should_buffer_presence(_, _, _) ->
false.
relationship_allows_presence(UserId, Relationships) when
is_integer(UserId), is_map(Relationships)
->
case maps:get(UserId, Relationships, 0) of
1 -> true;
3 -> true;
_ -> false
end;
relationship_allows_presence(_, _) ->
false.
is_group_dm_recipient(UserId, State) ->
GroupDmRecipients = presence_targets:group_dm_recipients_from_state(State),
lists:any(
fun({_ChannelId, Recipients}) ->
maps:is_key(UserId, Recipients)
end,
maps:to_list(GroupDmRecipients)
).
buffer_presence(Event, Data, State) ->
Pending = maps:get(pending_presences, State, []),
UserId = presence_user_id(Data),
maps:put(
pending_presences, Pending ++ [#{event => Event, data => Data, user_id => UserId}], State
).
maybe_flush_pending_presences(relationship_add, Data, State) ->
maybe_flush_relationship_pending_presences(Data, State);
maybe_flush_pending_presences(relationship_update, Data, State) ->
maybe_flush_relationship_pending_presences(Data, State);
maybe_flush_pending_presences(_, _, State) ->
State.
maybe_flush_relationship_pending_presences(Data, State) when is_map(Data) ->
case maps:get(<<"type">>, Data, 0) of
1 ->
flush_pending_presences(relationship_target_id(Data), State);
3 ->
flush_pending_presences(relationship_target_id(Data), State);
_ ->
State
end;
maybe_flush_relationship_pending_presences(_Data, State) ->
State.
flush_pending_presences(undefined, State) ->
State;
flush_pending_presences(UserId, State) ->
Pending = maps:get(pending_presences, State, []),
{ToSend, Remaining} =
lists:partition(fun(P) -> maps:get(user_id, P, undefined) =:= UserId end, Pending),
FlushedState =
lists:foldl(
fun(P, AccState) ->
dispatch_presence_now(P, AccState)
end,
State,
ToSend
),
maps:put(pending_presences, Remaining, FlushedState).
dispatch_presence_now(P, State) ->
Event = maps:get(event, P),
Data = maps:get(data, P),
Seq = maps:get(seq, State),
Buffer = maps:get(buffer, State),
SocketPid = maps:get(socket_pid, State, undefined),
NewSeq = Seq + 1,
Request = #{event => Event, data => Data, seq => NewSeq},
NewBuffer = Buffer ++ [Request],
case SocketPid of
undefined ->
ok;
Pid when is_pid(Pid) ->
case erlang:is_process_alive(Pid) of
true ->
Pid ! {dispatch, Event, Data, NewSeq},
ok;
false ->
ok
end
end,
maps:merge(State, #{seq => NewSeq, buffer => NewBuffer}).
presence_user_id(Data) when is_map(Data) ->
User = maps:get(<<"user">>, Data, #{}),
map_utils:get_integer(User, <<"id">>, undefined);
presence_user_id(_) ->
undefined.
relationship_target_id(Data) when is_map(Data) ->
type_conv:extract_id(Data, <<"id">>).
flush_all_pending_presences(State) ->
Pending = maps:get(pending_presences, State, []),
FlushedState =
lists:foldl(
fun(P, AccState) ->
dispatch_presence_now(P, AccState)
end,
State,
Pending
),
maps:put(pending_presences, [], FlushedState).
should_ignore_event(Event, State) ->
IgnoredEvents = maps:get(ignored_events, State, #{}),
case event_name(Event) of
undefined ->
false;
EventName ->
maps:is_key(EventName, IgnoredEvents)
end.
event_name(Event) when is_binary(Event) ->
Event;
event_name(Event) when is_atom(Event) ->
try constants:dispatch_event_atom(Event) of
Name when is_binary(Name) ->
Name
catch
_:_ ->
undefined
end;
event_name(_) ->
undefined.
update_channels_map(channel_create, Data, State) when is_map(Data) ->
case maps:get(<<"type">>, Data, undefined) of
1 ->
add_channel_to_state(Data, State);
3 ->
add_channel_to_state(Data, State);
_ ->
State
end;
update_channels_map(channel_update, Data, State) when is_map(Data) ->
case maps:get(<<"type">>, Data, undefined) of
1 ->
add_channel_to_state(Data, State);
3 ->
add_channel_to_state(Data, State);
_ ->
State
end;
update_channels_map(channel_delete, Data, State) when is_map(Data) ->
case maps:get(<<"id">>, Data, undefined) of
undefined ->
State;
ChannelIdBin ->
case validation:validate_snowflake(<<"id">>, ChannelIdBin) of
{ok, ChannelId} ->
Channels = maps:get(channels, State, #{}),
NewChannels = maps:remove(ChannelId, Channels),
maps:put(channels, NewChannels, State);
{error, _, _} ->
State
end
end;
update_channels_map(channel_recipient_add, Data, State) when is_map(Data) ->
update_recipient_membership(add, Data, State);
update_channels_map(channel_recipient_remove, Data, State) when is_map(Data) ->
update_recipient_membership(remove, Data, State);
update_channels_map(_Event, _Data, State) ->
State.
add_channel_to_state(Data, State) ->
case maps:get(<<"id">>, Data, undefined) of
undefined ->
State;
ChannelIdBin ->
case validation:validate_snowflake(<<"id">>, ChannelIdBin) of
{ok, ChannelId} ->
Channels = maps:get(channels, State, #{}),
NewChannels = maps:put(ChannelId, Data, Channels),
UserId = maps:get(user_id, State),
logger:info(
"[session_dispatch] Added/updated channel ~p for user ~p, type: ~p",
[ChannelId, UserId, maps:get(<<"type">>, Data, 0)]
),
maps:put(channels, NewChannels, State);
{error, _, _} ->
State
end
end.
update_recipient_membership(Action, Data, State) ->
ChannelIdBin = maps:get(<<"channel_id">>, Data, undefined),
case validation:validate_snowflake(<<"channel_id">>, ChannelIdBin) of
{ok, ChannelId} ->
Channels = maps:get(channels, State, #{}),
case maps:get(ChannelId, Channels, undefined) of
undefined ->
State;
Channel ->
case maps:get(<<"type">>, Channel, 0) of
3 ->
UserMap = maps:get(<<"user">>, Data, #{}),
RecipientId = type_conv:extract_id(UserMap, <<"id">>),
case RecipientId of
undefined ->
State;
_ ->
UpdatedChannel = update_channel_recipient(
Channel, RecipientId, UserMap, Action
),
NewChannels = maps:put(ChannelId, UpdatedChannel, Channels),
maps:put(channels, NewChannels, State)
end;
_ ->
State
end
end;
_ ->
State
end.
update_channel_recipient(Channel, RecipientId, UserMap, add) ->
RecipientIds = maps:get(<<"recipient_ids">>, Channel, []),
Recipients = maps:get(<<"recipients">>, Channel, []),
NewRecipientIds = add_unique_id(RecipientId, RecipientIds),
NewRecipients = add_unique_user(UserMap, Recipients),
Channel#{<<"recipient_ids">> => NewRecipientIds, <<"recipients">> => NewRecipients};
update_channel_recipient(Channel, RecipientId, _UserMap, remove) ->
RecipientIds = maps:get(<<"recipient_ids">>, Channel, []),
Recipients = maps:get(<<"recipients">>, Channel, []),
NewRecipientIds = lists:filter(
fun(Id) -> Id =/= integer_to_binary(RecipientId) andalso Id =/= RecipientId end,
RecipientIds
),
NewRecipients = lists:filter(
fun(R) ->
case type_conv:extract_id(R, <<"id">>) of
RecipientId -> false;
_ -> true
end
end,
Recipients
),
Channel#{<<"recipient_ids">> => NewRecipientIds, <<"recipients">> => NewRecipients}.
add_unique_id(Id, List) ->
case lists:member(Id, List) orelse lists:member(integer_to_binary(Id), List) of
true -> List;
false -> [Id | List]
end.
add_unique_user(UserMap, List) when is_map(UserMap) ->
case type_conv:extract_id(UserMap, <<"id">>) of
undefined ->
List;
Id ->
case
lists:any(
fun(R) -> type_conv:extract_id(R, <<"id">>) =:= Id end,
List
)
of
true -> List;
false -> [UserMap | List]
end
end.
update_relationships_map(relationship_add, Data, State) ->
upsert_relationship(Data, State);
update_relationships_map(relationship_update, Data, State) ->
upsert_relationship(Data, State);
update_relationships_map(relationship_remove, Data, State) ->
case type_conv:extract_id(Data, <<"id">>) of
undefined ->
State;
UserId ->
Relationships = maps:get(relationships, State, #{}),
NewRelationships = maps:remove(UserId, Relationships),
maps:put(relationships, NewRelationships, State)
end;
update_relationships_map(_, _, State) ->
State.
upsert_relationship(Data, State) ->
case type_conv:extract_id(Data, <<"id">>) of
undefined ->
State;
UserId ->
Type = maps:get(<<"type">>, Data, 0),
Relationships = maps:get(relationships, State, #{}),
NewRelationships = maps:put(UserId, Type, Relationships),
maps:put(relationships, NewRelationships, State)
end.
sync_presence_targets(State) ->
PresencePid = maps:get(presence_pid, State, undefined),
case PresencePid of
undefined ->
State;
Pid when is_pid(Pid) ->
FriendIds = presence_targets:friend_ids_from_state(State),
GroupRecipients = presence_targets:group_dm_recipients_from_state(State),
gen_server:cast(Pid, {sync_friends, FriendIds}),
gen_server:cast(Pid, {sync_group_dm_recipients, GroupRecipients}),
State
end.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
base_state_for_presence_buffer_test(Opts) ->
maps:merge(
#{
seq => 0,
user_id => 1,
buffer => [],
socket_pid => undefined,
channels => #{},
relationships => #{},
suppress_presence_updates => false,
pending_presences => [],
presence_pid => undefined,
ignored_events => #{}
},
Opts
).
presence_update_with_guild_id_not_buffered_test() ->
State0 = base_state_for_presence_buffer_test(#{}),
Presence = #{
<<"guild_id">> => <<"123">>,
<<"user">> => #{<<"id">> => <<"2">>},
<<"status">> => <<"idle">>
},
{noreply, State1} = handle_dispatch(presence_update, Presence, State0),
?assertEqual([], maps:get(pending_presences, State1, [])),
?assertEqual(1, length(maps:get(buffer, State1, []))),
ok.
presence_update_without_guild_id_buffered_for_non_relationship_test() ->
State0 = base_state_for_presence_buffer_test(#{}),
Presence = #{
<<"user">> => #{<<"id">> => <<"2">>},
<<"status">> => <<"online">>
},
{noreply, State1} = handle_dispatch(presence_update, Presence, State0),
?assertEqual(1, length(maps:get(pending_presences, State1, []))),
?assertEqual([], maps:get(buffer, State1, [])),
ok.
presence_update_without_guild_id_not_buffered_for_relationship_test() ->
State0 = base_state_for_presence_buffer_test(#{relationships => #{2 => 1}}),
Presence = #{
<<"user">> => #{<<"id">> => <<"2">>},
<<"status">> => <<"online">>
},
{noreply, State1} = handle_dispatch(presence_update, Presence, State0),
?assertEqual([], maps:get(pending_presences, State1, [])),
?assertEqual(1, length(maps:get(buffer, State1, []))),
ok.
presence_update_without_guild_id_buffered_for_outgoing_request_relationship_test() ->
State0 = base_state_for_presence_buffer_test(#{relationships => #{2 => 4}}),
Presence = #{
<<"user">> => #{<<"id">> => <<"2">>},
<<"status">> => <<"online">>
},
{noreply, State1} = handle_dispatch(presence_update, Presence, State0),
?assertEqual(1, length(maps:get(pending_presences, State1, []))),
?assertEqual([], maps:get(buffer, State1, [])),
ok.
presence_update_without_guild_id_not_buffered_for_incoming_request_relationship_test() ->
State0 = base_state_for_presence_buffer_test(#{relationships => #{2 => 3}}),
Presence = #{
<<"user">> => #{<<"id">> => <<"2">>},
<<"status">> => <<"online">>
},
{noreply, State1} = handle_dispatch(presence_update, Presence, State0),
?assertEqual([], maps:get(pending_presences, State1, [])),
?assertEqual(1, length(maps:get(buffer, State1, []))),
ok.
-endif.

View File

@@ -0,0 +1,559 @@
%% 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(session_manager).
-behaviour(gen_server).
-include_lib("fluxer_gateway/include/timeout_config.hrl").
-export([start_link/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-export_type([session_data/0, user_id/0]).
-type session_id() :: binary().
-type user_id() :: integer().
-type session_ref() :: {pid(), reference()}.
-type status() :: online | offline | idle | dnd.
-type identify_timestamp() :: integer().
-define(IDENTIFY_FLAG_USE_CANARY_API, 16#1).
-type identify_request() :: #{
session_id := session_id(),
identify_data := map(),
version := non_neg_integer(),
peer_ip := term(),
token := binary()
}.
-type session_data() :: #{
id := session_id(),
user_id := user_id(),
user_data := map(),
version := non_neg_integer(),
token_hash := binary(),
auth_session_id_hash := binary(),
properties := map(),
status := status(),
afk := boolean(),
mobile := boolean(),
socket_pid := pid(),
guilds := [integer()],
ready := map(),
ignored_events := [binary()]
}.
-type state() :: #{
sessions := #{session_id() => session_ref()},
api_host := string(),
api_canary_host := undefined | string(),
identify_attempts := [identify_timestamp()]
}.
-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-spec init([]) -> {ok, state()}.
init([]) ->
fluxer_gateway_env:load(),
process_flag(trap_exit, true),
ApiHost = fluxer_gateway_env:get(api_host),
ApiCanaryHost = fluxer_gateway_env:get(api_canary_host),
{ok, #{
sessions => #{},
api_host => ApiHost,
api_canary_host => ApiCanaryHost,
identify_attempts => []
}}.
-spec handle_call(Request, From, State) -> Result when
Request ::
{start, identify_request(), pid()}
| {lookup, session_id()}
| get_local_count
| get_global_count
| term(),
From :: gen_server:from(),
State :: state(),
Result :: {reply, Reply, state()},
Reply ::
{success, pid()}
| {ok, pid()}
| {error, not_found}
| {error, identify_rate_limited}
| {error, invalid_token}
| {error, rate_limited}
| {error, {server_error, non_neg_integer()}}
| {error, {http_error, non_neg_integer()}}
| {error, {network_error, term()}}
| {error, registration_failed}
| {error, term()}
| {ok, non_neg_integer()}
| ok.
handle_call(
{start, Request, SocketPid},
_From,
State
) ->
Sessions = maps:get(sessions, State),
Attempts = maps:get(identify_attempts, State),
SessionId = maps:get(session_id, Request),
case maps:get(SessionId, Sessions, undefined) of
{Pid, _Ref} ->
{reply, {success, Pid}, State};
undefined ->
SessionName = process_registry:build_process_name(session, SessionId),
case whereis(SessionName) of
undefined ->
case check_identify_rate_limit(Attempts) of
{ok, NewAttempts} ->
handle_identify_request(
Request,
SocketPid,
SessionId,
Sessions,
maps:put(identify_attempts, NewAttempts, State)
);
{error, rate_limited} ->
{reply, {error, identify_rate_limited}, State}
end;
Pid ->
Ref = monitor(process, Pid),
NewSessions = maps:put(SessionId, {Pid, Ref}, Sessions),
{reply, {success, Pid}, maps:put(sessions, NewSessions, State)}
end
end;
handle_call({lookup, SessionId}, _From, State) ->
Sessions = maps:get(sessions, State),
case maps:get(SessionId, Sessions, undefined) of
{Pid, _Ref} ->
{reply, {ok, Pid}, State};
undefined ->
SessionName = process_registry:build_process_name(session, SessionId),
case whereis(SessionName) of
undefined ->
{reply, {error, not_found}, State};
Pid ->
Ref = monitor(process, Pid),
NewSessions = maps:put(SessionId, {Pid, Ref}, Sessions),
{reply, {ok, Pid}, maps:put(sessions, NewSessions, State)}
end
end;
handle_call(get_local_count, _From, State) ->
Sessions = maps:get(sessions, State),
{reply, {ok, maps:size(Sessions)}, State};
handle_call(get_global_count, _From, State) ->
Sessions = maps:get(sessions, State),
{reply, {ok, maps:size(Sessions)}, State};
handle_call(_, _From, State) ->
{reply, ok, State}.
-spec handle_identify_request(
identify_request(),
pid(),
session_id(),
#{session_id() => session_ref()},
state()
) ->
{reply,
{success, pid()}
| {error, invalid_token}
| {error, rate_limited}
| {error, {server_error, non_neg_integer()}}
| {error, {http_error, non_neg_integer()}}
| {error, {network_error, term()}}
| {error, registration_failed}
| {error, term()},
state()}.
handle_identify_request(
Request, SocketPid, SessionId, Sessions, State
) ->
IdentifyData = maps:get(identify_data, Request),
Version = maps:get(version, Request),
PeerIP = maps:get(peer_ip, Request),
UseCanary = should_use_canary_api(IdentifyData),
{_UsedCanary, RpcClient} = select_rpc_client(State, UseCanary),
case fetch_rpc_data(Request, PeerIP, RpcClient) of
{ok, Data} ->
UserDataMap = maps:get(<<"user">>, Data),
UserId = type_conv:extract_id(UserDataMap, <<"id">>),
AuthSessionIdHashEncoded = maps:get(<<"auth_session_id_hash">>, Data, undefined),
AuthSessionIdHash =
case AuthSessionIdHashEncoded of
undefined -> <<>>;
null -> <<>>;
_ -> base64url:decode(AuthSessionIdHashEncoded)
end,
Status = parse_presence(Data, IdentifyData),
GuildIds = parse_guild_ids(Data),
Properties = maps:get(properties, IdentifyData),
Presence = map_utils:get_safe(IdentifyData, presence, null),
IgnoredEvents = map_utils:get_safe(IdentifyData, ignored_events, []),
InitialGuildId = map_utils:get_safe(IdentifyData, initial_guild_id, undefined),
Bot = map_utils:get_safe(UserDataMap, <<"bot">>, false),
ReadyData =
case Bot of
true -> maps:merge(Data, #{<<"guilds">> => []});
false -> Data
end,
UserSettingsMap = map_utils:get_safe(Data, <<"user_settings">>, #{}),
CustomStatusFromSettings = map_utils:get_safe(
UserSettingsMap, <<"custom_status">>, null
),
PresenceCustomStatus = get_presence_custom_status(Presence),
CustomStatus =
case CustomStatusFromSettings of
null -> PresenceCustomStatus;
_ -> CustomStatusFromSettings
end,
Mobile =
case Presence of
null -> map_utils:get_safe(Properties, <<"mobile">>, false);
P when is_map(P) -> map_utils:get_safe(P, <<"mobile">>, false);
_ -> false
end,
Afk =
case Presence of
null -> false;
P2 when is_map(P2) -> map_utils:get_safe(P2, <<"afk">>, false);
_ -> false
end,
UserData0 = #{
<<"id">> => maps:get(<<"id">>, UserDataMap),
<<"username">> => maps:get(<<"username">>, UserDataMap),
<<"discriminator">> => maps:get(<<"discriminator">>, UserDataMap),
<<"avatar">> => maps:get(<<"avatar">>, UserDataMap),
<<"avatar_color">> => map_utils:get_safe(
UserDataMap, <<"avatar_color">>, undefined
),
<<"bot">> => map_utils:get_safe(UserDataMap, <<"bot">>, undefined),
<<"system">> => map_utils:get_safe(UserDataMap, <<"system">>, undefined),
<<"flags">> => maps:get(<<"flags">>, UserDataMap)
},
UserData = user_utils:normalize_user(UserData0),
SessionData = #{
id => SessionId,
user_id => UserId,
user_data => UserData,
custom_status => CustomStatus,
version => Version,
token_hash => utils:hash_token(maps:get(token, IdentifyData)),
auth_session_id_hash => AuthSessionIdHash,
properties => Properties,
status => Status,
afk => Afk,
mobile => Mobile,
socket_pid => SocketPid,
guilds => GuildIds,
ready => ReadyData,
bot => Bot,
ignored_events => IgnoredEvents,
initial_guild_id => InitialGuildId
},
SessionName = process_registry:build_process_name(session, SessionId),
case whereis(SessionName) of
undefined ->
case session:start_link(SessionData) of
{ok, Pid} ->
case
process_registry:register_and_monitor(SessionName, Pid, Sessions)
of
{ok, RegisteredPid, Ref, NewSessions0} ->
CleanSessions = maps:remove(SessionName, NewSessions0),
NewSessions = maps:put(
SessionId, {RegisteredPid, Ref}, CleanSessions
),
{reply, {success, RegisteredPid}, maps:put(
sessions, NewSessions, State
)};
{error, registration_race_condition} ->
{reply, {error, registration_failed}, State};
{error, _Reason} ->
{reply, {error, registration_failed}, State}
end;
Error ->
{reply, Error, State}
end;
ExistingPid ->
Ref = monitor(process, ExistingPid),
CleanSessions = maps:remove(SessionName, Sessions),
NewSessions = maps:put(SessionId, {ExistingPid, Ref}, CleanSessions),
{reply, {success, ExistingPid}, maps:put(sessions, NewSessions, State)}
end;
{error, invalid_token} ->
{reply, {error, invalid_token}, State};
{error, rate_limited} ->
{reply, {error, rate_limited}, State};
{error, Reason} ->
{reply, {error, Reason}, State}
end.
-spec handle_cast(term(), state()) -> {noreply, state()}.
handle_cast(_, State) ->
{noreply, State}.
select_rpc_client(State, true) ->
case maps:get(api_canary_host, State) of
undefined ->
logger:warning(
"[session_manager] Canary API requested but not configured, falling back to stable API"
),
{false, maps:get(api_host, State)};
CanaryHost ->
{true, CanaryHost}
end;
select_rpc_client(State, false) ->
{false, maps:get(api_host, State)}.
should_use_canary_api(IdentifyData) ->
case map_utils:get_safe(IdentifyData, flags, 0) of
Flags when is_integer(Flags), Flags >= 0 ->
(Flags band ?IDENTIFY_FLAG_USE_CANARY_API) =/= 0;
_ ->
false
end.
-spec handle_info(Info, State) -> {noreply, state()} when
Info :: {'DOWN', reference(), process, pid(), term()} | term(),
State :: state().
handle_info({'DOWN', _Ref, process, Pid, _Reason}, State) ->
Sessions = maps:get(sessions, State),
NewSessions = process_registry:cleanup_on_down(Pid, Sessions),
{noreply, maps:put(sessions, NewSessions, State)};
handle_info(_, State) ->
{noreply, State}.
-spec terminate(Reason, State) -> ok when
Reason :: term(),
State :: state().
terminate(_Reason, _State) ->
ok.
-spec code_change(OldVsn, State, Extra) -> {ok, state()} when
OldVsn :: term(),
State :: state() | tuple(),
Extra :: term().
code_change(_OldVsn, State, _Extra) when is_map(State) ->
{ok, State};
code_change(_OldVsn, State, _Extra) when is_tuple(State), element(1, State) =:= state ->
Sessions = element(2, State),
ApiHost = element(3, State),
ApiCanaryHost = element(4, State),
IdentifyAttempts = element(5, State),
{ok, #{
sessions => Sessions,
api_host => ApiHost,
api_canary_host => ApiCanaryHost,
identify_attempts => IdentifyAttempts
}};
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
-spec fetch_rpc_data(map(), term(), string()) ->
{ok, map()}
| {error, invalid_token}
| {error, rate_limited}
| {error, {server_error, non_neg_integer()}}
| {error, {http_error, non_neg_integer()}}
| {error, {network_error, term()}}.
fetch_rpc_data(Request, PeerIP, ApiHost) ->
StartTime = erlang:system_time(millisecond),
Result = do_fetch_rpc_data(Request, PeerIP, ApiHost),
EndTime = erlang:system_time(millisecond),
LatencyMs = EndTime - StartTime,
gateway_metrics_collector:record_rpc_latency(LatencyMs),
Result.
-spec do_fetch_rpc_data(map(), term(), string()) ->
{ok, map()}
| {error, invalid_token}
| {error, rate_limited}
| {error, {server_error, non_neg_integer()}}
| {error, {http_error, non_neg_integer()}}
| {error, {network_error, term()}}.
do_fetch_rpc_data(Request, PeerIP, ApiHost) ->
Url = rpc_client:get_rpc_url(ApiHost),
Headers = rpc_client:get_rpc_headers() ++ [{<<"content-type">>, <<"application/json">>}],
IdentifyData = maps:get(identify_data, Request),
Properties = map_utils:get_safe(IdentifyData, properties, #{}),
LatitudeRaw = map_utils:get_safe(Properties, <<"latitude">>, undefined),
LongitudeRaw = map_utils:get_safe(Properties, <<"longitude">>, undefined),
Latitude =
case LatitudeRaw of
undefined -> undefined;
null -> undefined;
SafeLatitude -> SafeLatitude
end,
Longitude =
case LongitudeRaw of
undefined -> undefined;
null -> undefined;
SafeLongitude -> SafeLongitude
end,
RpcRequest = #{
<<"type">> => <<"session">>,
<<"token">> => maps:get(token, IdentifyData),
<<"version">> => maps:get(version, Request),
<<"ip">> => PeerIP
},
RpcRequestWithLatitude =
case Latitude of
undefined -> RpcRequest;
LatitudeValue -> maps:put(<<"latitude">>, LatitudeValue, RpcRequest)
end,
RpcRequestWithLongitude =
case Longitude of
undefined -> RpcRequestWithLatitude;
LongitudeValue -> maps:put(<<"longitude">>, LongitudeValue, RpcRequestWithLatitude)
end,
Body = jsx:encode(RpcRequestWithLongitude),
case hackney:request(post, Url, Headers, Body, []) of
{ok, 200, _RespHeaders, ClientRef} ->
case hackney:body(ClientRef) of
{ok, ResponseBody} ->
hackney:close(ClientRef),
ResponseData = jsx:decode(ResponseBody, [{return_maps, true}]),
{ok, maps:get(<<"data">>, ResponseData)};
{error, BodyError} ->
hackney:close(ClientRef),
logger:error("[session_manager] Failed to read response body: ~p", [BodyError]),
{error, {network_error, BodyError}}
end;
{ok, 401, _, ClientRef} ->
hackney:close(ClientRef),
logger:info("[session_manager] RPC authentication failed (401)"),
{error, invalid_token};
{ok, 429, _, ClientRef} ->
hackney:close(ClientRef),
logger:warning("[session_manager] RPC rate limited (429)"),
{error, rate_limited};
{ok, StatusCode, _, ClientRef} when StatusCode >= 500 ->
ErrorBody =
case hackney:body(ClientRef) of
{ok, Body2} -> Body2;
{error, _} -> <<"<unable to read error body>">>
end,
hackney:close(ClientRef),
logger:error("[session_manager] RPC server error ~p: ~s", [StatusCode, ErrorBody]),
{error, {server_error, StatusCode}};
{ok, StatusCode, _, ClientRef} when StatusCode >= 400 ->
ErrorBody =
case hackney:body(ClientRef) of
{ok, Body2} -> Body2;
{error, _} -> <<"<unable to read error body>">>
end,
hackney:close(ClientRef),
logger:warning("[session_manager] RPC client error ~p: ~s", [StatusCode, ErrorBody]),
{error, {http_error, StatusCode}};
{ok, StatusCode, _, ClientRef} ->
hackney:close(ClientRef),
logger:warning("[session_manager] RPC unexpected status: ~p", [StatusCode]),
{error, {http_error, StatusCode}};
{error, Reason} ->
logger:error("[session_manager] RPC request failed: ~p", [Reason]),
{error, {network_error, Reason}}
end.
-spec parse_presence(map(), map()) -> status().
parse_presence(Data, IdentifyData) ->
StoredStatus = get_stored_status(Data),
PresenceStatus =
case map_utils:get_safe(IdentifyData, presence, null) of
null ->
undefined;
Presence when is_map(Presence) ->
map_utils:get_safe(Presence, status, <<"online">>);
_ ->
undefined
end,
SelectedStatus = select_initial_status(PresenceStatus, StoredStatus),
utils:parse_status(SelectedStatus).
-spec parse_guild_ids(map()) -> [integer()].
parse_guild_ids(Data) ->
GuildIds = map_utils:get_safe(Data, <<"guild_ids">>, []),
[utils:binary_to_integer_safe(Id) || Id <- GuildIds, Id =/= undefined].
-spec check_identify_rate_limit(list()) -> {ok, list()} | {error, rate_limited}.
check_identify_rate_limit(Attempts) ->
case fluxer_gateway_env:get(identify_rate_limit_enabled) of
true ->
Now = erlang:system_time(millisecond),
WindowDuration = 5000,
AttemptsInWindow = [T || T <- Attempts, (Now - T) < WindowDuration],
AttemptsCount = length(AttemptsInWindow),
MaxIdentifiesPerWindow = 1,
case AttemptsCount >= MaxIdentifiesPerWindow of
true ->
{error, rate_limited};
false ->
NewAttempts = [Now | AttemptsInWindow],
{ok, NewAttempts}
end;
_ ->
{ok, Attempts}
end.
-spec get_presence_custom_status(term()) -> map() | null.
get_presence_custom_status(Presence) ->
case Presence of
null -> null;
Map when is_map(Map) -> map_utils:get_safe(Map, <<"custom_status">>, null);
_ -> null
end.
-spec get_stored_status(map()) -> binary().
get_stored_status(Data) ->
case map_utils:get_safe(Data, <<"user_settings">>, null) of
null ->
<<"online">>;
UserSettings ->
case normalize_status(map_utils:get_safe(UserSettings, <<"status">>, <<"online">>)) of
undefined -> <<"online">>;
Value -> Value
end
end.
-spec select_initial_status(binary() | undefined, binary()) -> binary().
select_initial_status(PresenceStatus, StoredStatus) ->
NormalizedPresence = normalize_status(PresenceStatus),
case {NormalizedPresence, StoredStatus} of
{undefined, Stored} ->
Stored;
{<<"unknown">>, Stored} ->
Stored;
{<<"online">>, Stored} when Stored =/= <<"online">> ->
Stored;
{Presence, _} ->
Presence
end.
-spec normalize_status(term()) -> binary() | undefined.
normalize_status(undefined) ->
undefined;
normalize_status(null) ->
undefined;
normalize_status(Status) when is_binary(Status) ->
Status;
normalize_status(Status) when is_atom(Status) ->
try constants:status_type_atom(Status) of
Value when is_binary(Value) -> Value
catch
_:_ -> undefined
end;
normalize_status(_) ->
undefined.

View File

@@ -0,0 +1,106 @@
%% 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(session_monitor).
-export([
handle_process_down/3,
find_guild_by_ref/2,
find_call_by_ref/2
]).
handle_process_down(Ref, _Reason, State) ->
SocketRef = maps:get(socket_mref, State, undefined),
PresenceRef = maps:get(presence_mref, State, undefined),
Guilds = maps:get(guilds, State),
Calls = maps:get(calls, State, #{}),
case Ref of
SocketRef when Ref =:= SocketRef ->
self() ! {presence_update, #{status => offline}},
erlang:send_after(10000, self(), resume_timeout),
{noreply, maps:merge(State, #{socket_pid => undefined, socket_mref => undefined})};
PresenceRef when Ref =:= PresenceRef ->
self() ! {presence_connect, 0},
{noreply, maps:put(presence_pid, undefined, State)};
_ ->
case find_guild_by_ref(Ref, Guilds) of
{ok, GuildId} ->
handle_guild_down(GuildId, _Reason, State, Guilds);
not_found ->
case find_call_by_ref(Ref, Calls) of
{ok, ChannelId} ->
handle_call_down(ChannelId, _Reason, State, Calls);
not_found ->
{noreply, State}
end
end
end.
handle_guild_down(GuildId, Reason, State, Guilds) ->
case Reason of
killed ->
gen_server:cast(self(), {guild_leave, GuildId}),
{noreply, State};
_ ->
GuildDeleteData = #{
<<"id">> => integer_to_binary(GuildId),
<<"unavailable">> => true
},
{noreply, UpdatedState} = session_dispatch:handle_dispatch(
guild_delete, GuildDeleteData, State
),
NewGuilds = maps:put(GuildId, undefined, Guilds),
erlang:send_after(1000, self(), {guild_connect, GuildId, 0}),
{noreply, maps:put(guilds, NewGuilds, UpdatedState)}
end.
handle_call_down(ChannelId, Reason, State, Calls) ->
case Reason of
killed ->
NewCalls = maps:remove(ChannelId, Calls),
{noreply, maps:put(calls, NewCalls, State)};
_ ->
CallDeleteData = #{
<<"channel_id">> => integer_to_binary(ChannelId),
<<"unavailable">> => true
},
{noreply, UpdatedState} = session_dispatch:handle_dispatch(
call_delete, CallDeleteData, State
),
NewCalls = maps:put(ChannelId, undefined, Calls),
erlang:send_after(1000, self(), {call_reconnect, ChannelId, 0}),
{noreply, maps:put(calls, NewCalls, UpdatedState)}
end.
find_guild_by_ref(Ref, Guilds) ->
find_by_ref(Ref, Guilds).
find_call_by_ref(Ref, Calls) ->
find_by_ref(Ref, Calls).
find_by_ref(Ref, Map) ->
maps:fold(
fun
(Id, {_Pid, R}, _) when R =:= Ref -> {ok, Id};
(_, _, Acc) -> Acc
end,
not_found,
Map
).

View File

@@ -0,0 +1,345 @@
%% 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(session_passive).
-export([
is_passive/2,
set_active/2,
set_passive/2,
should_receive_event/5,
get_user_roles_for_guild/2,
should_receive_typing/2,
set_typing_override/3,
get_typing_override/2,
is_guild_synced/2,
mark_guild_synced/2,
clear_guild_synced/2
]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
is_passive(GuildId, SessionData) ->
case maps:get(bot, SessionData, false) of
true ->
false;
false ->
ActiveGuilds = maps:get(active_guilds, SessionData, sets:new()),
not sets:is_element(GuildId, ActiveGuilds)
end.
set_active(GuildId, SessionData) ->
ActiveGuilds = maps:get(active_guilds, SessionData, sets:new()),
NewActiveGuilds = sets:add_element(GuildId, ActiveGuilds),
maps:put(active_guilds, NewActiveGuilds, SessionData).
set_passive(GuildId, SessionData) ->
ActiveGuilds = maps:get(active_guilds, SessionData, sets:new()),
NewActiveGuilds = sets:del_element(GuildId, ActiveGuilds),
maps:put(active_guilds, NewActiveGuilds, SessionData).
should_receive_event(Event, EventData, GuildId, SessionData, State) ->
case Event of
typing_start ->
should_receive_typing(GuildId, SessionData);
_ ->
case maps:get(bot, SessionData, false) of
true ->
true;
false ->
case is_message_event(Event) of
true ->
case is_small_guild(State) of
true ->
true;
false ->
case is_passive(GuildId, SessionData) of
false -> true;
true -> should_passive_receive(Event, EventData, SessionData)
end
end;
false ->
case is_passive(GuildId, SessionData) of
false -> true;
true -> should_passive_receive(Event, EventData, SessionData)
end
end
end
end.
is_small_guild(State) ->
MemberCount = maps:get(member_count, State, undefined),
case MemberCount of
undefined -> false; %% Conservative: treat as large
Count when is_integer(Count) -> Count =< 250
end.
is_message_event(message_create) -> true;
is_message_event(message_update) -> true;
is_message_event(message_delete) -> true;
is_message_event(message_delete_bulk) -> true;
is_message_event(_) -> false.
should_passive_receive(message_create, EventData, SessionData) ->
is_user_mentioned(EventData, SessionData);
should_passive_receive(guild_delete, _EventData, _SessionData) ->
true;
should_passive_receive(channel_create, _EventData, _SessionData) ->
true;
should_passive_receive(channel_delete, _EventData, _SessionData) ->
true;
should_passive_receive(passive_updates, _EventData, _SessionData) ->
true;
should_passive_receive(guild_update, _EventData, _SessionData) ->
true;
should_passive_receive(guild_member_update, EventData, SessionData) ->
UserId = maps:get(user_id, SessionData),
MemberUser = maps:get(<<"user">>, EventData, #{}),
MemberUserId = map_utils:get_integer(MemberUser, <<"id">>, undefined),
UserId =:= MemberUserId;
should_passive_receive(guild_member_remove, EventData, SessionData) ->
UserId = maps:get(user_id, SessionData),
MemberUser = maps:get(<<"user">>, EventData, #{}),
MemberUserId = map_utils:get_integer(MemberUser, <<"id">>, undefined),
UserId =:= MemberUserId;
should_passive_receive(voice_state_update, EventData, SessionData) ->
UserId = maps:get(user_id, SessionData),
EventUserId = map_utils:get_integer(EventData, <<"user_id">>, undefined),
UserId =:= EventUserId;
should_passive_receive(voice_server_update, _EventData, _SessionData) ->
true;
should_passive_receive(_, _, _) ->
false.
is_user_mentioned(EventData, SessionData) ->
UserId = maps:get(user_id, SessionData),
MentionEveryone = maps:get(<<"mention_everyone">>, EventData, false),
Mentions = maps:get(<<"mentions">>, EventData, []),
MentionRoles = maps:get(<<"mention_roles">>, EventData, []),
UserRoles = maps:get(user_roles, SessionData, []),
MentionEveryone orelse
is_user_in_mentions(UserId, Mentions) orelse
has_mentioned_role(UserRoles, MentionRoles).
is_user_in_mentions(_UserId, []) ->
false;
is_user_in_mentions(UserId, [#{<<"id">> := Id} | Rest]) when is_binary(Id) ->
case validation:validate_snowflake(<<"id">>, Id) of
{ok, ParsedId} ->
UserId =:= ParsedId orelse is_user_in_mentions(UserId, Rest);
{error, _, _} ->
is_user_in_mentions(UserId, Rest)
end;
is_user_in_mentions(UserId, [_ | Rest]) ->
is_user_in_mentions(UserId, Rest).
has_mentioned_role([], _MentionRoles) ->
false;
has_mentioned_role([RoleId | Rest], MentionRoles) ->
RoleIdBin = integer_to_binary(RoleId),
lists:member(RoleIdBin, MentionRoles) orelse
lists:member(RoleId, MentionRoles) orelse
has_mentioned_role(Rest, MentionRoles).
get_user_roles_for_guild(UserId, GuildState) ->
Data = maps:get(data, GuildState, #{}),
Members = maps:get(<<"members">>, Data, []),
case find_member_by_user_id(UserId, Members) of
undefined -> [];
Member -> extract_role_ids(maps:get(<<"roles">>, Member, []))
end.
find_member_by_user_id(_UserId, []) ->
undefined;
find_member_by_user_id(UserId, [Member | Rest]) ->
User = maps:get(<<"user">>, Member, #{}),
MemberUserId = map_utils:get_integer(User, <<"id">>, undefined),
case UserId =:= MemberUserId of
true -> Member;
false -> find_member_by_user_id(UserId, Rest)
end.
extract_role_ids(Roles) ->
lists:filtermap(
fun(Role) when is_binary(Role) ->
case validation:validate_snowflake(<<"role">>, Role) of
{ok, RoleId} -> {true, RoleId};
{error, _, _} -> false
end;
(Role) when is_integer(Role) ->
{true, Role};
(_) ->
false
end,
Roles
).
should_receive_typing(GuildId, SessionData) ->
case get_typing_override(GuildId, SessionData) of
undefined ->
not is_passive(GuildId, SessionData);
TypingFlag ->
TypingFlag
end.
set_typing_override(GuildId, TypingFlag, SessionData) ->
TypingOverrides = maps:get(typing_overrides, SessionData, #{}),
NewTypingOverrides = maps:put(GuildId, TypingFlag, TypingOverrides),
maps:put(typing_overrides, NewTypingOverrides, SessionData).
get_typing_override(GuildId, SessionData) ->
TypingOverrides = maps:get(typing_overrides, SessionData, #{}),
maps:get(GuildId, TypingOverrides, undefined).
is_guild_synced(GuildId, SessionData) ->
SyncedGuilds = maps:get(synced_guilds, SessionData, sets:new()),
sets:is_element(GuildId, SyncedGuilds).
mark_guild_synced(GuildId, SessionData) ->
SyncedGuilds = maps:get(synced_guilds, SessionData, sets:new()),
NewSyncedGuilds = sets:add_element(GuildId, SyncedGuilds),
maps:put(synced_guilds, NewSyncedGuilds, SessionData).
clear_guild_synced(GuildId, SessionData) ->
SyncedGuilds = maps:get(synced_guilds, SessionData, sets:new()),
NewSyncedGuilds = sets:del_element(GuildId, SyncedGuilds),
maps:put(synced_guilds, NewSyncedGuilds, SessionData).
-ifdef(TEST).
is_passive_test() ->
SessionData = #{active_guilds => sets:from_list([123, 456])},
?assertEqual(false, is_passive(123, SessionData)),
?assertEqual(false, is_passive(456, SessionData)),
?assertEqual(true, is_passive(789, SessionData)),
?assertEqual(true, is_passive(123, #{})),
ok.
set_active_test() ->
SessionData = #{active_guilds => sets:from_list([123])},
NewSessionData = set_active(456, SessionData),
?assertEqual(false, is_passive(456, NewSessionData)),
?assertEqual(false, is_passive(123, NewSessionData)),
ok.
set_passive_test() ->
SessionData = #{active_guilds => sets:from_list([123, 456])},
NewSessionData = set_passive(123, SessionData),
?assertEqual(true, is_passive(123, NewSessionData)),
?assertEqual(false, is_passive(456, NewSessionData)),
ok.
should_receive_event_active_session_test() ->
SessionData = #{user_id => 1, active_guilds => sets:from_list([123])},
State = #{member_count => 100},
?assertEqual(true, should_receive_event(message_create, #{}, 123, SessionData, State)),
?assertEqual(true, should_receive_event(typing_start, #{}, 123, SessionData, State)),
ok.
should_receive_event_passive_guild_delete_test() ->
SessionData = #{user_id => 1, active_guilds => sets:new()},
State = #{member_count => 100},
?assertEqual(true, should_receive_event(guild_delete, #{}, 123, SessionData, State)),
ok.
should_receive_event_passive_channel_create_test() ->
SessionData = #{user_id => 1, active_guilds => sets:new()},
State = #{member_count => 100},
?assertEqual(true, should_receive_event(channel_create, #{}, 123, SessionData, State)),
ok.
should_receive_event_passive_channel_delete_test() ->
SessionData = #{user_id => 1, active_guilds => sets:new()},
State = #{member_count => 100},
?assertEqual(true, should_receive_event(channel_delete, #{}, 123, SessionData, State)),
ok.
should_receive_event_passive_passive_updates_test() ->
SessionData = #{user_id => 1, active_guilds => sets:new()},
State = #{member_count => 100},
?assertEqual(true, should_receive_event(passive_updates, #{}, 123, SessionData, State)),
ok.
should_receive_event_passive_message_not_mentioned_test() ->
SessionData = #{user_id => 1, active_guilds => sets:new(), user_roles => []},
EventData = #{<<"mentions">> => [], <<"mention_roles">> => [], <<"mention_everyone">> => false},
State = #{member_count => 300}, %% Large guild
?assertEqual(false, should_receive_event(message_create, EventData, 123, SessionData, State)),
ok.
should_receive_event_passive_message_user_mentioned_test() ->
SessionData = #{user_id => 1, active_guilds => sets:new(), user_roles => []},
EventData = #{
<<"mentions">> => [#{<<"id">> => <<"1">>}],
<<"mention_roles">> => [],
<<"mention_everyone">> => false
},
State = #{member_count => 300}, %% Large guild
?assertEqual(true, should_receive_event(message_create, EventData, 123, SessionData, State)),
ok.
should_receive_event_passive_message_mention_everyone_test() ->
SessionData = #{user_id => 1, active_guilds => sets:new(), user_roles => []},
EventData = #{<<"mentions">> => [], <<"mention_roles">> => [], <<"mention_everyone">> => true},
State = #{member_count => 300}, %% Large guild
?assertEqual(true, should_receive_event(message_create, EventData, 123, SessionData, State)),
ok.
should_receive_event_passive_message_role_mentioned_test() ->
SessionData = #{user_id => 1, active_guilds => sets:new(), user_roles => [100]},
EventData = #{
<<"mentions">> => [], <<"mention_roles">> => [<<"100">>], <<"mention_everyone">> => false
},
State = #{member_count => 300}, %% Large guild
?assertEqual(true, should_receive_event(message_create, EventData, 123, SessionData, State)),
ok.
should_receive_event_passive_other_event_test() ->
SessionData = #{user_id => 1, active_guilds => sets:new()},
State = #{member_count => 300}, %% Large guild
?assertEqual(false, should_receive_event(typing_start, #{}, 123, SessionData, State)),
?assertEqual(false, should_receive_event(message_update, #{}, 123, SessionData, State)),
ok.
should_receive_event_small_guild_all_sessions_receive_messages_test() ->
SessionData = #{user_id => 1, active_guilds => sets:new()},
State = #{member_count => 100}, %% Small guild
?assertEqual(true, should_receive_event(message_create, #{}, 123, SessionData, State)),
?assertEqual(true, should_receive_event(message_update, #{}, 123, SessionData, State)),
?assertEqual(true, should_receive_event(message_delete, #{}, 123, SessionData, State)),
ok.
is_passive_bot_always_active_test() ->
BotSessionData = #{user_id => 1, active_guilds => sets:new(), bot => true},
?assertEqual(false, is_passive(123, BotSessionData)),
?assertEqual(false, is_passive(456, BotSessionData)),
?assertEqual(false, is_passive(789, BotSessionData)),
ok.
should_receive_event_bot_always_receives_test() ->
BotSessionData = #{user_id => 1, active_guilds => sets:new(), bot => true},
State = #{member_count => 300},
?assertEqual(true, should_receive_event(message_create, #{}, 123, BotSessionData, State)),
?assertEqual(true, should_receive_event(typing_start, #{}, 123, BotSessionData, State)),
?assertEqual(true, should_receive_event(message_update, #{}, 123, BotSessionData, State)),
?assertEqual(true, should_receive_event(guild_delete, #{}, 123, BotSessionData, State)),
ok.
-endif.

View File

@@ -0,0 +1,443 @@
%% 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(session_ready).
-export([
process_guild_state/2,
mark_guild_unavailable/2,
check_readiness/1,
dispatch_ready_data/1,
update_ready_guilds/2
]).
process_guild_state(GuildState, State) ->
Ready = maps:get(ready, State),
CollectedGuilds = maps:get(collected_guild_states, State),
case Ready of
undefined ->
{noreply, StateAfterCreate} = session_dispatch:handle_dispatch(
guild_create, GuildState, State
),
dispatch_guild_initial_presences(GuildState, StateAfterCreate);
_ ->
NewCollectedGuilds = [GuildState | CollectedGuilds],
NewState = maps:put(collected_guild_states, NewCollectedGuilds, State),
check_readiness(update_ready_guilds(GuildState, NewState))
end.
dispatch_guild_initial_presences(_GuildState, State) ->
{noreply, State}.
mark_guild_unavailable(GuildId, State) ->
CollectedGuilds = maps:get(collected_guild_states, State),
Ready = maps:get(ready, State),
UnavailableState = #{<<"id">> => integer_to_binary(GuildId), <<"unavailable">> => true},
NewCollectedGuilds = [UnavailableState | CollectedGuilds],
NewState = maps:put(collected_guild_states, NewCollectedGuilds, State),
case Ready of
undefined -> {noreply, NewState};
_ -> {noreply, update_ready_guilds(UnavailableState, NewState)}
end.
check_readiness(State) ->
Ready = maps:get(ready, State),
PresencePid = maps:get(presence_pid, State, undefined),
Guilds = maps:get(guilds, State),
case Ready of
undefined ->
{noreply, State};
_ when PresencePid =/= undefined ->
AllGuildsReady = lists:all(fun({_, V}) -> V =/= undefined end, maps:to_list(Guilds)),
if
AllGuildsReady -> dispatch_ready_data(State);
true -> {noreply, State}
end;
_ ->
{noreply, State}
end.
dispatch_ready_data(State) ->
Ready = maps:get(ready, State),
CollectedGuilds = maps:get(collected_guild_states, State),
CollectedSessions = maps:get(collected_sessions, State),
CollectedPresences = collect_ready_presences(State, CollectedGuilds),
Users = collect_ready_users(State, CollectedGuilds),
Version = maps:get(version, State),
UserId = maps:get(user_id, State),
SessionId = maps:get(id, State),
SocketPid = maps:get(socket_pid, State, undefined),
Guilds = maps:get(guilds, State),
IsBot = maps:get(bot, State, false),
ReadyData =
case Ready of
undefined -> #{<<"guilds">> => []};
R -> R
end,
ReadyDataWithStrippedRelationships = strip_user_from_relationships(ReadyData),
ReadyDataBotStripped =
case IsBot of
true -> maps:put(<<"guilds">>, [], ReadyDataWithStrippedRelationships);
false -> ReadyDataWithStrippedRelationships
end,
UnavailableGuilds = [
#{<<"id">> => integer_to_binary(GuildId), <<"unavailable">> => true}
|| {GuildId, undefined} <- maps:to_list(Guilds)
],
StrippedGuilds = [strip_users_from_guild_members(G) || G <- lists:reverse(CollectedGuilds)],
AllGuildStates = StrippedGuilds ++ UnavailableGuilds,
ReadyDataWithoutGuildIds = maps:remove(<<"guild_ids">>, ReadyDataBotStripped),
GuildsForReady =
case IsBot of
true -> [];
false -> AllGuildStates
end,
logger:debug(
"[session_ready] dispatching READY for user ~p session ~p",
[UserId, SessionId]
),
FinalReadyData = maps:merge(ReadyDataWithoutGuildIds, #{
<<"guilds">> => GuildsForReady,
<<"sessions">> => CollectedSessions,
<<"presences">> => CollectedPresences,
<<"users">> => Users,
<<"version">> => Version,
<<"session_id">> => SessionId
}),
case SocketPid of
undefined ->
{stop, normal, State};
Pid when is_pid(Pid) ->
metrics_client:counter(<<"gateway.ready">>),
StateAfterReady = dispatch_event(ready, FinalReadyData, State),
SessionCount = length(CollectedSessions),
GuildCount = length(GuildsForReady),
PresenceCount = length(CollectedPresences),
Dimensions = #{
<<"session_id">> => SessionId,
<<"user_id">> => integer_to_binary(UserId),
<<"bot">> => bool_to_binary(IsBot)
},
metrics_client:gauge(<<"gateway.sessions.active">>, Dimensions, SessionCount),
metrics_client:gauge(<<"gateway.guilds.active">>, Dimensions, GuildCount),
metrics_client:gauge(<<"gateway.presences.active">>, Dimensions, PresenceCount),
StateAfterGuildCreates =
case IsBot of
true ->
lists:foldl(
fun(GuildState, AccState) ->
dispatch_event(guild_create, GuildState, AccState)
end,
StateAfterReady,
AllGuildStates
);
false ->
StateAfterReady
end,
PrivateChannels = get_private_channels(StateAfterGuildCreates),
spawn(fun() ->
dispatch_call_creates_for_channels(
PrivateChannels, SessionId, StateAfterGuildCreates
)
end),
FinalState = maps:merge(StateAfterGuildCreates, #{
ready => undefined,
collected_guild_states => [],
collected_sessions => []
}),
{noreply, FinalState}
end.
dispatch_event(Event, Data, State) ->
Seq = maps:get(seq, State),
SocketPid = maps:get(socket_pid, State, undefined),
NewSeq = Seq + 1,
case SocketPid of
undefined -> ok;
Pid when is_pid(Pid) -> Pid ! {dispatch, Event, Data, NewSeq}
end,
maps:put(seq, NewSeq, State).
update_ready_guilds(GuildState, State) ->
case maps:get(bot, State, false) of
true ->
State;
false ->
Ready = maps:get(ready, State),
case is_map(Ready) of
true ->
Guilds = maps:get(<<"guilds">>, Ready, []),
NewGuilds = Guilds ++ [GuildState],
NewReady = maps:put(<<"guilds">>, NewGuilds, Ready),
maps:put(ready, NewReady, State);
false ->
State
end
end.
collect_ready_users(State, CollectedGuilds) ->
case maps:get(bot, State, false) of
true ->
[];
false ->
collect_ready_users_nonbot(State, CollectedGuilds)
end.
collect_ready_users_nonbot(State, CollectedGuilds) ->
Ready = maps:get(ready, State, #{}),
Relationships = map_utils:ensure_list(map_utils:get_safe(Ready, <<"relationships">>, [])),
RelUsers = [
user_utils:normalize_user(maps:get(<<"user">>, Rel, undefined))
|| Rel <- Relationships
],
Channels = maps:get(channels, State, #{}),
ChannelUsers = collect_channel_users(maps:values(Channels)),
GuildUsers = collect_guild_users(CollectedGuilds),
Users0 = [U || U <- RelUsers ++ ChannelUsers ++ GuildUsers, is_map(U)],
dedup_users(Users0).
collect_ready_presences(State, _CollectedGuilds) ->
CurrentUserId = maps:get(user_id, State),
IsBot = maps:get(bot, State, false),
{FriendIds, GdmIds} =
case IsBot of
true ->
{[], []};
false ->
FIds = presence_targets:friend_ids_from_state(State),
GdmMap = presence_targets:group_dm_recipients_from_state(State),
GIds = lists:append([
maps:keys(Recipients)
|| {_Cid, Recipients} <- maps:to_list(GdmMap)
]),
{FIds, GIds}
end,
Targets = lists:usort(FriendIds ++ GdmIds) -- [CurrentUserId],
case Targets of
[] ->
[];
_ ->
Cached = presence_cache:bulk_get(Targets),
Visible = [P || P <- Cached, presence_visible(P)],
dedup_presences(Visible)
end.
presence_user_id(P) when is_map(P) ->
User = maps:get(<<"user">>, P, #{}),
map_utils:get_integer(User, <<"id">>, undefined);
presence_user_id(_) ->
undefined.
presence_visible(P) ->
Status = maps:get(<<"status">>, P, <<"offline">>),
Status =/= <<"offline">> andalso Status =/= <<"invisible">>.
dedup_presences(Presences) ->
Map =
lists:foldl(
fun(P, Acc) ->
case presence_user_id(P) of
undefined -> Acc;
Id -> maps:put(Id, P, Acc)
end
end,
#{},
Presences
),
maps:values(Map).
collect_channel_users(Channels) ->
lists:foldl(
fun(Channel, Acc) ->
Type = maps:get(<<"type">>, Channel, 0),
case Type =:= 1 orelse Type =:= 3 of
true ->
RecipientsRaw = map_utils:ensure_list(maps:get(<<"recipients">>, Channel, [])),
Recipients = [user_utils:normalize_user(R) || R <- RecipientsRaw],
Recipients ++ Acc;
false ->
Acc
end
end,
[],
Channels
).
collect_guild_users(GuildStates) ->
lists:foldl(
fun(GuildState, Acc) ->
Members = map_utils:ensure_list(maps:get(<<"members">>, GuildState, [])),
MemberUsers = [
user_utils:normalize_user(maps:get(<<"user">>, M, undefined))
|| M <- Members
],
MemberUsers ++ Acc
end,
[],
ensure_list(GuildStates)
).
dedup_users(Users) ->
Map =
lists:foldl(
fun(U, Acc) ->
Id = maps:get(<<"id">>, U, undefined),
case Id of
undefined -> Acc;
_ -> maps:put(Id, U, Acc)
end
end,
#{},
Users
),
maps:values(Map).
ensure_list(List) when is_list(List) -> List;
ensure_list(_) -> [].
strip_users_from_guild_members(GuildState) when is_map(GuildState) ->
case maps:get(<<"unavailable">>, GuildState, false) of
true ->
GuildState;
false ->
Members = map_utils:ensure_list(maps:get(<<"members">>, GuildState, [])),
StrippedMembers = [strip_user_from_member(M) || M <- Members],
maps:put(<<"members">>, StrippedMembers, GuildState)
end;
strip_users_from_guild_members(GuildState) ->
GuildState.
strip_user_from_member(Member) when is_map(Member) ->
case maps:get(<<"user">>, Member, undefined) of
undefined ->
Member;
User when is_map(User) ->
UserId = maps:get(<<"id">>, User, undefined),
maps:put(<<"user">>, #{<<"id">> => UserId}, Member);
_ ->
Member
end;
strip_user_from_member(Member) ->
Member.
strip_user_from_relationships(ReadyData) when is_map(ReadyData) ->
Relationships = map_utils:ensure_list(maps:get(<<"relationships">>, ReadyData, [])),
StrippedRelationships = [strip_user_from_relationship(R) || R <- Relationships],
maps:put(<<"relationships">>, StrippedRelationships, ReadyData);
strip_user_from_relationships(ReadyData) ->
ReadyData.
strip_user_from_relationship(Relationship) when is_map(Relationship) ->
case maps:get(<<"user">>, Relationship, undefined) of
undefined ->
Relationship;
User when is_map(User) ->
UserId = maps:get(<<"id">>, User, undefined),
RelWithoutUser = maps:remove(<<"user">>, Relationship),
case maps:get(<<"id">>, RelWithoutUser, undefined) of
undefined -> maps:put(<<"id">>, UserId, RelWithoutUser);
_ -> RelWithoutUser
end;
_ ->
Relationship
end;
strip_user_from_relationship(Relationship) ->
Relationship.
get_private_channels(State) ->
Channels = maps:get(channels, State, #{}),
maps:filter(
fun(_ChannelId, Channel) ->
ChannelType = maps:get(<<"type">>, Channel, 0),
ChannelType =:= 1 orelse ChannelType =:= 3
end,
Channels
).
dispatch_call_creates_for_channels(PrivateChannels, SessionId, State) ->
lists:foreach(
fun({ChannelId, _Channel}) ->
dispatch_call_create_for_channel(ChannelId, SessionId, State)
end,
maps:to_list(PrivateChannels)
).
dispatch_call_create_for_channel(ChannelId, _SessionId, State) ->
try
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
{ok, CallPid} ->
dispatch_call_create_from_pid(CallPid, State);
_ ->
ok
end
catch
_:_ -> ok
end.
dispatch_call_create_from_pid(CallPid, State) ->
case gen_server:call(CallPid, {get_state}, 5000) of
{ok, CallData} ->
CreatedAt = maps:get(created_at, CallData, 0),
Now = erlang:system_time(millisecond),
CallAge = Now - CreatedAt,
case CallAge < 5000 of
true ->
ok;
false ->
ChannelIdBin = maps:get(channel_id, CallData),
case validation:validate_snowflake(<<"channel_id">>, ChannelIdBin) of
{ok, ChannelId} ->
SessionPid = self(),
gen_server:cast(SessionPid, {call_monitor, ChannelId, CallPid}),
dispatch_event(call_create, CallData, State),
SessionId = maps:get(id, State),
metrics_client:counter(<<"gateway.calls.total">>, #{
<<"channel_id">> => integer_to_binary(ChannelId),
<<"session_id">> => SessionId,
<<"status">> => <<"create">>
});
{error, _, Reason} ->
logger:warning("[session_ready] Invalid channel_id in call data: ~p", [
Reason
]),
ok
end
end;
_ ->
ok
end.
-spec bool_to_binary(term()) -> binary().
bool_to_binary(true) -> <<"true">>;
bool_to_binary(false) -> <<"false">>.

View File

@@ -0,0 +1,333 @@
%% 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(session_voice).
-export([
handle_voice_state_update/2,
handle_voice_disconnect/1,
handle_voice_token_request/8
]).
handle_voice_state_update(Data, State) ->
GuildIdRaw = maps:get(<<"guild_id">>, Data, null),
ChannelIdRaw = maps:get(<<"channel_id">>, Data, null),
ConnectionId = maps:get(<<"connection_id">>, Data, null),
SelfMute = maps:get(<<"self_mute">>, Data, false),
SelfDeaf = maps:get(<<"self_deaf">>, Data, false),
SelfVideo = maps:get(<<"self_video">>, Data, false),
SelfStream = maps:get(<<"self_stream">>, Data, false),
ViewerStreamKey = maps:get(<<"viewer_stream_key">>, Data, undefined),
IsMobile = maps:get(<<"is_mobile">>, Data, false),
Latitude = maps:get(<<"latitude">>, Data, null),
Longitude = maps:get(<<"longitude">>, Data, null),
SessionId = maps:get(id, State),
UserId = maps:get(user_id, State),
Guilds = maps:get(guilds, State),
GuildIdResult = validation:validate_optional_snowflake(GuildIdRaw),
ChannelIdResult = validation:validate_optional_snowflake(ChannelIdRaw),
case {GuildIdResult, ChannelIdResult} of
{{ok, GuildId}, {ok, ChannelId}} ->
handle_validated_voice_state_update(
GuildId,
ChannelId,
ConnectionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
Latitude,
Longitude,
SessionId,
UserId,
Guilds,
State
);
{Error = {error, _, _}, _} ->
{reply, Error, State};
{_, Error = {error, _, _}} ->
{reply, Error, State}
end.
handle_validated_voice_state_update(
null,
null,
null,
_SelfMute,
_SelfDeaf,
_SelfVideo,
_SelfStream,
_ViewerStreamKey,
_IsMobile,
_Latitude,
_Longitude,
_SessionId,
_UserId,
_Guilds,
State
) ->
handle_voice_disconnect(State);
handle_validated_voice_state_update(
null,
null,
ConnectionId,
_SelfMute,
_SelfDeaf,
_SelfVideo,
_SelfStream,
_ViewerStreamKey,
_IsMobile,
_Latitude,
_Longitude,
SessionId,
UserId,
_Guilds,
State
) when is_binary(ConnectionId) ->
Request = #{
user_id => UserId,
channel_id => null,
session_id => SessionId,
connection_id => ConnectionId,
self_mute => false,
self_deaf => false,
self_video => false,
self_stream => false,
viewer_stream_key => null,
is_mobile => false,
latitude => null,
longitude => null
},
StateWithSessionPid = maps:put(session_pid, self(), State),
case dm_voice:voice_state_update(Request, StateWithSessionPid) of
{reply, #{success := true}, NewState} ->
CleanState = maps:remove(session_pid, NewState),
{reply, ok, CleanState};
{reply, {error, Category, ErrorAtom}, _StateWithPid} ->
{reply, {error, Category, ErrorAtom}, State}
end;
handle_validated_voice_state_update(
null,
ChannelId,
ConnectionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
Latitude,
Longitude,
SessionId,
UserId,
_Guilds,
State
) when is_integer(ChannelId), (is_binary(ConnectionId) orelse ConnectionId =:= null) ->
Request = #{
user_id => UserId,
channel_id => ChannelId,
session_id => SessionId,
connection_id => ConnectionId,
self_mute => SelfMute,
self_deaf => SelfDeaf,
self_video => SelfVideo,
self_stream => SelfStream,
viewer_stream_key => ViewerStreamKey,
is_mobile => IsMobile,
latitude => Latitude,
longitude => Longitude
},
StateWithSessionPid = maps:put(session_pid, self(), State),
case dm_voice:voice_state_update(Request, StateWithSessionPid) of
{reply, #{success := true, needs_token := true}, NewState} ->
SessionPid = self(),
spawn(fun() ->
dm_voice:get_voice_token(
ChannelId, UserId, SessionId, SessionPid, Latitude, Longitude
)
end),
CleanState = maps:remove(session_pid, NewState),
{reply, ok, CleanState};
{reply, #{success := true}, NewState} ->
CleanState = maps:remove(session_pid, NewState),
{reply, ok, CleanState};
{reply, {error, Category, ErrorAtom}, _StateWithPid} ->
{reply, {error, Category, ErrorAtom}, State}
end;
handle_validated_voice_state_update(
GuildId,
ChannelId,
ConnectionId,
SelfMute,
SelfDeaf,
SelfVideo,
SelfStream,
ViewerStreamKey,
IsMobile,
Latitude,
Longitude,
SessionId,
UserId,
Guilds,
State
) when is_integer(GuildId) ->
case maps:get(GuildId, Guilds, undefined) of
undefined ->
logger:warning("[session_voice] Guild not found in session: ~p", [GuildId]),
{reply, gateway_errors:error(voice_guild_not_found), State};
{GuildPid, _Ref} when is_pid(GuildPid) ->
Request = #{
user_id => UserId,
channel_id => ChannelId,
session_id => SessionId,
connection_id => ConnectionId,
self_mute => SelfMute,
self_deaf => SelfDeaf,
self_video => SelfVideo,
self_stream => SelfStream,
viewer_stream_key => ViewerStreamKey,
is_mobile => IsMobile,
latitude => Latitude,
longitude => Longitude
},
logger:debug(
"[session_voice] Calling guild process for voice state update: GuildId=~p, ChannelId=~p, ConnectionId=~p",
[GuildId, ChannelId, ConnectionId]
),
case guild_client:voice_state_update(GuildPid, Request, 12000) of
{ok, #{needs_token := true}} ->
logger:debug("[session_voice] Voice state update succeeded, needs token"),
SessionPid = self(),
spawn(fun() ->
handle_voice_token_request(
GuildId,
ChannelId,
UserId,
ConnectionId,
SessionId,
SessionPid,
Latitude,
Longitude
)
end),
{reply, ok, State};
{ok, _} ->
logger:debug("[session_voice] Voice state update succeeded"),
{reply, ok, State};
{error, timeout} ->
logger:error(
"[session_voice] Voice state update timed out (>12s) for GuildId=~p, ChannelId=~p",
[GuildId, ChannelId]
),
{reply, gateway_errors:error(timeout), State};
{error, noproc} ->
logger:error(
"[session_voice] Guild process not running for GuildId=~p",
[GuildId]
),
{reply, gateway_errors:error(internal_error), State};
{error, Category, ErrorAtom} ->
logger:warning("[session_voice] Voice state update failed: ~p", [ErrorAtom]),
{reply, {error, Category, ErrorAtom}, State}
end;
_ ->
logger:warning("[session_voice] Invalid guild pid in session"),
{reply, gateway_errors:error(internal_error), State}
end;
handle_validated_voice_state_update(
GuildId,
ChannelId,
ConnectionId,
_SelfMute,
_SelfDeaf,
_SelfVideo,
_SelfStream,
_ViewerStreamKey,
_IsMobile,
_Latitude,
_Longitude,
_SessionId,
_UserId,
_Guilds,
State
) ->
logger:warning(
"[session_voice] Invalid voice state update parameters: GuildId=~p, ChannelId=~p, ConnectionId=~p",
[GuildId, ChannelId, ConnectionId]
),
{reply, gateway_errors:error(validation_invalid_params), State}.
handle_voice_disconnect(State) ->
Guilds = maps:get(guilds, State),
UserId = maps:get(user_id, State),
SessionId = maps:get(id, State),
ConnectionId = maps:get(connection_id, State),
lists:foreach(
fun
({_GuildId, {GuildPid, _Ref}}) when is_pid(GuildPid) ->
Request = #{
user_id => UserId,
channel_id => null,
session_id => SessionId,
connection_id => ConnectionId,
self_mute => false,
self_deaf => false,
self_video => false,
self_stream => false,
viewer_stream_key => null
},
_ = guild_client:voice_state_update(GuildPid, Request, 10000);
(_) ->
ok
end,
maps:to_list(Guilds)
),
{reply, #{success := true}, NewState} = dm_voice:disconnect_voice_user(UserId, State),
{reply, ok, NewState}.
handle_voice_token_request(
GuildId, ChannelId, UserId, ConnectionId, _SessionId, SessionPid, Latitude, Longitude
) ->
Req = voice_utils:build_voice_token_rpc_request(
GuildId, ChannelId, UserId, ConnectionId, Latitude, Longitude
),
case rpc_client:call(Req) of
{ok, Data} ->
Token = maps:get(<<"token">>, Data),
Endpoint = maps:get(<<"endpoint">>, Data),
VoiceServerUpdate = #{
<<"token">> => Token,
<<"endpoint">> => Endpoint,
<<"guild_id">> => integer_to_binary(GuildId),
<<"connection_id">> => ConnectionId
},
gen_server:cast(SessionPid, {dispatch, voice_server_update, VoiceServerUpdate});
{error, _Reason} ->
ok
end.

View File

@@ -0,0 +1,411 @@
%% 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(gateway_metrics_collector).
-behaviour(gen_server).
-export([start_link/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-export([
inc_connections/0,
inc_disconnections/0,
inc_heartbeat_success/0,
inc_heartbeat_failure/0,
inc_resume_success/0,
inc_resume_failure/0,
inc_identify_rate_limited/0,
record_rpc_latency/1,
inc_websocket_close/1
]).
-type state() :: #{
report_interval_ms := pos_integer(),
timer_ref := reference() | undefined,
connections := non_neg_integer(),
disconnections := non_neg_integer(),
heartbeat_success := non_neg_integer(),
heartbeat_failure := non_neg_integer(),
resume_success := non_neg_integer(),
resume_failure := non_neg_integer(),
identify_rate_limited := non_neg_integer(),
rpc_latencies := [non_neg_integer()]
}.
-define(DEFAULT_REPORT_INTERVAL_MS, 30000).
-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-spec init(list()) -> {ok, state()}.
init([]) ->
Enabled = get_enabled(),
ReportInterval = get_report_interval(),
BaseState = #{
report_interval_ms => ReportInterval,
timer_ref => undefined,
connections => 0,
disconnections => 0,
heartbeat_success => 0,
heartbeat_failure => 0,
resume_success => 0,
resume_failure => 0,
identify_rate_limited => 0,
rpc_latencies => []
},
case Enabled of
true ->
logger:info("[gateway_metrics_collector] starting with ~p ms interval", [ReportInterval]),
TimerRef = schedule_collection(ReportInterval),
{ok, BaseState#{timer_ref := TimerRef}};
false ->
logger:info("[gateway_metrics_collector] disabled"),
{ok, BaseState}
end.
-spec handle_call(term(), gen_server:from(), state()) -> {reply, term(), state()}.
handle_call(_Request, _From, State) ->
{reply, ok, State}.
-spec handle_cast(term(), state()) -> {noreply, state()}.
handle_cast(inc_connections, #{connections := Connections} = State) ->
{noreply, State#{connections := Connections + 1}};
handle_cast(inc_disconnections, #{disconnections := Disconnections} = State) ->
{noreply, State#{disconnections := Disconnections + 1}};
handle_cast(inc_heartbeat_success, #{heartbeat_success := HeartbeatSuccess} = State) ->
{noreply, State#{heartbeat_success := HeartbeatSuccess + 1}};
handle_cast(inc_heartbeat_failure, #{heartbeat_failure := HeartbeatFailure} = State) ->
{noreply, State#{heartbeat_failure := HeartbeatFailure + 1}};
handle_cast(inc_resume_success, #{resume_success := ResumeSuccess} = State) ->
{noreply, State#{resume_success := ResumeSuccess + 1}};
handle_cast(inc_resume_failure, #{resume_failure := ResumeFailure} = State) ->
{noreply, State#{resume_failure := ResumeFailure + 1}};
handle_cast(inc_identify_rate_limited, #{identify_rate_limited := IdentifyRateLimited} = State) ->
{noreply, State#{identify_rate_limited := IdentifyRateLimited + 1}};
handle_cast({record_rpc_latency, LatencyMs}, #{rpc_latencies := Latencies} = State) ->
MaxLatencies = 1000,
NewLatencies = case length(Latencies) >= MaxLatencies of
true -> [LatencyMs | lists:sublist(Latencies, MaxLatencies - 1)];
false -> [LatencyMs | Latencies]
end,
{noreply, State#{rpc_latencies := NewLatencies}};
handle_cast(_Msg, State) ->
{noreply, State}.
-spec handle_info(term(), state()) -> {noreply, state()}.
handle_info(collect_and_report, #{report_interval_ms := Interval} = State) ->
collect_and_report_metrics(State),
TimerRef = schedule_collection(Interval),
ResetState = State#{
timer_ref := TimerRef,
connections := 0,
disconnections := 0,
heartbeat_success := 0,
heartbeat_failure := 0,
resume_success := 0,
resume_failure := 0,
identify_rate_limited := 0,
rpc_latencies := []
},
{noreply, ResetState};
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(term(), state()) -> ok.
terminate(_Reason, #{timer_ref := TimerRef}) ->
case TimerRef of
undefined -> ok;
Ref -> erlang:cancel_timer(Ref)
end,
ok.
-spec code_change(term(), state() | tuple(), term()) -> {ok, state()}.
code_change(_OldVsn, {state, ReportIntervalMs, TimerRef, Connections, Disconnections,
HeartbeatSuccess, HeartbeatFailure, ResumeSuccess, ResumeFailure,
IdentifyRateLimited, RpcLatencies}, _Extra) ->
{ok, #{
report_interval_ms => ReportIntervalMs,
timer_ref => TimerRef,
connections => Connections,
disconnections => Disconnections,
heartbeat_success => HeartbeatSuccess,
heartbeat_failure => HeartbeatFailure,
resume_success => ResumeSuccess,
resume_failure => ResumeFailure,
identify_rate_limited => IdentifyRateLimited,
rpc_latencies => RpcLatencies
}};
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
-spec schedule_collection(pos_integer()) -> reference().
schedule_collection(IntervalMs) ->
erlang:send_after(IntervalMs, self(), collect_and_report).
-spec get_enabled() -> boolean().
get_enabled() ->
case fluxer_gateway_env:get(gateway_metrics_enabled) of
false -> false;
_ -> metrics_client:is_enabled()
end.
-spec get_report_interval() -> pos_integer().
get_report_interval() ->
case fluxer_gateway_env:get(gateway_metrics_report_interval_ms) of
Value when is_integer(Value), Value > 0 -> Value;
_ -> ?DEFAULT_REPORT_INTERVAL_MS
end.
-spec collect_and_report_metrics(state()) -> ok.
collect_and_report_metrics(State) ->
Gauges = lists:flatten([
collect_process_counts(),
collect_mailbox_sizes(),
collect_memory_stats(),
collect_system_stats(),
collect_event_metrics(State)
]),
case Gauges of
[] -> ok;
_ -> metrics_client:batch(Gauges)
end.
-spec collect_event_metrics(state()) -> [map()].
collect_event_metrics(State) ->
#{
rpc_latencies := RpcLatencies,
connections := Connections,
disconnections := Disconnections,
heartbeat_success := HeartbeatSuccess,
heartbeat_failure := HeartbeatFailure,
resume_success := ResumeSuccess,
resume_failure := ResumeFailure,
identify_rate_limited := IdentifyRateLimited
} = State,
RpcLatencyStats = calculate_latency_stats(RpcLatencies),
lists:flatten([
[gauge(<<"gateway.websocket.connections">>, Connections)],
[gauge(<<"gateway.websocket.disconnections">>, Disconnections)],
[gauge(<<"gateway.heartbeat.success">>, HeartbeatSuccess)],
[gauge(<<"gateway.heartbeat.failure">>, HeartbeatFailure)],
[gauge(<<"gateway.resume.success">>, ResumeSuccess)],
[gauge(<<"gateway.resume.failure">>, ResumeFailure)],
[gauge(<<"gateway.identify.rate_limited">>, IdentifyRateLimited)],
RpcLatencyStats
]).
-spec calculate_latency_stats([non_neg_integer()]) -> [map()].
calculate_latency_stats([]) ->
[];
calculate_latency_stats(Latencies) ->
Sorted = lists:sort(Latencies),
Count = length(Sorted),
Sum = lists:sum(Sorted),
Avg = Sum / Count,
Min = hd(Sorted),
Max = lists:last(Sorted),
P50 = percentile(Sorted, 50),
P95 = percentile(Sorted, 95),
P99 = percentile(Sorted, 99),
[
gauge(<<"gateway.rpc.latency.avg">>, Avg),
gauge(<<"gateway.rpc.latency.min">>, Min),
gauge(<<"gateway.rpc.latency.max">>, Max),
gauge(<<"gateway.rpc.latency.p50">>, P50),
gauge(<<"gateway.rpc.latency.p95">>, P95),
gauge(<<"gateway.rpc.latency.p99">>, P99),
gauge(<<"gateway.rpc.latency.count">>, Count)
].
-spec percentile([number()], number()) -> number().
percentile(SortedList, Percent) ->
Len = length(SortedList),
Index = max(1, min(Len, round(Len * Percent / 100))),
lists:nth(Index, SortedList).
-spec collect_process_counts() -> [map()].
collect_process_counts() ->
SessionCount = get_manager_count(session_manager),
GuildCount = get_manager_count(guild_manager),
PresenceCount = get_manager_count(presence_manager),
CallCount = get_manager_count(call_manager),
[
gauge(<<"gateway.sessions.count">>, SessionCount),
gauge(<<"gateway.guilds.count">>, GuildCount),
gauge(<<"gateway.presences.count">>, PresenceCount),
gauge(<<"gateway.calls.count">>, CallCount)
].
-spec get_manager_count(atom()) -> non_neg_integer().
get_manager_count(Manager) ->
case catch gen_server:call(Manager, get_global_count, 1000) of
{ok, Count} when is_integer(Count) -> Count;
Count when is_integer(Count) -> Count;
_ -> 0
end.
-spec collect_mailbox_sizes() -> [map()].
collect_mailbox_sizes() ->
Managers = [
{session_manager, <<"gateway.mailbox.session_manager">>},
{guild_manager, <<"gateway.mailbox.guild_manager">>},
{presence_manager, <<"gateway.mailbox.presence_manager">>},
{call_manager, <<"gateway.mailbox.call_manager">>},
{push, <<"gateway.mailbox.push">>},
{presence_cache, <<"gateway.mailbox.presence_cache">>},
{presence_bus, <<"gateway.mailbox.presence_bus">>}
],
MailboxMetrics = lists:filtermap(fun({Manager, MetricName}) ->
case get_mailbox_size(Manager) of
undefined -> false;
Size -> {true, gauge(MetricName, Size)}
end
end, Managers),
TotalMailbox = lists:foldl(fun({Manager, _}, Acc) ->
case get_mailbox_size(Manager) of
undefined -> Acc;
Size -> Acc + Size
end
end, 0, Managers),
[gauge(<<"gateway.mailbox.total">>, TotalMailbox) | MailboxMetrics].
-spec get_mailbox_size(atom()) -> non_neg_integer() | undefined.
get_mailbox_size(Manager) ->
case whereis(Manager) of
undefined -> undefined;
Pid ->
case erlang:process_info(Pid, message_queue_len) of
{message_queue_len, Size} -> Size;
undefined -> undefined
end
end.
-spec collect_memory_stats() -> [map()].
collect_memory_stats() ->
PresenceCacheMemory = get_presence_cache_memory(),
PushMemory = get_push_process_memory(),
GuildMemoryStats = collect_guild_memory_stats(),
lists:flatten([
[gauge(<<"gateway.memory.presence_cache">>, PresenceCacheMemory)],
[gauge(<<"gateway.memory.push">>, PushMemory)],
GuildMemoryStats
]).
-spec collect_guild_memory_stats() -> [map()].
collect_guild_memory_stats() ->
case catch process_memory_stats:get_guild_memory_stats(10000) of
GuildStats when is_list(GuildStats), length(GuildStats) > 0 ->
Memories = [maps:get(memory, G, 0) || G <- GuildStats],
TotalMemory = lists:sum(Memories),
GuildCount = length(Memories),
AvgMemory = TotalMemory / GuildCount,
MaxMemory = lists:max(Memories),
MinMemory = lists:min(Memories),
[
gauge(<<"gateway.memory.guilds.total">>, TotalMemory),
gauge(<<"gateway.memory.guilds.count">>, GuildCount),
gauge(<<"gateway.memory.guilds.avg">>, AvgMemory),
gauge(<<"gateway.memory.guilds.max">>, MaxMemory),
gauge(<<"gateway.memory.guilds.min">>, MinMemory)
];
_ ->
[]
end.
-spec get_presence_cache_memory() -> non_neg_integer().
get_presence_cache_memory() ->
case catch presence_cache:get_memory_stats() of
{ok, #{memory_bytes := Bytes}} -> Bytes;
_ -> 0
end.
-spec get_push_process_memory() -> non_neg_integer().
get_push_process_memory() ->
case whereis(push) of
undefined -> 0;
Pid ->
case erlang:process_info(Pid, memory) of
{memory, Bytes} -> Bytes;
undefined -> 0
end
end.
-spec collect_system_stats() -> [map()].
collect_system_stats() ->
{TotalMemory, ProcessMemory, SystemMemory} = get_memory_info(),
ProcessCount = erlang:system_info(process_count),
[
gauge(<<"gateway.memory.total">>, TotalMemory),
gauge(<<"gateway.memory.processes">>, ProcessMemory),
gauge(<<"gateway.memory.system">>, SystemMemory),
gauge(<<"gateway.process_count">>, ProcessCount)
].
-spec get_memory_info() -> {non_neg_integer(), non_neg_integer(), non_neg_integer()}.
get_memory_info() ->
MemData = erlang:memory(),
Total = proplists:get_value(total, MemData, 0),
Processes = proplists:get_value(processes, MemData, 0),
System = proplists:get_value(system, MemData, 0),
{Total, Processes, System}.
-spec gauge(binary(), number()) -> map().
gauge(Name, Value) ->
#{
type => gauge,
name => Name,
dimensions => #{},
value => Value
}.
-spec inc_connections() -> ok.
inc_connections() ->
gen_server:cast(?MODULE, inc_connections).
-spec inc_disconnections() -> ok.
inc_disconnections() ->
gen_server:cast(?MODULE, inc_disconnections).
-spec inc_heartbeat_success() -> ok.
inc_heartbeat_success() ->
gen_server:cast(?MODULE, inc_heartbeat_success).
-spec inc_heartbeat_failure() -> ok.
inc_heartbeat_failure() ->
gen_server:cast(?MODULE, inc_heartbeat_failure).
-spec inc_resume_success() -> ok.
inc_resume_success() ->
gen_server:cast(?MODULE, inc_resume_success).
-spec inc_resume_failure() -> ok.
inc_resume_failure() ->
gen_server:cast(?MODULE, inc_resume_failure).
-spec inc_identify_rate_limited() -> ok.
inc_identify_rate_limited() ->
gen_server:cast(?MODULE, inc_identify_rate_limited).
-spec record_rpc_latency(non_neg_integer()) -> ok.
record_rpc_latency(LatencyMs) ->
gen_server:cast(?MODULE, {record_rpc_latency, LatencyMs}).
-spec inc_websocket_close(atom()) -> ok.
inc_websocket_close(Reason) ->
ReasonBin = atom_to_binary(Reason, utf8),
metrics_client:counter(<<"gateway.websocket.close">>, #{<<"reason">> => ReasonBin}).

View File

@@ -0,0 +1,165 @@
%% 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(metrics_client).
-export([
counter/1,
counter/2,
gauge/2,
gauge/3,
histogram/2,
histogram/3,
crash/2,
batch/1,
is_enabled/0
]).
-spec counter(binary()) -> ok.
counter(Name) ->
counter(Name, #{}).
-spec counter(binary(), map()) -> ok.
counter(Name, Dimensions) ->
fire_and_forget(<<"/metrics/counter">>, #{
<<"name">> => Name,
<<"dimensions">> => Dimensions,
<<"value">> => 1
}).
-spec gauge(binary(), number()) -> ok.
gauge(Name, Value) ->
gauge(Name, #{}, Value).
-spec gauge(binary(), map(), number()) -> ok.
gauge(Name, Dimensions, Value) ->
fire_and_forget(<<"/metrics/gauge">>, #{
<<"name">> => Name,
<<"dimensions">> => Dimensions,
<<"value">> => Value
}).
-spec histogram(binary(), number()) -> ok.
histogram(Name, ValueMs) ->
histogram(Name, #{}, ValueMs).
-spec histogram(binary(), map(), number()) -> ok.
histogram(Name, Dimensions, ValueMs) ->
fire_and_forget(<<"/metrics/histogram">>, #{
<<"name">> => Name,
<<"dimensions">> => Dimensions,
<<"value_ms">> => ValueMs
}).
-spec crash(binary(), binary()) -> ok.
crash(GuildId, Stacktrace) ->
fire_and_forget(<<"/metrics/crash">>, #{
<<"guild_id">> => GuildId,
<<"stacktrace">> => Stacktrace
}).
-spec is_enabled() -> boolean().
is_enabled() ->
case metrics_host() of
Host when is_list(Host), Host =/= "" -> true;
Host when is_binary(Host), byte_size(Host) > 0 -> true;
_ -> false
end.
fire_and_forget(Path, Body) ->
case metrics_host() of
Host when is_list(Host), Host =/= "" ->
spawn(fun() -> do_send(Host, Path, Body) end),
ok;
Host when is_binary(Host), byte_size(Host) > 0 ->
spawn(fun() -> do_send(Host, Path, Body) end),
ok;
_ ->
ok
end.
do_send(Host, Path, Body) ->
do_send(Host, Path, Body, 0).
do_send(Host, Path, Body, Attempt) ->
Url = iolist_to_binary(["http://", Host, Path]),
Headers = [{<<"Content-Type">>, <<"application/json">>}],
JsonBody = jsx:encode(Body),
MaxRetries = 1,
case
hackney:request(post, Url, Headers, JsonBody, [
{recv_timeout, 5000}, {connect_timeout, 2000}
])
of
{ok, StatusCode, _RespHeaders, ClientRef} when StatusCode >= 200, StatusCode < 300 ->
hackney:skip_body(ClientRef),
ok;
{ok, StatusCode, _RespHeaders, ClientRef} ->
hackney:skip_body(ClientRef),
case Attempt < MaxRetries of
true ->
do_send(Host, Path, Body, Attempt + 1);
false ->
logger:warning("Failed to send metric after ~p attempts: ~p ~s", [Attempt + 1, StatusCode, Path]),
ok
end;
{error, Reason} ->
case Attempt < MaxRetries of
true ->
do_send(Host, Path, Body, Attempt + 1);
false ->
logger:warning("Failed to send metric after ~p attempts: ~p ~s", [Attempt + 1, Reason, Path]),
ok
end
end.
-spec batch([map()]) -> ok.
batch(Metrics) when is_list(Metrics) ->
case metrics_host() of
Host when is_list(Host), Host =/= "" ->
spawn(fun() -> do_batch(Host, Metrics) end),
ok;
Host when is_binary(Host), byte_size(Host) > 0 ->
spawn(fun() -> do_batch(Host, Metrics) end),
ok;
_ ->
ok
end.
do_batch(Host, Metrics) ->
Gauges = [format_gauge(M) || M <- Metrics, maps:get(type, M, undefined) =:= gauge],
Counters = [format_counter(M) || M <- Metrics, maps:get(type, M, undefined) =:= counter],
Histograms = [format_histogram(M) || M <- Metrics, maps:get(type, M, undefined) =:= histogram],
Body = #{
<<"gauges">> => Gauges,
<<"counters">> => Counters,
<<"histograms">> => Histograms
},
do_send(Host, <<"/metrics/batch">>, Body).
format_gauge(#{name := Name, dimensions := Dims, value := Value}) ->
#{<<"name">> => Name, <<"dimensions">> => Dims, <<"value">> => Value}.
format_counter(#{name := Name, dimensions := Dims, value := Value}) ->
#{<<"name">> => Name, <<"dimensions">> => Dims, <<"value">> => Value}.
format_histogram(#{name := Name, dimensions := Dims, value := Value}) ->
#{<<"name">> => Name, <<"dimensions">> => Dims, <<"value_ms">> => Value}.
metrics_host() ->
fluxer_gateway_env:get(metrics_host).

View File

@@ -0,0 +1,97 @@
%% 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(process_memory_stats).
-export([get_guild_memory_stats/1]).
get_guild_memory_stats(Limit) ->
AllProcesses = erlang:processes(),
GuildProcessInfos = lists:filtermap(
fun(Pid) ->
case get_guild_process_info(Pid) of
undefined -> false;
Info -> {true, Info}
end
end,
AllProcesses
),
Sorted = lists:sort(
fun(#{memory := M1}, #{memory := M2}) -> M1 >= M2 end,
GuildProcessInfos
),
lists:sublist(Sorted, Limit).
get_guild_process_info(Pid) ->
case erlang:process_info(Pid, [registered_name, memory, initial_call, dictionary]) of
undefined ->
undefined;
InfoList ->
Memory = proplists:get_value(memory, InfoList, 0),
InitialCall = proplists:get_value(initial_call, InfoList),
Dictionary = proplists:get_value(dictionary, InfoList, []),
Module =
case lists:keyfind('$initial_call', 1, Dictionary) of
{'$initial_call', {M, _, _}} ->
M;
_ ->
case InitialCall of
{M, _, _} -> M;
_ -> undefined
end
end,
case Module of
guild ->
case catch sys:get_state(Pid, 100) of
State when is_map(State) ->
GuildId = maps:get(id, State, undefined),
Data = maps:get(data, State, #{}),
Guild = maps:get(<<"guild">>, Data, #{}),
GuildName = maps:get(<<"name">>, Guild, <<"Unknown">>),
GuildIcon = maps:get(<<"icon">>, Guild, null),
Members = maps:get(<<"members">>, Data, []),
MemberCount = length(Members),
SessionCount = map_size(maps:get(sessions, State, #{})),
PresenceCount = map_size(maps:get(presences, State, #{})),
#{
guild_id =>
case GuildId of
undefined -> null;
Id -> integer_to_binary(Id)
end,
guild_name => GuildName,
guild_icon => GuildIcon,
memory => Memory,
member_count => MemberCount,
session_count => SessionCount,
presence_count => PresenceCount
};
_ ->
undefined
end;
_ ->
undefined
end
end.

View File

@@ -0,0 +1,664 @@
%% 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(process_registry).
-export([
build_process_name/2,
register_and_monitor/3,
lookup_or_monitor/3,
safe_unregister/1,
cleanup_on_down/2,
get_count/1
]).
-type process_id() :: integer() | binary() | string().
-type process_prefix() :: atom() | string().
-type process_map() :: #{term() => {pid(), reference()} | loading}.
-type register_result() :: {ok, pid(), reference(), process_map()} | {error, term()}.
-type lookup_result() :: {ok, pid(), reference(), process_map()} | {error, not_found}.
-export_type([process_id/0, process_prefix/0, process_map/0]).
-spec build_process_name(process_prefix(), process_id()) -> atom().
build_process_name(Prefix, Id) when is_atom(Prefix), is_integer(Id) ->
list_to_atom(atom_to_list(Prefix) ++ "_" ++ integer_to_list(Id));
build_process_name(Prefix, Id) when is_atom(Prefix), is_binary(Id) ->
list_to_atom(atom_to_list(Prefix) ++ "_" ++ binary_to_list(Id));
build_process_name(Prefix, Id) when is_atom(Prefix), is_list(Id) ->
list_to_atom(atom_to_list(Prefix) ++ "_" ++ Id);
build_process_name(Prefix, Id) when is_list(Prefix), is_integer(Id) ->
list_to_atom(Prefix ++ "_" ++ integer_to_list(Id));
build_process_name(Prefix, Id) when is_list(Prefix), is_binary(Id) ->
list_to_atom(Prefix ++ "_" ++ binary_to_list(Id));
build_process_name(Prefix, Id) when is_list(Prefix), is_list(Id) ->
list_to_atom(Prefix ++ "_" ++ Id).
-spec register_and_monitor(atom(), pid(), process_map()) -> register_result().
register_and_monitor(Name, Pid, ProcessMap) ->
try
register(Name, Pid),
Ref = monitor(process, Pid),
NewMap = maps:put(Name, {Pid, Ref}, ProcessMap),
{ok, Pid, Ref, NewMap}
catch
error:badarg ->
catch gen_server:stop(Pid, normal, 5000),
case whereis(Name) of
undefined ->
{error, registration_race_condition};
ExistingPid ->
ExistingRef = monitor(process, ExistingPid),
ExistingMap = maps:put(Name, {ExistingPid, ExistingRef}, ProcessMap),
{ok, ExistingPid, ExistingRef, ExistingMap}
end;
Error:Reason ->
{error, {Error, Reason}}
end.
-spec lookup_or_monitor(atom(), term(), process_map()) -> lookup_result().
lookup_or_monitor(Name, Key, ProcessMap) ->
case whereis(Name) of
undefined ->
{error, not_found};
Pid ->
Ref = monitor(process, Pid),
NewMap = maps:put(Key, {Pid, Ref}, ProcessMap),
{ok, Pid, Ref, NewMap}
end.
-spec safe_unregister(atom()) -> ok.
safe_unregister(Name) ->
try
unregister(Name),
ok
catch
error:badarg ->
ok;
_:_ ->
ok
end.
-spec cleanup_on_down(pid(), process_map()) -> process_map().
cleanup_on_down(DeadPid, ProcessMap) ->
maps:filter(
fun
(_Key, loading) ->
true;
(_Key, {Pid, _Ref}) ->
Pid =/= DeadPid
end,
ProcessMap
).
-spec get_count(process_map()) -> non_neg_integer().
get_count(ProcessMap) ->
maps:size(
maps:filter(
fun
(_Key, loading) -> false;
(_Key, {_Pid, _Ref}) -> true
end,
ProcessMap
)
).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
build_process_name_integer_atom_test() ->
?assertEqual('guild_123456', build_process_name(guild, 123456)),
?assertEqual('channel_0', build_process_name(channel, 0)),
?assertEqual('voice_999', build_process_name(voice, 999)).
build_process_name_integer_string_test() ->
?assertEqual('channel_999', build_process_name("channel", 999)),
?assertEqual('guild_12345', build_process_name("guild", 12345)),
?assertEqual('voice_0', build_process_name("voice", 0)).
build_process_name_binary_atom_test() ->
?assertEqual('guild_123456', build_process_name(guild, <<"123456">>)),
?assertEqual('voice_789', build_process_name(voice, <<"789">>)),
?assertEqual('channel_abc', build_process_name(channel, <<"abc">>)).
build_process_name_binary_string_test() ->
?assertEqual('voice_789', build_process_name("voice", <<"789">>)),
?assertEqual('guild_test', build_process_name("guild", <<"test">>)),
?assertEqual('channel_123', build_process_name("channel", <<"123">>)).
build_process_name_string_atom_test() ->
?assertEqual('guild_123456', build_process_name(guild, "123456")),
?assertEqual('channel_abc', build_process_name(channel, "abc")),
?assertEqual('voice_xyz', build_process_name(voice, "xyz")).
build_process_name_string_string_test() ->
?assertEqual('channel_abc', build_process_name("channel", "abc")),
?assertEqual('guild_test', build_process_name("guild", "test")),
?assertEqual('voice_123', build_process_name("voice", "123")).
build_process_name_special_chars_test() ->
?assertEqual('guild_123_456', build_process_name(guild, "123_456")),
?assertEqual('channel_test-channel', build_process_name(channel, "test-channel")).
register_and_monitor_success_test() ->
Name = test_process_reg_success,
ProcessMap = #{},
Pid = spawn(fun() -> timer:sleep(100) end),
Result = register_and_monitor(Name, Pid, ProcessMap),
?assertMatch({ok, Pid, _Ref, _NewMap}, Result),
{ok, ReturnedPid, Ref, NewMap} = Result,
?assertEqual(Pid, ReturnedPid),
?assertEqual(Pid, whereis(Name)),
?assertEqual(1, maps:size(NewMap)),
?assertEqual({Pid, Ref}, maps:get(Name, NewMap)),
?assert(is_reference(Ref)),
unregister(Name).
register_and_monitor_existing_map_test() ->
Name = test_process_reg_existing,
ExistingPid = list_to_pid("<0.100.0>"),
ExistingRef = make_ref(),
ProcessMap = #{other_process => {ExistingPid, ExistingRef}},
Pid = spawn(fun() -> timer:sleep(100) end),
{ok, _ReturnedPid, _Ref, NewMap} = register_and_monitor(Name, Pid, ProcessMap),
?assertEqual(2, maps:size(NewMap)),
?assert(maps:is_key(other_process, NewMap)),
?assert(maps:is_key(Name, NewMap)),
unregister(Name).
register_and_monitor_race_condition_test() ->
Name = test_process_race,
ProcessMap = #{},
WinnerPid = spawn(fun() -> timer:sleep(200) end),
register(Name, WinnerPid),
LoserPid = spawn(fun() -> timer:sleep(100) end),
Result = register_and_monitor(Name, LoserPid, ProcessMap),
?assertMatch({ok, WinnerPid, _Ref, _NewMap}, Result),
{ok, ReturnedPid, Ref, NewMap} = Result,
?assertEqual(WinnerPid, ReturnedPid),
?assertEqual(WinnerPid, whereis(Name)),
timer:sleep(50),
?assertEqual(false, is_process_alive(LoserPid)),
?assertEqual({WinnerPid, Ref}, maps:get(Name, NewMap)),
unregister(Name).
register_and_monitor_race_dead_test() ->
Name = test_process_race_dead,
ProcessMap = #{},
DeadPid = spawn(fun() -> ok end),
timer:sleep(10),
?assertEqual(false, is_process_alive(DeadPid)),
NewPid = spawn(fun() -> timer:sleep(100) end),
Result = register_and_monitor(Name, NewPid, ProcessMap),
?assertMatch({ok, NewPid, _Ref, _NewMap}, Result),
catch unregister(Name).
register_and_monitor_dead_process_test() ->
Name = test_process_dead,
ProcessMap = #{},
DeadPid = spawn(fun() -> exit(normal) end),
timer:sleep(10),
?assertEqual(false, is_process_alive(DeadPid)),
Result = register_and_monitor(Name, DeadPid, ProcessMap),
case Result of
{ok, DeadPid, _Ref, _NewMap} ->
?assertEqual(DeadPid, whereis(Name)),
catch unregister(Name);
{error, _} ->
ok
end.
register_and_monitor_concurrent_test_() ->
{timeout, 10, fun() ->
Name = test_process_concurrent,
Parent = self(),
Pids = [
spawn(fun() ->
Pid = spawn(fun() -> timer:sleep(200) end),
Result = register_and_monitor(Name, Pid, #{}),
Parent ! {self(), Result}
end)
|| _ <- lists:seq(1, 5)
],
Results = [
receive
{P, R} -> R
after 2000 -> timeout
end
|| P <- Pids
],
?assertEqual(5, length(Results)),
SuccessResults = [R || R <- Results, element(1, R) =:= ok],
RaceErrors = [R || R <- Results, R =:= {error, registration_race_condition}],
Timeouts = [R || R <- Results, R =:= timeout],
?assertEqual(0, length(Timeouts)),
?assert(length(SuccessResults) >= 1),
?assertEqual(5, length(SuccessResults) + length(RaceErrors)),
case SuccessResults of
[] ->
?assert(false);
[{ok, FirstPid, _, _} | RestResults] ->
AllSamePid = lists:all(
fun
({ok, P, _, _}) -> P =:= FirstPid;
(_) -> false
end,
RestResults
),
?assert(AllSamePid)
end,
catch unregister(Name)
end}.
lookup_or_monitor_success_test() ->
Name = test_lookup_success,
Key = test_key,
ProcessMap = #{},
Pid = spawn(fun() -> timer:sleep(200) end),
register(Name, Pid),
Result = lookup_or_monitor(Name, Key, ProcessMap),
?assertMatch({ok, Pid, _Ref, _NewMap}, Result),
{ok, ReturnedPid, Ref, NewMap} = Result,
?assertEqual(Pid, ReturnedPid),
?assert(is_reference(Ref)),
?assertEqual({Pid, Ref}, maps:get(Key, NewMap)),
unregister(Name).
lookup_or_monitor_not_found_test() ->
Name = test_lookup_not_found_99999,
Key = test_key,
ProcessMap = #{},
Result = lookup_or_monitor(Name, Key, ProcessMap),
?assertEqual({error, not_found}, Result).
lookup_or_monitor_existing_map_test() ->
Name = test_lookup_existing,
Key = new_key,
ExistingPid = list_to_pid("<0.100.0>"),
ExistingRef = make_ref(),
ProcessMap = #{existing_key => {ExistingPid, ExistingRef}},
Pid = spawn(fun() -> timer:sleep(200) end),
register(Name, Pid),
{ok, _ReturnedPid, _Ref, NewMap} = lookup_or_monitor(Name, Key, ProcessMap),
?assertEqual(2, maps:size(NewMap)),
?assert(maps:is_key(existing_key, NewMap)),
?assert(maps:is_key(Key, NewMap)),
unregister(Name).
lookup_or_monitor_different_key_test() ->
Name = test_lookup_diff_key,
Key = different_key_name,
ProcessMap = #{},
Pid = spawn(fun() -> timer:sleep(200) end),
register(Name, Pid),
{ok, _ReturnedPid, Ref, NewMap} = lookup_or_monitor(Name, Key, ProcessMap),
?assertEqual({Pid, Ref}, maps:get(Key, NewMap)),
?assertEqual(false, maps:is_key(Name, NewMap)),
unregister(Name).
lookup_or_monitor_dead_process_test() ->
Name = test_lookup_dead,
Key = test_key,
ProcessMap = #{},
Pid = spawn(fun() -> ok end),
register(Name, Pid),
timer:sleep(10),
Result = lookup_or_monitor(Name, Key, ProcessMap),
?assertEqual({error, not_found}, Result).
safe_unregister_registered_test() ->
Name = test_safe_unreg_registered,
Pid = spawn(fun() -> timer:sleep(100) end),
register(Name, Pid),
?assertEqual(Pid, whereis(Name)),
?assertEqual(ok, safe_unregister(Name)),
?assertEqual(undefined, whereis(Name)).
safe_unregister_unregistered_test() ->
?assertEqual(ok, safe_unregister(nonexistent_process_name_12345)).
safe_unregister_multiple_test() ->
Name = test_safe_unreg_multiple,
Pid = spawn(fun() -> timer:sleep(100) end),
register(Name, Pid),
?assertEqual(ok, safe_unregister(Name)),
?assertEqual(ok, safe_unregister(Name)),
?assertEqual(ok, safe_unregister(Name)).
safe_unregister_edge_cases_test() ->
?assertEqual(ok, safe_unregister(undefined_name_xyz)),
?assertEqual(ok, safe_unregister('some_random_name')),
?assertEqual(ok, safe_unregister('')).
cleanup_on_down_preserves_loading_test() ->
DeadPid = list_to_pid("<0.100.0>"),
AlivePid = list_to_pid("<0.101.0>"),
Ref1 = make_ref(),
Ref2 = make_ref(),
Map = #{
guild_1 => {DeadPid, Ref1},
guild_2 => loading,
guild_3 => {AlivePid, Ref2}
},
Result = cleanup_on_down(DeadPid, Map),
?assertEqual(2, maps:size(Result)),
?assertEqual(loading, maps:get(guild_2, Result)),
?assertEqual({AlivePid, Ref2}, maps:get(guild_3, Result)),
?assertEqual(false, maps:is_key(guild_1, Result)).
cleanup_on_down_multiple_loading_test() ->
DeadPid = list_to_pid("<0.100.0>"),
AlivePid = list_to_pid("<0.101.0>"),
Ref1 = make_ref(),
Ref2 = make_ref(),
Map = #{
guild_1 => {DeadPid, Ref1},
guild_2 => loading,
guild_3 => {AlivePid, Ref2},
guild_4 => loading,
guild_5 => loading
},
Result = cleanup_on_down(DeadPid, Map),
?assertEqual(4, maps:size(Result)),
?assertEqual(loading, maps:get(guild_2, Result)),
?assertEqual(loading, maps:get(guild_4, Result)),
?assertEqual(loading, maps:get(guild_5, Result)),
?assertEqual({AlivePid, Ref2}, maps:get(guild_3, Result)),
?assertEqual(false, maps:is_key(guild_1, Result)).
cleanup_on_down_single_removal_test() ->
DeadPid = list_to_pid("<0.100.0>"),
AlivePid1 = list_to_pid("<0.101.0>"),
AlivePid2 = list_to_pid("<0.102.0>"),
Ref1 = make_ref(),
Ref2 = make_ref(),
Ref3 = make_ref(),
Map = #{
guild_1 => {AlivePid1, Ref1},
guild_2 => {DeadPid, Ref2},
guild_3 => {AlivePid2, Ref3}
},
Result = cleanup_on_down(DeadPid, Map),
?assertEqual(2, maps:size(Result)),
?assertEqual({AlivePid1, Ref1}, maps:get(guild_1, Result)),
?assertEqual({AlivePid2, Ref3}, maps:get(guild_3, Result)),
?assertEqual(false, maps:is_key(guild_2, Result)).
cleanup_on_down_empty_test() ->
DeadPid = list_to_pid("<0.100.0>"),
Result = cleanup_on_down(DeadPid, #{}),
?assertEqual(#{}, Result).
cleanup_on_down_only_loading_test() ->
DeadPid = list_to_pid("<0.100.0>"),
Map = #{
guild_1 => loading,
guild_2 => loading
},
Result = cleanup_on_down(DeadPid, Map),
?assertEqual(Map, Result).
cleanup_on_down_pid_not_found_test() ->
DeadPid = list_to_pid("<0.100.0>"),
AlivePid = list_to_pid("<0.101.0>"),
Ref = make_ref(),
Map = #{
guild_1 => {AlivePid, Ref},
guild_2 => loading
},
Result = cleanup_on_down(DeadPid, Map),
?assertEqual(Map, Result).
cleanup_on_down_duplicate_pids_test() ->
DeadPid = list_to_pid("<0.100.0>"),
Ref1 = make_ref(),
Ref2 = make_ref(),
Map = #{
guild_1 => {DeadPid, Ref1},
guild_2 => {DeadPid, Ref2}
},
Result = cleanup_on_down(DeadPid, Map),
?assertEqual(0, maps:size(Result)).
get_count_mixed_test() ->
Pid1 = list_to_pid("<0.100.0>"),
Pid2 = list_to_pid("<0.101.0>"),
Ref1 = make_ref(),
Ref2 = make_ref(),
Map = #{
guild_1 => {Pid1, Ref1},
guild_2 => loading,
guild_3 => {Pid2, Ref2},
guild_4 => loading
},
?assertEqual(2, get_count(Map)).
get_count_empty_test() ->
?assertEqual(0, get_count(#{})).
get_count_only_loading_test() ->
Map = #{
guild_1 => loading,
guild_2 => loading
},
?assertEqual(0, get_count(Map)).
get_count_only_processes_test() ->
Pid1 = list_to_pid("<0.100.0>"),
Pid2 = list_to_pid("<0.101.0>"),
Pid3 = list_to_pid("<0.102.0>"),
Ref1 = make_ref(),
Ref2 = make_ref(),
Ref3 = make_ref(),
Map = #{
guild_1 => {Pid1, Ref1},
guild_2 => {Pid2, Ref2},
guild_3 => {Pid3, Ref3}
},
?assertEqual(3, get_count(Map)).
get_count_single_test() ->
Pid = list_to_pid("<0.100.0>"),
Ref = make_ref(),
Map = #{guild_1 => {Pid, Ref}},
?assertEqual(1, get_count(Map)).
get_count_single_loading_test() ->
Map = #{guild_1 => loading},
?assertEqual(0, get_count(Map)).
integration_full_lifecycle_test() ->
Id = 12345,
Name = build_process_name(guild, Id),
?assertEqual('guild_12345', Name),
Pid = spawn(fun() -> timer:sleep(200) end),
{ok, Pid, _Ref, Map1} = register_and_monitor(Name, Pid, #{}),
?assertEqual(1, get_count(Map1)),
Map2 = maps:put(guild_67890, loading, Map1),
?assertEqual(1, get_count(Map2)),
OtherName = build_process_name(channel, 67890),
OtherPid = spawn(fun() -> timer:sleep(200) end),
register(OtherName, OtherPid),
{ok, OtherPid, _OtherRef, Map3} = lookup_or_monitor(OtherName, channel_67890, Map2),
?assertEqual(2, get_count(Map3)),
Map4 = cleanup_on_down(Pid, Map3),
?assertEqual(1, get_count(Map4)),
?assertEqual(loading, maps:get(guild_67890, Map4)),
safe_unregister(Name),
safe_unregister(OtherName),
?assertEqual(undefined, whereis(Name)),
?assertEqual(undefined, whereis(OtherName)).
integration_process_death_test_() ->
{timeout, 10, fun() ->
Name = test_integration_death,
Pid = spawn(fun() ->
receive
die -> exit(normal)
after 100 -> exit(normal)
end
end),
{ok, Pid, Ref, Map} = register_and_monitor(Name, Pid, #{}),
?assertEqual(1, get_count(Map)),
Pid ! die,
receive
{'DOWN', Ref, process, Pid, _Reason} ->
Map2 = cleanup_on_down(Pid, Map),
?assertEqual(0, get_count(Map2)),
safe_unregister(Name)
after 500 ->
?assert(false)
end
end}.
integration_race_conditions_test_() ->
{timeout, 10, fun() ->
Name = test_integration_race,
Parent = self(),
FirstPid = spawn(fun() -> timer:sleep(300) end),
{ok, FirstPid, _FirstRef, _Map1} = register_and_monitor(Name, FirstPid, #{}),
Workers = [
spawn(fun() ->
NewPid = spawn(fun() -> timer:sleep(100) end),
Result = register_and_monitor(Name, NewPid, #{}),
Parent ! {register_result, Result}
end)
|| _ <- lists:seq(1, 3)
],
Results = [
receive
{register_result, R} -> R
after 1000 -> timeout
end
|| _ <- Workers
],
AllGotFirstPid = lists:all(
fun
({ok, P, _, _}) -> P =:= FirstPid;
(_) -> false
end,
Results
),
?assert(AllGotFirstPid),
safe_unregister(Name)
end}.
integration_rapid_cycles_test_() ->
{timeout, 10, fun() ->
lists:foreach(
fun(N) ->
Name = list_to_atom("test_rapid_" ++ integer_to_list(N)),
Pid = spawn(fun() -> timer:sleep(50) end),
{ok, Pid, _Ref, Map} = register_and_monitor(Name, Pid, #{}),
?assertEqual(1, get_count(Map)),
safe_unregister(Name),
?assertEqual(undefined, whereis(Name))
end,
lists:seq(1, 10)
)
end}.
-endif.

View File

@@ -0,0 +1,32 @@
%% 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(backoff_utils).
-export([
calculate/1,
calculate/2
]).
-spec calculate(non_neg_integer()) -> non_neg_integer().
calculate(Attempt) ->
calculate(Attempt, 30000).
-spec calculate(non_neg_integer(), pos_integer()) -> non_neg_integer().
calculate(Attempt, MaxMs) ->
BackoffMs = round(1000 * math:pow(2, Attempt)),
min(BackoffMs, MaxMs).

View File

@@ -0,0 +1,368 @@
%% 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(constants).
-export([
gateway_opcode/1,
opcode_to_num/1,
close_code_to_num/1,
dispatch_event_atom/1,
status_type_atom/1,
max_payload_size/0,
heartbeat_interval/0,
heartbeat_timeout/0,
random_session_bytes/0,
view_channel_permission/0,
administrator_permission/0,
manage_roles_permission/0,
manage_channels_permission/0,
connect_permission/0,
speak_permission/0,
stream_permission/0,
use_vad_permission/0,
kick_members_permission/0,
ban_members_permission/0
]).
gateway_opcode(0) -> dispatch;
gateway_opcode(1) -> heartbeat;
gateway_opcode(2) -> identify;
gateway_opcode(3) -> presence_update;
gateway_opcode(4) -> voice_state_update;
gateway_opcode(5) -> voice_server_ping;
gateway_opcode(6) -> resume;
gateway_opcode(7) -> reconnect;
gateway_opcode(8) -> request_guild_members;
gateway_opcode(9) -> invalid_session;
gateway_opcode(10) -> hello;
gateway_opcode(11) -> heartbeat_ack;
gateway_opcode(12) -> gateway_error;
gateway_opcode(13) -> call_connect;
gateway_opcode(14) -> lazy_request;
gateway_opcode(_) -> unknown.
opcode_to_num(dispatch) -> 0;
opcode_to_num(heartbeat) -> 1;
opcode_to_num(identify) -> 2;
opcode_to_num(presence_update) -> 3;
opcode_to_num(voice_state_update) -> 4;
opcode_to_num(voice_server_ping) -> 5;
opcode_to_num(resume) -> 6;
opcode_to_num(reconnect) -> 7;
opcode_to_num(request_guild_members) -> 8;
opcode_to_num(invalid_session) -> 9;
opcode_to_num(hello) -> 10;
opcode_to_num(heartbeat_ack) -> 11;
opcode_to_num(gateway_error) -> 12;
opcode_to_num(call_connect) -> 13;
opcode_to_num(lazy_request) -> 14.
close_code_to_num(unknown_error) -> 4000;
close_code_to_num(unknown_opcode) -> 4001;
close_code_to_num(decode_error) -> 4002;
close_code_to_num(not_authenticated) -> 4003;
close_code_to_num(authentication_failed) -> 4004;
close_code_to_num(already_authenticated) -> 4005;
close_code_to_num(invalid_seq) -> 4007;
close_code_to_num(rate_limited) -> 4008;
close_code_to_num(session_timeout) -> 4009;
close_code_to_num(invalid_shard) -> 4010;
close_code_to_num(sharding_required) -> 4011;
close_code_to_num(invalid_api_version) -> 4012.
dispatch_event_atom(<<"READY">>) ->
ready;
dispatch_event_atom(<<"RESUMED">>) ->
resumed;
dispatch_event_atom(<<"SESSIONS_REPLACE">>) ->
sessions_replace;
dispatch_event_atom(<<"USER_UPDATE">>) ->
user_update;
dispatch_event_atom(<<"USER_SETTINGS_UPDATE">>) ->
user_settings_update;
dispatch_event_atom(<<"USER_GUILD_SETTINGS_UPDATE">>) ->
user_guild_settings_update;
dispatch_event_atom(<<"USER_PINNED_DMS_UPDATE">>) ->
user_pinned_dms_update;
dispatch_event_atom(<<"USER_NOTE_UPDATE">>) ->
user_note_update;
dispatch_event_atom(<<"RECENT_MENTION_DELETE">>) ->
recent_mention_delete;
dispatch_event_atom(<<"SAVED_MESSAGE_CREATE">>) ->
saved_message_create;
dispatch_event_atom(<<"SAVED_MESSAGE_DELETE">>) ->
saved_message_delete;
dispatch_event_atom(<<"AUTH_SESSION_CHANGE">>) ->
auth_session_change;
dispatch_event_atom(<<"PRESENCE_UPDATE">>) ->
presence_update;
dispatch_event_atom(<<"GUILD_CREATE">>) ->
guild_create;
dispatch_event_atom(<<"GUILD_UPDATE">>) ->
guild_update;
dispatch_event_atom(<<"GUILD_DELETE">>) ->
guild_delete;
dispatch_event_atom(<<"GUILD_MEMBER_ADD">>) ->
guild_member_add;
dispatch_event_atom(<<"GUILD_MEMBER_UPDATE">>) ->
guild_member_update;
dispatch_event_atom(<<"GUILD_MEMBER_REMOVE">>) ->
guild_member_remove;
dispatch_event_atom(<<"GUILD_ROLE_CREATE">>) ->
guild_role_create;
dispatch_event_atom(<<"GUILD_ROLE_UPDATE">>) ->
guild_role_update;
dispatch_event_atom(<<"GUILD_ROLE_UPDATE_BULK">>) ->
guild_role_update_bulk;
dispatch_event_atom(<<"GUILD_ROLE_DELETE">>) ->
guild_role_delete;
dispatch_event_atom(<<"GUILD_EMOJIS_UPDATE">>) ->
guild_emojis_update;
dispatch_event_atom(<<"GUILD_STICKERS_UPDATE">>) ->
guild_stickers_update;
dispatch_event_atom(<<"GUILD_BAN_ADD">>) ->
guild_ban_add;
dispatch_event_atom(<<"GUILD_BAN_REMOVE">>) ->
guild_ban_remove;
dispatch_event_atom(<<"GUILD_MEMBERS_CHUNK">>) ->
guild_members_chunk;
dispatch_event_atom(<<"CHANNEL_CREATE">>) ->
channel_create;
dispatch_event_atom(<<"CHANNEL_UPDATE">>) ->
channel_update;
dispatch_event_atom(<<"CHANNEL_UPDATE_BULK">>) ->
channel_update_bulk;
dispatch_event_atom(<<"PASSIVE_UPDATES">>) ->
passive_updates;
dispatch_event_atom(<<"CHANNEL_DELETE">>) ->
channel_delete;
dispatch_event_atom(<<"CHANNEL_RECIPIENT_ADD">>) ->
channel_recipient_add;
dispatch_event_atom(<<"CHANNEL_RECIPIENT_REMOVE">>) ->
channel_recipient_remove;
dispatch_event_atom(<<"CHANNEL_PINS_UPDATE">>) ->
channel_pins_update;
dispatch_event_atom(<<"CHANNEL_PINS_ACK">>) ->
channel_pins_ack;
dispatch_event_atom(<<"INVITE_CREATE">>) ->
invite_create;
dispatch_event_atom(<<"INVITE_DELETE">>) ->
invite_delete;
dispatch_event_atom(<<"MESSAGE_CREATE">>) ->
message_create;
dispatch_event_atom(<<"MESSAGE_UPDATE">>) ->
message_update;
dispatch_event_atom(<<"MESSAGE_DELETE">>) ->
message_delete;
dispatch_event_atom(<<"MESSAGE_DELETE_BULK">>) ->
message_delete_bulk;
dispatch_event_atom(<<"MESSAGE_REACTION_ADD">>) ->
message_reaction_add;
dispatch_event_atom(<<"MESSAGE_REACTION_REMOVE">>) ->
message_reaction_remove;
dispatch_event_atom(<<"MESSAGE_REACTION_REMOVE_ALL">>) ->
message_reaction_remove_all;
dispatch_event_atom(<<"MESSAGE_REACTION_REMOVE_EMOJI">>) ->
message_reaction_remove_emoji;
dispatch_event_atom(<<"MESSAGE_ACK">>) ->
message_ack;
dispatch_event_atom(<<"TYPING_START">>) ->
typing_start;
dispatch_event_atom(<<"WEBHOOKS_UPDATE">>) ->
webhooks_update;
dispatch_event_atom(<<"RELATIONSHIP_ADD">>) ->
relationship_add;
dispatch_event_atom(<<"RELATIONSHIP_UPDATE">>) ->
relationship_update;
dispatch_event_atom(<<"RELATIONSHIP_REMOVE">>) ->
relationship_remove;
dispatch_event_atom(<<"VOICE_STATE_UPDATE">>) ->
voice_state_update;
dispatch_event_atom(<<"VOICE_SERVER_UPDATE">>) ->
voice_server_update;
dispatch_event_atom(<<"FAVORITE_MEME_CREATE">>) ->
favorite_meme_create;
dispatch_event_atom(<<"FAVORITE_MEME_UPDATE">>) ->
favorite_meme_update;
dispatch_event_atom(<<"FAVORITE_MEME_DELETE">>) ->
favorite_meme_delete;
dispatch_event_atom(<<"CALL_CREATE">>) ->
call_create;
dispatch_event_atom(<<"CALL_UPDATE">>) ->
call_update;
dispatch_event_atom(<<"CALL_DELETE">>) ->
call_delete;
dispatch_event_atom(<<"GUILD_MEMBER_LIST_UPDATE">>) ->
guild_member_list_update;
dispatch_event_atom(<<"GUILD_SYNC">>) ->
guild_sync;
dispatch_event_atom(ready) ->
<<"READY">>;
dispatch_event_atom(resumed) ->
<<"RESUMED">>;
dispatch_event_atom(sessions_replace) ->
<<"SESSIONS_REPLACE">>;
dispatch_event_atom(user_update) ->
<<"USER_UPDATE">>;
dispatch_event_atom(user_settings_update) ->
<<"USER_SETTINGS_UPDATE">>;
dispatch_event_atom(user_guild_settings_update) ->
<<"USER_GUILD_SETTINGS_UPDATE">>;
dispatch_event_atom(user_pinned_dms_update) ->
<<"USER_PINNED_DMS_UPDATE">>;
dispatch_event_atom(user_note_update) ->
<<"USER_NOTE_UPDATE">>;
dispatch_event_atom(recent_mention_delete) ->
<<"RECENT_MENTION_DELETE">>;
dispatch_event_atom(saved_message_create) ->
<<"SAVED_MESSAGE_CREATE">>;
dispatch_event_atom(saved_message_delete) ->
<<"SAVED_MESSAGE_DELETE">>;
dispatch_event_atom(auth_session_change) ->
<<"AUTH_SESSION_CHANGE">>;
dispatch_event_atom(presence_update) ->
<<"PRESENCE_UPDATE">>;
dispatch_event_atom(guild_create) ->
<<"GUILD_CREATE">>;
dispatch_event_atom(guild_update) ->
<<"GUILD_UPDATE">>;
dispatch_event_atom(guild_delete) ->
<<"GUILD_DELETE">>;
dispatch_event_atom(guild_member_add) ->
<<"GUILD_MEMBER_ADD">>;
dispatch_event_atom(guild_member_update) ->
<<"GUILD_MEMBER_UPDATE">>;
dispatch_event_atom(guild_member_remove) ->
<<"GUILD_MEMBER_REMOVE">>;
dispatch_event_atom(guild_role_create) ->
<<"GUILD_ROLE_CREATE">>;
dispatch_event_atom(guild_role_update) ->
<<"GUILD_ROLE_UPDATE">>;
dispatch_event_atom(guild_role_update_bulk) ->
<<"GUILD_ROLE_UPDATE_BULK">>;
dispatch_event_atom(guild_role_delete) ->
<<"GUILD_ROLE_DELETE">>;
dispatch_event_atom(guild_emojis_update) ->
<<"GUILD_EMOJIS_UPDATE">>;
dispatch_event_atom(guild_stickers_update) ->
<<"GUILD_STICKERS_UPDATE">>;
dispatch_event_atom(guild_ban_add) ->
<<"GUILD_BAN_ADD">>;
dispatch_event_atom(guild_ban_remove) ->
<<"GUILD_BAN_REMOVE">>;
dispatch_event_atom(guild_members_chunk) ->
<<"GUILD_MEMBERS_CHUNK">>;
dispatch_event_atom(channel_create) ->
<<"CHANNEL_CREATE">>;
dispatch_event_atom(channel_update) ->
<<"CHANNEL_UPDATE">>;
dispatch_event_atom(channel_update_bulk) ->
<<"CHANNEL_UPDATE_BULK">>;
dispatch_event_atom(passive_updates) ->
<<"PASSIVE_UPDATES">>;
dispatch_event_atom(channel_delete) ->
<<"CHANNEL_DELETE">>;
dispatch_event_atom(channel_recipient_add) ->
<<"CHANNEL_RECIPIENT_ADD">>;
dispatch_event_atom(channel_recipient_remove) ->
<<"CHANNEL_RECIPIENT_REMOVE">>;
dispatch_event_atom(channel_pins_update) ->
<<"CHANNEL_PINS_UPDATE">>;
dispatch_event_atom(channel_pins_ack) ->
<<"CHANNEL_PINS_ACK">>;
dispatch_event_atom(invite_create) ->
<<"INVITE_CREATE">>;
dispatch_event_atom(invite_delete) ->
<<"INVITE_DELETE">>;
dispatch_event_atom(message_create) ->
<<"MESSAGE_CREATE">>;
dispatch_event_atom(message_update) ->
<<"MESSAGE_UPDATE">>;
dispatch_event_atom(message_delete) ->
<<"MESSAGE_DELETE">>;
dispatch_event_atom(message_delete_bulk) ->
<<"MESSAGE_DELETE_BULK">>;
dispatch_event_atom(message_reaction_add) ->
<<"MESSAGE_REACTION_ADD">>;
dispatch_event_atom(message_reaction_remove) ->
<<"MESSAGE_REACTION_REMOVE">>;
dispatch_event_atom(message_reaction_remove_all) ->
<<"MESSAGE_REACTION_REMOVE_ALL">>;
dispatch_event_atom(message_reaction_remove_emoji) ->
<<"MESSAGE_REACTION_REMOVE_EMOJI">>;
dispatch_event_atom(message_ack) ->
<<"MESSAGE_ACK">>;
dispatch_event_atom(typing_start) ->
<<"TYPING_START">>;
dispatch_event_atom(webhooks_update) ->
<<"WEBHOOKS_UPDATE">>;
dispatch_event_atom(relationship_add) ->
<<"RELATIONSHIP_ADD">>;
dispatch_event_atom(relationship_update) ->
<<"RELATIONSHIP_UPDATE">>;
dispatch_event_atom(relationship_remove) ->
<<"RELATIONSHIP_REMOVE">>;
dispatch_event_atom(voice_state_update) ->
<<"VOICE_STATE_UPDATE">>;
dispatch_event_atom(voice_server_update) ->
<<"VOICE_SERVER_UPDATE">>;
dispatch_event_atom(favorite_meme_create) ->
<<"FAVORITE_MEME_CREATE">>;
dispatch_event_atom(favorite_meme_update) ->
<<"FAVORITE_MEME_UPDATE">>;
dispatch_event_atom(favorite_meme_delete) ->
<<"FAVORITE_MEME_DELETE">>;
dispatch_event_atom(call_create) ->
<<"CALL_CREATE">>;
dispatch_event_atom(call_update) ->
<<"CALL_UPDATE">>;
dispatch_event_atom(call_delete) ->
<<"CALL_DELETE">>;
dispatch_event_atom(guild_member_list_update) ->
<<"GUILD_MEMBER_LIST_UPDATE">>;
dispatch_event_atom(guild_sync) ->
<<"GUILD_SYNC">>;
dispatch_event_atom(EventBinary) when is_binary(EventBinary) -> EventBinary;
dispatch_event_atom(EventAtom) when is_atom(EventAtom) ->
list_to_binary(string:uppercase(atom_to_list(EventAtom))).
status_type_atom(<<"online">>) -> online;
status_type_atom(<<"dnd">>) -> dnd;
status_type_atom(<<"idle">>) -> idle;
status_type_atom(<<"invisible">>) -> invisible;
status_type_atom(<<"offline">>) -> offline;
status_type_atom(online) -> <<"online">>;
status_type_atom(dnd) -> <<"dnd">>;
status_type_atom(idle) -> <<"idle">>;
status_type_atom(invisible) -> <<"invisible">>;
status_type_atom(offline) -> <<"offline">>.
max_payload_size() -> 4096.
heartbeat_interval() -> 41250.
heartbeat_timeout() -> 45000.
random_session_bytes() -> 16.
view_channel_permission() -> 1024.
administrator_permission() -> 8.
manage_roles_permission() -> 268435456.
manage_channels_permission() -> 16.
connect_permission() -> 1048576.
speak_permission() -> 2097152.
stream_permission() -> 512.
use_vad_permission() -> 33554432.
kick_members_permission() -> 2.
ban_members_permission() -> 4.

View File

@@ -0,0 +1,63 @@
%% 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(custom_status_validation).
-export([
validate/2
]).
-spec validate(integer(), map() | null) -> {ok, map()} | {error, term()}.
validate(_UserId, null) ->
{ok, null};
validate(UserId, CustomStatus) when is_map(CustomStatus) ->
Request = build_request(UserId, CustomStatus),
rpc_client:call(Request).
build_request(UserId, CustomStatus) ->
#{
<<"type">> => <<"validate_custom_status">>,
<<"user_id">> => type_conv:to_binary(UserId),
<<"custom_status">> => build_custom_status_payload(CustomStatus)
}.
build_custom_status_payload(CustomStatus) ->
Field1 = put_optional_field(
maps:new(),
<<"text">>,
maps:get(<<"text">>, CustomStatus, undefined)
),
Field2 = put_optional_field(
Field1,
<<"expires_at">>,
maps:get(<<"expires_at">>, CustomStatus, undefined)
),
Field3 = put_optional_field(
Field2,
<<"emoji_id">>,
maps:get(<<"emoji_id">>, CustomStatus, undefined)
),
put_optional_field(
Field3,
<<"emoji_name">>,
maps:get(<<"emoji_name">>, CustomStatus, undefined)
).
put_optional_field(Map, _Key, undefined) ->
Map;
put_optional_field(Map, Key, Value) ->
maps:put(Key, Value, Map).

View File

@@ -0,0 +1,647 @@
%% 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(list_ops).
-export([
replace_by_id/3,
remove_by_id/2,
replace_by_user_id/3,
remove_by_user_id/2,
bulk_update/2,
extract_user_id/1
]).
-type item() :: map() | term().
-type id() :: binary() | integer().
-type item_list() :: [item()].
-spec replace_by_id(item_list(), id(), item()) -> item_list().
replace_by_id(Items, Id, NewItem) when is_list(Items) ->
lists:map(
fun
(Item) when is_map(Item) ->
case maps:get(<<"id">>, Item, undefined) of
Id -> NewItem;
_ -> Item
end;
(Item) ->
Item
end,
Items
);
replace_by_id(_, _, _) ->
[].
-spec remove_by_id(item_list(), id()) -> item_list().
remove_by_id(Items, Id) when is_list(Items) ->
lists:filter(
fun
(Item) when is_map(Item) ->
maps:get(<<"id">>, Item, undefined) =/= Id;
(_Item) ->
true
end,
Items
);
remove_by_id(_, _) ->
[].
-spec replace_by_user_id(item_list(), integer(), item()) -> item_list().
replace_by_user_id(Items, UserId, NewItem) when is_list(Items), is_integer(UserId) ->
lists:map(
fun
(Item) when is_map(Item) ->
ItemUserId = extract_user_id(Item),
case ItemUserId =:= UserId of
true -> NewItem;
false -> Item
end;
(Item) ->
Item
end,
Items
);
replace_by_user_id(_, _, _) ->
[].
-spec remove_by_user_id(item_list(), integer()) -> item_list().
remove_by_user_id(Items, UserId) when is_list(Items), is_integer(UserId) ->
lists:filter(
fun
(Item) when is_map(Item) ->
ItemUserId = extract_user_id(Item),
ItemUserId =/= UserId;
(_Item) ->
true
end,
Items
);
remove_by_user_id(_, _) ->
[].
-spec bulk_update(item_list(), item_list()) -> item_list().
bulk_update(Items, Updates) when is_list(Items), is_list(Updates) ->
UpdateMap = lists:foldl(
fun
(Item, Acc) when is_map(Item) ->
case maps:get(<<"id">>, Item, undefined) of
undefined -> Acc;
ItemId -> maps:put(ItemId, Item, Acc)
end;
(_, Acc) ->
Acc
end,
#{},
Updates
),
lists:map(
fun
(Item) when is_map(Item) ->
ItemId = maps:get(<<"id">>, Item, undefined),
case maps:get(ItemId, UpdateMap, undefined) of
undefined -> Item;
UpdatedItem -> UpdatedItem
end;
(Item) ->
Item
end,
Items
);
bulk_update(Items, _) when is_list(Items) ->
Items;
bulk_update(_, _) ->
[].
-spec extract_user_id(map() | term()) -> integer().
extract_user_id(Item) ->
UserMap = map_utils:ensure_map(map_utils:get_safe(Item, <<"user">>, #{})),
case maps:find(<<"id">>, UserMap) of
error ->
0;
{ok, RawId} ->
case type_conv:to_integer(RawId) of
undefined -> undefined;
Value -> Value
end
end.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
make_item_with_id(Id) ->
#{<<"id">> => Id, <<"data">> => <<"test">>}.
make_item_with_user_id(UserId) ->
#{
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
<<"data">> => <<"member">>
}.
replace_by_id_success_test() ->
Items = [
make_item_with_id(<<"1">>),
make_item_with_id(<<"2">>),
make_item_with_id(<<"3">>)
],
NewItem = #{<<"id">> => <<"2">>, <<"data">> => <<"updated">>},
Result = replace_by_id(Items, <<"2">>, NewItem),
?assertEqual(3, length(Result)),
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
?assertEqual(NewItem, lists:nth(2, Result)),
?assertEqual(make_item_with_id(<<"3">>), lists:nth(3, Result)).
replace_by_id_no_match_test() ->
Items = [
make_item_with_id(<<"1">>),
make_item_with_id(<<"2">>)
],
NewItem = #{<<"id">> => <<"99">>, <<"data">> => <<"new">>},
Result = replace_by_id(Items, <<"99">>, NewItem),
?assertEqual(Items, Result).
replace_by_id_empty_list_test() ->
Result = replace_by_id([], <<"1">>, #{<<"id">> => <<"1">>}),
?assertEqual([], Result).
replace_by_id_mixed_list_test() ->
Items = [
make_item_with_id(<<"1">>),
<<"non_map_item">>,
make_item_with_id(<<"2">>),
{tuple, item},
make_item_with_id(<<"3">>)
],
NewItem = #{<<"id">> => <<"2">>, <<"data">> => <<"replaced">>},
Result = replace_by_id(Items, <<"2">>, NewItem),
?assertEqual(5, length(Result)),
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
?assertEqual(<<"non_map_item">>, lists:nth(2, Result)),
?assertEqual(NewItem, lists:nth(3, Result)),
?assertEqual({tuple, item}, lists:nth(4, Result)),
?assertEqual(make_item_with_id(<<"3">>), lists:nth(5, Result)).
replace_by_id_integer_id_test() ->
Items = [
#{<<"id">> => 1, <<"data">> => <<"a">>},
#{<<"id">> => 2, <<"data">> => <<"b">>}
],
NewItem = #{<<"id">> => 2, <<"data">> => <<"updated">>},
Result = replace_by_id(Items, 2, NewItem),
?assertEqual(2, length(Result)),
?assertEqual(#{<<"id">> => 1, <<"data">> => <<"a">>}, lists:nth(1, Result)),
?assertEqual(NewItem, lists:nth(2, Result)).
replace_by_id_invalid_input_test() ->
?assertEqual([], replace_by_id(not_a_list, <<"1">>, #{})),
?assertEqual([], replace_by_id(#{}, <<"1">>, #{})),
?assertEqual([], replace_by_id(undefined, <<"1">>, #{})).
replace_by_id_item_without_id_test() ->
Items = [
#{<<"id">> => <<"1">>},
#{<<"name">> => <<"no_id">>},
#{<<"id">> => <<"2">>}
],
NewItem = #{<<"id">> => <<"2">>, <<"updated">> => true},
Result = replace_by_id(Items, <<"2">>, NewItem),
?assertEqual(3, length(Result)),
?assertEqual(#{<<"id">> => <<"1">>}, lists:nth(1, Result)),
?assertEqual(#{<<"name">> => <<"no_id">>}, lists:nth(2, Result)),
?assertEqual(NewItem, lists:nth(3, Result)).
remove_by_id_success_test() ->
Items = [
make_item_with_id(<<"1">>),
make_item_with_id(<<"2">>),
make_item_with_id(<<"3">>)
],
Result = remove_by_id(Items, <<"2">>),
?assertEqual(2, length(Result)),
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
?assertEqual(make_item_with_id(<<"3">>), lists:nth(2, Result)).
remove_by_id_no_match_test() ->
Items = [
make_item_with_id(<<"1">>),
make_item_with_id(<<"2">>)
],
Result = remove_by_id(Items, <<"99">>),
?assertEqual(Items, Result).
remove_by_id_multiple_matches_test() ->
Items = [
make_item_with_id(<<"1">>),
#{<<"id">> => <<"2">>, <<"version">> => 1},
#{<<"id">> => <<"2">>, <<"version">> => 2},
make_item_with_id(<<"3">>)
],
Result = remove_by_id(Items, <<"2">>),
?assertEqual(2, length(Result)),
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
?assertEqual(make_item_with_id(<<"3">>), lists:nth(2, Result)).
remove_by_id_empty_list_test() ->
Result = remove_by_id([], <<"1">>),
?assertEqual([], Result).
remove_by_id_mixed_list_test() ->
Items = [
make_item_with_id(<<"1">>),
<<"non_map">>,
make_item_with_id(<<"2">>),
[list, item],
make_item_with_id(<<"3">>)
],
Result = remove_by_id(Items, <<"2">>),
?assertEqual(4, length(Result)),
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
?assertEqual(<<"non_map">>, lists:nth(2, Result)),
?assertEqual([list, item], lists:nth(3, Result)),
?assertEqual(make_item_with_id(<<"3">>), lists:nth(4, Result)).
remove_by_id_invalid_input_test() ->
?assertEqual([], remove_by_id(not_a_list, <<"1">>)),
?assertEqual([], remove_by_id(undefined, <<"1">>)),
?assertEqual([], remove_by_id(123, <<"1">>)).
remove_by_id_all_items_match_test() ->
Items = [
make_item_with_id(<<"1">>),
make_item_with_id(<<"1">>),
make_item_with_id(<<"1">>)
],
Result = remove_by_id(Items, <<"1">>),
?assertEqual([], Result).
replace_by_user_id_success_test() ->
Items = [
make_item_with_user_id(100),
make_item_with_user_id(200),
make_item_with_user_id(300)
],
NewItem = #{
<<"user">> => #{<<"id">> => <<"200">>},
<<"data">> => <<"updated">>
},
Result = replace_by_user_id(Items, 200, NewItem),
?assertEqual(3, length(Result)),
?assertEqual(make_item_with_user_id(100), lists:nth(1, Result)),
?assertEqual(NewItem, lists:nth(2, Result)),
?assertEqual(make_item_with_user_id(300), lists:nth(3, Result)).
replace_by_user_id_no_match_test() ->
Items = [
make_item_with_user_id(100),
make_item_with_user_id(200)
],
NewItem = make_item_with_user_id(999),
Result = replace_by_user_id(Items, 999, NewItem),
?assertEqual(Items, Result).
replace_by_user_id_empty_list_test() ->
Result = replace_by_user_id([], 100, make_item_with_user_id(100)),
?assertEqual([], Result).
replace_by_user_id_nested_extraction_test() ->
Items = [
#{
<<"user">> => #{<<"id">> => <<"123">>, <<"name">> => <<"alice">>},
<<"role">> => <<"admin">>
},
#{<<"user">> => #{<<"id">> => <<"456">>, <<"name">> => <<"bob">>}, <<"role">> => <<"user">>}
],
NewItem = #{<<"user">> => #{<<"id">> => <<"456">>}, <<"role">> => <<"moderator">>},
Result = replace_by_user_id(Items, 456, NewItem),
?assertEqual(2, length(Result)),
?assertEqual(lists:nth(1, Items), lists:nth(1, Result)),
?assertEqual(NewItem, lists:nth(2, Result)).
replace_by_user_id_mixed_list_test() ->
Items = [
make_item_with_user_id(100),
<<"string_item">>,
make_item_with_user_id(200),
{tuple},
#{<<"other">> => <<"map">>}
],
NewItem = make_item_with_user_id(200),
Result = replace_by_user_id(Items, 200, NewItem),
?assertEqual(5, length(Result)),
?assertEqual(make_item_with_user_id(100), lists:nth(1, Result)),
?assertEqual(<<"string_item">>, lists:nth(2, Result)),
?assertEqual(NewItem, lists:nth(3, Result)),
?assertEqual({tuple}, lists:nth(4, Result)),
?assertEqual(#{<<"other">> => <<"map">>}, lists:nth(5, Result)).
replace_by_user_id_invalid_structure_test() ->
Items = [
#{<<"user">> => <<"not_a_map">>, <<"data">> => <<"x">>},
#{<<"no_user_key">> => <<"y">>},
make_item_with_user_id(100)
],
NewItem = make_item_with_user_id(100),
Result = replace_by_user_id(Items, 100, NewItem),
?assertEqual(3, length(Result)),
?assertEqual(lists:nth(1, Items), lists:nth(1, Result)),
?assertEqual(lists:nth(2, Items), lists:nth(2, Result)),
?assertEqual(NewItem, lists:nth(3, Result)).
replace_by_user_id_invalid_input_test() ->
?assertEqual([], replace_by_user_id(not_a_list, 100, #{})),
?assertEqual([], replace_by_user_id(undefined, 100, #{})),
?assertEqual([], replace_by_user_id([make_item_with_user_id(100)], <<"not_integer">>, #{})).
remove_by_user_id_success_test() ->
Items = [
make_item_with_user_id(100),
make_item_with_user_id(200),
make_item_with_user_id(300)
],
Result = remove_by_user_id(Items, 200),
?assertEqual(2, length(Result)),
?assertEqual(make_item_with_user_id(100), lists:nth(1, Result)),
?assertEqual(make_item_with_user_id(300), lists:nth(2, Result)).
remove_by_user_id_no_match_test() ->
Items = [
make_item_with_user_id(100),
make_item_with_user_id(200)
],
Result = remove_by_user_id(Items, 999),
?assertEqual(Items, Result).
remove_by_user_id_multiple_matches_test() ->
Items = [
make_item_with_user_id(100),
#{<<"user">> => #{<<"id">> => <<"200">>}, <<"version">> => 1},
#{<<"user">> => #{<<"id">> => <<"200">>}, <<"version">> => 2},
make_item_with_user_id(300)
],
Result = remove_by_user_id(Items, 200),
?assertEqual(2, length(Result)),
?assertEqual(make_item_with_user_id(100), lists:nth(1, Result)),
?assertEqual(make_item_with_user_id(300), lists:nth(2, Result)).
remove_by_user_id_empty_list_test() ->
Result = remove_by_user_id([], 100),
?assertEqual([], Result).
remove_by_user_id_mixed_list_test() ->
Items = [
make_item_with_user_id(100),
<<"non_map">>,
make_item_with_user_id(200),
[list],
#{<<"invalid">> => <<"structure">>}
],
Result = remove_by_user_id(Items, 200),
?assertEqual(4, length(Result)),
?assertEqual(make_item_with_user_id(100), lists:nth(1, Result)),
?assertEqual(<<"non_map">>, lists:nth(2, Result)),
?assertEqual([list], lists:nth(3, Result)),
?assertEqual(#{<<"invalid">> => <<"structure">>}, lists:nth(4, Result)).
remove_by_user_id_invalid_nested_structure_test() ->
Items = [
#{<<"user">> => <<"not_a_map">>},
#{<<"no_user">> => <<"field">>},
#{<<"user">> => #{<<"no_id">> => <<"field">>}},
make_item_with_user_id(100)
],
Result = remove_by_user_id(Items, 0),
?assertEqual(1, length(Result)),
?assertEqual(make_item_with_user_id(100), lists:nth(1, Result)).
remove_by_user_id_invalid_input_test() ->
?assertEqual([], remove_by_user_id(not_a_list, 100)),
?assertEqual([], remove_by_user_id(undefined, 100)),
?assertEqual([], remove_by_user_id([make_item_with_user_id(100)], <<"not_integer">>)).
bulk_update_multiple_updates_test() ->
Items = [
make_item_with_id(<<"1">>),
make_item_with_id(<<"2">>),
make_item_with_id(<<"3">>),
make_item_with_id(<<"4">>)
],
Updates = [
#{<<"id">> => <<"2">>, <<"data">> => <<"updated_2">>},
#{<<"id">> => <<"4">>, <<"data">> => <<"updated_4">>}
],
Result = bulk_update(Items, Updates),
?assertEqual(4, length(Result)),
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
?assertEqual(#{<<"id">> => <<"2">>, <<"data">> => <<"updated_2">>}, lists:nth(2, Result)),
?assertEqual(make_item_with_id(<<"3">>), lists:nth(3, Result)),
?assertEqual(#{<<"id">> => <<"4">>, <<"data">> => <<"updated_4">>}, lists:nth(4, Result)).
bulk_update_partial_updates_test() ->
Items = [
make_item_with_id(<<"1">>),
make_item_with_id(<<"2">>),
make_item_with_id(<<"3">>)
],
Updates = [
#{<<"id">> => <<"2">>, <<"data">> => <<"updated">>}
],
Result = bulk_update(Items, Updates),
?assertEqual(3, length(Result)),
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
?assertEqual(#{<<"id">> => <<"2">>, <<"data">> => <<"updated">>}, lists:nth(2, Result)),
?assertEqual(make_item_with_id(<<"3">>), lists:nth(3, Result)).
bulk_update_no_matches_test() ->
Items = [
make_item_with_id(<<"1">>),
make_item_with_id(<<"2">>)
],
Updates = [
#{<<"id">> => <<"99">>, <<"data">> => <<"new">>},
#{<<"id">> => <<"98">>, <<"data">> => <<"new2">>}
],
Result = bulk_update(Items, Updates),
?assertEqual(Items, Result).
bulk_update_empty_lists_test() ->
?assertEqual([], bulk_update([], [])),
?assertEqual([], bulk_update([], [make_item_with_id(<<"1">>)])),
Items = [make_item_with_id(<<"1">>)],
?assertEqual(Items, bulk_update(Items, [])).
bulk_update_updates_without_id_test() ->
Items = [
make_item_with_id(<<"1">>),
make_item_with_id(<<"2">>)
],
Updates = [
#{<<"name">> => <<"no_id">>},
#{<<"id">> => <<"2">>, <<"data">> => <<"updated">>}
],
Result = bulk_update(Items, Updates),
?assertEqual(2, length(Result)),
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
?assertEqual(#{<<"id">> => <<"2">>, <<"data">> => <<"updated">>}, lists:nth(2, Result)).
bulk_update_mixed_items_list_test() ->
Items = [
make_item_with_id(<<"1">>),
<<"non_map_item">>,
make_item_with_id(<<"2">>),
{tuple, item}
],
Updates = [
#{<<"id">> => <<"2">>, <<"data">> => <<"updated">>}
],
Result = bulk_update(Items, Updates),
?assertEqual(4, length(Result)),
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
?assertEqual(<<"non_map_item">>, lists:nth(2, Result)),
?assertEqual(#{<<"id">> => <<"2">>, <<"data">> => <<"updated">>}, lists:nth(3, Result)),
?assertEqual({tuple, item}, lists:nth(4, Result)).
bulk_update_mixed_updates_list_test() ->
Items = [
make_item_with_id(<<"1">>),
make_item_with_id(<<"2">>)
],
Updates = [
<<"non_map">>,
#{<<"id">> => <<"1">>, <<"data">> => <<"updated">>},
{tuple},
#{<<"no_id">> => <<"field">>}
],
Result = bulk_update(Items, Updates),
?assertEqual(2, length(Result)),
?assertEqual(#{<<"id">> => <<"1">>, <<"data">> => <<"updated">>}, lists:nth(1, Result)),
?assertEqual(make_item_with_id(<<"2">>), lists:nth(2, Result)).
bulk_update_duplicate_ids_in_updates_test() ->
Items = [
make_item_with_id(<<"1">>),
make_item_with_id(<<"2">>)
],
Updates = [
#{<<"id">> => <<"1">>, <<"data">> => <<"first_update">>},
#{<<"id">> => <<"1">>, <<"data">> => <<"second_update">>}
],
Result = bulk_update(Items, Updates),
?assertEqual(2, length(Result)),
?assertEqual(#{<<"id">> => <<"1">>, <<"data">> => <<"second_update">>}, lists:nth(1, Result)),
?assertEqual(make_item_with_id(<<"2">>), lists:nth(2, Result)).
bulk_update_invalid_input_test() ->
Items = [make_item_with_id(<<"1">>)],
?assertEqual(Items, bulk_update(Items, not_a_list)),
?assertEqual(Items, bulk_update(Items, undefined)),
?assertEqual(Items, bulk_update(Items, #{})),
?assertEqual([], bulk_update(not_a_list, [make_item_with_id(<<"1">>)])),
?assertEqual([], bulk_update(undefined, [])).
bulk_update_item_without_id_preserved_test() ->
Items = [
make_item_with_id(<<"1">>),
#{<<"name">> => <<"no_id_item">>},
make_item_with_id(<<"2">>)
],
Updates = [
#{<<"id">> => <<"1">>, <<"data">> => <<"updated">>}
],
Result = bulk_update(Items, Updates),
?assertEqual(3, length(Result)),
?assertEqual(#{<<"id">> => <<"1">>, <<"data">> => <<"updated">>}, lists:nth(1, Result)),
?assertEqual(#{<<"name">> => <<"no_id_item">>}, lists:nth(2, Result)),
?assertEqual(make_item_with_id(<<"2">>), lists:nth(3, Result)).
extract_user_id_valid_structure_test() ->
Item = #{<<"user">> => #{<<"id">> => <<"12345">>}},
?assertEqual(12345, extract_user_id(Item)).
extract_user_id_missing_user_test() ->
Item = #{<<"other">> => <<"field">>},
?assertEqual(0, extract_user_id(Item)).
extract_user_id_missing_id_test() ->
Item = #{<<"user">> => #{<<"name">> => <<"alice">>}},
?assertEqual(0, extract_user_id(Item)).
extract_user_id_non_map_test() ->
?assertEqual(0, extract_user_id(<<"string">>)),
?assertEqual(0, extract_user_id([list])),
?assertEqual(0, extract_user_id({tuple})),
?assertEqual(0, extract_user_id(undefined)),
?assertEqual(0, extract_user_id(123)).
extract_user_id_user_not_map_test() ->
Item = #{<<"user">> => <<"not_a_map">>},
?assertEqual(0, extract_user_id(Item)).
extract_user_id_nested_structure_test() ->
Item = #{
<<"user">> => #{
<<"id">> => <<"999">>,
<<"name">> => <<"bob">>,
<<"extra">> => #{<<"nested">> => <<"data">>}
},
<<"role">> => <<"admin">>
},
?assertEqual(999, extract_user_id(Item)).
extract_user_id_empty_id_test() ->
Item = #{<<"user">> => #{<<"id">> => <<>>}},
?assertEqual(undefined, extract_user_id(Item)).
extract_user_id_empty_user_map_test() ->
Item = #{<<"user">> => #{}},
?assertEqual(0, extract_user_id(Item)).
extract_user_id_invalid_id_format_test() ->
Item = #{<<"user">> => #{<<"id">> => <<"not_a_number">>}},
?assertEqual(undefined, extract_user_id(Item)).
-endif.

View File

@@ -0,0 +1,559 @@
%% 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(map_utils).
-export([
get_safe/3,
get_nested/3,
ensure_map/1,
ensure_list/1,
filter_by_field/3,
find_by_field/3,
get_integer/3,
get_binary/3
]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-type key() :: atom() | binary() | term().
-type path() :: [key()].
-type default() :: term().
-spec get_safe(Map :: map() | term(), Key :: key(), Default :: default()) -> term().
get_safe(Map, Key, Default) when is_map(Map) ->
maps:get(Key, Map, Default);
get_safe(_NotMap, _Key, Default) ->
Default.
-spec get_nested(Map :: map() | term(), Path :: path(), Default :: default()) -> term().
get_nested(Map, [], _Default) when is_map(Map) ->
Map;
get_nested(_NotMap, [], Default) ->
Default;
get_nested(Map, [Key | Rest], Default) when is_map(Map) ->
case maps:find(Key, Map) of
{ok, Value} ->
get_nested(Value, Rest, Default);
error ->
Default
end;
get_nested(_NotMap, _Path, Default) ->
Default.
-spec ensure_map(term()) -> map().
ensure_map(Map) when is_map(Map) ->
Map;
ensure_map(_NotMap) ->
#{}.
-spec ensure_list(term()) -> list().
ensure_list(List) when is_list(List) ->
List;
ensure_list(_NotList) ->
[].
-spec get_integer(map() | term(), key(), term()) -> integer() | term().
get_integer(Map, Key, Default) when is_map(Map) ->
case type_conv:to_integer(maps:get(Key, Map, undefined)) of
undefined -> Default;
Value -> Value
end;
get_integer(_NotMap, _Key, Default) ->
Default.
-spec get_binary(map() | term(), key(), term()) -> binary() | term().
get_binary(Map, Key, Default) when is_map(Map) ->
case type_conv:to_binary(maps:get(Key, Map, undefined)) of
undefined -> Default;
Value -> Value
end;
get_binary(_NotMap, _Key, Default) ->
Default.
-spec filter_by_field(List :: list(), Field :: key(), Value :: term()) -> list(map()).
filter_by_field(List, Field, Value) when is_list(List) ->
lists:filter(
fun
(Item) when is_map(Item) ->
case maps:find(Field, Item) of
{ok, Value} -> true;
_ -> false
end;
(_NotMap) ->
false
end,
List
);
filter_by_field(_NotList, _Field, _Value) ->
[].
-spec find_by_field(List :: list(), Field :: key(), Value :: term()) -> {ok, map()} | error.
find_by_field(List, Field, Value) when is_list(List) ->
find_by_field_loop(List, Field, Value);
find_by_field(_NotList, _Field, _Value) ->
error.
-spec find_by_field_loop(list(), key(), term()) -> {ok, map()} | error.
find_by_field_loop([], _Field, _Value) ->
error;
find_by_field_loop([Item | Rest], Field, Value) when is_map(Item) ->
case maps:find(Field, Item) of
{ok, Value} ->
{ok, Item};
_ ->
find_by_field_loop(Rest, Field, Value)
end;
find_by_field_loop([_NotMap | Rest], Field, Value) ->
find_by_field_loop(Rest, Field, Value).
-ifdef(TEST).
get_safe_basic_test() ->
Map = #{key => value, number => 42},
?assertEqual(value, get_safe(Map, key, default)),
?assertEqual(42, get_safe(Map, number, 0)),
?assertEqual(default, get_safe(Map, missing, default)),
?assertEqual(0, get_safe(Map, missing, 0)).
get_safe_various_input_types_test() ->
?assertEqual(default, get_safe(not_a_map, key, default)),
?assertEqual(default, get_safe([], key, default)),
?assertEqual(default, get_safe(123, key, default)),
?assertEqual(default, get_safe(<<"binary">>, key, default)),
?assertEqual(default, get_safe(undefined, key, default)),
?assertEqual(default, get_safe(atom, key, default)),
?assertEqual(default, get_safe({tuple, value}, key, default)),
?assertEqual(default, get_safe(self(), key, default)).
get_safe_various_key_types_test() ->
Map = #{
atom_key => atom_value,
<<"binary_key">> => binary_value,
123 => number_key_value,
{tuple, key} => tuple_key_value
},
?assertEqual(atom_value, get_safe(Map, atom_key, default)),
?assertEqual(binary_value, get_safe(Map, <<"binary_key">>, default)),
?assertEqual(number_key_value, get_safe(Map, 123, default)),
?assertEqual(tuple_key_value, get_safe(Map, {tuple, key}, default)),
?assertEqual(default, get_safe(Map, missing_atom, default)),
?assertEqual(default, get_safe(Map, <<"missing_binary">>, default)),
?assertEqual(default, get_safe(Map, 999, default)).
get_safe_default_types_test() ->
Map = #{key => value},
?assertEqual(nil, get_safe(Map, missing, nil)),
?assertEqual(0, get_safe(Map, missing, 0)),
?assertEqual(<<"default">>, get_safe(Map, missing, <<"default">>)),
?assertEqual([], get_safe(Map, missing, [])),
?assertEqual(#{}, get_safe(Map, missing, #{})),
?assertEqual({tuple, default}, get_safe(Map, missing, {tuple, default})).
get_nested_basic_test() ->
Map = #{
level1 => #{
level2 => #{
level3 => deep_value
},
other => other_value
},
simple => simple_value
},
?assertEqual(#{level3 => deep_value}, get_nested(Map, [level1, level2], default)),
?assertEqual(
#{other => other_value, level2 => #{level3 => deep_value}},
get_nested(Map, [level1], default)
),
?assertEqual(default, get_nested(Map, [level1, level2, level3], default)),
?assertEqual(default, get_nested(Map, [level1, other], default)),
?assertEqual(default, get_nested(Map, [simple], default)),
?assertEqual(Map, get_nested(Map, [], default)),
?assertEqual(default, get_nested(Map, [level1, missing], default)),
?assertEqual(default, get_nested(Map, [missing, level2], default)),
?assertEqual(default, get_nested(Map, [level1, level2, missing], default)).
get_nested_deep_nesting_test() ->
DeepMap = #{
l1 => #{
l2 => #{
l3 => #{
l4 => #{
l5 => final_value,
other5 => value5
},
other4 => value4
},
other3 => value3
}
}
},
Level4Map = get_nested(DeepMap, [l1, l2, l3, l4], default),
?assert(is_map(Level4Map)),
?assertEqual(final_value, maps:get(l5, Level4Map)),
?assertEqual(value5, maps:get(other5, Level4Map)),
Level3Map = get_nested(DeepMap, [l1, l2, l3], default),
?assert(is_map(Level3Map)),
?assertEqual(value4, maps:get(other4, Level3Map)),
Level2Map = get_nested(DeepMap, [l1, l2], default),
?assert(is_map(Level2Map)),
?assertEqual(value3, maps:get(other3, Level2Map)),
?assertEqual(default, get_nested(DeepMap, [l1, l2, l3, l4, l5], default)),
?assertEqual(default, get_nested(DeepMap, [l1, l2, l3, l4, other5], default)),
?assertEqual(default, get_nested(DeepMap, [l1, l2, l3, other4], default)),
?assertEqual(default, get_nested(DeepMap, [l1, l2, other3], default)),
?assertEqual(default, get_nested(DeepMap, [l1, l2, l3, l4, l5, extra], default)),
?assertEqual(default, get_nested(DeepMap, [l1, l2, missing, l4, l5], default)),
?assertEqual(default, get_nested(DeepMap, [missing, l2, l3, l4, l5], default)).
get_nested_partial_paths_test() ->
Map = #{
user => #{
name => <<"Alice">>,
age => 30,
address => #{
city => <<"New York">>,
zip => 10001
}
},
count => 42,
tags => [tag1, tag2, tag3]
},
UserMap = get_nested(Map, [user], default),
?assert(is_map(UserMap)),
?assertEqual(<<"Alice">>, maps:get(name, UserMap)),
AddressMap = get_nested(Map, [user, address], default),
?assert(is_map(AddressMap)),
?assertEqual(<<"New York">>, maps:get(city, AddressMap)),
?assertEqual(default, get_nested(Map, [count], default)),
?assertEqual(default, get_nested(Map, [user, name], default)),
?assertEqual(default, get_nested(Map, [user, age], default)),
?assertEqual(default, get_nested(Map, [tags], default)),
?assertEqual(default, get_nested(Map, [count, extra], default)),
?assertEqual(default, get_nested(Map, [count, deep, path], default)),
?assertEqual(default, get_nested(Map, [user, name, extra], default)),
?assertEqual(default, get_nested(Map, [tags, extra], default)),
?assertEqual(default, get_nested(Map, [user, age, extra, path], default)).
get_nested_edge_cases_test() ->
Map = #{key => #{nested => value}},
?assertEqual(default, get_nested(not_a_map, [], default)),
?assertEqual(default, get_nested([], [], default)),
?assertEqual(default, get_nested(123, [], default)),
?assertEqual(default, get_nested(not_a_map, [key], default)),
?assertEqual(default, get_nested([], [key], default)),
?assertEqual(default, get_nested(123, [key, nested], default)),
?assertEqual(default, get_safe(undefined, key, default)),
?assertEqual(#{nested => value}, get_nested(Map, [key], default)),
?assertEqual(default, get_nested(Map, [key, nested], default)),
BinaryMap = #{<<"key">> => #{<<"nested">> => <<"value">>}},
?assertEqual(
#{<<"nested">> => <<"value">>},
get_nested(BinaryMap, [<<"key">>], default)
),
?assertEqual(default, get_nested(BinaryMap, [<<"key">>, <<"nested">>], default)).
get_integer_basic_test() ->
Map = #{id => <<"42">>, <<"count">> => 10},
?assertEqual(42, get_integer(Map, id, 0)),
?assertEqual(10, get_integer(Map, <<"count">>, 0)),
?assertEqual(99, get_integer(Map, missing, 99)).
get_integer_invalid_input_test() ->
?assertEqual(7, get_integer(undefined, id, 7)),
?assertEqual(undefined, get_integer(#{}, id, undefined)),
?assertEqual(0, get_integer(#{id => <<"abc">>}, id, 0)).
get_binary_basic_test() ->
Map = #{<<"name">> => <<"fluxer">>, tag => atom},
?assertEqual(<<"fluxer">>, get_binary(Map, <<"name">>, <<"default">>)),
?assertEqual(<<"atom">>, get_binary(Map, tag, <<"default">>)).
get_binary_invalid_input_test() ->
?assertEqual(<<"default">>, get_binary(not_a_map, <<"id">>, <<"default">>)),
?assertEqual(undefined, get_binary(#{}, <<"missing">>, undefined)),
?assertEqual(<<"default">>, get_binary(#{num => 123}, <<"num">>, <<"default">>)).
ensure_map_test() ->
Map = #{key => value, nested => #{inner => data}},
?assertEqual(Map, ensure_map(Map)),
?assertEqual(#{}, ensure_map(#{})).
ensure_map_all_input_types_test() ->
?assertEqual(#{}, ensure_map(not_a_map)),
?assertEqual(#{}, ensure_map([])),
?assertEqual(#{}, ensure_map([1, 2, 3])),
?assertEqual(#{}, ensure_map(123)),
?assertEqual(#{}, ensure_map(123.456)),
?assertEqual(#{}, ensure_map(<<"binary">>)),
?assertEqual(#{}, ensure_map("string")),
?assertEqual(#{}, ensure_map(undefined)),
?assertEqual(#{}, ensure_map(atom)),
?assertEqual(#{}, ensure_map(true)),
?assertEqual(#{}, ensure_map(false)),
?assertEqual(#{}, ensure_map({tuple, value})),
?assertEqual(#{}, ensure_map(self())),
?assertEqual(#{}, ensure_map(make_ref())),
?assertEqual(#{}, ensure_map(fun() -> ok end)).
ensure_list_test() ->
List = [1, 2, 3],
?assertEqual(List, ensure_list(List)),
ComplexList = [#{a => 1}, {tuple}, <<"binary">>, atom],
?assertEqual(ComplexList, ensure_list(ComplexList)),
?assertEqual([], ensure_list([])).
ensure_list_all_input_types_test() ->
?assertEqual([], ensure_list(not_a_list)),
?assertEqual([], ensure_list(#{})),
?assertEqual([], ensure_list(#{key => value})),
?assertEqual([], ensure_list(123)),
?assertEqual([], ensure_list(123.456)),
?assertEqual([], ensure_list(<<"binary">>)),
?assertEqual("string", ensure_list("string")),
?assert(is_list(ensure_list("string"))),
?assertEqual([], ensure_list(undefined)),
?assertEqual([], ensure_list(atom)),
?assertEqual([], ensure_list(true)),
?assertEqual([], ensure_list(false)),
?assertEqual([], ensure_list({tuple, value})),
?assertEqual([], ensure_list(self())),
?assertEqual([], ensure_list(make_ref())),
?assertEqual([], ensure_list(fun() -> ok end)).
filter_by_field_basic_test() ->
List = [
#{id => 1, type => a, name => <<"first">>},
#{id => 2, type => b, name => <<"second">>},
#{id => 3, type => a, name => <<"third">>},
#{id => 4, type => c},
#{id => 5, type => a}
],
Filtered = filter_by_field(List, type, a),
?assertEqual(3, length(Filtered)),
?assert(lists:all(fun(M) -> maps:get(type, M) =:= a end, Filtered)),
?assertEqual(
[#{id => 2, type => b, name => <<"second">>}],
filter_by_field(List, id, 2)
),
?assertEqual([], filter_by_field(List, type, nonexistent)),
?assertEqual([], filter_by_field(List, missing_field, value)).
filter_by_field_mixed_lists_test() ->
MixedList = [
#{id => 1, type => a},
not_a_map,
#{id => 2, type => b},
123,
#{id => 3, type => a},
<<"binary">>,
undefined,
#{id => 4, type => a},
[],
{tuple, value},
#{id => 5, type => c}
],
Result = filter_by_field(MixedList, type, a),
?assertEqual(3, length(Result)),
?assert(lists:all(fun is_map/1, Result)),
?assert(lists:all(fun(M) -> maps:get(type, M) =:= a end, Result)),
Ids = [maps:get(id, M) || M <- Result],
?assertEqual([1, 3, 4], Ids),
ResultB = filter_by_field(MixedList, type, b),
?assertEqual(1, length(ResultB)),
?assertEqual([#{id => 2, type => b}], ResultB).
filter_by_field_edge_cases_test() ->
?assertEqual([], filter_by_field([], field, value)),
NonMaps = [123, atom, <<"binary">>, {tuple}, []],
?assertEqual([], filter_by_field(NonMaps, field, value)),
NoFieldList = [#{a => 1}, #{b => 2}, #{c => 3}],
?assertEqual([], filter_by_field(NoFieldList, missing, value)),
?assertEqual([], filter_by_field(not_a_list, field, value)),
?assertEqual([], filter_by_field(#{}, field, value)),
?assertEqual([], filter_by_field(123, field, value)),
BinaryList = [
#{<<"key">> => <<"value1">>},
#{<<"key">> => <<"value2">>},
#{<<"other">> => <<"value1">>}
],
?assertEqual(
[#{<<"key">> => <<"value1">>}],
filter_by_field(BinaryList, <<"key">>, <<"value1">>)
),
ComplexList = [
#{data => #{nested => value}},
#{data => [1, 2, 3]},
#{data => #{nested => value}},
#{other => data}
],
ComplexFiltered = filter_by_field(ComplexList, data, #{nested => value}),
?assertEqual(2, length(ComplexFiltered)).
find_by_field_basic_test() ->
List = [
#{id => 1, type => a},
#{id => 2, type => b},
#{id => 3, type => a},
#{id => 4, type => c}
],
?assertEqual({ok, #{id => 2, type => b}}, find_by_field(List, id, 2)),
?assertEqual({ok, #{id => 4, type => c}}, find_by_field(List, id, 4)),
?assertEqual(error, find_by_field(List, id, 999)),
?assertEqual(error, find_by_field(List, type, nonexistent)),
?assertEqual(error, find_by_field([], id, 1)).
find_by_field_multiple_matches_test() ->
List = [
#{id => 1, type => a, order => first},
#{id => 2, type => b, order => second},
#{id => 3, type => a, order => third},
#{id => 4, type => c, order => fourth},
#{id => 5, type => a, order => fifth}
],
{ok, First} = find_by_field(List, type, a),
?assertEqual(1, maps:get(id, First)),
?assertEqual(first, maps:get(order, First)),
?assertNotEqual(third, maps:get(order, First)),
?assertNotEqual(fifth, maps:get(order, First)),
List2 = [
#{name => <<"Alice">>, age => 25},
#{name => <<"Bob">>, age => 30},
#{name => <<"Charlie">>, age => 25},
#{name => <<"Diana">>, age => 25}
],
{ok, FirstAge25} = find_by_field(List2, age, 25),
?assertEqual(<<"Alice">>, maps:get(name, FirstAge25)).
find_by_field_no_matches_test() ->
List = [
#{id => 1, type => a},
#{id => 2, type => b},
#{id => 3, type => c}
],
?assertEqual(error, find_by_field(List, id, 999)),
?assertEqual(error, find_by_field(List, type, z)),
?assertEqual(error, find_by_field(List, missing_field, value)),
?assertEqual(error, find_by_field(List, id, <<"wrong_type">>)),
?assertEqual(error, find_by_field([], any_field, any_value)).
find_by_field_with_non_maps_test() ->
MixedList = [
not_a_map,
123,
#{id => 1, type => a},
<<"binary">>,
undefined,
#{id => 2, type => b},
[],
#{id => 3, type => a}
],
{ok, Found1} = find_by_field(MixedList, type, a),
?assertEqual(1, maps:get(id, Found1)),
{ok, Found2} = find_by_field(MixedList, id, 2),
?assertEqual(b, maps:get(type, Found2)),
MixedList2 = [atom, 456, {tuple}, #{id => 5, type => z}],
?assertEqual({ok, #{id => 5, type => z}}, find_by_field(MixedList2, id, 5)),
OnlyNonMaps = [atom, 123, <<"binary">>, {tuple}, []],
?assertEqual(error, find_by_field(OnlyNonMaps, field, value)).
find_by_field_invalid_input_test() ->
?assertEqual(error, find_by_field(not_a_list, field, value)),
?assertEqual(error, find_by_field(#{}, field, value)),
?assertEqual(error, find_by_field(123, field, value)),
?assertEqual(error, find_by_field(<<"binary">>, field, value)),
?assertEqual(error, find_by_field(undefined, field, value)),
?assertEqual(error, find_by_field(atom, field, value)),
?assertEqual(error, find_by_field({tuple}, field, value)).
find_by_field_complex_values_test() ->
List = [
#{<<"id">> => <<"first">>, <<"data">> => <<"value1">>},
#{<<"id">> => <<"second">>, <<"data">> => <<"value2">>},
#{<<"id">> => <<"third">>, <<"data">> => <<"value1">>}
],
{ok, Found} = find_by_field(List, <<"data">>, <<"value1">>),
?assertEqual(<<"first">>, maps:get(<<"id">>, Found)),
ComplexList = [
#{key => #{nested => value1}, id => 1},
#{key => [1, 2, 3], id => 2},
#{key => #{nested => value1}, id => 3}
],
{ok, ComplexFound} = find_by_field(ComplexList, key, #{nested => value1}),
?assertEqual(1, maps:get(id, ComplexFound)),
{ok, ListFound} = find_by_field(ComplexList, key, [1, 2, 3]),
?assertEqual(2, maps:get(id, ListFound)).
-endif.

View File

@@ -0,0 +1,636 @@
%% 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(type_conv).
-export([
to_integer/1,
to_binary/1,
to_list/1,
extract_id/2,
extract_id_required/2
]).
-type convertible_to_integer() :: integer() | binary() | list() | atom().
-type convertible_to_binary() :: binary() | integer() | list() | atom().
-type convertible_to_list() :: list() | binary() | atom().
-spec to_integer(convertible_to_integer() | undefined) -> integer() | undefined.
to_integer(undefined) ->
undefined;
to_integer(Value) when is_integer(Value) ->
Value;
to_integer(Value) when is_binary(Value) ->
try
binary_to_integer(Value)
catch
error:badarg ->
undefined
end;
to_integer(Value) when is_list(Value) ->
try
list_to_integer(Value)
catch
error:badarg ->
undefined
end;
to_integer(Value) when is_atom(Value) ->
try
list_to_integer(atom_to_list(Value))
catch
error:badarg ->
undefined
end;
to_integer(_) ->
undefined.
-spec to_binary(convertible_to_binary() | undefined) -> binary() | undefined.
to_binary(undefined) ->
undefined;
to_binary(Value) when is_binary(Value) ->
Value;
to_binary(Value) when is_integer(Value) ->
integer_to_binary(Value);
to_binary(Value) when is_list(Value) ->
try
list_to_binary(Value)
catch
error:badarg ->
undefined
end;
to_binary(Value) when is_atom(Value) ->
atom_to_binary(Value, utf8);
to_binary(_) ->
undefined.
-spec to_list(convertible_to_list() | undefined) -> list() | undefined.
to_list(undefined) ->
undefined;
to_list(Value) when is_list(Value) ->
Value;
to_list(Value) when is_binary(Value) ->
binary_to_list(Value);
to_list(Value) when is_atom(Value) ->
atom_to_list(Value);
to_list(_) ->
undefined.
-spec extract_id(map(), atom() | binary()) -> integer() | undefined.
extract_id(Map, Field) when is_map(Map), is_atom(Field) ->
case maps:get(Field, Map, undefined) of
undefined ->
undefined;
Value ->
to_integer(Value)
end;
extract_id(Map, Field) when is_map(Map), is_binary(Field) ->
case maps:get(Field, Map, undefined) of
undefined ->
undefined;
Value ->
to_integer(Value)
end;
extract_id(_, _) ->
undefined.
-spec extract_id_required(map(), atom() | binary()) -> integer().
extract_id_required(Map, Field) ->
case extract_id(Map, Field) of
undefined ->
0;
Value when is_integer(Value) ->
Value
end.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
to_integer_with_integer_test() ->
?assertEqual(42, to_integer(42)),
?assertEqual(0, to_integer(0)),
?assertEqual(-100, to_integer(-100)).
to_integer_with_integer_edge_cases_test() ->
?assertEqual(1234567890123456789, to_integer(1234567890123456789)),
?assertEqual(9223372036854775807, to_integer(9223372036854775807)),
?assertEqual(-9223372036854775807, to_integer(-9223372036854775807)).
to_integer_with_binary_valid_test() ->
?assertEqual(123, to_integer(<<"123">>)),
?assertEqual(0, to_integer(<<"0">>)),
?assertEqual(-456, to_integer(<<"-456">>)).
to_integer_with_binary_edge_cases_test() ->
?assertEqual(1234567890123456789, to_integer(<<"1234567890123456789">>)),
?assertEqual(9223372036854775807, to_integer(<<"9223372036854775807">>)),
?assertEqual(-9223372036854775807, to_integer(<<"-9223372036854775807">>)),
?assertEqual(1, to_integer(<<"1">>)),
?assertEqual(123, to_integer(<<"00123">>)),
?assertEqual(0, to_integer(<<"0">>)).
to_integer_with_binary_invalid_test() ->
?assertEqual(undefined, to_integer(<<"not_a_number">>)),
?assertEqual(undefined, to_integer(<<"12.34">>)),
?assertEqual(undefined, to_integer(<<"">>)),
?assertEqual(undefined, to_integer(<<" ">>)),
?assertEqual(undefined, to_integer(<<"abc123">>)),
?assertEqual(undefined, to_integer(<<"123abc">>)),
?assertEqual(undefined, to_integer(<<"12 34">>)),
?assertEqual(undefined, to_integer(<<"--123">>)),
?assertEqual(undefined, to_integer(<<"+-123">>)).
to_integer_with_binary_special_chars_test() ->
?assertEqual(undefined, to_integer(<<"!@#$%">>)),
?assertEqual(undefined, to_integer(<<"">>)),
?assertEqual(undefined, to_integer(<<"①②③">>)),
?assertEqual(undefined, to_integer(<<"一二三">>)),
?assertEqual(undefined, to_integer(<<"null">>)),
?assertEqual(undefined, to_integer(<<"NaN">>)),
?assertEqual(undefined, to_integer(<<"Infinity">>)).
to_integer_with_list_valid_test() ->
?assertEqual(789, to_integer("789")),
?assertEqual(-123, to_integer("-123")),
?assertEqual(0, to_integer("0")).
to_integer_with_list_edge_cases_test() ->
?assertEqual(1234567890123456789, to_integer("1234567890123456789")),
?assertEqual(5, to_integer("5")),
?assertEqual(42, to_integer("00042")),
?assertEqual(-42, to_integer("-00042")).
to_integer_with_list_invalid_test() ->
?assertEqual(undefined, to_integer("invalid")),
?assertEqual(undefined, to_integer("12.34")),
?assertEqual(undefined, to_integer("")),
?assertEqual(undefined, to_integer(" ")),
?assertEqual(undefined, to_integer("abc")),
?assertEqual(undefined, to_integer("123abc")),
?assertEqual(undefined, to_integer("12 34")),
?assertEqual(undefined, to_integer([1, 2, 3])).
to_integer_with_list_special_chars_test() ->
?assertEqual(undefined, to_integer("!@#$%")),
?assertEqual(undefined, to_integer("hello world")),
?assertEqual(undefined, to_integer("--456")),
?assertEqual(undefined, to_integer("null")).
to_integer_with_atom_valid_test() ->
?assertEqual(123, to_integer('123')),
?assertEqual(-456, to_integer('-456')),
?assertEqual(0, to_integer('0')).
to_integer_with_atom_invalid_test() ->
?assertEqual(undefined, to_integer(test)),
?assertEqual(undefined, to_integer('not_a_number')),
?assertEqual(undefined, to_integer(hello)),
?assertEqual(undefined, to_integer(true)),
?assertEqual(undefined, to_integer(false)),
?assertEqual(undefined, to_integer(nil)),
?assertEqual(undefined, to_integer('')).
to_integer_with_undefined_test() ->
?assertEqual(undefined, to_integer(undefined)).
to_integer_with_invalid_types_test() ->
?assertEqual(undefined, to_integer(12.34)),
?assertEqual(undefined, to_integer(-45.67)),
?assertEqual(undefined, to_integer(0.0)),
?assertEqual(undefined, to_integer(#{key => value})),
?assertEqual(undefined, to_integer(#{})),
?assertEqual(undefined, to_integer({1, 2, 3})),
?assertEqual(undefined, to_integer({})),
Ref = make_ref(),
?assertEqual(undefined, to_integer(Ref)),
?assertEqual(undefined, to_integer(self())),
?assertEqual(undefined, to_integer(erlang:list_to_port("#Port<0.0>"))).
to_binary_with_binary_test() ->
?assertEqual(<<"test">>, to_binary(<<"test">>)),
?assertEqual(<<>>, to_binary(<<>>)).
to_binary_with_binary_edge_cases_test() ->
?assertEqual(<<"hello world">>, to_binary(<<"hello world">>)),
?assertEqual(<<"!@#$%^&*()">>, to_binary(<<"!@#$%^&*()">>)),
?assertEqual(<<"line1\nline2">>, to_binary(<<"line1\nline2">>)),
?assertEqual(<<"tab\there">>, to_binary(<<"tab\there">>)),
LongBinary = binary:copy(<<"x">>, 10000),
?assertEqual(LongBinary, to_binary(LongBinary)).
to_binary_with_binary_unicode_test() ->
?assertEqual(<<"Hello 世界"/utf8>>, to_binary(<<"Hello 世界"/utf8>>)),
?assertEqual(<<"Здравствуй мир"/utf8>>, to_binary(<<"Здравствуй мир"/utf8>>)),
?assertEqual(<<"مرحبا بالعالم"/utf8>>, to_binary(<<"مرحبا بالعالم"/utf8>>)),
?assertEqual(<<"🚀🌟💻"/utf8>>, to_binary(<<"🚀🌟💻"/utf8>>)),
?assertEqual(<<"Ñoño"/utf8>>, to_binary(<<"Ñoño"/utf8>>)),
?assertEqual(<<"Café"/utf8>>, to_binary(<<"Café"/utf8>>)).
to_binary_with_integer_test() ->
?assertEqual(<<"42">>, to_binary(42)),
?assertEqual(<<"0">>, to_binary(0)),
?assertEqual(<<"-100">>, to_binary(-100)).
to_binary_with_integer_edge_cases_test() ->
?assertEqual(<<"1234567890123456789">>, to_binary(1234567890123456789)),
?assertEqual(<<"9223372036854775807">>, to_binary(9223372036854775807)),
?assertEqual(<<"-9223372036854775807">>, to_binary(-9223372036854775807)),
?assertEqual(<<"1">>, to_binary(1)),
?assertEqual(<<"-1">>, to_binary(-1)).
to_binary_with_list_valid_test() ->
?assertEqual(<<"hello">>, to_binary("hello")),
?assertEqual(<<>>, to_binary("")).
to_binary_with_list_edge_cases_test() ->
?assertEqual(<<"hello world">>, to_binary("hello world")),
?assertEqual(<<"!@#$%">>, to_binary("!@#$%")),
?assertEqual(<<"line1\nline2">>, to_binary("line1\nline2")),
LongString = lists:duplicate(10000, $x),
LongBinary = binary:copy(<<"x">>, 10000),
?assertEqual(LongBinary, to_binary(LongString)).
to_binary_with_list_unicode_test() ->
?assertEqual(undefined, to_binary([72, 101, 108, 108, 111, 32, 19990, 30028])),
?assertEqual(undefined, to_binary([128640, 127775, 128187])),
?assertEqual(<<67, 97, 102, 233>>, to_binary([67, 97, 102, 233])),
?assertEqual(<<"Hello">>, to_binary([72, 101, 108, 108, 111])),
?assertEqual(<<0, 1, 127, 255>>, to_binary([0, 1, 127, 255])).
to_binary_with_list_invalid_test() ->
?assertEqual(<<1, 2, 3>>, to_binary([1, 2, 3])),
?assertEqual(undefined, to_binary([256])),
?assertEqual(undefined, to_binary([1000])),
?assertEqual(undefined, to_binary([-1])),
?assertEqual(undefined, to_binary([hello, world])),
?assertEqual(undefined, to_binary([1, 2, atom])).
to_binary_with_atom_test() ->
?assertEqual(<<"test">>, to_binary(test)),
?assertEqual(<<"hello_world">>, to_binary(hello_world)),
?assertEqual(<<"true">>, to_binary(true)),
?assertEqual(<<"false">>, to_binary(false)),
?assertEqual(<<"">>, to_binary('')).
to_binary_with_atom_edge_cases_test() ->
?assertEqual(<<"Hello World">>, to_binary('Hello World')),
?assertEqual(<<"123">>, to_binary('123')),
?assertEqual(<<"hello-world">>, to_binary('hello-world')),
?assertEqual(<<"test@example">>, to_binary('test@example')),
?assertEqual(undefined, to_binary(undefined)).
to_binary_with_invalid_types_test() ->
?assertEqual(undefined, to_binary(12.34)),
?assertEqual(undefined, to_binary(-45.67)),
?assertEqual(undefined, to_binary(0.0)),
?assertEqual(undefined, to_binary(#{key => value})),
?assertEqual(undefined, to_binary(#{})),
?assertEqual(undefined, to_binary({1, 2, 3})),
?assertEqual(undefined, to_binary({})),
Ref = make_ref(),
?assertEqual(undefined, to_binary(Ref)),
?assertEqual(undefined, to_binary(self())),
?assertEqual(undefined, to_binary(erlang:list_to_port("#Port<0.0>"))).
to_list_with_list_test() ->
?assertEqual("test", to_list("test")),
?assertEqual([], to_list([])),
?assertEqual([1, 2, 3], to_list([1, 2, 3])).
to_list_with_list_edge_cases_test() ->
?assertEqual("hello world", to_list("hello world")),
?assertEqual("!@#$%^&*()", to_list("!@#$%^&*()")),
?assertEqual([true, false, nil], to_list([true, false, nil])),
?assertEqual([[1, 2], [3, 4]], to_list([[1, 2], [3, 4]])),
LongList = lists:duplicate(10000, $x),
?assertEqual(LongList, to_list(LongList)).
to_list_with_list_unicode_test() ->
?assertEqual(
[72, 101, 108, 108, 111, 32, 19990, 30028],
to_list([72, 101, 108, 108, 111, 32, 19990, 30028])
),
?assertEqual([67, 97, 102, 233], to_list([67, 97, 102, 233])),
?assertEqual([128640, 127775, 128187], to_list([128640, 127775, 128187])).
to_list_with_binary_test() ->
?assertEqual("hello", to_list(<<"hello">>)),
?assertEqual("", to_list(<<>>)).
to_list_with_binary_edge_cases_test() ->
?assertEqual("hello world", to_list(<<"hello world">>)),
?assertEqual("!@#$%", to_list(<<"!@#$%">>)),
?assertEqual("line1\nline2", to_list(<<"line1\nline2">>)),
?assertEqual("tab\there", to_list(<<"tab\there">>)),
LongBinary = binary:copy(<<"x">>, 10000),
LongList = lists:duplicate(10000, $x),
?assertEqual(LongList, to_list(LongBinary)).
to_list_with_binary_unicode_test() ->
?assertEqual(
[72, 101, 108, 108, 111, 32, 228, 184, 150, 231, 149, 140], to_list(<<"Hello 世界"/utf8>>)
),
?assertEqual([67, 97, 102, 195, 169], to_list(<<"Café"/utf8>>)),
?assertEqual(<<"Hello 世界"/utf8>>, list_to_binary(to_list(<<"Hello 世界"/utf8>>))).
to_list_with_atom_test() ->
?assertEqual("test", to_list(test)),
?assertEqual("hello_world", to_list(hello_world)),
?assertEqual("true", to_list(true)),
?assertEqual("false", to_list(false)),
?assertEqual("", to_list('')).
to_list_with_atom_edge_cases_test() ->
?assertEqual([72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100], to_list('Hello World')),
?assertEqual([49, 50, 51], to_list('123')),
?assertEqual([104, 101, 108, 108, 111, 45, 119, 111, 114, 108, 100], to_list('hello-world')),
?assertEqual(undefined, to_list(undefined)).
to_list_with_invalid_types_test() ->
?assertEqual(undefined, to_list(42)),
?assertEqual(undefined, to_list(-123)),
?assertEqual(undefined, to_list(0)),
?assertEqual(undefined, to_list(12.34)),
?assertEqual(undefined, to_list(-45.67)),
?assertEqual(undefined, to_list(0.0)),
?assertEqual(undefined, to_list(#{key => value})),
?assertEqual(undefined, to_list(#{})),
?assertEqual(undefined, to_list({1, 2, 3})),
?assertEqual(undefined, to_list({})),
Ref = make_ref(),
?assertEqual(undefined, to_list(Ref)),
?assertEqual(undefined, to_list(self())),
?assertEqual(undefined, to_list(erlang:list_to_port("#Port<0.0>"))).
extract_id_with_atom_key_integer_test() ->
Map1 = #{user_id => 123},
?assertEqual(123, extract_id(Map1, user_id)),
Map2 = #{user_id => 0},
?assertEqual(0, extract_id(Map2, user_id)),
Map3 = #{user_id => -456},
?assertEqual(-456, extract_id(Map3, user_id)).
extract_id_with_atom_key_binary_test() ->
Map1 = #{user_id => <<"456">>},
?assertEqual(456, extract_id(Map1, user_id)),
Map2 = #{user_id => <<"0">>},
?assertEqual(0, extract_id(Map2, user_id)),
Map3 = #{user_id => <<"-789">>},
?assertEqual(-789, extract_id(Map3, user_id)).
extract_id_with_atom_key_list_test() ->
Map1 = #{user_id => "789"},
?assertEqual(789, extract_id(Map1, user_id)),
Map2 = #{user_id => "0"},
?assertEqual(0, extract_id(Map2, user_id)),
Map3 = #{user_id => "-123"},
?assertEqual(-123, extract_id(Map3, user_id)).
extract_id_with_atom_key_edge_cases_test() ->
Map1 = #{user_id => 1234567890123456789},
?assertEqual(1234567890123456789, extract_id(Map1, user_id)),
Map2 = #{user_id => <<"9223372036854775807">>},
?assertEqual(9223372036854775807, extract_id(Map2, user_id)),
Map3 = #{user_id => <<"00123">>},
?assertEqual(123, extract_id(Map3, user_id)).
extract_id_with_atom_key_missing_test() ->
Map1 = #{other_field => 999},
?assertEqual(undefined, extract_id(Map1, user_id)),
Map2 = #{},
?assertEqual(undefined, extract_id(Map2, user_id)).
extract_id_with_atom_key_undefined_value_test() ->
Map1 = #{user_id => undefined},
?assertEqual(undefined, extract_id(Map1, user_id)).
extract_id_with_atom_key_invalid_value_test() ->
Map1 = #{user_id => "invalid"},
?assertEqual(undefined, extract_id(Map1, user_id)),
Map2 = #{user_id => <<"not_a_number">>},
?assertEqual(undefined, extract_id(Map2, user_id)),
Map3 = #{user_id => "12.34"},
?assertEqual(undefined, extract_id(Map3, user_id)),
Map4 = #{user_id => #{nested => map}},
?assertEqual(undefined, extract_id(Map4, user_id)),
Map5 = #{user_id => [1, 2, 3]},
?assertEqual(undefined, extract_id(Map5, user_id)),
Map6 = #{user_id => 12.34},
?assertEqual(undefined, extract_id(Map6, user_id)).
extract_id_with_binary_key_integer_test() ->
Map1 = #{<<"user_id">> => 123},
?assertEqual(123, extract_id(Map1, <<"user_id">>)),
Map2 = #{<<"user_id">> => 0},
?assertEqual(0, extract_id(Map2, <<"user_id">>)),
Map3 = #{<<"user_id">> => -789},
?assertEqual(-789, extract_id(Map3, <<"user_id">>)).
extract_id_with_binary_key_binary_test() ->
Map1 = #{<<"user_id">> => <<"456">>},
?assertEqual(456, extract_id(Map1, <<"user_id">>)),
Map2 = #{<<"user_id">> => <<"0">>},
?assertEqual(0, extract_id(Map2, <<"user_id">>)),
Map3 = #{<<"user_id">> => <<"-123">>},
?assertEqual(-123, extract_id(Map3, <<"user_id">>)).
extract_id_with_binary_key_list_test() ->
Map1 = #{<<"user_id">> => "789"},
?assertEqual(789, extract_id(Map1, <<"user_id">>)),
Map2 = #{<<"user_id">> => "0"},
?assertEqual(0, extract_id(Map2, <<"user_id">>)).
extract_id_with_binary_key_edge_cases_test() ->
Map1 = #{<<"user_id">> => 1234567890123456789},
?assertEqual(1234567890123456789, extract_id(Map1, <<"user_id">>)),
Map2 = #{<<>> => 123},
?assertEqual(123, extract_id(Map2, <<>>)),
Map3 = #{<<"user:id">> => 456},
?assertEqual(456, extract_id(Map3, <<"user:id">>)).
extract_id_with_binary_key_missing_test() ->
Map1 = #{<<"other_field">> => 999},
?assertEqual(undefined, extract_id(Map1, <<"user_id">>)),
Map2 = #{},
?assertEqual(undefined, extract_id(Map2, <<"user_id">>)).
extract_id_with_binary_key_undefined_value_test() ->
Map1 = #{<<"user_id">> => undefined},
?assertEqual(undefined, extract_id(Map1, <<"user_id">>)).
extract_id_with_binary_key_invalid_value_test() ->
Map1 = #{<<"user_id">> => "invalid"},
?assertEqual(undefined, extract_id(Map1, <<"user_id">>)),
Map2 = #{<<"user_id">> => <<"not_a_number">>},
?assertEqual(undefined, extract_id(Map2, <<"user_id">>)),
Map3 = #{<<"user_id">> => 12.34},
?assertEqual(undefined, extract_id(Map3, <<"user_id">>)).
extract_id_with_invalid_map_test() ->
?assertEqual(undefined, extract_id(not_a_map, user_id)),
?assertEqual(undefined, extract_id(123, user_id)),
?assertEqual(undefined, extract_id("string", user_id)),
?assertEqual(undefined, extract_id(<<"binary">>, user_id)),
?assertEqual(undefined, extract_id([1, 2, 3], user_id)),
?assertEqual(undefined, extract_id({tuple}, user_id)),
?assertEqual(undefined, extract_id(undefined, user_id)).
extract_id_with_invalid_key_type_test() ->
Map = #{user_id => 123},
?assertEqual(undefined, extract_id(Map, 123)),
?assertEqual(undefined, extract_id(Map, "user_id")),
?assertEqual(undefined, extract_id(Map, {user_id})),
?assertEqual(undefined, extract_id(Map, [user_id])),
?assertEqual(undefined, extract_id(Map, 12.34)).
extract_id_with_both_invalid_test() ->
?assertEqual(undefined, extract_id(not_a_map, 123)),
?assertEqual(undefined, extract_id(undefined, undefined)),
?assertEqual(undefined, extract_id(123, "key")).
extract_id_required_with_valid_integer_test() ->
Map1 = #{user_id => 123},
?assertEqual(123, extract_id_required(Map1, user_id)),
Map2 = #{user_id => 0},
?assertEqual(0, extract_id_required(Map2, user_id)),
Map3 = #{user_id => -456},
?assertEqual(-456, extract_id_required(Map3, user_id)).
extract_id_required_with_valid_binary_test() ->
Map1 = #{user_id => <<"456">>},
?assertEqual(456, extract_id_required(Map1, user_id)),
Map2 = #{<<"user_id">> => <<"789">>},
?assertEqual(789, extract_id_required(Map2, <<"user_id">>)).
extract_id_required_with_valid_list_test() ->
Map1 = #{user_id => "123"},
?assertEqual(123, extract_id_required(Map1, user_id)),
Map2 = #{user_id => "-456"},
?assertEqual(-456, extract_id_required(Map2, user_id)).
extract_id_required_with_edge_cases_test() ->
Map1 = #{user_id => 1234567890123456789},
?assertEqual(1234567890123456789, extract_id_required(Map1, user_id)),
Map2 = #{<<"user_id">> => <<"9223372036854775807">>},
?assertEqual(9223372036854775807, extract_id_required(Map2, <<"user_id">>)).
extract_id_required_with_missing_field_test() ->
Map1 = #{other_field => 999},
?assertEqual(0, extract_id_required(Map1, user_id)),
Map2 = #{},
?assertEqual(0, extract_id_required(Map2, user_id)),
Map3 = #{<<"other_field">> => 999},
?assertEqual(0, extract_id_required(Map3, <<"user_id">>)).
extract_id_required_with_undefined_value_test() ->
Map1 = #{user_id => undefined},
?assertEqual(0, extract_id_required(Map1, user_id)),
Map2 = #{<<"user_id">> => undefined},
?assertEqual(0, extract_id_required(Map2, <<"user_id">>)).
extract_id_required_with_invalid_value_test() ->
Map1 = #{user_id => "invalid"},
?assertEqual(0, extract_id_required(Map1, user_id)),
Map2 = #{user_id => <<"not_a_number">>},
?assertEqual(0, extract_id_required(Map2, user_id)),
Map3 = #{user_id => "12.34"},
?assertEqual(0, extract_id_required(Map3, user_id)),
Map4 = #{user_id => #{nested => map}},
?assertEqual(0, extract_id_required(Map4, user_id)),
Map5 = #{user_id => [1, 2, 3]},
?assertEqual(0, extract_id_required(Map5, user_id)),
Map6 = #{user_id => 12.34},
?assertEqual(0, extract_id_required(Map6, user_id)),
Map7 = #{user_id => test_atom},
?assertEqual(0, extract_id_required(Map7, user_id)).
extract_id_required_with_invalid_map_test() ->
?assertEqual(0, extract_id_required(not_a_map, user_id)),
?assertEqual(0, extract_id_required(123, user_id)),
?assertEqual(0, extract_id_required("string", user_id)),
?assertEqual(0, extract_id_required(<<"binary">>, user_id)),
?assertEqual(0, extract_id_required([1, 2, 3], user_id)),
?assertEqual(0, extract_id_required({tuple}, user_id)),
?assertEqual(0, extract_id_required(undefined, user_id)).
extract_id_required_with_invalid_key_test() ->
Map = #{user_id => 123},
?assertEqual(0, extract_id_required(Map, 123)),
?assertEqual(0, extract_id_required(Map, "user_id")),
?assertEqual(0, extract_id_required(Map, {user_id})),
?assertEqual(0, extract_id_required(Map, [user_id])).
extract_id_required_with_both_invalid_test() ->
?assertEqual(0, extract_id_required(not_a_map, 123)),
?assertEqual(0, extract_id_required(undefined, undefined)),
?assertEqual(0, extract_id_required(123, "key")).
extract_id_required_returns_integer_test() ->
Map1 = #{user_id => 123},
Result1 = extract_id_required(Map1, user_id),
?assert(is_integer(Result1)),
Map2 = #{other => value},
Result2 = extract_id_required(Map2, user_id),
?assert(is_integer(Result2)),
?assertEqual(0, Result2),
Result3 = extract_id_required(not_a_map, user_id),
?assert(is_integer(Result3)),
?assertEqual(0, Result3).
-endif.

View 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(user_utils).
-export([normalize_user/1]).
normalize_user(User) when is_map(User) ->
AllowedKeys = [
<<"id">>,
<<"username">>,
<<"discriminator">>,
<<"global_name">>,
<<"avatar">>,
<<"avatar_color">>,
<<"bot">>,
<<"system">>,
<<"flags">>,
<<"banner">>,
<<"banner_color">>
],
CleanPairs =
lists:foldl(
fun(Key, Acc) ->
Value = maps:get(Key, User, undefined),
case is_undefined(Value) of
true -> Acc;
false -> [{Key, Value} | Acc]
end
end,
[],
AllowedKeys
),
maps:from_list(lists:reverse(CleanPairs));
normalize_user(_) ->
#{}.
is_undefined(undefined) -> true;
is_undefined(_) -> false.

View File

@@ -0,0 +1,144 @@
%% 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(utils).
-import(type_conv, [to_integer/1]).
-export([
binary_to_integer_safe/1,
generate_session_id/0,
generate_resume_token/0,
hash_token/1,
parse_status/1,
safe_json_decode/1,
check_user_data_differs/2,
partial_user_fields/0,
parse_iso8601_to_unix_ms/1
]).
binary_to_integer_safe(Bin) when is_binary(Bin) ->
try
binary_to_integer(Bin)
catch
_:_ ->
try
list_to_integer(binary_to_list(Bin))
catch
_:_ -> undefined
end
end;
binary_to_integer_safe(Int) when is_integer(Int) -> Int;
binary_to_integer_safe(_) ->
undefined.
generate_session_id() ->
Bytes = crypto:strong_rand_bytes(constants:random_session_bytes()),
binary:encode_hex(Bytes).
generate_resume_token() ->
Bytes = crypto:strong_rand_bytes(32),
base64url:encode(Bytes).
hash_token(Token) ->
crypto:hash(sha256, Token).
parse_status(Status) when is_binary(Status) ->
constants:status_type_atom(Status);
parse_status(Status) when is_atom(Status) ->
Status;
parse_status(_) ->
online.
safe_json_decode(Bin) ->
try
jsx:decode(Bin, [{return_maps, true}])
catch
_:_ -> #{}
end.
parse_iso8601_to_unix_ms(Binary) when is_binary(Binary) ->
Pattern =
<<"^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,9}))?Z$">>,
case re:run(Binary, Pattern, [{capture, [1, 2, 3, 4, 5, 6, 7], list}]) of
{match, [YearBin, MonthBin, DayBin, HourBin, MinuteBin, SecondBin, FractionBin]} ->
Year = to_integer(YearBin),
Month = to_integer(MonthBin),
Day = to_integer(DayBin),
Hour = to_integer(HourBin),
Minute = to_integer(MinuteBin),
Second = to_integer(SecondBin),
FractionMs = fractional_ms(FractionBin),
case {Year, Month, Day, Hour, Minute, Second} of
{Y, M, D, H, Min, S} when
is_integer(Y) and is_integer(M) and is_integer(D) and is_integer(H) and
is_integer(Min) and is_integer(S)
->
Seconds = calendar:datetime_to_gregorian_seconds({{Y, M, D}, {H, Min, S}}),
Seconds * 1000 + FractionMs;
_ ->
undefined
end;
_ ->
undefined
end;
parse_iso8601_to_unix_ms(_) ->
undefined.
fractional_ms(Fraction) when is_list(Fraction) ->
Normalized =
case length(Fraction) of
Len when Len >= 3 -> lists:sublist(Fraction, 3);
Len when Len > 0 -> Fraction ++ lists:duplicate(3 - Len, $0);
_ -> "000"
end,
case Normalized of
[] ->
0;
_ ->
case catch list_to_integer(Normalized) of
{'EXIT', _} -> 0;
Value -> Value
end
end;
fractional_ms(_) ->
0.
partial_user_fields() ->
[
<<"id">>,
<<"username">>,
<<"discriminator">>,
<<"global_name">>,
<<"avatar">>,
<<"avatar_color">>,
<<"bot">>,
<<"system">>,
<<"flags">>,
<<"banner">>,
<<"banner_color">>
].
check_user_data_differs(CurrentUserData, NewUserData) ->
CheckedFields = partial_user_fields(),
lists:any(
fun(Field) ->
CurrentValue = maps:get(Field, CurrentUserData, undefined),
NewValue = maps:get(Field, NewUserData, undefined),
CurrentValue =/= NewValue orelse
(maps:is_key(Field, CurrentUserData) andalso not maps:is_key(Field, NewUserData))
end,
CheckedFields
).

Some files were not shown because too many files have changed in this diff Show More