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

1747 lines
71 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_connection).
-import(guild_voice_unclaimed_account_utils, [parse_unclaimed_error/1]).
-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]).
-export([request_voice_token/5]).
-export([request_voice_token/6]).
-export([sweep_expired_pending_joins/1]).
-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 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_keys := term()
}.
-spec voice_state_update(map(), guild_state()) ->
{reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
voice_state_update(Request, State) ->
Context = build_context(Request),
case maps:get(user_id, Context) of
undefined ->
{reply, gateway_errors:error(voice_invalid_user_id), State};
UserId ->
VoiceStates = voice_state_utils:voice_states(State),
case guild_voice_member:find_member_by_user_id(UserId, State) of
undefined ->
{reply, gateway_errors:error(voice_member_not_found), State};
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()} | {reply, {error, atom(), atom()}, guild_state()}.
handle_member_voice(Context, Member, VoiceStates, State) ->
case maps:get(channel_id, Context) of
undefined ->
{reply, gateway_errors:error(voice_invalid_channel_id), State};
null ->
handle_disconnect(Context, VoiceStates, State);
ChannelIdValue ->
handle_voice_connect_or_update(Context, ChannelIdValue, Member, VoiceStates, State)
end.
-spec handle_disconnect(context(), voice_state_map(), guild_state()) ->
{reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
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
).
-spec handle_voice_connect_or_update(
context(), integer(), map(), voice_state_map(), guild_state()
) -> {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_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 ->
{reply, gateway_errors:error(voice_channel_not_found), State};
_ ->
case ConnectionId of
undefined ->
handle_new_connection(Context, Member, Channel, VoiceStates, State);
_ ->
handle_update_connection(
Context,
ChannelIdValue,
Member,
Channel,
VoiceStates,
State
)
end
end.
-spec handle_update_connection(
context(), integer(), map(), map(), voice_state_map(), guild_state()
) -> {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
handle_update_connection(Context, ChannelIdValue, Member, Channel, VoiceStates, State) ->
ConnectionId = maps:get(connection_id, Context),
UserId = maps:get(user_id, Context),
case maps:get(ConnectionId, VoiceStates, undefined) of
undefined ->
case
maybe_restore_pending_connection(
ConnectionId, ChannelIdValue, UserId, VoiceStates, State
)
of
{ok, UpdatedVoiceStates, UpdatedState} ->
logger:debug(
"Restored pending voice connection during update",
#{
connection_id => ConnectionId,
user_id => UserId,
channel_id => ChannelIdValue
}
),
ExistingVoiceState = maps:get(ConnectionId, UpdatedVoiceStates),
ExistingChannelIdBin = maps:get(<<"channel_id">>, ExistingVoiceState, null),
NewChannelIdBin = integer_to_binary(ChannelIdValue),
IsChannelChange = ExistingChannelIdBin =/= NewChannelIdBin,
GuildId = map_utils:get_integer(UpdatedState, id, undefined),
ViewerKeyResult =
resolve_viewer_stream_keys(
Context,
GuildId,
ChannelIdValue,
UpdatedVoiceStates,
ExistingVoiceState
),
normal_update_connection(
Context,
ChannelIdValue,
Member,
Channel,
UpdatedVoiceStates,
UpdatedState,
IsChannelChange,
ViewerKeyResult
);
{error, ErrorAtom} ->
logger:debug(
"Failed to restore pending voice connection during update",
#{
connection_id => ConnectionId,
user_id => UserId,
channel_id => ChannelIdValue,
error => ErrorAtom
}
),
{reply, gateway_errors:error(ErrorAtom), State}
end;
ExistingVoiceState ->
case guild_voice_state:user_matches_voice_state(ExistingVoiceState, UserId) of
false ->
{reply, gateway_errors:error(voice_user_mismatch), State};
true ->
ExistingChannelIdBin = maps:get(<<"channel_id">>, ExistingVoiceState, null),
NewChannelIdBin = integer_to_binary(ChannelIdValue),
IsChannelChange = ExistingChannelIdBin =/= NewChannelIdBin,
GuildId = map_utils:get_integer(State, id, undefined),
ViewerKeyResult =
resolve_viewer_stream_keys(
Context, GuildId, ChannelIdValue, VoiceStates, ExistingVoiceState
),
normal_update_connection(
Context,
ChannelIdValue,
Member,
Channel,
VoiceStates,
State,
IsChannelChange,
ViewerKeyResult
)
end
end.
-spec maybe_restore_pending_connection(
binary(),
integer(),
integer(),
voice_state_map(),
guild_state()
) -> {ok, voice_state_map(), guild_state()} | {error, atom()}.
maybe_restore_pending_connection(ConnectionId, ChannelIdValue, UserId, VoiceStates, State) ->
PendingConnections = pending_voice_connections(State),
case maps:get(ConnectionId, PendingConnections, undefined) of
undefined ->
{error, voice_connection_not_found};
PendingData ->
PendingUserId = pending_get_integer(PendingData, user_id),
PendingChannelId = pending_get_integer(PendingData, channel_id),
logger:debug(
"Checking pending voice connection for restore",
#{
connection_id => ConnectionId,
user_id => UserId,
channel_id => ChannelIdValue,
pending_user_id => PendingUserId,
pending_channel_id => PendingChannelId
}
),
case {PendingUserId, PendingChannelId} of
{UserId, ChannelIdValue} ->
case pending_get_integer(PendingData, expires_at) of
ExpiresAt when is_integer(ExpiresAt) ->
Now = erlang:system_time(millisecond),
case Now >= ExpiresAt of
true ->
{error, voice_pending_expired};
false ->
restore_pending_connection(
ConnectionId,
PendingConnections,
PendingData,
VoiceStates,
State
)
end;
_ ->
restore_pending_connection(
ConnectionId,
PendingConnections,
PendingData,
VoiceStates,
State
)
end;
_ ->
{error, voice_connection_not_found}
end
end.
-spec restore_pending_connection(
binary(),
pending_voice_connections(),
map(),
voice_state_map(),
guild_state()
) -> {ok, voice_state_map(), guild_state()} | {error, atom()}.
restore_pending_connection(ConnectionId, PendingConnections, PendingData, VoiceStates, State) ->
VoiceState = resolve_voice_state_from_pending(ConnectionId, PendingData, State, VoiceStates),
case VoiceState of
undefined ->
{error, voice_connection_not_found};
_ ->
NewPendingConnections = maps:remove(ConnectionId, PendingConnections),
StateWithoutPending = maps:put(pending_voice_connections, NewPendingConnections, State),
UpdatedVoiceStates = maps:put(ConnectionId, VoiceState, VoiceStates),
StateWithVoiceStates = maps:put(voice_states, UpdatedVoiceStates, StateWithoutPending),
StateCleared = clear_virtual_access_flags_from_voice_state(
VoiceState, StateWithVoiceStates
),
ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
guild_voice_broadcast:broadcast_voice_state_update(VoiceState, StateCleared, ChannelIdBin),
{ok, UpdatedVoiceStates, StateCleared}
end.
-spec normal_update_connection(
context(),
integer(),
map(),
map(),
voice_state_map(),
guild_state(),
boolean(),
{ok, term()} | {error, atom()}
) -> {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
normal_update_connection(
Context,
ChannelIdValue,
Member,
Channel,
VoiceStates,
State,
IsChannelChange,
ViewerKeyResult
) ->
ConnectionId = maps:get(connection_id, Context),
UserId = maps:get(user_id, Context),
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 check_camera_user_limit(Context, ChannelIdValue, VoiceStates) of
{error, CameraErrorAtom} ->
{reply, gateway_errors:error(CameraErrorAtom), State};
ok ->
case ViewerKeyResult of
{error, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
{ok, ParsedViewerKey} ->
case IsChannelChange of
true ->
handle_client_channel_move(
Context, ChannelIdValue, Member, ConnectionId,
VoiceStates, State, ParsedViewerKey
);
false ->
Flags = voice_state_utils:voice_flags_from_context(Context),
guild_voice_state:update_voice_state_data(
ConnectionId,
integer_to_binary(ChannelIdValue),
Flags,
Member,
maps:get(ConnectionId, VoiceStates),
VoiceStates,
State,
false,
ParsedViewerKey
)
end
end
end
end.
-spec handle_client_channel_move(
context(), integer(), map(), binary(), voice_state_map(), guild_state(), term()
) -> {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
handle_client_channel_move(
Context, ChannelIdValue, Member, ConnectionId, VoiceStates, State, _ParsedViewerKey
) ->
UserId = maps:get(user_id, Context),
SessionId = maps:get(session_id, Context),
ExistingVoiceState = maps:get(ConnectionId, VoiceStates),
OldChannelIdBin = maps:get(<<"channel_id">>, ExistingVoiceState, null),
State0 = guild_virtual_channel_access:mark_pending_join(UserId, ChannelIdValue, State),
State1 = guild_virtual_channel_access:mark_preserve(UserId, ChannelIdValue, State0),
State2 = guild_virtual_channel_access:mark_move_pending(UserId, ChannelIdValue, State1),
NewVoiceStates = maps:remove(ConnectionId, VoiceStates),
State3 = maps:put(voice_states, NewVoiceStates, State2),
DisconnectVoiceState = maps:put(<<"channel_id">>, null, ExistingVoiceState),
guild_voice_broadcast:broadcast_voice_state_update(
DisconnectVoiceState, State3, OldChannelIdBin
),
case resolve_guild_identity(State3) of
{error, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State3};
{ok, GuildId, _GuildIdBin} ->
VoicePermissions = voice_utils:compute_voice_permissions(
UserId, ChannelIdValue, State3
),
TokenNonce = voice_utils:generate_token_nonce(),
case request_voice_token(GuildId, ChannelIdValue, UserId, ConnectionId, VoicePermissions, TokenNonce) of
{ok, TokenData} ->
Token = maps:get(token, TokenData),
Endpoint = maps:get(endpoint, TokenData),
NewConnectionId = maps:get(connection_id, TokenData),
SessionIdBin = normalize_session_id(SessionId),
ServerMute = maps:get(<<"mute">>, Member, false),
ServerDeaf = maps:get(<<"deaf">>, Member, false),
Flags = voice_state_utils:voice_flags_from_context(Context),
#{
self_mute := SelfMuteFlag,
self_deaf := SelfDeafFlag,
self_video := SelfVideoFlag,
self_stream := SelfStreamFlag,
is_mobile := IsMobileFlag
} = Flags,
ChannelIdBin = integer_to_binary(ChannelIdValue),
UserIdBin = integer_to_binary(UserId),
GuildIdBin2 = integer_to_binary(GuildId),
VoiceState0 = guild_voice_state:create_voice_state(
GuildIdBin2,
ChannelIdBin,
UserIdBin,
NewConnectionId,
ServerMute,
ServerDeaf,
Flags,
[]
),
VoiceState1 = maybe_attach_session_id(VoiceState0, SessionIdBin),
VoiceState = maybe_attach_member(VoiceState1, Member),
Now = erlang:system_time(millisecond),
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_keys => [],
voice_state => VoiceState,
token_nonce => TokenNonce,
created_at => Now,
expires_at => Now + 30000
},
PendingConnections = pending_voice_connections(State3),
NewPendingConnections = maps:put(
NewConnectionId, PendingMetadata, PendingConnections
),
State4 = maps:put(
pending_voice_connections, NewPendingConnections, State3
),
{reply,
#{
success => true,
needs_token => true,
token => Token,
endpoint => Endpoint,
connection_id => NewConnectionId,
voice_state => VoiceState
},
State4};
{error, _Reason} ->
{reply, gateway_errors:error(voice_token_failed), State3}
end
end.
-spec handle_new_connection(context(), map(), map(), voice_state_map(), guild_state()) ->
{reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
handle_new_connection(Context, Member, Channel, VoiceStates, State) ->
ChannelIdValue = maps:get(channel_id, Context),
GuildId = map_utils:get_integer(State, id, undefined),
ViewerKeyResult = resolve_viewer_stream_keys(Context, GuildId, ChannelIdValue, VoiceStates, #{}),
normal_new_connection(Context, Member, Channel, VoiceStates, State, ViewerKeyResult).
-spec normal_new_connection(
context(), map(), map(), voice_state_map(), guild_state(), {ok, term()} | {error, atom()}
) -> {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
normal_new_connection(Context, Member, Channel, VoiceStates, State, ViewerKeyResult) ->
UserId = maps:get(user_id, Context),
ChannelIdValue = maps:get(channel_id, Context),
PermCheck = guild_voice_permissions:check_voice_permissions_and_limits(
UserId, ChannelIdValue, Channel, VoiceStates, State, false
),
case PermCheck of
{error, _Category, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
{ok, allowed} ->
case check_camera_user_limit(Context, ChannelIdValue, VoiceStates) of
{error, CameraErrorAtom} ->
{reply, gateway_errors:error(CameraErrorAtom), State};
ok ->
case ViewerKeyResult of
{error, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
{ok, ParsedViewerKey} ->
get_voice_token_and_create_state(Context, Member, ParsedViewerKey, State)
end
end
end.
-spec get_voice_token_and_create_state(context(), map(), term(), guild_state()) ->
{reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
get_voice_token_and_create_state(Context, Member, ParsedViewerStreamKey, State) ->
ChannelIdValue = maps:get(channel_id, Context),
UserId = maps:get(user_id, Context),
State0 = guild_virtual_channel_access:clear_pending_join(UserId, ChannelIdValue, State),
State1 = guild_virtual_channel_access:clear_preserve(UserId, ChannelIdValue, State0),
State2 = guild_virtual_channel_access:clear_move_pending(UserId, ChannelIdValue, State1),
case resolve_guild_identity(State2) of
{error, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State2};
{ok, GuildId, GuildIdBin} ->
VoicePermissions = voice_utils:compute_voice_permissions(
UserId, ChannelIdValue, State2
),
TokenNonce = voice_utils:generate_token_nonce(),
case request_voice_token(GuildId, ChannelIdValue, UserId, null, VoicePermissions, TokenNonce) of
{ok, TokenData} ->
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),
#{
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),
%% NOTE: We intentionally do NOT add the voice state to voice_states
%% or broadcast it yet. The voice state will only be added and
%% broadcast when LiveKit confirms the user has actually connected
%% via confirm_voice_connection_from_livekit/2.
%% This prevents users from appearing in voice channels before
%% they're actually connected and ready to communicate.
Now = erlang:system_time(millisecond),
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_keys => ParsedViewerStreamKey,
voice_state => VoiceState,
token_nonce => TokenNonce,
created_at => Now,
expires_at => Now + 30000
},
PendingConnections = pending_voice_connections(State2),
NewPendingConnections = maps:put(
ConnectionId,
PendingMetadata,
PendingConnections
),
NewState = maps:put(
pending_voice_connections, NewPendingConnections, State2
),
{reply,
#{
success => true,
token => Token,
endpoint => Endpoint,
connection_id => ConnectionId,
voice_state => VoiceState
},
NewState};
{error, _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_keys => maps:get(viewer_stream_keys, Request, undefined)
}.
-spec normalize_connection_id(term()) -> binary() | 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.
-spec maybe_attach_session_id(voice_state(), binary() | undefined) -> voice_state().
maybe_attach_session_id(VoiceState, undefined) ->
VoiceState;
maybe_attach_session_id(VoiceState, SessionId) when is_binary(SessionId) ->
maps:put(<<"session_id">>, SessionId, VoiceState).
-spec maybe_attach_member(voice_state(), map()) -> voice_state().
maybe_attach_member(VoiceState, Member) when is_map(Member) ->
case maps:size(Member) of
0 -> VoiceState;
_ -> maps:put(<<"member">>, Member, VoiceState)
end.
-spec normalize_session_id(term()) -> binary() | undefined.
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.
-spec resolve_viewer_stream_keys(
context(), integer() | undefined, integer(), voice_state_map(), voice_state()
) ->
{ok, list()} | {error, atom()}.
resolve_viewer_stream_keys(Context, GuildId, ChannelIdValue, VoiceStates, ExistingVoiceState) ->
RawKeys = maps:get(viewer_stream_keys, Context, undefined),
case RawKeys of
undefined ->
{ok, maps:get(<<"viewer_stream_keys">>, ExistingVoiceState, [])};
null ->
{ok, []};
Keys when is_list(Keys) ->
validate_viewer_stream_keys(Keys, GuildId, ChannelIdValue, VoiceStates, []);
_ ->
{error, voice_invalid_state}
end.
-spec validate_viewer_stream_keys(
list(), integer() | undefined, integer(), voice_state_map(), list()
) ->
{ok, list()} | {error, atom()}.
validate_viewer_stream_keys([], _GuildId, _ChannelIdValue, _VoiceStates, Acc) ->
{ok, lists:reverse(Acc)};
validate_viewer_stream_keys([Key | Rest], GuildId, ChannelIdValue, VoiceStates, Acc) ->
case validate_single_viewer_stream_key(Key, GuildId, ChannelIdValue, VoiceStates) of
{ok, ValidKey} ->
validate_viewer_stream_keys(Rest, GuildId, ChannelIdValue, VoiceStates, [ValidKey | Acc]);
{error, _} = Error ->
Error
end.
-spec validate_single_viewer_stream_key(
term(), integer() | undefined, integer(), voice_state_map()
) ->
{ok, binary()} | {error, atom()}.
validate_single_viewer_stream_key(RawKey, _GuildId, _ChannelIdValue, _VoiceStates) when not is_binary(RawKey) ->
{error, voice_invalid_state};
validate_single_viewer_stream_key(RawKey, GuildId, ChannelIdValue, VoiceStates) ->
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.
-spec check_camera_user_limit(context(), integer(), voice_state_map()) -> ok | {error, atom()}.
check_camera_user_limit(Context, ChannelIdValue, VoiceStates) ->
SelfVideo = maps:get(self_video, Context, false),
case SelfVideo of
false ->
ok;
true ->
UserIds = lists:foldl(
fun({_ConnId, VS}, Acc) ->
case map_utils:get_integer(VS, <<"channel_id">>, undefined) of
ChannelIdValue ->
UserId = map_utils:get_integer(VS, <<"user_id">>, undefined),
case UserId of
undefined -> Acc;
_ -> sets:add_element(UserId, Acc)
end;
_ ->
Acc
end
end,
sets:new(),
maps:to_list(VoiceStates)
),
case sets:size(UserIds) > 25 of
true -> {error, voice_camera_user_limit};
false -> ok
end
end.
-spec resolve_voice_state_from_pending(binary(), map(), guild_state(), voice_state_map()) ->
voice_state() | undefined.
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.
-spec build_voice_state_from_pending(map(), binary(), guild_state()) -> voice_state() | undefined.
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 = #{
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),
ViewerStreamKeys = pending_get_value(PendingData, viewer_stream_keys),
VoiceState0 = guild_voice_state:create_voice_state(
GuildIdBin,
ChannelIdBin,
UserIdBin,
ConnectionId,
ServerMute,
ServerDeaf,
Flags,
ViewerStreamKeys
),
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.
-spec pending_get_value(map(), atom()) -> term().
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.
-spec pending_get_integer(map(), atom()) -> integer() | undefined.
pending_get_integer(PendingData, Key) ->
case pending_get_value(PendingData, Key) of
undefined -> undefined;
Value -> type_conv:to_integer(Value)
end.
-spec pending_get_boolean(map(), atom()) -> boolean().
pending_get_boolean(PendingData, Key) ->
case pending_get_value(PendingData, Key) of
true -> true;
false -> false;
_ -> false
end.
-spec pending_get_binary(map(), atom()) -> binary() | undefined.
pending_get_binary(PendingData, Key) ->
case pending_get_value(PendingData, Key) of
undefined -> undefined;
Value -> type_conv:to_binary(Value)
end.
-spec pending_get_map(map(), atom()) -> map().
pending_get_map(PendingData, Key) ->
case pending_get_value(PendingData, Key) of
Map when is_map(Map) -> Map;
_ -> #{}
end.
-spec resolve_guild_identity(guild_state()) ->
{ok, integer(), binary()} | {error, atom()}.
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}
]).
-spec resolve_guild_id_priority([
{term(), fun((term()) -> {ok, integer(), binary()} | {error, atom()})}
]) ->
{ok, integer(), binary()} | {error, atom()}.
resolve_guild_id_priority([]) ->
{error, voice_guild_id_missing};
resolve_guild_id_priority([{undefined, _} | Rest]) ->
resolve_guild_id_priority(Rest);
resolve_guild_id_priority([{Value, NormalizeFun} | _]) ->
NormalizeFun(Value).
-spec normalize_guild_id(term()) -> {ok, integer(), binary()} | {error, atom()}.
normalize_guild_id(Value) ->
case type_conv:to_integer(Value) of
undefined ->
{error, voice_invalid_guild_id};
Int ->
{ok, Int, guild_id_binary(Value, Int)}
end.
-spec guild_id_binary(term(), integer()) -> binary().
guild_id_binary(Value, Int) ->
case type_conv:to_binary(Value) of
undefined -> integer_to_binary(Int);
Bin -> Bin
end.
-spec guild_data(guild_state()) -> map().
guild_data(State) ->
map_utils:ensure_map(maps:get(data, State, #{})).
-spec confirm_voice_connection_from_livekit(map(), guild_state()) ->
{reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
confirm_voice_connection_from_livekit(Request, State) ->
ConnectionId = maps:get(connection_id, Request, undefined),
TokenNonce = maps:get(token_nonce, Request, undefined),
case ConnectionId of
undefined ->
{reply, gateway_errors:error(voice_missing_connection_id), State};
_ ->
logger:debug(
"Confirming voice connection from LiveKit",
#{connection_id => ConnectionId, token_nonce => TokenNonce}
),
PendingConnections = pending_voice_connections(State),
case maps:get(ConnectionId, PendingConnections, undefined) of
undefined ->
logger:debug(
"No pending voice connection found for LiveKit confirm",
#{connection_id => ConnectionId}
),
VoiceStates = voice_state_utils:voice_states(State),
case maps:get(ConnectionId, VoiceStates, undefined) of
VoiceState when is_map(VoiceState) ->
{reply, #{success => true}, State};
_ ->
try_restore_from_recently_disconnected(ConnectionId, State)
end;
PendingData ->
logger:debug(
"Found pending voice connection for LiveKit confirm",
#{
connection_id => ConnectionId,
pending_user_id => pending_get_integer(PendingData, user_id),
pending_channel_id => pending_get_integer(PendingData, channel_id)
}
),
case validate_pending_nonce_and_expiry(TokenNonce, PendingData) of
{error, ErrorAtom} ->
logger:debug(
"LiveKit confirm rejected due to pending validation",
#{connection_id => ConnectionId, error => ErrorAtom}
),
{reply, gateway_errors:error(ErrorAtom), State};
ok ->
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 ->
{reply, #{success => true}, StateWithoutPending};
_ ->
UpdatedVoiceStates = maps:put(ConnectionId, VoiceState, VoiceStates),
StateWithVoiceStates = maps:put(
voice_states, UpdatedVoiceStates, StateWithoutPending
),
StateCleared = clear_virtual_access_flags_from_voice_state(
VoiceState, StateWithVoiceStates
),
ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
guild_voice_broadcast:broadcast_voice_state_update(
VoiceState, StateCleared, ChannelIdBin
),
{reply, #{success => true}, StateCleared}
end
end
end
end.
-spec validate_pending_nonce_and_expiry(binary() | undefined, map()) -> ok | {error, atom()}.
validate_pending_nonce_and_expiry(TokenNonce, PendingData) ->
ExpiresAt = maps:get(expires_at, PendingData, undefined),
Now = erlang:system_time(millisecond),
case ExpiresAt of
ExpiresAtVal when is_integer(ExpiresAtVal), Now >= ExpiresAtVal ->
{error, voice_pending_expired};
_ ->
PendingNonce = maps:get(token_nonce, PendingData, undefined),
case TokenNonce of
undefined ->
ok;
PendingNonce ->
ok;
_ ->
{error, voice_nonce_mismatch}
end
end.
-spec try_restore_from_recently_disconnected(binary(), guild_state()) ->
{reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
try_restore_from_recently_disconnected(ConnectionId, State) ->
Cache = guild_voice_disconnect:recently_disconnected_voice_states(State),
Now = erlang:system_time(millisecond),
case maps:get(ConnectionId, Cache, undefined) of
#{voice_state := VoiceState, disconnected_at := DisconnectedAt} when
(Now - DisconnectedAt) < 60000
->
restore_recently_disconnected(ConnectionId, VoiceState, Cache, State);
_ ->
{reply, gateway_errors:error(voice_connection_not_found), State}
end.
-spec restore_recently_disconnected(binary(), voice_state(), map(), guild_state()) ->
{reply, map(), guild_state()}.
restore_recently_disconnected(ConnectionId, VoiceState, Cache, State) ->
VoiceStates = voice_state_utils:voice_states(State),
UpdatedVoiceStates = maps:put(ConnectionId, VoiceState, VoiceStates),
NewCache = maps:remove(ConnectionId, Cache),
State0 = maps:put(voice_states, UpdatedVoiceStates, State),
State1 = maps:put(recently_disconnected_voice_states, NewCache, State0),
ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
guild_voice_broadcast:broadcast_voice_state_update(VoiceState, State1, ChannelIdBin),
{reply, #{success => true}, State1}.
-spec clear_virtual_access_flags_from_voice_state(voice_state(), guild_state()) -> guild_state().
clear_virtual_access_flags_from_voice_state(VoiceState, State) when is_map(VoiceState) ->
UserId = map_utils:get_integer(VoiceState, <<"user_id">>, undefined),
ChannelId = map_utils:get_integer(VoiceState, <<"channel_id">>, undefined),
case is_integer(UserId) andalso is_integer(ChannelId) of
true ->
State0 = guild_virtual_channel_access:clear_pending_join(UserId, ChannelId, State),
State1 = guild_virtual_channel_access:clear_preserve(UserId, ChannelId, State0),
guild_virtual_channel_access:clear_move_pending(UserId, ChannelId, State1);
false ->
State
end.
-spec request_voice_token(integer(), integer(), integer(), map()) ->
{ok, map()} | {error, term()}.
request_voice_token(GuildId, ChannelId, UserId, VoicePermissions) ->
request_voice_token(GuildId, ChannelId, UserId, null, VoicePermissions).
-spec request_voice_token(integer(), integer(), integer(), binary() | null, map()) ->
{ok, map()} | {error, term()}.
request_voice_token(GuildId, ChannelId, UserId, ConnectionId, VoicePermissions) ->
request_voice_token(GuildId, ChannelId, UserId, ConnectionId, VoicePermissions, null).
-spec request_voice_token(integer(), integer(), integer(), binary() | null, map(), binary() | null) ->
{ok, map()} | {error, term()}.
request_voice_token(GuildId, ChannelId, UserId, ConnectionId, VoicePermissions, TokenNonce) ->
Req = voice_utils:build_voice_token_rpc_request(
GuildId, ChannelId, UserId, ConnectionId, null, null, VoicePermissions, TokenNonce
),
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, {http_error, _Status, Body}} ->
case parse_unclaimed_error(Body) of
true ->
{error, voice_unclaimed_account};
false ->
{error, voice_token_failed}
end;
{error, _Reason} ->
{error, voice_token_failed}
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.
-spec sweep_expired_pending_joins(guild_state()) -> guild_state().
sweep_expired_pending_joins(State) ->
Now = erlang:system_time(millisecond),
PendingConnections = maps:get(pending_voice_connections, State, #{}),
{Expired, Remaining} = maps:fold(
fun(ConnId, Metadata, {ExpAcc, RemAcc}) ->
ExpiresAt = maps:get(expires_at, Metadata, Now + 999999),
case Now >= ExpiresAt of
true -> {[{ConnId, Metadata} | ExpAcc], RemAcc};
false -> {ExpAcc, maps:put(ConnId, Metadata, RemAcc)}
end
end,
{[], #{}},
PendingConnections
),
lists:foreach(
fun({ConnId, Metadata}) ->
UserId = maps:get(user_id, Metadata, undefined),
GuildId = maps:get(guild_id, Metadata, undefined),
ChannelId = maps:get(channel_id, Metadata, undefined),
case {GuildId, ChannelId, UserId} of
{GId, CId, UId} when is_integer(GId), is_integer(CId), is_integer(UId) ->
spawn(fun() ->
guild_voice_disconnect:force_disconnect_participant(GId, CId, UId, ConnId)
end);
_ ->
ok
end
end,
Expired
),
StateCleared = lists:foldl(
fun({_ConnId, Metadata}, AccState) ->
ExpUserId = maps:get(user_id, Metadata, undefined),
ExpChannelId = maps:get(channel_id, Metadata, undefined),
case is_integer(ExpUserId) andalso is_integer(ExpChannelId) of
true ->
S1 = guild_virtual_channel_access:clear_pending_join(
ExpUserId, ExpChannelId, AccState
),
S2 = guild_virtual_channel_access:clear_preserve(
ExpUserId, ExpChannelId, S1
),
guild_virtual_channel_access:clear_move_pending(
ExpUserId, ExpChannelId, S2
);
false ->
AccState
end
end,
State,
Expired
),
maps:put(pending_voice_connections, Remaining, StateCleared).
-ifdef(TEST).
required_voice_perms() ->
constants:view_channel_permission() bor constants:connect_permission().
base_test_member(UserId) ->
#{<<"user">> => #{<<"id">> => integer_to_binary(UserId)}}.
base_test_channel(ChannelId) ->
#{
<<"id">> => integer_to_binary(ChannelId),
<<"type">> => 2,
<<"user_limit">> => 0
}.
base_test_state() ->
#{
id => 999,
data => #{
<<"channels">> => [base_test_channel(100)],
<<"members">> => [base_test_member(10)]
},
voice_states => #{},
test_perm_fun => fun(_) -> required_voice_perms() end
}.
replace_channels(State, Channels) ->
Data0 = maps:get(data, State, #{}),
Data1 = maps:put(<<"channels">>, Channels, Data0),
maps:put(data, Data1, State).
replace_guild_id(State, GuildIdValue) ->
Data0 = maps:get(data, State, #{}),
Data1 = maps:put(<<"id">>, GuildIdValue, Data0),
maps:put(data, Data1, State).
replace_guild_meta_id(State, GuildIdValue) ->
Data0 = maps:get(data, State, #{}),
Guild0 = maps:get(<<"guild">>, Data0, #{}),
Guild1 = maps:put(<<"id">>, GuildIdValue, Guild0),
Data1 = maps:put(<<"guild">>, Guild1, Data0),
maps:put(data, Data1, State).
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() ->
{reply, {error, validation_error, voice_invalid_user_id}, _} =
voice_state_update(#{channel_id => null}, #{}).
voice_state_update_member_not_found_test() ->
State = base_test_state(),
{reply, {error, not_found, voice_member_not_found}, _} =
voice_state_update(#{user_id => 99, channel_id => null}, State).
voice_state_update_invalid_channel_id_test() ->
State = base_test_state(),
{reply, {error, validation_error, voice_invalid_channel_id}, _} =
voice_state_update(#{user_id => 10, channel_id => undefined}, State).
voice_state_update_channel_not_found_test() ->
State = replace_channels(base_test_state(), []),
{reply, {error, not_found, voice_channel_not_found}, _} =
voice_state_update(#{user_id => 10, channel_id => 999}, State).
voice_state_update_connection_not_found_test() ->
State = base_test_state(),
Request = #{
user_id => 10,
channel_id => 100,
connection_id => <<"missing-conn">>
},
{reply, {error, not_found, voice_connection_not_found}, _} =
voice_state_update(Request, State).
voice_state_update_invalid_viewer_stream_keys_test() ->
VoiceStates = #{
<<"conn-1">> => #{
<<"channel_id">> => <<"100">>,
<<"user_id">> => <<"10">>
}
},
State = maps:put(voice_states, VoiceStates, base_test_state()),
Request = #{
user_id => 10,
channel_id => 100,
connection_id => <<"conn-1">>,
viewer_stream_keys => 123
},
{reply, {error, validation_error, voice_invalid_state}, _} =
voice_state_update(Request, State).
voice_state_update_viewer_stream_keys_missing_connection_test() ->
VoiceStates = #{
<<"conn-1">> => #{
<<"channel_id">> => <<"100">>,
<<"user_id">> => <<"10">>
}
},
State = maps:put(voice_states, VoiceStates, base_test_state()),
Request = #{
user_id => 10,
channel_id => 100,
connection_id => <<"conn-1">>,
viewer_stream_keys => [<<"999:100:missing-conn">>]
},
{reply, {error, not_found, voice_connection_not_found}, _} =
voice_state_update(Request, State).
voice_state_update_guild_id_missing_test() ->
State0 = base_test_state(),
State1 = replace_guild_id(State0, undefined),
State2 = replace_guild_meta_id(State1, undefined),
State = maps:remove(id, State2),
Request = #{
user_id => 10,
channel_id => 100
},
{reply, {error, validation_error, voice_guild_id_missing}, _} =
voice_state_update(Request, State).
voice_state_update_invalid_guild_id_test() ->
State0 = base_test_state(),
State1 = replace_guild_id(State0, <<"nope">>),
State2 = replace_guild_meta_id(State1, <<"nope">>),
State = maps:put(id, undefined, State2),
Request = #{
user_id => 10,
channel_id => 100
},
{reply, {error, validation_error, voice_invalid_guild_id}, _} =
voice_state_update(Request, State).
confirm_voice_connection_missing_id_test() ->
State = base_test_state(),
{reply, {error, validation_error, voice_missing_connection_id}, _} =
confirm_voice_connection_from_livekit(#{}, State).
confirm_voice_connection_moves_pending_to_voice_states_test() ->
VoiceState = #{
<<"user_id">> => <<"5">>,
<<"guild_id">> => <<"999">>,
<<"channel_id">> => <<"100">>
},
PendingData = #{
user_id => 5,
guild_id => 999,
channel_id => 100,
voice_state => VoiceState
},
PendingConnections = #{<<"conn1">> => PendingData},
State = maps:merge(base_test_state(), #{
pending_voice_connections => PendingConnections,
voice_states => #{}
}),
{reply, #{success := true}, NewState} =
confirm_voice_connection_from_livekit(#{connection_id => <<"conn1">>}, State),
NewVoiceStates = maps:get(voice_states, NewState),
NewPending = maps:get(pending_voice_connections, NewState, #{}),
?assert(maps:is_key(<<"conn1">>, NewVoiceStates)),
?assertNot(maps:is_key(<<"conn1">>, NewPending)).
confirm_voice_connection_clears_pending_even_without_voice_state_test() ->
PendingData = #{
user_id => 5,
channel_id => 100
},
PendingConnections = #{<<"conn1">> => PendingData},
State = maps:merge(base_test_state(), #{
pending_voice_connections => PendingConnections,
voice_states => #{}
}),
{reply, #{success := true}, NewState} =
confirm_voice_connection_from_livekit(#{connection_id => <<"conn1">>}, State),
NewPending = maps:get(pending_voice_connections, NewState, #{}),
?assertNot(maps:is_key(<<"conn1">>, NewPending)).
confirm_voice_connection_not_found_in_pending_test() ->
State = maps:merge(base_test_state(), #{
pending_voice_connections => #{}
}),
{reply, {error, not_found, voice_connection_not_found}, _} =
confirm_voice_connection_from_livekit(#{connection_id => <<"missing">>}, State).
confirm_voice_connection_found_in_voice_states_test() ->
VoiceState = #{
<<"user_id">> => <<"5">>,
<<"guild_id">> => <<"999">>,
<<"channel_id">> => <<"200">>
},
State = maps:merge(base_test_state(), #{
pending_voice_connections => #{},
voice_states => #{<<"conn1">> => VoiceState}
}),
{reply, #{success := true}, NewState} =
confirm_voice_connection_from_livekit(#{connection_id => <<"conn1">>}, State),
NewVoiceStates = maps:get(voice_states, NewState),
?assert(maps:is_key(<<"conn1">>, NewVoiceStates)),
?assertEqual(VoiceState, maps:get(<<"conn1">>, NewVoiceStates)).
resolve_voice_state_from_pending_uses_stored_voice_state_test() ->
VoiceState = #{
<<"user_id">> => <<"5">>,
<<"guild_id">> => <<"999">>,
<<"channel_id">> => <<"100">>
},
PendingData = #{
user_id => 5,
guild_id => 999,
channel_id => 100,
voice_state => VoiceState
},
State = base_test_state(),
Result = resolve_voice_state_from_pending(<<"conn1">>, PendingData, State, #{}),
?assertEqual(VoiceState, Result).
resolve_voice_state_from_pending_prefers_existing_voice_state_test() ->
ExistingVoiceState = #{
<<"user_id">> => <<"5">>,
<<"guild_id">> => <<"999">>,
<<"channel_id">> => <<"100">>,
<<"existing">> => true
},
PendingVoiceState = #{
<<"user_id">> => <<"5">>,
<<"guild_id">> => <<"999">>,
<<"channel_id">> => <<"100">>,
<<"existing">> => false
},
PendingData = #{
user_id => 5,
voice_state => PendingVoiceState
},
VoiceStates = #{<<"conn1">> => ExistingVoiceState},
State = base_test_state(),
Result = resolve_voice_state_from_pending(<<"conn1">>, PendingData, State, VoiceStates),
?assertEqual(ExistingVoiceState, Result).
normalize_boolean_test() ->
?assertEqual(true, normalize_boolean(true)),
?assertEqual(true, normalize_boolean(<<"true">>)),
?assertEqual(false, normalize_boolean(false)),
?assertEqual(false, normalize_boolean(<<"false">>)),
?assertEqual(false, normalize_boolean(<<"other">>)),
?assertEqual(false, normalize_boolean(123)).
normalize_session_id_test() ->
?assertEqual(undefined, normalize_session_id(undefined)),
?assertEqual(undefined, normalize_session_id(null)),
?assertEqual(<<"abc">>, normalize_session_id(<<"abc">>)),
?assertEqual(<<"123">>, normalize_session_id(123)),
?assertEqual(<<"test">>, normalize_session_id("test")).
pending_get_value_test() ->
Data = #{key1 => value1, <<"key2">> => value2},
?assertEqual(value1, pending_get_value(Data, key1)),
?assertEqual(value2, pending_get_value(Data, key2)),
?assertEqual(undefined, pending_get_value(Data, missing)).
try_restore_from_recently_disconnected_restores_test() ->
VS = #{
<<"user_id">> => <<"5">>,
<<"guild_id">> => <<"10">>,
<<"channel_id">> => <<"20">>,
<<"connection_id">> => <<"conn">>
},
Now = erlang:system_time(millisecond),
Cache = #{<<"conn">> => #{voice_state => VS, disconnected_at => Now - 5000}},
State = #{
voice_states => #{},
recently_disconnected_voice_states => Cache,
sessions => #{},
data => #{},
id => 10
},
{reply, #{success := true}, NewState} =
try_restore_from_recently_disconnected(<<"conn">>, State),
NewVoiceStates = maps:get(voice_states, NewState),
?assert(maps:is_key(<<"conn">>, NewVoiceStates)),
NewCache = maps:get(recently_disconnected_voice_states, NewState),
?assertNot(maps:is_key(<<"conn">>, NewCache)).
try_restore_from_recently_disconnected_expired_test() ->
VS = #{
<<"user_id">> => <<"5">>,
<<"guild_id">> => <<"10">>,
<<"channel_id">> => <<"20">>,
<<"connection_id">> => <<"conn">>
},
Now = erlang:system_time(millisecond),
Cache = #{<<"conn">> => #{voice_state => VS, disconnected_at => Now - 70000}},
State = #{
voice_states => #{},
recently_disconnected_voice_states => Cache,
data => #{}
},
{reply, {error, not_found, voice_connection_not_found}, _} =
try_restore_from_recently_disconnected(<<"conn">>, State).
try_restore_from_recently_disconnected_not_found_test() ->
State = #{voice_states => #{}, data => #{}},
{reply, {error, not_found, voice_connection_not_found}, _} =
try_restore_from_recently_disconnected(<<"conn">>, State).
voice_state_update_connection_user_mismatch_test() ->
VoiceStates = #{
<<"conn-1">> => #{
<<"channel_id">> => <<"100">>,
<<"user_id">> => <<"20">>
}
},
State = maps:put(voice_states, VoiceStates, base_test_state()),
Request = #{
user_id => 10,
channel_id => 100,
connection_id => <<"conn-1">>
},
{reply, {error, validation_error, voice_user_mismatch}, _} =
voice_state_update(Request, State).
voice_state_update_connection_owner_match_proceeds_test() ->
VoiceStates = #{
<<"conn-1">> => #{
<<"channel_id">> => <<"100">>,
<<"user_id">> => <<"10">>
}
},
State = maps:put(voice_states, VoiceStates, base_test_state()),
Request = #{
user_id => 10,
channel_id => 100,
connection_id => <<"conn-1">>
},
case voice_state_update(Request, State) of
{reply, {error, validation_error, voice_user_mismatch}, _} ->
error(should_not_get_user_mismatch);
{reply, _, _} ->
ok
end.
validate_pending_nonce_valid_test() ->
Now = erlang:system_time(millisecond),
PendingData = #{
token_nonce => <<"abc123">>,
created_at => Now - 5000,
expires_at => Now + 25000
},
?assertEqual(ok, validate_pending_nonce_and_expiry(<<"abc123">>, PendingData)).
validate_pending_nonce_mismatch_test() ->
Now = erlang:system_time(millisecond),
PendingData = #{
token_nonce => <<"abc123">>,
created_at => Now - 5000,
expires_at => Now + 25000
},
?assertEqual(
{error, voice_nonce_mismatch},
validate_pending_nonce_and_expiry(<<"wrong-nonce">>, PendingData)
).
validate_pending_nonce_expired_test() ->
Now = erlang:system_time(millisecond),
PendingData = #{
token_nonce => <<"abc123">>,
created_at => Now - 35000,
expires_at => Now - 5000
},
?assertEqual(
{error, voice_pending_expired},
validate_pending_nonce_and_expiry(<<"abc123">>, PendingData)
).
validate_pending_nonce_undefined_backwards_compat_test() ->
Now = erlang:system_time(millisecond),
PendingData = #{
created_at => Now - 5000,
expires_at => Now + 25000
},
?assertEqual(ok, validate_pending_nonce_and_expiry(undefined, PendingData)).
validate_pending_nonce_missing_expires_at_test() ->
PendingData = #{
token_nonce => <<"abc123">>
},
?assertEqual(ok, validate_pending_nonce_and_expiry(<<"abc123">>, PendingData)).
sweep_expired_pending_joins_removes_expired_test() ->
Now = erlang:system_time(millisecond),
ExpiredMetadata = #{
user_id => 10,
guild_id => 999,
channel_id => 100,
expires_at => Now - 1000
},
ValidMetadata = #{
user_id => 11,
guild_id => 999,
channel_id => 101,
expires_at => Now + 25000
},
PendingConnections = #{
<<"expired-conn">> => ExpiredMetadata,
<<"valid-conn">> => ValidMetadata
},
State = maps:put(pending_voice_connections, PendingConnections, base_test_state()),
NewState = sweep_expired_pending_joins(State),
NewPending = maps:get(pending_voice_connections, NewState, #{}),
?assertNot(maps:is_key(<<"expired-conn">>, NewPending)),
?assert(maps:is_key(<<"valid-conn">>, NewPending)).
sweep_expired_pending_joins_keeps_valid_test() ->
Now = erlang:system_time(millisecond),
ValidMetadata1 = #{
user_id => 10,
guild_id => 999,
channel_id => 100,
expires_at => Now + 25000
},
ValidMetadata2 = #{
user_id => 11,
guild_id => 999,
channel_id => 101,
expires_at => Now + 30000
},
PendingConnections = #{
<<"conn-1">> => ValidMetadata1,
<<"conn-2">> => ValidMetadata2
},
State = maps:put(pending_voice_connections, PendingConnections, base_test_state()),
NewState = sweep_expired_pending_joins(State),
NewPending = maps:get(pending_voice_connections, NewState, #{}),
?assertEqual(2, maps:size(NewPending)),
?assert(maps:is_key(<<"conn-1">>, NewPending)),
?assert(maps:is_key(<<"conn-2">>, NewPending)).
sweep_expired_pending_joins_clears_virtual_access_test() ->
Now = erlang:system_time(millisecond),
ExpiredMetadata = #{
user_id => 10,
guild_id => 999,
channel_id => 100,
expires_at => Now - 1000
},
PendingConnections = #{<<"expired-conn">> => ExpiredMetadata},
State = maps:put(pending_voice_connections, PendingConnections, base_test_state()),
State1 = guild_virtual_channel_access:mark_pending_join(10, 100, State),
State2 = guild_virtual_channel_access:mark_preserve(10, 100, State1),
State3 = guild_virtual_channel_access:mark_move_pending(10, 100, State2),
?assert(guild_virtual_channel_access:is_pending_join(10, 100, State3)),
?assert(guild_virtual_channel_access:has_preserve(10, 100, State3)),
?assert(guild_virtual_channel_access:is_move_pending(10, 100, State3)),
NewState = sweep_expired_pending_joins(State3),
?assertNot(guild_virtual_channel_access:is_pending_join(10, 100, NewState)),
?assertNot(guild_virtual_channel_access:has_preserve(10, 100, NewState)),
?assertNot(guild_virtual_channel_access:is_move_pending(10, 100, NewState)).
sweep_expired_pending_joins_empty_map_test() ->
State = maps:put(pending_voice_connections, #{}, base_test_state()),
NewState = sweep_expired_pending_joins(State),
NewPending = maps:get(pending_voice_connections, NewState, #{}),
?assertEqual(0, maps:size(NewPending)).
sweep_expired_pending_joins_missing_user_id_test() ->
Now = erlang:system_time(millisecond),
InvalidMetadata = #{
guild_id => 999,
channel_id => 100,
expires_at => Now - 1000
},
PendingConnections = #{<<"invalid-conn">> => InvalidMetadata},
State = maps:put(pending_voice_connections, PendingConnections, base_test_state()),
NewState = sweep_expired_pending_joins(State),
NewPending = maps:get(pending_voice_connections, NewState, #{}),
?assertNot(maps:is_key(<<"invalid-conn">>, NewPending)).
confirm_voice_connection_validates_nonce_test() ->
Now = erlang:system_time(millisecond),
PendingData = #{
user_id => 5,
guild_id => 999,
channel_id => 100,
token_nonce => <<"valid-nonce">>,
created_at => Now - 5000,
expires_at => Now + 25000,
voice_state => #{
<<"user_id">> => <<"5">>,
<<"guild_id">> => <<"999">>,
<<"channel_id">> => <<"100">>
}
},
PendingConnections = #{<<"conn1">> => PendingData},
State = maps:merge(base_test_state(), #{
pending_voice_connections => PendingConnections,
voice_states => #{}
}),
{reply, {error, validation_error, voice_nonce_mismatch}, _} =
confirm_voice_connection_from_livekit(
#{connection_id => <<"conn1">>, token_nonce => <<"wrong-nonce">>},
State
).
confirm_voice_connection_validates_expiry_test() ->
Now = erlang:system_time(millisecond),
PendingData = #{
user_id => 5,
guild_id => 999,
channel_id => 100,
token_nonce => <<"valid-nonce">>,
created_at => Now - 35000,
expires_at => Now - 5000,
voice_state => #{
<<"user_id">> => <<"5">>,
<<"guild_id">> => <<"999">>,
<<"channel_id">> => <<"100">>
}
},
PendingConnections = #{<<"conn1">> => PendingData},
State = maps:merge(base_test_state(), #{
pending_voice_connections => PendingConnections,
voice_states => #{}
}),
{reply, {error, validation_error, voice_pending_expired}, _} =
confirm_voice_connection_from_livekit(
#{connection_id => <<"conn1">>, token_nonce => <<"valid-nonce">>},
State
).
confirm_voice_connection_accepts_valid_nonce_test() ->
Now = erlang:system_time(millisecond),
PendingData = #{
user_id => 5,
guild_id => 999,
channel_id => 100,
token_nonce => <<"valid-nonce">>,
created_at => Now - 5000,
expires_at => Now + 25000,
voice_state => #{
<<"user_id">> => <<"5">>,
<<"guild_id">> => <<"999">>,
<<"channel_id">> => <<"100">>
}
},
PendingConnections = #{<<"conn1">> => PendingData},
State = maps:merge(base_test_state(), #{
pending_voice_connections => PendingConnections,
voice_states => #{}
}),
{reply, #{success := true}, NewState} =
confirm_voice_connection_from_livekit(
#{connection_id => <<"conn1">>, token_nonce => <<"valid-nonce">>},
State
),
NewVoiceStates = maps:get(voice_states, NewState),
?assert(maps:is_key(<<"conn1">>, NewVoiceStates)).
confirm_voice_connection_accepts_undefined_nonce_backwards_compat_test() ->
Now = erlang:system_time(millisecond),
PendingData = #{
user_id => 5,
guild_id => 999,
channel_id => 100,
created_at => Now - 5000,
expires_at => Now + 25000,
voice_state => #{
<<"user_id">> => <<"5">>,
<<"guild_id">> => <<"999">>,
<<"channel_id">> => <<"100">>
}
},
PendingConnections = #{<<"conn1">> => PendingData},
State = maps:merge(base_test_state(), #{
pending_voice_connections => PendingConnections,
voice_states => #{}
}),
{reply, #{success := true}, NewState} =
confirm_voice_connection_from_livekit(#{connection_id => <<"conn1">>}, State),
NewVoiceStates = maps:get(voice_states, NewState),
?assert(maps:is_key(<<"conn1">>, NewVoiceStates)).
-endif.