Files
fluxer/fluxer_gateway/src/guild/voice/guild_voice_move.erl
2026-02-17 12:22:36 +00:00

501 lines
20 KiB
Erlang

%% 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_update_for_move/6]).
-export([send_voice_server_updates_for_move/4]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-type guild_state() :: map().
-type voice_state() :: map().
-type voice_state_map() :: #{binary() => voice_state()}.
-type move_request() :: #{
user_id := integer(),
moderator_id := integer(),
channel_id := integer() | null,
connection_id => binary() | null,
mute := boolean(),
deaf := boolean()
}.
-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),
logger:debug(
"Handling voice move_member request",
#{
user_id => UserId,
moderator_id => ModeratorId,
channel_id => ChannelId,
connection_id => ConnectionId
}
),
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
),
logger:debug(
"Selected voice connections to move",
#{
user_id => UserId,
connection_id => ConnectionId,
connections_to_move_count => maps:size(ConnectionsToMove)
}
),
handle_move(
ConnectionsToMove, ChannelId, UserId, ModeratorId, ConnectionId, VoiceStates, State
)
end.
-spec find_user_voice_states(integer(), voice_state_map()) -> voice_state_map().
find_user_voice_states(UserId, VoiceStates) ->
maps:filter(
fun(_ConnId, VoiceState) ->
voice_state_utils:voice_state_user_id(VoiceState) =:= UserId
end,
VoiceStates
).
-spec select_connections_to_move(binary() | null, integer(), voice_state_map(), voice_state_map()) ->
voice_state_map().
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.
-spec handle_move(
voice_state_map(),
integer() | null,
integer(),
integer(),
binary() | null,
voice_state_map(),
guild_state()
) -> {reply, map(), guild_state()}.
handle_move(ConnectionsToMove, ChannelId, UserId, ModeratorId, ConnectionId, VoiceStates, State) ->
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 ->
logger:debug(
"Disconnect move requested",
#{user_id => UserId, connection_id => ConnectionId}
),
handle_disconnect_move(ConnectionsToMove, UserId, VoiceStates, State);
ChannelIdValue ->
logger:debug(
"Channel move requested",
#{user_id => UserId, channel_id => ChannelIdValue, connection_id => ConnectionId}
),
handle_channel_move(
ConnectionsToMove, ChannelIdValue, UserId, ModeratorId, VoiceStates, State
)
end
end.
-spec handle_disconnect_move(voice_state_map(), integer(), voice_state_map(), guild_state()) ->
{reply, map(), guild_state()}.
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),
spawn(fun() ->
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
)
end),
{reply, #{success => true, user_id => UserId, connections_moved => ConnectionsToMove},
NewState}.
-spec handle_channel_move(
voice_state_map(), integer(), integer(), integer(), voice_state_map(), guild_state()
) -> {reply, map(), guild_state()}.
handle_channel_move(ConnectionsToMove, ChannelIdValue, UserId, ModeratorId, VoiceStates, State) ->
Channel = guild_voice_member:find_channel_by_id(ChannelIdValue, State),
case Channel of
undefined ->
{reply, gateway_errors:error(voice_channel_not_found), State};
_ ->
StateWithPending0 = guild_virtual_channel_access:mark_pending_join(
UserId, ChannelIdValue, State
),
StateWithPending1 = guild_virtual_channel_access:mark_preserve(
UserId, ChannelIdValue, StateWithPending0
),
StateWithPending2 = guild_virtual_channel_access:mark_move_pending(
UserId, ChannelIdValue, StateWithPending1
),
ChannelType = maps:get(<<"type">>, Channel, 0),
case ChannelType of
2 ->
check_move_permissions_and_execute(
ConnectionsToMove,
ChannelIdValue,
UserId,
ModeratorId,
VoiceStates,
StateWithPending2
);
_ ->
{reply, gateway_errors:error(voice_channel_not_voice), State}
end
end.
-spec check_move_permissions_and_execute(
voice_state_map(), integer(), integer(), integer(), voice_state_map(), guild_state()
) -> {reply, map(), guild_state()}.
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, ChannelIdValue, UserId, VoiceStates, State)
end.
-spec execute_move(voice_state_map(), integer(), integer(), voice_state_map(), guild_state()) ->
{reply, map(), guild_state()}.
execute_move(ConnectionsToMove, ChannelIdValue, UserId, VoiceStates, State) ->
StatePending = guild_virtual_channel_access:mark_pending_join(UserId, ChannelIdValue, State),
StatePending2 = guild_virtual_channel_access:mark_preserve(
UserId, ChannelIdValue, StatePending
),
StatePending3 = guild_virtual_channel_access:mark_move_pending(
UserId, ChannelIdValue, StatePending2
),
logger:debug(
"Executing voice channel move",
#{user_id => UserId, channel_id => ChannelIdValue}
),
NewVoiceStates = maps:fold(
fun(ConnId, _VoiceState, Acc) -> maps:remove(ConnId, Acc) end,
VoiceStates,
ConnectionsToMove
),
StateAfterDisconnect = maps:put(voice_states, NewVoiceStates, StatePending3),
StateWithVirtualAccess = maybe_add_virtual_access(UserId, ChannelIdValue, StateAfterDisconnect),
spawn(fun() ->
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, StateWithVirtualAccess, OldChannelIdBin
)
end,
ConnectionsToMove
)
end),
SessionData = extract_session_data(ConnectionsToMove),
{reply,
#{
success => true,
needs_token => true,
session_data => SessionData,
connections_to_move => ConnectionsToMove
},
StateWithVirtualAccess}.
-spec extract_session_data(voice_state_map()) -> [map()].
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).
-spec send_voice_server_update_for_move(
integer(), integer(), integer(), binary() | undefined, pid()
) -> ok.
send_voice_server_update_for_move(GuildId, ChannelId, UserId, SessionId, GuildPid) ->
send_voice_server_update_for_move(GuildId, ChannelId, UserId, SessionId, null, GuildPid).
-spec send_voice_server_update_for_move(
integer(), integer(), integer(), binary() | undefined, binary() | null, pid()
) -> ok.
send_voice_server_update_for_move(GuildId, ChannelId, UserId, SessionId, OldConnectionId, GuildPid) ->
case SessionId of
undefined ->
ok;
_ ->
spawn(fun() ->
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, OldConnectionId, 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,
ChannelId,
SessionId,
Token,
Endpoint,
ConnectionId,
State
);
{error, _Reason} ->
ok
end;
_ ->
ok
end
end),
ok
end.
-spec maybe_add_virtual_access(integer(), integer(), guild_state()) -> guild_state().
maybe_add_virtual_access(UserId, ChannelId, State) ->
Member = guild_permissions:find_member_by_user_id(UserId, State),
case Member of
undefined ->
State;
_ ->
Permissions = guild_permissions:get_member_permissions(UserId, ChannelId, State),
ViewPerm = constants:view_channel_permission(),
ConnectPerm = constants:connect_permission(),
HasView = (Permissions band ViewPerm) =:= ViewPerm,
HasConnect = (Permissions band ConnectPerm) =:= ConnectPerm,
case HasView andalso HasConnect of
true ->
State;
false ->
NewState = guild_virtual_channel_access:add_virtual_access(
UserId, ChannelId, State
),
guild_virtual_channel_access:dispatch_channel_visibility_change(
UserId, ChannelId, add, NewState
),
NewState
end
end.
-spec send_voice_server_updates_for_move(integer(), integer(), [map()], pid()) -> ok.
send_voice_server_updates_for_move(GuildId, ChannelId, SessionDataList, GuildPid) ->
spawn(fun() ->
lists:foreach(
fun(SessionInfo) ->
send_single_voice_server_update(GuildId, ChannelId, SessionInfo, GuildPid)
end,
SessionDataList
)
end),
ok.
-spec send_single_voice_server_update(integer(), integer(), map(), pid()) -> ok.
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),
OldConnectionId = maps:get(connection_id, SessionInfo, null),
Member = maps:get(member, SessionInfo),
ServerMute = maps:get(<<"mute">>, Member, false),
ServerDeaf = maps:get(<<"deaf">>, Member, false),
case member_user_id(Member) of
undefined ->
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, OldConnectionId, 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:call(
GuildPid,
{store_pending_connection, NewConnectionId, PendingMetadata},
10000
),
guild_voice_broadcast:broadcast_voice_server_update_to_session(
GuildId,
ChannelId,
SessionId,
Token,
Endpoint,
NewConnectionId,
StateData
);
{error, _Reason} ->
ok
end;
_ ->
ok
end
end.
-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, #{})).
normalize_channel_id_test() ->
?assertEqual(null, normalize_channel_id(null)),
?assertEqual(123, normalize_channel_id(123)),
?assertEqual(456, normalize_channel_id(<<"456">>)),
?assertEqual(null, normalize_channel_id(undefined)).
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.