initial commit
This commit is contained in:
637
fluxer_gateway/src/call/call.erl
Normal file
637
fluxer_gateway/src/call/call.erl
Normal 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}.
|
||||
170
fluxer_gateway/src/call/call_manager.erl
Normal file
170
fluxer_gateway/src/call/call_manager.erl
Normal 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.
|
||||
24
fluxer_gateway/src/fluxer_gateway.app.src
Normal file
24
fluxer_gateway/src/fluxer_gateway.app.src
Normal 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, []}
|
||||
]}.
|
||||
54
fluxer_gateway/src/gateway/fluxer_gateway_app.erl
Normal file
54
fluxer_gateway/src/gateway/fluxer_gateway_app.erl
Normal 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.
|
||||
268
fluxer_gateway/src/gateway/fluxer_gateway_env.erl
Normal file
268
fluxer_gateway/src/gateway/fluxer_gateway_env.erl
Normal 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.
|
||||
92
fluxer_gateway/src/gateway/fluxer_gateway_sup.erl
Normal file
92
fluxer_gateway/src/gateway/fluxer_gateway_sup.erl
Normal 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
|
||||
]}}.
|
||||
77
fluxer_gateway/src/gateway/gateway_codec.erl
Normal file
77
fluxer_gateway/src/gateway/gateway_codec.erl
Normal 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.
|
||||
106
fluxer_gateway/src/gateway/gateway_compress.erl
Normal file
106
fluxer_gateway/src/gateway/gateway_compress.erl
Normal 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.
|
||||
143
fluxer_gateway/src/gateway/gateway_errors.erl
Normal file
143
fluxer_gateway/src/gateway/gateway_errors.erl
Normal 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.
|
||||
786
fluxer_gateway/src/gateway/gateway_handler.erl
Normal file
786
fluxer_gateway/src/gateway/gateway_handler.erl
Normal 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.
|
||||
215
fluxer_gateway/src/gateway/gateway_rpc_call.erl
Normal file
215
fluxer_gateway/src/gateway/gateway_rpc_call.erl
Normal 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.
|
||||
785
fluxer_gateway/src/gateway/gateway_rpc_guild.erl
Normal file
785
fluxer_gateway/src/gateway/gateway_rpc_guild.erl
Normal 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.
|
||||
133
fluxer_gateway/src/gateway/gateway_rpc_http_handler.erl
Normal file
133
fluxer_gateway/src/gateway/gateway_rpc_http_handler.erl
Normal 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}.
|
||||
80
fluxer_gateway/src/gateway/gateway_rpc_misc.erl
Normal file
80
fluxer_gateway/src/gateway/gateway_rpc_misc.erl
Normal 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
|
||||
}.
|
||||
182
fluxer_gateway/src/gateway/gateway_rpc_presence.erl
Normal file
182
fluxer_gateway/src/gateway/gateway_rpc_presence.erl
Normal 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.
|
||||
43
fluxer_gateway/src/gateway/gateway_rpc_push.erl
Normal file
43
fluxer_gateway/src/gateway/gateway_rpc_push.erl
Normal 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.
|
||||
36
fluxer_gateway/src/gateway/gateway_rpc_router.erl
Normal file
36
fluxer_gateway/src/gateway/gateway_rpc_router.erl
Normal 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.
|
||||
29
fluxer_gateway/src/gateway/health_handler.erl
Normal file
29
fluxer_gateway/src/gateway/health_handler.erl
Normal 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}.
|
||||
357
fluxer_gateway/src/gateway/hot_reload.erl
Normal file
357
fluxer_gateway/src/gateway/hot_reload.erl
Normal 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.
|
||||
266
fluxer_gateway/src/gateway/hot_reload_handler.erl
Normal file
266
fluxer_gateway/src/gateway/hot_reload_handler.erl
Normal 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}.
|
||||
101
fluxer_gateway/src/gateway/rendezvous_router.erl
Normal file
101
fluxer_gateway/src/gateway/rendezvous_router.erl
Normal 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.
|
||||
85
fluxer_gateway/src/gateway/rpc_client.erl
Normal file
85
fluxer_gateway/src/gateway/rpc_client.erl
Normal 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>>}].
|
||||
572
fluxer_gateway/src/guild/guild.erl
Normal file
572
fluxer_gateway/src/guild/guild.erl
Normal 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)).
|
||||
143
fluxer_gateway/src/guild/guild_availability.erl
Normal file
143
fluxer_gateway/src/guild/guild_availability.erl
Normal 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.
|
||||
75
fluxer_gateway/src/guild/guild_client.erl
Normal file
75
fluxer_gateway/src/guild/guild_client.erl
Normal 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.
|
||||
313
fluxer_gateway/src/guild/guild_data.erl
Normal file
313
fluxer_gateway/src/guild/guild_data.erl
Normal 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.
|
||||
510
fluxer_gateway/src/guild/guild_dispatch.erl
Normal file
510
fluxer_gateway/src/guild/guild_dispatch.erl
Normal 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
|
||||
).
|
||||
368
fluxer_gateway/src/guild/guild_manager.erl
Normal file
368
fluxer_gateway/src/guild/guild_manager.erl
Normal 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.
|
||||
530
fluxer_gateway/src/guild/guild_manager_shard.erl
Normal file
530
fluxer_gateway/src/guild/guild_manager_shard.erl
Normal 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.
|
||||
1027
fluxer_gateway/src/guild/guild_member_list.erl
Normal file
1027
fluxer_gateway/src/guild/guild_member_list.erl
Normal file
File diff suppressed because it is too large
Load Diff
328
fluxer_gateway/src/guild/guild_member_storage.erl
Normal file
328
fluxer_gateway/src/guild/guild_member_storage.erl
Normal 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.
|
||||
535
fluxer_gateway/src/guild/guild_members.erl
Normal file
535
fluxer_gateway/src/guild/guild_members.erl
Normal 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.
|
||||
175
fluxer_gateway/src/guild/guild_passive_sync.erl
Normal file
175
fluxer_gateway/src/guild/guild_passive_sync.erl
Normal 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.
|
||||
560
fluxer_gateway/src/guild/guild_permissions.erl
Normal file
560
fluxer_gateway/src/guild/guild_permissions.erl
Normal 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.
|
||||
302
fluxer_gateway/src/guild/guild_presence.erl
Normal file
302
fluxer_gateway/src/guild/guild_presence.erl
Normal 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).
|
||||
505
fluxer_gateway/src/guild/guild_request_members.erl
Normal file
505
fluxer_gateway/src/guild/guild_request_members.erl
Normal 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.
|
||||
348
fluxer_gateway/src/guild/guild_sessions.erl
Normal file
348
fluxer_gateway/src/guild/guild_sessions.erl
Normal 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.
|
||||
466
fluxer_gateway/src/guild/guild_state.erl
Normal file
466
fluxer_gateway/src/guild/guild_state.erl
Normal 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.
|
||||
185
fluxer_gateway/src/guild/guild_subscriptions.erl
Normal file
185
fluxer_gateway/src/guild/guild_subscriptions.erl
Normal 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.
|
||||
23
fluxer_gateway/src/guild/guild_sync.erl
Normal file
23
fluxer_gateway/src/guild/guild_sync.erl
Normal 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}).
|
||||
236
fluxer_gateway/src/guild/guild_unified_subscriptions.erl
Normal file
236
fluxer_gateway/src/guild/guild_unified_subscriptions.erl
Normal 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.
|
||||
152
fluxer_gateway/src/guild/guild_user_data.erl
Normal file
152
fluxer_gateway/src/guild/guild_user_data.erl
Normal 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.
|
||||
127
fluxer_gateway/src/guild/guild_virtual_channel_access.erl
Normal file
127
fluxer_gateway/src/guild/guild_virtual_channel_access.erl
Normal 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.
|
||||
170
fluxer_gateway/src/guild/guild_visibility.erl
Normal file
170
fluxer_gateway/src/guild/guild_visibility.erl
Normal 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).
|
||||
626
fluxer_gateway/src/guild/voice/dm_voice.erl
Normal file
626
fluxer_gateway/src/guild/voice/dm_voice.erl
Normal 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}.
|
||||
125
fluxer_gateway/src/guild/voice/guild_voice.erl
Normal file
125
fluxer_gateway/src/guild/voice/guild_voice.erl
Normal 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}).
|
||||
117
fluxer_gateway/src/guild/voice/guild_voice_broadcast.erl
Normal file
117
fluxer_gateway/src/guild/voice/guild_voice_broadcast.erl
Normal 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.
|
||||
699
fluxer_gateway/src/guild/voice/guild_voice_connection.erl
Normal file
699
fluxer_gateway/src/guild/voice/guild_voice_connection.erl
Normal 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.
|
||||
319
fluxer_gateway/src/guild/voice/guild_voice_disconnect.erl
Normal file
319
fluxer_gateway/src/guild/voice/guild_voice_disconnect.erl
Normal 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.
|
||||
278
fluxer_gateway/src/guild/voice/guild_voice_member.erl
Normal file
278
fluxer_gateway/src/guild/voice/guild_voice_member.erl
Normal file
@@ -0,0 +1,278 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(guild_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.
|
||||
378
fluxer_gateway/src/guild/voice/guild_voice_move.erl
Normal file
378
fluxer_gateway/src/guild/voice/guild_voice_move.erl
Normal 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.
|
||||
304
fluxer_gateway/src/guild/voice/guild_voice_permission_sync.erl
Normal file
304
fluxer_gateway/src/guild/voice/guild_voice_permission_sync.erl
Normal 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.
|
||||
170
fluxer_gateway/src/guild/voice/guild_voice_permissions.erl
Normal file
170
fluxer_gateway/src/guild/voice/guild_voice_permissions.erl
Normal 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.
|
||||
127
fluxer_gateway/src/guild/voice/guild_voice_region.erl
Normal file
127
fluxer_gateway/src/guild/voice/guild_voice_region.erl
Normal 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.
|
||||
205
fluxer_gateway/src/guild/voice/guild_voice_state.erl
Normal file
205
fluxer_gateway/src/guild/voice/guild_voice_state.erl
Normal 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.
|
||||
105
fluxer_gateway/src/guild/voice/voice_disconnect_common.erl
Normal file
105
fluxer_gateway/src/guild/voice/voice_disconnect_common.erl
Normal 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).
|
||||
58
fluxer_gateway/src/guild/voice/voice_pending_common.erl
Normal file
58
fluxer_gateway/src/guild/voice/voice_pending_common.erl
Normal 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.
|
||||
164
fluxer_gateway/src/guild/voice/voice_state_utils.erl
Normal file
164
fluxer_gateway/src/guild/voice/voice_state_utils.erl
Normal 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
|
||||
>>.
|
||||
150
fluxer_gateway/src/guild/voice/voice_utils.erl
Normal file
150
fluxer_gateway/src/guild/voice/voice_utils.erl
Normal 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)
|
||||
}).
|
||||
874
fluxer_gateway/src/presence/presence.erl
Normal file
874
fluxer_gateway/src/presence/presence.erl
Normal 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.
|
||||
292
fluxer_gateway/src/presence/presence_bus.erl
Normal file
292
fluxer_gateway/src/presence/presence_bus.erl
Normal 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.
|
||||
159
fluxer_gateway/src/presence/presence_bus_shard.erl
Normal file
159
fluxer_gateway/src/presence/presence_bus_shard.erl
Normal file
@@ -0,0 +1,159 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(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)).
|
||||
334
fluxer_gateway/src/presence/presence_cache.erl
Normal file
334
fluxer_gateway/src/presence/presence_cache.erl
Normal 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.
|
||||
118
fluxer_gateway/src/presence/presence_cache_shard.erl
Normal file
118
fluxer_gateway/src/presence/presence_cache_shard.erl
Normal 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)).
|
||||
298
fluxer_gateway/src/presence/presence_manager.erl
Normal file
298
fluxer_gateway/src/presence/presence_manager.erl
Normal 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.
|
||||
227
fluxer_gateway/src/presence/presence_manager_shard.erl
Normal file
227
fluxer_gateway/src/presence/presence_manager_shard.erl
Normal 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.
|
||||
54
fluxer_gateway/src/presence/presence_payload.erl
Normal file
54
fluxer_gateway/src/presence/presence_payload.erl
Normal 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.
|
||||
116
fluxer_gateway/src/presence/presence_session.erl
Normal file
116
fluxer_gateway/src/presence/presence_session.erl
Normal 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
|
||||
).
|
||||
111
fluxer_gateway/src/presence/presence_status.erl
Normal file
111
fluxer_gateway/src/presence/presence_status.erl
Normal 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.
|
||||
97
fluxer_gateway/src/presence/presence_targets.erl
Normal file
97
fluxer_gateway/src/presence/presence_targets.erl
Normal 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.
|
||||
187
fluxer_gateway/src/presence/presence_utils.erl
Normal file
187
fluxer_gateway/src/presence/presence_utils.erl
Normal 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.
|
||||
268
fluxer_gateway/src/push/push.erl
Normal file
268
fluxer_gateway/src/push/push.erl
Normal 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.
|
||||
161
fluxer_gateway/src/push/push_cache.erl
Normal file
161
fluxer_gateway/src/push/push_cache.erl
Normal 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.
|
||||
37
fluxer_gateway/src/push/push_core.erl
Normal file
37
fluxer_gateway/src/push/push_core.erl
Normal 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}).
|
||||
312
fluxer_gateway/src/push/push_eligibility.erl
Normal file
312
fluxer_gateway/src/push/push_eligibility.erl
Normal 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.
|
||||
35
fluxer_gateway/src/push/push_logger_filter.erl
Normal file
35
fluxer_gateway/src/push/push_logger_filter.erl
Normal 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.
|
||||
131
fluxer_gateway/src/push/push_notification.erl
Normal file
131
fluxer_gateway/src/push/push_notification.erl
Normal 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)
|
||||
}
|
||||
}.
|
||||
320
fluxer_gateway/src/push/push_sender.erl
Normal file
320
fluxer_gateway/src/push/push_sender.erl
Normal 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
|
||||
}.
|
||||
102
fluxer_gateway/src/push/push_subscriptions.erl
Normal file
102
fluxer_gateway/src/push/push_subscriptions.erl
Normal 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).
|
||||
262
fluxer_gateway/src/push/push_utils.erl
Normal file
262
fluxer_gateway/src/push/push_utils.erl
Normal 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.
|
||||
370
fluxer_gateway/src/session/session.erl
Normal file
370
fluxer_gateway/src/session/session.erl
Normal 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">> => []}.
|
||||
301
fluxer_gateway/src/session/session_connection.erl
Normal file
301
fluxer_gateway/src/session/session_connection.erl
Normal 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().
|
||||
486
fluxer_gateway/src/session/session_dispatch.erl
Normal file
486
fluxer_gateway/src/session/session_dispatch.erl
Normal 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.
|
||||
559
fluxer_gateway/src/session/session_manager.erl
Normal file
559
fluxer_gateway/src/session/session_manager.erl
Normal 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.
|
||||
106
fluxer_gateway/src/session/session_monitor.erl
Normal file
106
fluxer_gateway/src/session/session_monitor.erl
Normal 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
|
||||
).
|
||||
345
fluxer_gateway/src/session/session_passive.erl
Normal file
345
fluxer_gateway/src/session/session_passive.erl
Normal 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.
|
||||
443
fluxer_gateway/src/session/session_ready.erl
Normal file
443
fluxer_gateway/src/session/session_ready.erl
Normal 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">>.
|
||||
333
fluxer_gateway/src/session/session_voice.erl
Normal file
333
fluxer_gateway/src/session/session_voice.erl
Normal 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.
|
||||
411
fluxer_gateway/src/telemetry/gateway_metrics_collector.erl
Normal file
411
fluxer_gateway/src/telemetry/gateway_metrics_collector.erl
Normal 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}).
|
||||
165
fluxer_gateway/src/telemetry/metrics_client.erl
Normal file
165
fluxer_gateway/src/telemetry/metrics_client.erl
Normal 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).
|
||||
97
fluxer_gateway/src/telemetry/process_memory_stats.erl
Normal file
97
fluxer_gateway/src/telemetry/process_memory_stats.erl
Normal 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.
|
||||
664
fluxer_gateway/src/telemetry/process_registry.erl
Normal file
664
fluxer_gateway/src/telemetry/process_registry.erl
Normal 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.
|
||||
32
fluxer_gateway/src/utils/backoff_utils.erl
Normal file
32
fluxer_gateway/src/utils/backoff_utils.erl
Normal 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).
|
||||
368
fluxer_gateway/src/utils/constants.erl
Normal file
368
fluxer_gateway/src/utils/constants.erl
Normal 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.
|
||||
63
fluxer_gateway/src/utils/custom_status_validation.erl
Normal file
63
fluxer_gateway/src/utils/custom_status_validation.erl
Normal 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).
|
||||
647
fluxer_gateway/src/utils/list_ops.erl
Normal file
647
fluxer_gateway/src/utils/list_ops.erl
Normal 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.
|
||||
559
fluxer_gateway/src/utils/map_utils.erl
Normal file
559
fluxer_gateway/src/utils/map_utils.erl
Normal 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.
|
||||
636
fluxer_gateway/src/utils/type_conv.erl
Normal file
636
fluxer_gateway/src/utils/type_conv.erl
Normal 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.
|
||||
53
fluxer_gateway/src/utils/user_utils.erl
Normal file
53
fluxer_gateway/src/utils/user_utils.erl
Normal file
@@ -0,0 +1,53 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(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.
|
||||
144
fluxer_gateway/src/utils/utils.erl
Normal file
144
fluxer_gateway/src/utils/utils.erl
Normal 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
Reference in New Issue
Block a user