feat(voice): add opt-in peer-to-peer calls and country rollouts (#3367)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-10 15:45:17 +02:00
1 parent e5a568acca
commit bef5e33eec
215 files changed
+15840 -2693

No files matched your search

+42 -12
View File
@@ -25,6 +25,7 @@
| {stop_ringing, [integer()]}
| {join, integer(), map(), binary(), pid()}
| {join, integer(), map(), binary(), pid(), binary() | undefined}
| {join, integer(), map(), binary(), pid(), binary(), pos_integer()}
| {confirm_connection, binary()}
| {disconnect_user_if_in_channel, integer(), integer(), binary() | undefined}
| {leave, binary()}
@@ -33,7 +34,8 @@
| {get_pending_connections}.
-type cast_request() ::
{join_async, integer(), map(), binary(), pid()}
| {set_caller, map()}.
| {set_caller, map()}
| {voice_signal, binary(), binary(), map()}.
-type info_message() ::
{'DOWN', reference(), process, pid(), term()}
| {ring_timeout, integer()}
@@ -134,9 +136,11 @@ handle_call_request({ring_recipients, Recipients}, State) ->
handle_call_request({stop_ringing, Recipients}, State) ->
handle_stop_ringing(Recipients, State);
handle_call_request({join, UserId, VS, SId, SPid}, State) ->
handle_join_request(UserId, VS, SId, SPid, undefined, State);
handle_join_request(UserId, VS, SId, SPid, undefined, undefined, State);
handle_call_request({join, UserId, VS, SId, SPid, CId}, State) ->
handle_join_request(UserId, VS, SId, SPid, CId, State);
handle_join_request(UserId, VS, SId, SPid, CId, undefined, State);
handle_call_request({join, UserId, VS, SId, SPid, CId, MaxParticipants}, State) ->
handle_join_request(UserId, VS, SId, SPid, CId, MaxParticipants, State);
handle_call_request({confirm_connection, ConnectionId}, State) ->
handle_confirm_connection(ConnectionId, State);
handle_call_request({disconnect_user_if_in_channel, UserId, ExpChId, CId}, State) ->
@@ -151,14 +155,16 @@ handle_call_request({get_pending_connections}, State) ->
handle_get_pending_connections(State).
-spec handle_join_request(
integer(), map(), binary(), pid(), binary() | undefined, map()
) -> {reply, ok, map()}.
handle_join_request(UserId, VS, SId, SPid, CId, #{sessions := Sessions} = State) ->
integer(), map(), binary(), pid(), binary() | undefined, pos_integer() | undefined, map()
) -> {reply, ok | {error, atom()}, map()}.
handle_join_request(
UserId, VS, SId, SPid, CId, MaxParticipants, #{sessions := Sessions} = State
) ->
case maps:get(SId, Sessions, undefined) of
{UserId, SPid, _Ref} ->
{reply, ok, State};
_ ->
call_voice:handle_join_internal(UserId, VS, SId, SPid, CId, State)
call_voice:handle_join_internal(UserId, VS, SId, SPid, CId, MaxParticipants, State)
end.
-spec handle_cast(term(), map()) -> {noreply, map()}.
@@ -168,6 +174,9 @@ handle_cast(Request, State) ->
call_voice:handle_join_async(UserId, VoiceState, SessionId, SessionPid, State);
{ok, {set_caller, Caller}} ->
{noreply, call_state:put_caller(Caller, State)};
{ok, {voice_signal, SessionId, To, Data}} ->
ok = call_voice:relay_voice_signal(SessionId, To, Data, State),
{noreply, State};
error ->
{noreply, State}
end.
@@ -269,6 +278,18 @@ decode_session_call_request(
is_binary(ConnectionId)
->
{ok, {join, UserId, VoiceState, SessionId, SessionPid, ConnectionId}};
decode_session_call_request(
{join, UserId, VoiceState, SessionId, SessionPid, ConnectionId, MaxParticipants}
) when
is_integer(UserId),
is_map(VoiceState),
is_binary(SessionId),
is_pid(SessionPid),
is_binary(ConnectionId),
is_integer(MaxParticipants),
MaxParticipants >= 1
->
{ok, {join, UserId, VoiceState, SessionId, SessionPid, ConnectionId, MaxParticipants}};
decode_session_call_request({confirm_connection, ConnectionId}) when is_binary(ConnectionId) ->
{ok, {confirm_connection, ConnectionId}};
decode_session_call_request(
@@ -323,6 +344,10 @@ decode_cast_request({join_async, UserId, VoiceState, SessionId, SessionPid}) whe
{ok, {join_async, UserId, VoiceState, SessionId, SessionPid}};
decode_cast_request({set_caller, Caller}) when is_map(Caller) ->
{ok, {set_caller, Caller}};
decode_cast_request({voice_signal, SessionId, To, Data}) when
is_binary(SessionId), is_binary(To), is_map(Data)
->
{ok, {voice_signal, SessionId, To, Data}};
decode_cast_request(_) ->
error.
@@ -354,9 +379,15 @@ build_get_state_reply(State) ->
handle_update_region(NewRegion, State) ->
OldRegion = maps:get(region, State, undefined),
NewState = State#{region => NewRegion},
UpdatedState = call_ringing:dispatch_call_update(NewState),
call_voice:maybe_spawn_region_switch(OldRegion, NewRegion, UpdatedState),
{reply, ok, UpdatedState}.
case voice_p2p:channel_mode(maps:values(maps:get(voice_states, NewState))) of
p2p ->
ConvertedState = call_voice:convert_to_sfu([], NewState),
{reply, ok, call_ringing:dispatch_call_update(ConvertedState)};
_ ->
UpdatedState = call_ringing:dispatch_call_update(NewState),
call_voice:maybe_spawn_region_switch(OldRegion, NewRegion, UpdatedState),
{reply, ok, UpdatedState}
end.
-spec handle_ring_recipients([integer()], map()) -> {reply, ok, map()}.
handle_ring_recipients(Recipients, State) ->
@@ -398,8 +429,7 @@ handle_confirm_connection(ConnectionId, State) ->
handle_update_voice_state(UserId, VoiceState, State) ->
case maps:is_key(UserId, maps:get(voice_states, State)) of
true ->
NewVS = (maps:get(voice_states, State))#{UserId => VoiceState},
NewState = State#{voice_states => NewVS},
NewState = call_voice:update_voice_state(UserId, VoiceState, State),
CountedState = call_state:sync_voice_state_count_diff(State, NewState),
UpdatedState = call_ringing:dispatch_call_update(CountedState),
{reply, ok, UpdatedState};
+9
View File
@@ -16,6 +16,7 @@
dispatch_call_create/1,
dispatch_call_update/1,
dispatch_call_delete/1,
dispatch_voice_state_update/2,
handle_ring_timeout/2,
handle_idle_timeout/1,
maybe_stop_or_reply/3,
@@ -251,6 +252,14 @@ dispatch_call_delete(State) ->
notify_call_ended(cancel_all_ringing_timers(State)),
ok.
-spec dispatch_voice_state_update(map(), map()) -> ok.
dispatch_voice_state_update(VoiceState, State) ->
Event = voice_state_utils:sanitize_voice_state_for_broadcast(
call_state:format_voice_state(VoiceState)
),
_ = dispatch_to_recipients(maps:get(recipients, State), voice_state_update, Event),
ok.
-spec dispatch_to_recipients([integer()], atom(), map()) -> pid().
dispatch_to_recipients(Recipients, EventType, Event) ->
proc_lib:spawn(fun() ->
+136 -47
View File
@@ -4,7 +4,7 @@
-typing([eqwalizer]).
-export([
handle_join_internal/6,
handle_join_internal/7,
handle_join_async/5,
handle_session_down/2,
handle_disconnect_user/4,
@@ -12,15 +12,44 @@
disconnect_user_after_pending_timeout/4,
maybe_notify_session_force_disconnect/4,
maybe_spawn_region_switch/3,
convert_to_sfu/2,
update_voice_state/3,
relay_voice_signal/4,
is_session_pid_alive/1
]).
-define(PENDING_CONNECTION_TIMEOUT_MS, 30000).
-spec handle_join_internal(
integer(), map(), binary(), pid(), binary() | undefined, map()
) -> {reply, ok, map()}.
handle_join_internal(UserId, VoiceState, SessionId, SessionPid, ConnectionId, State) ->
integer(), map(), binary(), pid(), binary() | undefined, pos_integer() | undefined, map()
) -> {reply, ok | {error, atom()}, map()}.
handle_join_internal(
UserId, VoiceState, SessionId, SessionPid, ConnectionId, MaxParticipants, State
) ->
case join_violation(UserId, VoiceState, MaxParticipants, State) of
{error, ErrorAtom} ->
{reply, {error, ErrorAtom}, State};
ok ->
NewState = admit_join(
UserId,
VoiceState,
SessionId,
SessionPid,
pending_connection_id(ConnectionId, VoiceState),
State
),
{reply, ok, NewState}
end.
-spec join_violation(integer(), map(), pos_integer() | undefined, map()) ->
ok | {error, atom()}.
join_violation(UserId, VoiceState, MaxParticipants, #{voice_states := VoiceStates}) ->
voice_p2p:invariant_violation(
maps:values(VoiceStates#{UserId => VoiceState}), MaxParticipants
).
-spec admit_join(integer(), map(), binary(), pid(), binary() | undefined, map()) -> map().
admit_join(UserId, VoiceState, SessionId, SessionPid, PendingConnectionId, State) ->
CleanState = call_ringing:cancel_ringing_timers([UserId], State),
BaseState = call_ringing:remove_users_from_ringing([UserId], CleanState),
#{
@@ -28,23 +57,24 @@ handle_join_internal(UserId, VoiceState, SessionId, SessionPid, ConnectionId, St
sessions := Sessions0,
participants_history := History0
} = BaseState,
NewVoiceStates = VoiceStates0#{UserId => VoiceState},
NewSessions = monitor_session_entry(
Sessions0, SessionId, UserId, SessionPid
),
NewHistory = sets:add_element(UserId, History0),
NewPending = build_pending(ConnectionId, UserId, SessionId, BaseState),
NewState = BaseState#{
voice_states => NewVoiceStates,
sessions => NewSessions,
pending_connections => NewPending,
participants_history => NewHistory
voice_states => VoiceStates0#{UserId => VoiceState},
sessions => monitor_session_entry(Sessions0, SessionId, UserId, SessionPid),
pending_connections => build_pending(PendingConnectionId, UserId, SessionId, BaseState),
participants_history => sets:add_element(UserId, History0)
},
StateWithTimer = call_ringing:reset_idle_timer(NewState),
{UpdatedState, Dispatched} = call_ringing:maybe_dispatch_state_update(
BaseState, StateWithTimer
),
{reply, ok, ensure_call_update(UpdatedState, Dispatched)}.
ensure_call_update(UpdatedState, Dispatched).
-spec pending_connection_id(binary() | undefined, map()) -> binary() | undefined.
pending_connection_id(ConnectionId, VoiceState) ->
case voice_p2p:is_p2p(VoiceState) of
true -> undefined;
false -> ConnectionId
end.
-spec build_pending(binary() | undefined, integer(), binary(), map()) -> map().
build_pending(undefined, _UserId, _SessionId, #{pending_connections := Pending}) ->
@@ -86,26 +116,17 @@ monitor_session_entry(Sessions, SessionId, UserId, SessionPid) ->
handle_join_async(
UserId, VoiceState, SessionId, SessionPid, #{channel_id := ChannelId} = State
) ->
CleanState = call_ringing:cancel_ringing_timers([UserId], State),
BaseState = call_ringing:remove_users_from_ringing([UserId], CleanState),
#{
voice_states := VoiceStates0,
sessions := Sessions0,
participants_history := History0
} = BaseState,
NewVS = VoiceStates0#{UserId => VoiceState},
NewSess = monitor_session_entry(Sessions0, SessionId, UserId, SessionPid),
NewHistory = sets:add_element(UserId, History0),
NewState = BaseState#{
voice_states => NewVS, sessions => NewSess, participants_history => NewHistory
},
StateWithTimer = call_ringing:reset_idle_timer(NewState),
{UpdState, Dispatched} = call_ringing:maybe_dispatch_state_update(
BaseState, StateWithTimer
),
FinalState = ensure_call_update(UpdState, Dispatched),
SessionPid ! {call_join_result, ChannelId, {ok, FinalState}},
{noreply, FinalState}.
case join_violation(UserId, VoiceState, undefined, State) of
{error, ErrorAtom} ->
SessionPid ! {call_join_result, ChannelId, {error, ErrorAtom}},
{noreply, State};
ok ->
FinalState = admit_join(
UserId, VoiceState, SessionId, SessionPid, undefined, State
),
SessionPid ! {call_join_result, ChannelId, {ok, FinalState}},
{noreply, FinalState}
end.
-spec maybe_remove_user_voice_state(integer(), map(), map()) -> map().
maybe_remove_user_voice_state(UserId, RemainingSessions, VoiceStates) ->
@@ -285,18 +306,86 @@ maybe_spawn_region_switch_for_participants(
ok
end.
-spec spawn_voice_server_updates(binary(), map(), map(), integer()) -> ok.
-spec spawn_voice_server_updates(binary() | undefined | null, map(), map(), integer()) -> ok.
spawn_voice_server_updates(NewRegion, VoiceStates, Sessions, ChannelId) ->
spawn(fun() ->
send_voice_server_updates(NewRegion, VoiceStates, Sessions, ChannelId)
end),
ok.
-spec update_voice_state(integer(), map(), map()) -> map().
update_voice_state(UserId, VoiceState, #{voice_states := VoiceStates} = State) ->
WasP2p = voice_p2p:is_p2p(maps:get(UserId, VoiceStates)),
case {WasP2p, voice_p2p:is_p2p(VoiceState)} of
{true, false} ->
convert_to_sfu([UserId], State#{voice_states => VoiceStates#{UserId => VoiceState}});
{false, true} ->
Converted = voice_p2p:to_sfu(VoiceState),
NewState = State#{voice_states => VoiceStates#{UserId => Converted}},
ok = call_ringing:dispatch_voice_state_update(Converted, NewState),
NewState;
_ ->
State#{voice_states => VoiceStates#{UserId => VoiceState}}
end.
-spec convert_to_sfu([integer()], map()) -> map().
convert_to_sfu(
ExtraUserIds,
#{voice_states := VoiceStates, sessions := Sessions, channel_id := ChannelId} = State
) ->
Converted = maps:map(
fun(_UserId, VoiceState) -> voice_p2p:to_sfu(VoiceState) end,
maps:filter(fun(_UserId, VoiceState) -> voice_p2p:is_p2p(VoiceState) end, VoiceStates)
),
NewVoiceStates = maps:merge(VoiceStates, Converted),
Reconnecting = maps:with(ExtraUserIds ++ maps:keys(Converted), NewVoiceStates),
case maps:size(Reconnecting) of
0 ->
State;
_ ->
NewState = State#{voice_states => NewVoiceStates},
ok = dispatch_voice_state_updates(Converted, NewState),
spawn_voice_server_updates(
maps:get(region, State, undefined), Reconnecting, Sessions, ChannelId
),
NewState
end.
-spec dispatch_voice_state_updates(map(), map()) -> ok.
dispatch_voice_state_updates(VoiceStates, State) ->
maps:foreach(
fun(_UserId, VoiceState) ->
call_ringing:dispatch_voice_state_update(VoiceState, State)
end,
VoiceStates
).
-spec relay_voice_signal(binary(), binary(), map(), map()) -> ok.
relay_voice_signal(
SessionId,
To,
Data,
#{voice_states := VoiceStates, sessions := Sessions, channel_id := ChannelId}
) ->
case voice_p2p:signal_route(maps:values(VoiceStates), SessionId, To) of
{ok, Sender, Target} ->
dispatch_voice_signal(
maps:get(maps:get(<<"session_id">>, Target, undefined), Sessions, undefined),
voice_p2p:signal_payload(null, ChannelId, Sender, Data)
);
error ->
ok
end.
-spec dispatch_voice_signal(term(), map()) -> ok.
dispatch_voice_signal({_UserId, SessionPid, _Ref}, Payload) when is_pid(SessionPid) ->
gateway_dispatch_relay:dispatch(SessionPid, voice_signal, Payload, 0);
dispatch_voice_signal(_Session, _Payload) ->
ok.
-spec send_voice_server_updates(
binary() | undefined | null, map(), map(), integer()
) -> ok.
send_voice_server_updates(NewRegion, _VS, _Sessions, _ChId) when not is_binary(NewRegion) ->
ok;
send_voice_server_updates(NewRegion, VoiceStates, Sessions, ChannelId) ->
maps:foreach(
fun(_SessionId, {UserId, SessionPid, _Ref}) ->
@@ -308,7 +397,9 @@ send_voice_server_updates(NewRegion, VoiceStates, Sessions, ChannelId) ->
),
ok.
-spec maybe_send_voice_server_update(integer(), pid(), binary(), integer(), map()) -> ok.
-spec maybe_send_voice_server_update(
integer(), pid(), binary() | undefined | null, integer(), map()
) -> ok.
maybe_send_voice_server_update(UserId, SessionPid, NewRegion, ChannelId, VoiceStates) ->
case maps:get(UserId, VoiceStates, undefined) of
undefined ->
@@ -317,7 +408,9 @@ maybe_send_voice_server_update(UserId, SessionPid, NewRegion, ChannelId, VoiceSt
send_voice_server_update(ChannelId, UserId, SessionPid, NewRegion, VoiceState)
end.
-spec send_voice_server_update(integer(), integer(), pid(), binary(), map()) -> ok.
-spec send_voice_server_update(
integer(), integer(), pid(), binary() | undefined | null, map()
) -> ok.
send_voice_server_update(ChannelId, UserId, SessionPid, NewRegion, VoiceState) ->
ConnectionId = maps:get(<<"connection_id">>, VoiceState, null),
case ConnectionId of
@@ -329,16 +422,12 @@ send_voice_server_update(ChannelId, UserId, SessionPid, NewRegion, VoiceState) -
Req0 = voice_utils:build_voice_token_rpc_request(
null, ChannelId, UserId, ConnectionId, null, null
),
Req = voice_utils:add_rtc_region_to_request(Req0, rpc_region(NewRegion)),
Req = voice_utils:add_rtc_region_to_request(
Req0, dm_voice_state:voice_region_for_rpc(NewRegion)
),
dispatch_voice_server_rpc(ChannelId, SessionPid, Req)
end.
-spec rpc_region(binary()) -> binary() | null.
rpc_region(<<"automatic">>) ->
null;
rpc_region(NewRegion) ->
NewRegion.
-spec dispatch_voice_server_rpc(integer(), pid(), map()) -> ok.
dispatch_voice_server_rpc(ChannelId, SessionPid, Req) ->
case rpc_client:call(Req) of
+18 -2
View File
@@ -7,7 +7,8 @@
-export([
error/1,
error_category/1
error_category/1,
voice_error_info/1
]).
-export_type([error_atom/0, error_category/0]).
@@ -37,6 +38,8 @@
| voice_pending_expired
| voice_camera_user_limit
| voice_e2ee_required
| voice_p2p_consent_required
| voice_p2p_unavailable
| dm_channel_not_found
| dm_not_recipient
| dm_invalid_channel_type
@@ -84,7 +87,8 @@ voice_error_category(ErrorAtom) ->
-spec voice_error_info(error_atom()) -> voice_error_info() | undefined.
voice_error_info(ErrorAtom) ->
maps:get(ErrorAtom, voice_error_infos(), undefined).
P2pInfo = maps:get(ErrorAtom, voice_p2p_error_infos(), undefined),
maps:get(ErrorAtom, voice_error_infos(), P2pInfo).
-spec voice_error_infos() -> #{error_atom() => voice_error_info()}.
voice_error_infos() ->
@@ -149,6 +153,18 @@ voice_error_infos() ->
{<<"VOICE_E2EE_REQUIRED">>, voice_e2ee_required_message(), permission_denied}
}.
-spec voice_p2p_error_infos() -> #{error_atom() => voice_error_info()}.
voice_p2p_error_infos() ->
#{
voice_p2p_consent_required =>
{<<"VOICE_P2P_CONSENT_REQUIRED">>,
<<"This call is peer-to-peer and requires agreement to join">>,
permission_denied},
voice_p2p_unavailable =>
{<<"VOICE_P2P_UNAVAILABLE">>, <<"Peer-to-peer voice is unavailable for this join">>,
voice_error}
}.
-spec voice_e2ee_required_message() -> binary().
voice_e2ee_required_message() ->
iolist_to_binary([
@@ -125,6 +125,8 @@ websocket_info({dispatch, Event, {pre_encoded, Bin} = Data, Seq}, State) when
is_integer(Seq), is_binary(Event), is_binary(Bin)
->
gateway_handler_dispatch:handle_dispatch(Event, Data, Seq, State);
websocket_info({gateway_error, ErrorAtom}, State) when is_atom(ErrorAtom) ->
gateway_handler_dispatch:handle_gateway_error(ErrorAtom, State);
websocket_info(rollout_config_changed, State) ->
gateway_handler_identify:handle_rollout_config_changed(State);
websocket_info({retry_pending_identify, Token}, State) when is_reference(Token) ->
@@ -6,6 +6,7 @@
-export([
handle_opcode/3,
handle_dispatch/4,
handle_gateway_error/2,
handle_session_down/1,
handle_session_reconnect/1,
handle_presence_update/3,
@@ -72,6 +73,13 @@ handle_authenticated_opcode(voice_state_update, _Data, State) ->
gateway_handler_encode:close_with_reason(
decode_error, <<"Invalid voice payload">>, State
);
handle_authenticated_opcode(voice_signal, Data, #{session_pid := Pid} = State) when
is_pid(Pid), is_map(Data)
->
gen_server:cast(Pid, {voice_signal, Data}),
{ok, State};
handle_authenticated_opcode(voice_signal, _Data, State) ->
{ok, State};
handle_authenticated_opcode(request_guild_members, Data, #{session_pid := Pid} = State) when
is_pid(Pid)
->
@@ -125,6 +133,22 @@ do_dispatch(Event, {pre_encoded, EncodedData}, Seq, State) ->
do_dispatch(Event, Data, Seq, State) ->
dispatch_standard(Event, Data, Seq, State).
-spec handle_gateway_error(atom(), state()) -> ws_result().
handle_gateway_error(ErrorAtom, State) ->
{Code, ErrorMessage, _Category} = gateway_errors:voice_error_info(ErrorAtom),
Message = #{
<<"op">> => constants:opcode_to_num(gateway_error),
<<"d">> => #{<<"code">> => Code, <<"message">> => ErrorMessage}
},
encode_frame(Message, State).
-spec encode_frame(map(), state()) -> ws_result().
encode_frame(Message, State) ->
case gateway_handler_encode:encode_and_compress(Message, State) of
{ok, Frame, NewState} -> {[Frame], NewState};
{error, _} -> {ok, State}
end.
-spec peer_ip(state()) -> binary().
peer_ip(#{peer_ip := PeerIP}) when is_binary(PeerIP) ->
PeerIP;
@@ -197,10 +221,7 @@ handle_session_down(State) ->
request_guild_members_pid => undefined,
request_guild_members_pending => undefined
},
case gateway_handler_encode:encode_and_compress(Message, NewState) of
{ok, Frame, NewState2} -> {[Frame], NewState2};
{error, _} -> {ok, NewState}
end.
encode_frame(Message, NewState).
-spec handle_session_reconnect(state()) -> ws_result().
handle_session_reconnect(State) ->
@@ -18,6 +18,8 @@
-define(PRESENCE_RATE_LIMIT_MAX_EVENTS, 5).
-define(FORUM_UNREADS_RATE_LIMIT_WINDOW_MS, 5000).
-define(FORUM_UNREADS_RATE_LIMIT_MAX_EVENTS, 5).
-define(VOICE_SIGNAL_RATE_LIMIT_WINDOW_MS, 10000).
-define(VOICE_SIGNAL_RATE_LIMIT_MAX_EVENTS, 240).
-define(SHARED_IP_RATE_TABLE, gateway_shared_ip_rate).
-define(SHARED_USER_RATE_TABLE, gateway_shared_user_rate).
@@ -109,6 +111,14 @@ check_opcode_rate_limit(State, request_forum_unreads, Now) ->
?FORUM_UNREADS_RATE_LIMIT_WINDOW_MS,
?FORUM_UNREADS_RATE_LIMIT_MAX_EVENTS
);
check_opcode_rate_limit(State, voice_signal, Now) ->
check_named_opcode_rate_limit(
State,
voice_signal,
Now,
?VOICE_SIGNAL_RATE_LIMIT_WINDOW_MS,
?VOICE_SIGNAL_RATE_LIMIT_MAX_EVENTS
);
check_opcode_rate_limit(State, _Op, _Now) ->
{ok, State}.
+1
View File
@@ -253,6 +253,7 @@ cast_handler(store_pending_connection) -> voice;
cast_handler(add_virtual_channel_access) -> voice;
cast_handler(remove_virtual_channel_access) -> voice;
cast_handler(cleanup_virtual_access_for_user) -> voice;
cast_handler(voice_signal) -> voice;
cast_handler(update_member_subscriptions) -> subscription;
cast_handler(update_dm_partners) -> dm_partners;
cast_handler(_) -> undefined.
+3 -1
View File
@@ -19,7 +19,9 @@
endpoint => binary(),
connection_id => binary(),
voice_state => map(),
needs_token => boolean()
needs_token => boolean(),
p2p => true,
ice_servers => [map()]
}.
-type voice_state_update_rejection() :: #{
@@ -198,7 +198,39 @@ handle_cast({cleanup_virtual_access_for_user, UId}, State) when
is_integer(UId)
->
NewState = guild_voice_disconnect:cleanup_virtual_channel_access_for_user(UId, State),
{noreply, NewState}.
{noreply, NewState};
handle_cast({voice_signal, Request}, State) when is_map(Request) ->
ok = relay_voice_signal(Request, State),
{noreply, State}.
-spec relay_voice_signal(map(), guild_state()) -> ok.
relay_voice_signal(
#{session_id := SessionId, channel_id := ChannelId, to := To, data := Data}, State
) ->
ChannelVoiceStates = maps:values(
voice_state_utils:channel_voice_states(ChannelId, voice_state_utils:voice_states(State))
),
case voice_p2p:signal_route(ChannelVoiceStates, SessionId, To) of
{ok, Sender, Target} ->
GuildId = maps:get(id, State),
dispatch_voice_signal(
maps:get(<<"session_id">>, Target, undefined),
voice_p2p:signal_payload(GuildId, ChannelId, Sender, Data),
GuildId,
State
);
error ->
ok
end.
-spec dispatch_voice_signal(term(), map(), integer(), guild_state()) -> ok.
dispatch_voice_signal(SessionId, Payload, GuildId, State) ->
case maps:get(SessionId, maps:get(sessions, State, #{}), undefined) of
#{pid := Pid} when is_pid(Pid) ->
gateway_dispatch_relay:dispatch(Pid, voice_signal, Payload, GuildId);
_ ->
ok
end.
-spec cast_relay_voice_server(
integer(), integer(), binary(), binary(), binary(), binary(), guild_state()
@@ -74,6 +74,8 @@ build_connect_request(
longitude => maps:get(longitude, Opts),
e2ee_capable => maps:get(e2ee_capable, Opts),
bot => maps:get(bot, Opts),
p2p => maps:get(p2p, Opts),
country_code => maps:get(country_code, Opts),
voice_states => maps:get(dm_voice_states, State, #{}),
state => State
}.
@@ -98,7 +100,9 @@ extract_request_opts(Request) ->
latitude => maps:get(latitude, Request, null),
longitude => maps:get(longitude, Request, null),
e2ee_capable => maps:get(e2ee_capable, Request, false),
bot => maps:get(bot, Request, false)
bot => maps:get(bot, Request, false),
p2p => maps:get(p2p, Request, undefined),
country_code => maps:get(country_code, Request, undefined)
}.
-spec handle_dm_connect_or_update(
@@ -245,13 +249,16 @@ commit_existing_update(
state := State
} = Req
) ->
UpdatedVS = build_updated_voice_state(ExistingVoiceState, Req),
OldChannelId = maps:get(<<"channel_id">>, ExistingVoiceState, null),
NeedsToken = not snowflake_id:equal(ChannelIdValue, OldChannelId),
P2p =
voice_p2p:is_p2p(ExistingVoiceState) andalso not NeedsToken andalso
not voice_p2p:declined(maps:get(p2p, Req, undefined)),
UpdatedVS = build_updated_voice_state(ExistingVoiceState, Req#{p2p => P2p}),
NewVoiceStates = VoiceStates#{ConnectionId => UpdatedVS},
NewState = State#{dm_voice_states => NewVoiceStates},
_ = voice_state_counts_cache:upsert_voice_state(UpdatedVS),
dm_voice_ring:broadcast_voice_state_update(ChannelIdValue, UpdatedVS, NewState),
OldChannelId = maps:get(<<"channel_id">>, ExistingVoiceState, null),
NeedsToken = not snowflake_id:equal(ChannelIdValue, OldChannelId),
UserId = maps:get(user_id, Req),
maybe_spawn_call_voice_state_update(NeedsToken, ChannelIdValue, UserId, UpdatedVS),
dm_voice_token:maybe_spawn_join_call(
@@ -294,6 +301,7 @@ build_updated_voice_state(ExistingVoiceState, Req) ->
<<"is_mobile">> => maps:get(is_mobile, Req),
<<"suppress">> => false,
<<"viewer_stream_keys">> => maps:get(viewer_stream_keys, Req),
<<"p2p">> => maps:get(p2p, Req),
<<"version">> => OldVersion + 1
}).
@@ -5,6 +5,7 @@
-export([handle_dm_disconnect/4, disconnect_voice_user/2]).
-export([resolve_call_region/1, resolve_call_region/2, voice_region_for_rpc/1]).
-export([call_voice_states/1]).
-export([normalize_session_id/1, resolve_effective_session_id/2]).
-export([validate_dm_viewer_stream_keys/3]).
-export([maybe_attach_voice_routing_metadata/3]).
@@ -212,6 +213,20 @@ call_region_from_pid(CallPid, Timeout) ->
null
end.
-spec call_voice_states(integer()) -> [voice_state()].
call_voice_states(ChannelId) ->
case call_manager:lookup(ChannelId) of
{ok, CallPid} -> call_voice_states_from_pid(CallPid);
_ -> []
end.
-spec call_voice_states_from_pid(pid()) -> [voice_state()].
call_voice_states_from_pid(CallPid) ->
case gateway_rpc_call_lookup:safe_gen_server_call(CallPid, {get_state}, 5000) of
{ok, {ok, #{voice_states := VoiceStates}}} when is_list(VoiceStates) -> VoiceStates;
_ -> []
end.
-spec voice_region_for_rpc(term()) -> binary() | null.
voice_region_for_rpc(<<"automatic">>) -> null;
voice_region_for_rpc(Region) when is_binary(Region) -> Region;
+156 -35
View File
@@ -4,7 +4,7 @@
-typing([eqwalizer]).
-export([get_dm_voice_token_and_create_state/1, get_voice_token/6]).
-export([join_or_create_call/5, join_or_create_call/6]).
-export([join_or_create_call/6]).
-export([maybe_spawn_join_call/6, dispatch_to_session/4]).
-export_type([
@@ -35,9 +35,42 @@ get_dm_voice_token_and_create_state(Req) ->
"dm_voice_token_create_request: user_id=~p channel_id=~p rtc_region=~p",
[UserId, ChannelId, Region]
),
case rpc_client:call(ReqWithRegion) of
case dm_join_decision(Req) of
{reject, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
Decision ->
request_dm_token(
voice_p2p:add_to_token_request(
ReqWithRegion, Decision, maps:get(country_code, Req, undefined)
),
Decision,
Req
)
end.
-spec dm_join_decision(token_request()) -> voice_p2p:join_decision().
dm_join_decision(Req) ->
UserId = maps:get(user_id, Req),
voice_p2p:join_decision(
voice_p2p:agreed(maps:get(p2p, Req, undefined), maps:get(bot, Req, false)),
[
VoiceState
|| VoiceState <- dm_voice_state:call_voice_states(maps:get(channel_id, Req)),
not guild_voice_state:user_matches_voice_state(VoiceState, UserId)
],
false,
false
).
-spec request_dm_token(map(), voice_p2p:join_decision(), token_request()) ->
{reply, map(), dm_state()} | {reply, {error, atom(), atom()}, dm_state()}.
request_dm_token(TokenReq, Decision, Req) ->
UserId = maps:get(user_id, Req),
ChannelId = maps:get(channel_id, Req),
State = maps:get(state, Req),
case rpc_client:call(TokenReq) of
{ok, Data} ->
handle_dm_token_success(Data, Req);
handle_dm_token_success(Decision, Data, Req);
{error, {rpc_error, _Status, Body}} ->
handle_token_rpc_error(UserId, ChannelId, Body, State);
{error, Reason} ->
@@ -60,8 +93,40 @@ handle_token_rpc_error(UserId, ChannelId, Body, State) ->
false -> {reply, gateway_errors:error(voice_token_failed), State}
end.
-spec handle_dm_token_success(map(), token_request()) -> {reply, map(), dm_state()}.
handle_dm_token_success(Data, Req) ->
-spec handle_dm_token_success(voice_p2p:join_decision(), map(), token_request()) ->
{reply, map(), dm_state()} | {reply, {error, atom(), atom()}, dm_state()}.
handle_dm_token_success(Decision, Data, Req) ->
case voice_p2p:admit(Decision, voice_p2p:grant(Data)) of
{reject, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), maps:get(state, Req)};
{p2p, ConnectionId, IceServers, MaxParticipants} ->
handle_dm_p2p_grant(ConnectionId, IceServers, MaxParticipants, Req);
sfu ->
handle_dm_sfu_token(Data, Req)
end.
-spec handle_dm_p2p_grant(binary(), [map()], pos_integer(), token_request()) ->
{reply, map(), dm_state()}.
handle_dm_p2p_grant(ConnectionId, IceServers, MaxParticipants, Req) ->
UserId = maps:get(user_id, Req),
ChannelId = maps:get(channel_id, Req),
SessionId = maps:get(session_id, Req),
State = maps:get(state, Req),
EffE2EE = maps:get(e2ee_capable, Req) andalso guild_voice_e2ee:is_e2ee_enabled_for_dm(),
VoiceState = build_voice_state(Req, ConnectionId, EffE2EE, true),
NewState = store_and_broadcast(ConnectionId, ChannelId, VoiceState, State),
SessionPid = maps:get(session_pid, State),
dispatch_to_session(
SessionPid,
voice_server_update,
voice_p2p:voice_server_update(ConnectionId, ChannelId, null, IceServers),
null
),
spawn_join_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, MaxParticipants),
{reply, #{success => true, needs_token => false, connection_id => ConnectionId}, NewState}.
-spec handle_dm_sfu_token(map(), token_request()) -> {reply, map(), dm_state()}.
handle_dm_sfu_token(Data, Req) ->
UserId = maps:get(user_id, Req),
ChannelId = maps:get(channel_id, Req),
SessionId = maps:get(session_id, Req),
@@ -71,7 +136,7 @@ handle_dm_token_success(Data, Req) ->
Endpoint = maps:get(<<"endpoint">>, Data),
ConnectionId = maps:get(<<"connectionId">>, Data),
EffE2EE = E2EECapable andalso guild_voice_e2ee:is_e2ee_enabled_for_dm(),
VoiceState0 = build_voice_state(Req, ConnectionId, EffE2EE),
VoiceState0 = build_voice_state(Req, ConnectionId, EffE2EE, false),
VoiceState = dm_voice_state:maybe_attach_voice_routing_metadata(
VoiceState0,
maps:get(<<"regionId">>, Data, undefined),
@@ -82,11 +147,11 @@ handle_dm_token_success(Data, Req) ->
VSUpdate = build_voice_server_update(Token, Endpoint, ChannelId, ConnectionId, E2EEKey),
SessionPid = maps:get(session_pid, State),
dispatch_to_session(SessionPid, voice_server_update, VSUpdate, null),
spawn_join_call(ChannelId, UserId, VoiceState, SessionId, SessionPid),
spawn_join_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, undefined),
{reply, #{success => true, needs_token => false, connection_id => ConnectionId}, NewState}.
-spec build_voice_state(token_request(), binary(), boolean()) -> voice_state().
build_voice_state(Req, ConnectionId, EffE2EE) ->
-spec build_voice_state(token_request(), binary(), boolean(), boolean()) -> voice_state().
build_voice_state(Req, ConnectionId, EffE2EE, P2p) ->
voice_state_utils:complete_voice_state(#{
<<"guild_id">> => null,
<<"user_id">> => integer_to_binary(maps:get(user_id, Req)),
@@ -102,7 +167,8 @@ build_voice_state(Req, ConnectionId, EffE2EE) ->
<<"self_stream">> => maps:get(self_stream, Req),
<<"suppress">> => false,
<<"viewer_stream_keys">> => maps:get(viewer_stream_keys, Req),
<<"e2ee_capable">> => EffE2EE
<<"e2ee_capable">> => EffE2EE,
<<"p2p">> => P2p
}).
-spec store_and_broadcast(binary(), integer(), voice_state(), dm_state()) -> dm_state().
@@ -162,17 +228,27 @@ dispatch_to_session_with_relay(SessionPid, Event, Payload, GuildId) ->
error:undef -> gen_server:cast(SessionPid, {dispatch, Event, Payload})
end.
-spec spawn_join_call(integer(), integer(), voice_state(), binary(), pid()) -> ok.
spawn_join_call(ChannelId, UserId, VoiceState, SessionId, SessionPid) ->
-spec spawn_join_call(
integer(), integer(), voice_state(), binary(), pid(), pos_integer() | undefined
) -> ok.
spawn_join_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, MaxParticipants) ->
spawn(fun() ->
safe_join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid)
safe_join_or_create_call(
ChannelId, UserId, VoiceState, SessionId, SessionPid, MaxParticipants
)
end),
ok.
-spec safe_join_or_create_call(integer(), integer(), voice_state(), binary(), pid()) -> ok.
safe_join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid) ->
-spec safe_join_or_create_call(
integer(), integer(), voice_state(), binary(), pid(), pos_integer() | undefined
) -> ok.
safe_join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, MaxParticipants) ->
_ = shard_utils:safe_apply(
fun() -> join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid) end,
fun() ->
join_or_create_call(
ChannelId, UserId, VoiceState, SessionId, SessionPid, MaxParticipants
)
end,
ok
),
ok.
@@ -187,7 +263,7 @@ maybe_spawn_join_call(true, ChannelId, UserId, VoiceState, SessionId, State) whe
->
case maps:get(session_pid, State, undefined) of
Pid when is_pid(Pid) ->
spawn_join_call(ChannelId, UserId, VoiceState, SessionId, Pid);
spawn_join_call(ChannelId, UserId, VoiceState, SessionId, Pid, undefined);
_ ->
ok
end;
@@ -246,21 +322,33 @@ handle_get_voice_token_ok(Data, UserId, ChannelId, SessionPid) ->
}},
ok.
-spec join_or_create_call(integer(), integer(), voice_state(), binary(), pid()) -> ok.
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid) ->
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, 10).
-spec join_or_create_call(
integer(), integer(), voice_state(), binary(), pid(), pos_integer() | undefined
) -> ok.
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, MaxParticipants) ->
join_or_create_call(
ChannelId, UserId, VoiceState, SessionId, SessionPid, MaxParticipants, 10
).
-spec join_or_create_call(
integer(), integer(), voice_state(), binary(), pid(), non_neg_integer()
integer(),
integer(),
voice_state(),
binary(),
pid(),
pos_integer() | undefined,
non_neg_integer()
) -> ok.
join_or_create_call(ChannelId, UserId, VoiceState, _SessId, SessionPid, 0) ->
join_or_create_call(ChannelId, UserId, VoiceState, _SessId, SessionPid, _MaxParticipants, 0) ->
logger:warning(
"dm_voice_join_or_create_call_exhausted_retries: user_id=~p channel_id=~p",
[UserId, ChannelId]
),
rollback_ghost_voice_state(ChannelId, VoiceState, SessionPid),
ok;
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, Retries) ->
join_or_create_call(
ChannelId, UserId, VoiceState, SessionId, SessionPid, MaxParticipants, Retries
) ->
ConnectionId = maps:get(<<"connection_id">>, VoiceState, undefined),
case call_manager:lookup(ChannelId) of
{ok, CallPid} ->
@@ -272,10 +360,13 @@ join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, Retrie
SessionId,
SessionPid,
ConnectionId,
MaxParticipants,
Retries
);
_ ->
retry_join(ChannelId, UserId, VoiceState, SessionId, SessionPid, Retries)
retry_join(
ChannelId, UserId, VoiceState, SessionId, SessionPid, MaxParticipants, Retries
)
end.
-spec do_join_call(
@@ -286,15 +377,28 @@ join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, Retrie
binary(),
pid(),
term(),
pos_integer() | undefined,
non_neg_integer()
) -> ok.
do_join_call(
CallPid, ChannelId, UserId, VoiceState, SessionId, SessionPid, ConnectionId, Retries
CallPid,
ChannelId,
UserId,
VoiceState,
SessionId,
SessionPid,
ConnectionId,
MaxParticipants,
Retries
) ->
JoinMsg =
case ConnectionId of
undefined -> {join, UserId, VoiceState, SessionId, SessionPid};
_ -> {join, UserId, VoiceState, SessionId, SessionPid, ConnectionId}
case {ConnectionId, MaxParticipants} of
{undefined, _} ->
{join, UserId, VoiceState, SessionId, SessionPid};
{_, undefined} ->
{join, UserId, VoiceState, SessionId, SessionPid, ConnectionId};
_ ->
{join, UserId, VoiceState, SessionId, SessionPid, ConnectionId, MaxParticipants}
end,
case gateway_rpc_call_lookup:safe_gen_server_call(CallPid, JoinMsg, 5000) of
{ok, ok} ->
@@ -304,12 +408,17 @@ do_join_call(
[UserId, ChannelId, Retries]
),
ok;
{ok, {error, ErrorAtom}} ->
gen_server:cast(SessionPid, {voice_rejected, ErrorAtom}),
rollback_ghost_voice_state(ChannelId, VoiceState, SessionPid);
Error ->
logger:warning(
dm_join_call_failed_log_message(),
[UserId, ChannelId, Retries, Error]
),
retry_join(ChannelId, UserId, VoiceState, SessionId, SessionPid, Retries)
retry_join(
ChannelId, UserId, VoiceState, SessionId, SessionPid, MaxParticipants, Retries
)
end.
-spec dm_join_call_failed_log_message() -> string().
@@ -317,14 +426,24 @@ dm_join_call_failed_log_message() ->
"dm_voice_join_or_create_call_failed: user_id=~p channel_id=~p "
"retries_left=~p error=~p".
-spec retry_join(integer(), integer(), voice_state(), binary(), pid(), non_neg_integer()) -> ok.
retry_join(ChannelId, UserId, VoiceState, SessionId, SessionPid, Retries) ->
-spec retry_join(
integer(),
integer(),
voice_state(),
binary(),
pid(),
pos_integer() | undefined,
non_neg_integer()
) -> ok.
retry_join(ChannelId, UserId, VoiceState, SessionId, SessionPid, MaxParticipants, Retries) ->
logger:warning(
"dm_voice_join_or_create_call_lookup_not_found: user_id=~p channel_id=~p retries_left=~p",
[UserId, ChannelId, Retries]
),
ok = gateway_retry_timer:wait(300),
join_or_create_call(ChannelId, UserId, VoiceState, SessionId, SessionPid, Retries - 1).
join_or_create_call(
ChannelId, UserId, VoiceState, SessionId, SessionPid, MaxParticipants, Retries - 1
).
-spec rollback_ghost_voice_state(integer(), voice_state(), pid()) -> ok.
rollback_ghost_voice_state(ChannelId, VoiceState, SessionPid) ->
@@ -357,7 +476,9 @@ do_join_call_dead_pid_exhausts_without_crash_test() ->
VoiceState = #{<<"connection_id">> => <<"connection">>},
?assertEqual(
ok,
do_join_call(DeadPid, 1234, 42, VoiceState, <<"session">>, self(), <<"connection">>, 1)
do_join_call(
DeadPid, 1234, 42, VoiceState, <<"session">>, self(), <<"connection">>, undefined, 1
)
),
receive
{'$gen_cast', {call_monitor, 1234, DeadPid}} ->
@@ -370,7 +491,7 @@ exhausted_retries_casts_force_disconnect_test() ->
VoiceState = #{<<"connection_id">> => <<"conn-x">>},
?assertEqual(
ok,
join_or_create_call(1234, 42, VoiceState, <<"session">>, self(), 0)
join_or_create_call(1234, 42, VoiceState, <<"session">>, self(), undefined, 0)
),
receive
{'$gen_cast', {call_force_disconnect, 1234, <<"conn-x">>}} ->
@@ -382,7 +503,7 @@ exhausted_retries_casts_force_disconnect_test() ->
exhausted_retries_without_connection_id_casts_undefined_test() ->
?assertEqual(
ok,
join_or_create_call(1234, 42, #{}, <<"session">>, self(), 0)
join_or_create_call(1234, 42, #{}, <<"session">>, self(), undefined, 0)
),
receive
{'$gen_cast', {call_force_disconnect, 1234, undefined}} ->
@@ -129,7 +129,7 @@ apply_same_channel_update(Update, ParsedViewerKey) ->
ChannelIdValue = maps:get(channel_id, Update),
VoicePerms = maps:get(voice_permissions, Update),
Flags = guild_voice_connection_util:voice_flags_for_permissions(Context, VoicePerms),
UpdateResult = guild_voice_state:update_voice_state_data(#{
{reply, Reply, NewState} = guild_voice_state:update_voice_state_data(#{
connection_id => maps:get(connection_id, Update),
channel_id => integer_to_binary(ChannelIdValue),
flags => Flags,
@@ -140,4 +140,7 @@ apply_same_channel_update(Update, ParsedViewerKey) ->
needs_token => false,
viewer_stream_keys => ParsedViewerKey
}),
UpdateResult.
case voice_p2p:declined(maps:get(p2p, Context, undefined)) of
true -> {reply, Reply, guild_voice_region:switch_to_sfu(ChannelIdValue, NewState)};
false -> {reply, Reply, NewState}
end.
@@ -86,7 +86,7 @@ log_pending_found(ConnectionId, PendingData) ->
).
-spec activate_pending(binary(), map(), map(), guild_state()) ->
{reply, map(), guild_state()}.
{reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
activate_pending(ConnectionId, PendingData, PendingConnections, State) ->
VoiceStates = voice_state_utils:voice_states(State),
VoiceState = guild_voice_connection_pending:resolve_voice_state_from_pending(
@@ -102,14 +102,29 @@ activate_pending(ConnectionId, PendingData, PendingConnections, State) ->
end.
-spec finalize_activation(binary(), voice_state(), map(), guild_state()) ->
{reply, map(), guild_state()}.
{reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
finalize_activation(ConnectionId, VoiceState, VoiceStates, State) ->
UpdatedVoiceStates = VoiceStates#{ConnectionId => VoiceState},
S1 = State#{voice_states => UpdatedVoiceStates},
S2 = guild_voice_connection_util:clear_virtual_access_flags(VoiceState, S1),
ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
guild_voice_broadcast:broadcast_voice_state_update(VoiceState, S2, ChannelIdBin),
{reply, #{success => true}, S2}.
case channel_invariant_violation(VoiceState, UpdatedVoiceStates) of
ok ->
S1 = State#{voice_states => UpdatedVoiceStates},
S2 = guild_voice_connection_util:clear_virtual_access_flags(VoiceState, S1),
ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
guild_voice_broadcast:broadcast_voice_state_update(VoiceState, S2, ChannelIdBin),
{reply, #{success => true}, S2};
{error, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State}
end.
-spec channel_invariant_violation(voice_state(), map()) -> ok | {error, atom()}.
channel_invariant_violation(VoiceState, VoiceStates) ->
voice_p2p:invariant_violation(
maps:values(
voice_state_utils:channel_voice_states(
voice_state_utils:voice_state_channel_id(VoiceState), VoiceStates
)
)
).
-spec try_restore_from_recently_disconnected(binary(), guild_state()) ->
{reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
@@ -126,10 +141,20 @@ try_restore_from_recently_disconnected(ConnectionId, State) ->
end.
-spec restore_recently_disconnected(binary(), voice_state(), map(), guild_state()) ->
{reply, map(), guild_state()}.
{reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
restore_recently_disconnected(ConnectionId, VoiceState, Cache, State) ->
VoiceStates = voice_state_utils:voice_states(State),
UpdatedVoiceStates = VoiceStates#{ConnectionId => VoiceState},
case channel_invariant_violation(VoiceState, UpdatedVoiceStates) of
ok ->
finalize_restore(ConnectionId, VoiceState, UpdatedVoiceStates, Cache, State);
{error, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State}
end.
-spec finalize_restore(binary(), voice_state(), map(), map(), guild_state()) ->
{reply, map(), guild_state()}.
finalize_restore(ConnectionId, VoiceState, UpdatedVoiceStates, Cache, State) ->
NewCache = maps:remove(ConnectionId, Cache),
S0 = State#{voice_states => UpdatedVoiceStates},
S1 = S0#{recently_disconnected_voice_states => NewCache},
@@ -4,7 +4,7 @@
-typing([eqwalizer]).
-export([handle_new_connection/5]).
-export([handle_new_connection/5, activate_p2p_voice_state/3]).
-export_type([
guild_state/0,
@@ -154,36 +154,81 @@ request_token_and_build(Context, Member, ParsedViewerStreamKey, State, GuildId,
TokenNonce = voice_utils:generate_token_nonce(),
Latitude = maps:get(latitude, Context, undefined),
Longitude = maps:get(longitude, Context, undefined),
Build = #{
context => Context,
member => Member,
viewer_stream_keys => ParsedViewerStreamKey,
state => State,
guild_id => GuildId,
guild_id_bin => GuildIdBin,
voice_permissions => VoicePermissions,
token_nonce => TokenNonce,
latitude => Latitude,
longitude => Longitude
},
case
guild_voice_connection_token:request_voice_token(
GuildId,
ChannelIdValue,
UserId,
null,
VoicePermissions,
TokenNonce,
Latitude,
Longitude
voice_p2p:guild_join_decision(
guild_voice_connection_util:p2p_agreed(Context), ChannelIdValue, State
)
of
{ok, TokenData} ->
build_new_voice_state(#{
context => Context,
member => Member,
viewer_stream_keys => ParsedViewerStreamKey,
state => State,
guild_id => GuildId,
guild_id_bin => GuildIdBin,
token_data => TokenData,
voice_permissions => VoicePermissions,
token_nonce => TokenNonce,
latitude => Latitude,
longitude => Longitude
});
{error, _Reason} ->
{reply, gateway_errors:error(voice_token_failed), State}
{reject, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
Decision ->
TokenResult = guild_voice_connection_token:request_voice_token(
GuildId,
ChannelIdValue,
UserId,
null,
VoicePermissions,
TokenNonce,
Latitude,
Longitude,
Decision,
maps:get(country_code, Context, undefined)
),
build_join_reply(Decision, TokenResult, Build)
end.
-spec build_join_reply(voice_p2p:join_decision(), {ok, term()} | {error, term()}, map()) ->
{reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
build_join_reply(_Decision, {error, _Reason}, #{state := State}) ->
{reply, gateway_errors:error(voice_token_failed), State};
build_join_reply(Decision, {ok, TokenData}, #{state := State} = Build) ->
case voice_p2p:admit(Decision, TokenData) of
{reject, ErrorAtom} ->
{reply, gateway_errors:error(ErrorAtom), State};
{p2p, ConnectionId, IceServers, _MaxParticipants} ->
VoiceBuild = voice_build_fields(Build#{token_data => #{}}),
activate_p2p_voice_state(
create_and_decorate_voice_state(VoiceBuild#{connection_id => ConnectionId}),
IceServers,
State
);
sfu ->
build_new_voice_state(Build#{token_data => TokenData})
end.
-spec activate_p2p_voice_state(voice_state(), [map()], guild_state()) ->
{reply, map(), guild_state()}.
activate_p2p_voice_state(VoiceState0, IceServers, State) ->
VoiceState = VoiceState0#{<<"p2p">> => true},
ConnectionId = maps:get(<<"connection_id">>, VoiceState),
VoiceStates = voice_state_utils:voice_states(State),
S1 = State#{voice_states => VoiceStates#{ConnectionId => VoiceState}},
S2 = guild_voice_connection_util:clear_virtual_access_flags(VoiceState, S1),
guild_voice_broadcast:broadcast_voice_state_update(
VoiceState, S2, maps:get(<<"channel_id">>, VoiceState)
),
{reply,
#{
success => true,
p2p => true,
ice_servers => IceServers,
connection_id => ConnectionId,
voice_state => voice_state_utils:external_voice_state(VoiceState)
},
S2}.
-spec build_new_voice_state(
map()
) -> {reply, map(), guild_state()}.
@@ -66,10 +66,7 @@ do_request_move_token(Context, ChannelIdValue, Member, SessionId, MoveState, Gui
TokenNonce = voice_utils:generate_token_nonce(),
Lat = maps:get(latitude, Context, undefined),
Lng = maps:get(longitude, Context, undefined),
TokenResult = guild_voice_connection_token:request_voice_token(
GuildId, ChannelIdValue, UserId, ConnId, VoicePerms, TokenNonce, Lat, Lng
),
handle_move_token_result(TokenResult, #{
Build = #{
context => Context,
channel_id => ChannelIdValue,
member => Member,
@@ -83,18 +80,60 @@ do_request_move_token(Context, ChannelIdValue, Member, SessionId, MoveState, Gui
token_nonce => TokenNonce,
latitude => Lat,
longitude => Lng
}).
},
case
voice_p2p:guild_join_decision(
guild_voice_connection_util:p2p_agreed(Context), ChannelIdValue, State
)
of
{reject, ErrorAtom} ->
move_error_reply(ErrorAtom, Build);
Decision ->
TokenResult = guild_voice_connection_token:request_voice_token(
GuildId,
ChannelIdValue,
UserId,
ConnId,
VoicePerms,
TokenNonce,
Lat,
Lng,
Decision,
maps:get(country_code, Context, undefined)
),
handle_move_token_result(Decision, TokenResult, Build)
end.
-spec handle_move_token_result(
{ok, map()} | {error, term()}, map()
voice_p2p:join_decision(), {ok, term()} | {error, term()}, map()
) -> {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
handle_move_token_result({ok, TokenData}, Build) ->
build_move_result(Build#{token_data => TokenData});
handle_move_token_result({error, _Reason}, Build) ->
#{state := State, context := Context, channel_id := ChannelIdValue} = Build,
handle_move_token_result(_Decision, {error, _Reason}, Build) ->
move_error_reply(voice_token_failed, Build);
handle_move_token_result(Decision, {ok, TokenData}, Build) ->
case voice_p2p:admit(Decision, TokenData) of
{reject, ErrorAtom} ->
move_error_reply(ErrorAtom, Build);
{p2p, ConnectionId, IceServers, _MaxParticipants} ->
build_p2p_move_result(ConnectionId, IceServers, Build#{token_data => #{}});
sfu ->
build_move_result(Build#{token_data => TokenData})
end.
-spec move_error_reply(atom(), map()) -> {reply, {error, atom(), atom()}, guild_state()}.
move_error_reply(ErrorAtom, #{state := State, context := Context, channel_id := ChannelIdValue}) ->
UserId = maps:get(user_id, Context),
{reply, gateway_errors:error(voice_token_failed),
clear_move_flags(UserId, ChannelIdValue, State)}.
{reply, gateway_errors:error(ErrorAtom), clear_move_flags(UserId, ChannelIdValue, State)}.
-spec build_p2p_move_result(binary(), [map()], map()) -> {reply, map(), guild_state()}.
build_p2p_move_result(ConnectionId, IceServers, Build) ->
State1 = disconnect_old_connection(Build),
State1Cleaned = cleanup_stale_virtual_access(Build, State1),
MoveBuild = move_build_fields(Build),
guild_voice_connection_join:activate_p2p_voice_state(
build_move_voice_state(MoveBuild#{connection_id => ConnectionId}),
IceServers,
State1Cleaned
).
-spec build_move_result(
map()
@@ -5,6 +5,7 @@
-typing([eqwalizer]).
-export([request_voice_token/8]).
-export([request_voice_token/10]).
-spec request_voice_token(
integer(),
@@ -26,15 +27,62 @@ request_voice_token(
Latitude,
Longitude
) ->
Req = voice_utils:build_voice_token_rpc_request(
GuildId,
ChannelId,
UserId,
ConnectionId,
Latitude,
Longitude,
VoicePermissions,
TokenNonce
case
request_voice_token(
GuildId,
ChannelId,
UserId,
ConnectionId,
VoicePermissions,
TokenNonce,
Latitude,
Longitude,
sfu,
undefined
)
of
{ok, TokenData} when is_map(TokenData) -> {ok, TokenData};
{ok, _Grant} -> {error, voice_token_failed};
{error, Reason} -> {error, Reason}
end.
-spec request_voice_token(
integer(),
integer(),
integer(),
binary() | null,
map(),
binary() | null,
binary() | undefined | null,
binary() | undefined | null,
voice_p2p:join_decision(),
binary() | undefined
) -> {ok, map() | voice_p2p:token_grant()} | {error, term()}.
request_voice_token(
GuildId,
ChannelId,
UserId,
ConnectionId,
VoicePermissions,
TokenNonce,
Latitude,
Longitude,
Decision,
CountryCode
) ->
Req = voice_p2p:add_to_token_request(
voice_utils:build_voice_token_rpc_request(
GuildId,
ChannelId,
UserId,
ConnectionId,
Latitude,
Longitude,
VoicePermissions,
TokenNonce
),
Decision,
CountryCode
),
log_token_start(GuildId, ChannelId, UserId, ConnectionId, TokenNonce),
case rpc_client:call(Req) of
@@ -58,8 +106,16 @@ token_start_log_message() ->
"guild_voice_request_token_start: guild_id=~p channel_id=~p "
"user_id=~p connection_id=~p token_nonce=~p".
-spec handle_token_success(integer(), integer(), integer(), map()) -> {ok, map()}.
-spec handle_token_success(integer(), integer(), integer(), map()) ->
{ok, map() | voice_p2p:token_grant()}.
handle_token_success(GuildId, ChannelId, UserId, Data) ->
case voice_p2p:grant(Data) of
sfu -> handle_sfu_token_success(GuildId, ChannelId, UserId, Data);
Grant -> {ok, Grant}
end.
-spec handle_sfu_token_success(integer(), integer(), integer(), map()) -> {ok, map()}.
handle_sfu_token_success(GuildId, ChannelId, UserId, Data) ->
logger:debug(
token_success_log_message(),
[
@@ -20,6 +20,7 @@
normalize_session_id/1,
normalize_optional_binary/1,
maybe_error_reply/5,
p2p_agreed/1,
clear_virtual_access_flags/2
]).
@@ -61,9 +62,15 @@ build_context(Request0) ->
latitude => Coord(maps:get(latitude, Request, undefined)),
longitude => Coord(maps:get(longitude, Request, undefined)),
e2ee_capable => Norm(maps:get(e2ee_capable, Request, false)),
bot => Norm(maps:get(bot, Request, false))
bot => Norm(maps:get(bot, Request, false)),
p2p => maps:get(p2p, Request, undefined),
country_code => maps:get(country_code, Request, undefined)
}.
-spec p2p_agreed(context()) -> boolean().
p2p_agreed(Context) ->
voice_p2p:agreed(maps:get(p2p, Context, undefined), maps:get(bot, Context, false)).
-spec voice_flags_for_permissions(context(), voice_utils:voice_permissions()) -> voice_flags().
voice_flags_for_permissions(Context, VoicePermissions) ->
voice_utils:apply_voice_permissions_to_flags(
@@ -160,10 +160,17 @@ check_perms_and_execute(Conns, ChannelId, UserId, ModId, VoiceStates, State) ->
ModPerms = guild_permissions:get_member_permissions(ModId, ChannelId, State),
ModHasConnect = permission_bits:has(ModPerms, ConnectPerm),
ModHasView = permission_bits:has(ModPerms, ViewPerm),
case ModHasConnect andalso ModHasView of
false ->
case
{
ModHasConnect andalso ModHasView,
voice_p2p:guild_join_decision(false, ChannelId, State)
}
of
{false, _Decision} ->
{reply, gateway_errors:error(voice_moderator_missing_connect), State};
true ->
{true, {reject, ErrorAtom}} ->
{reply, gateway_errors:error(ErrorAtom), State};
{true, _Decision} ->
execute_move(Conns, ChannelId, UserId, VoiceStates, State)
end.
@@ -5,6 +5,7 @@
-export([switch_voice_region_handler/2]).
-export([switch_voice_region/3]).
-export([switch_to_sfu/2]).
-export_type([
guild_state/0,
@@ -28,16 +29,60 @@ switch_voice_region_handler(Request, State) ->
undefined ->
{reply, gateway_errors:error(voice_channel_not_found), State};
_ ->
switch_voice_region_channel_reply(Channel, State)
switch_voice_region_channel_reply(Channel, ChannelId, State)
end.
-spec switch_voice_region_channel_reply(map(), guild_state()) -> guild_reply(map()).
switch_voice_region_channel_reply(Channel, State) ->
-spec switch_voice_region_channel_reply(map(), integer(), guild_state()) -> guild_reply(map()).
switch_voice_region_channel_reply(Channel, ChannelId, State) ->
case map_utils:get_integer(Channel, <<"type">>, undefined) of
2 -> {reply, #{success => true}, State};
2 -> {reply, #{success => true}, convert_to_sfu(Channel, ChannelId, State)};
_ -> {reply, gateway_errors:error(voice_channel_not_voice), State}
end.
-spec switch_to_sfu(integer(), guild_state()) -> guild_state().
switch_to_sfu(ChannelId, State) ->
Channel = guild_voice_member:find_channel_by_id(ChannelId, State),
NewState = convert_to_sfu(Channel, ChannelId, State),
ok = spawn_switch_if_converted(ChannelId, State, NewState),
NewState.
-spec spawn_switch_if_converted(integer(), guild_state(), guild_state()) -> ok.
spawn_switch_if_converted(_ChannelId, State, State) ->
ok;
spawn_switch_if_converted(ChannelId, _State, #{id := GuildId} = NewState) ->
GuildPid = maps:get(guild_pid, NewState, self()),
spawn(fun() -> switch_voice_region(GuildId, ChannelId, GuildPid) end),
ok.
-spec convert_to_sfu(map() | undefined, integer(), guild_state()) -> guild_state().
convert_to_sfu(Channel, ChannelId, State) ->
VoiceStates = voice_state_utils:voice_states(State),
P2pVoiceStates = maps:filter(
fun(_ConnectionId, VoiceState) -> voice_p2p:is_p2p(VoiceState) end,
voice_state_utils:channel_voice_states(ChannelId, VoiceStates)
),
case voice_p2p:pinned(Channel) orelse maps:size(P2pVoiceStates) =:= 0 of
true -> State;
false -> mark_voice_states_sfu(P2pVoiceStates, VoiceStates, State)
end.
-spec mark_voice_states_sfu(map(), map(), guild_state()) -> guild_state().
mark_voice_states_sfu(P2pVoiceStates, VoiceStates, State) ->
Converted = maps:map(
fun(_ConnectionId, VoiceState) -> voice_p2p:to_sfu(VoiceState) end,
P2pVoiceStates
),
NewState = State#{voice_states => maps:merge(VoiceStates, Converted)},
maps:foreach(
fun(_ConnectionId, VoiceState) ->
guild_voice_broadcast:broadcast_voice_state_update(
VoiceState, NewState, maps:get(<<"channel_id">>, VoiceState, null)
)
end,
Converted
),
NewState.
-spec switch_voice_region(integer(), integer(), pid()) -> ok.
switch_voice_region(GuildId, ChannelId, GuildPid) ->
case guild_voice_server_state:guild_state_call(GuildPid, 10000) of
@@ -103,10 +148,12 @@ collect_user_in_channel(ConnectionId, VoiceState, ChannelId, Acc) ->
-spec collect_user_voice_state(binary(), voice_state(), list()) -> list().
collect_user_voice_state(ConnectionId, VoiceState, Acc) ->
case voice_state_utils:voice_state_user_id(VoiceState) of
undefined ->
case {voice_p2p:is_p2p(VoiceState), voice_state_utils:voice_state_user_id(VoiceState)} of
{true, _UserId} ->
Acc;
UserId ->
{false, undefined} ->
Acc;
{false, UserId} ->
SessionId = maps:get(<<"session_id">>, VoiceState, undefined),
[{UserId, SessionId, ConnectionId, VoiceState} | Acc]
end.
@@ -124,6 +124,7 @@ init(#{guild_id := GuildId, guild_pid := GuildPid} = Args) ->
guild_voice_server_sync:ensure_registry(),
ets:insert(?REGISTRY_TABLE, {GuildId, self()}),
erlang:send_after(?SWEEP_INTERVAL_MS, self(), sweep_pending_joins),
erlang:send_after(?SEEDED_SESSION_CHECK_DELAY_MS, self(), check_p2p_sessions),
InitialVoiceStates = voice_state_utils:ensure_voice_states(
maps:get(initial_voice_states, Args, #{})
),
@@ -254,6 +255,12 @@ handle_info(sweep_pending_joins, State) ->
NewGuildState = guild_voice_connection:sweep_expired_pending_joins(GuildState),
{noreply, guild_voice_server_state:apply_guild_state(NewGuildState, State2)}
end;
handle_info(check_p2p_sessions, State) ->
erlang:send_after(?SEEDED_SESSION_CHECK_DELAY_MS, self(), check_p2p_sessions),
ok = spawn_seeded_session_check(
first, seeded_sessions(p2p_connection_ids(State), State), State
),
{noreply, State};
handle_info({check_seeded_sessions, Round, ConnectionIds}, State) ->
ok = spawn_seeded_session_check(Round, seeded_sessions(ConnectionIds, State), State),
{noreply, State};
@@ -313,6 +320,15 @@ schedule_seeded_session_check(Round, ConnectionIds) ->
),
ok.
-spec p2p_connection_ids(server_state()) -> [binary()].
p2p_connection_ids(State) ->
maps:keys(
maps:filter(
fun(_ConnectionId, VoiceState) -> voice_p2p:is_p2p(VoiceState) end,
maps:get(voice_states, State, #{})
)
).
-spec seeded_sessions([binary()], server_state()) -> #{binary() => {integer(), binary()}}.
seeded_sessions(ConnectionIds, State) ->
VoiceStates = maps:get(voice_states, State, #{}),
@@ -575,6 +591,24 @@ seeded_session_check_reports_only_gone_sessions_test() ->
after 1000 -> ?assert(false)
end.
p2p_session_check_reports_only_gone_p2p_sessions_test() ->
State0 = seeded_test_state(self()),
#{<<"ghost">> := Ghost, <<"live">> := Live} = VoiceStates = maps:get(voice_states, State0),
State = State0#{
voice_states => VoiceStates#{
<<"ghost">> => Ghost#{<<"p2p">> => true},
<<"live">> => Live#{<<"p2p">> => true},
<<"sfu-ghost">> => Ghost#{<<"connection_id">> => <<"sfu-ghost">>}
},
test_session_gone_fun => fun(SessionId) -> SessionId =:= <<"dead">> end
},
{noreply, State} = handle_info(check_p2p_sessions, State),
receive
{seeded_sessions_gone, first, Gone} ->
?assertEqual(#{<<"ghost">> => {5, <<"dead">>}}, Gone)
after 1000 -> ?assert(false)
end.
gone_seeded_connection_is_removed_test() ->
TestFun = fun(_, _, _, _) -> {ok, #{success => true}} end,
GuildPid = spawn(fun() -> guild_state_reply_loop(TestFun) end),
@@ -0,0 +1,221 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(voice_p2p).
-typing([eqwalizer]).
-export([
agreed/2,
declined/1,
is_p2p/1,
pinned/1,
channel_mode/1,
join_decision/4,
guild_join_decision/3,
admit/2,
invariant_violation/1,
invariant_violation/2,
reports_rejection/1,
to_sfu/1,
add_to_token_request/3,
grant/1,
voice_server_update/4,
signal_route/3,
signal_payload/4
]).
-export_type([
voice_state/0, channel_mode/0, rejection/0, join_decision/0, grant/0, token_grant/0
]).
-define(MAX_PARTICIPANTS, 4).
-type voice_state() :: map().
-type channel_mode() :: empty | p2p | sfu | mixed.
-type rejection() :: {reject, atom()}.
-type join_decision() :: {p2p, initiator | joiner, pos_integer()} | sfu | rejection().
-type grant() :: {p2p, binary(), [map()], pos_integer()}.
-type token_grant() :: grant() | {declined, voice_channel_full}.
-spec agreed(term(), term()) -> boolean().
agreed(true, true) -> false;
agreed(true, _Bot) -> true;
agreed(_P2p, _Bot) -> false.
-spec declined(term()) -> boolean().
declined(false) -> true;
declined(_) -> false.
-spec is_p2p(voice_state()) -> boolean().
is_p2p(#{<<"p2p">> := true}) -> true;
is_p2p(_VoiceState) -> false.
-spec pinned(map() | undefined) -> boolean().
pinned(#{<<"rtc_p2p">> := true}) -> true;
pinned(_Channel) -> false.
-spec channel_mode([voice_state()]) -> channel_mode().
channel_mode([]) ->
empty;
channel_mode(VoiceStates) ->
case {lists:all(fun is_p2p/1, VoiceStates), lists:any(fun is_p2p/1, VoiceStates)} of
{true, _} -> p2p;
{false, true} -> mixed;
{false, false} -> sfu
end.
-spec join_decision(boolean(), [voice_state()], boolean(), boolean()) -> join_decision().
join_decision(Agreed, VoiceStates, HasPendingJoins, Pinned) ->
case {channel_mode(VoiceStates), HasPendingJoins} of
{p2p, _} -> join_p2p_decision(Agreed, length(VoiceStates) + 1);
{empty, false} -> start_decision(Agreed, Pinned);
_ -> sfu
end.
-spec join_p2p_decision(boolean(), pos_integer()) -> join_decision().
join_p2p_decision(false, _ParticipantCount) ->
{reject, voice_p2p_consent_required};
join_p2p_decision(true, ParticipantCount) when ParticipantCount > ?MAX_PARTICIPANTS ->
{reject, voice_channel_full};
join_p2p_decision(true, ParticipantCount) ->
{p2p, joiner, ParticipantCount}.
-spec start_decision(boolean(), boolean()) -> join_decision().
start_decision(true, _Pinned) -> {p2p, initiator, 1};
start_decision(false, _Pinned) -> sfu.
-spec guild_join_decision(boolean(), integer(), map()) -> join_decision().
guild_join_decision(Agreed, ChannelId, State) ->
ChannelVoiceStates = maps:values(
voice_state_utils:channel_voice_states(ChannelId, voice_state_utils:voice_states(State))
),
HasPendingJoins = lists:any(
fun(Pending) -> maps:get(channel_id, Pending, undefined) =:= ChannelId end,
maps:values(guild_voice_connection_pending:pending_voice_connections(State))
),
Pinned = pinned(guild_voice_member:find_channel_by_id(ChannelId, State)),
join_decision(Agreed, ChannelVoiceStates, HasPendingJoins, Pinned).
-spec admit(join_decision(), token_grant() | term()) -> grant() | sfu | rejection().
admit({p2p, _Role, _Count}, {p2p, _ConnectionId, _IceServers, _MaxParticipants} = Grant) ->
Grant;
admit({p2p, _Role, _Count}, {declined, ErrorAtom}) ->
{reject, ErrorAtom};
admit({p2p, initiator, _Count}, _Token) ->
sfu;
admit({p2p, joiner, _Count}, _Token) ->
{reject, voice_p2p_unavailable};
admit(sfu, {p2p, _ConnectionId, _IceServers, _MaxParticipants}) ->
{reject, voice_token_failed};
admit(sfu, {declined, _ErrorAtom}) ->
{reject, voice_token_failed};
admit(sfu, _Token) ->
sfu.
-spec invariant_violation([voice_state()]) -> ok | {error, atom()}.
invariant_violation(VoiceStates) ->
invariant_violation(VoiceStates, undefined).
-spec invariant_violation([voice_state()], term()) -> ok | {error, atom()}.
invariant_violation(VoiceStates, MaxParticipants) ->
case channel_mode(VoiceStates) of
mixed ->
{error, voice_p2p_consent_required};
p2p ->
case length(VoiceStates) > participant_cap(MaxParticipants) of
true -> {error, voice_channel_full};
false -> ok
end;
_ ->
ok
end.
-spec participant_cap(term()) -> pos_integer().
participant_cap(MaxParticipants) when
is_integer(MaxParticipants), MaxParticipants >= 1, MaxParticipants =< ?MAX_PARTICIPANTS
->
MaxParticipants;
participant_cap(_MaxParticipants) ->
?MAX_PARTICIPANTS.
-spec reports_rejection(term()) -> boolean().
reports_rejection(voice_p2p_consent_required) -> true;
reports_rejection(voice_p2p_unavailable) -> true;
reports_rejection(voice_channel_full) -> true;
reports_rejection(_) -> false.
-spec to_sfu(voice_state()) -> voice_state().
to_sfu(VoiceState) ->
VoiceState#{
<<"p2p">> => false,
<<"version">> => voice_state_utils:voice_state_version(VoiceState) + 1
}.
-spec add_to_token_request(map(), join_decision(), binary() | undefined) -> map().
add_to_token_request(Request, {p2p, Role, ParticipantCount}, CountryCode) ->
WithP2p = Request#{
<<"p2p">> => true,
<<"p2p_initiator">> => Role =:= initiator,
<<"p2p_participant_count">> => ParticipantCount
},
case CountryCode of
Code when is_binary(Code) -> WithP2p#{<<"country_code">> => Code};
_ -> WithP2p
end;
add_to_token_request(Request, _Decision, _CountryCode) ->
Request.
-spec grant(map()) -> token_grant() | sfu.
grant(
#{<<"p2p">> := true, <<"connectionId">> := ConnectionId, <<"iceServers">> := IceServers} =
Data
) when
is_binary(ConnectionId), is_list(IceServers)
->
{p2p, ConnectionId,
[
maps:with([<<"urls">>], Server)
|| Server <- IceServers, is_map(Server)
],
participant_cap(maps:get(<<"maxParticipants">>, Data, undefined))};
grant(#{<<"p2p">> := false, <<"declineReason">> := <<"channel_full">>}) ->
{declined, voice_channel_full};
grant(_Data) ->
sfu.
-spec voice_server_update(binary(), integer(), integer() | null, [map()]) -> map().
voice_server_update(ConnectionId, ChannelId, GuildId, IceServers) ->
Update = #{
<<"p2p">> => true,
<<"ice_servers">> => IceServers,
<<"connection_id">> => ConnectionId,
<<"channel_id">> => integer_to_binary(ChannelId)
},
put_guild_id(Update, GuildId).
-spec signal_route([voice_state()], binary(), binary()) ->
{ok, voice_state(), voice_state()} | error.
signal_route(VoiceStates, SessionId, To) ->
Peers = [VoiceState || VoiceState <- VoiceStates, is_p2p(VoiceState)],
Senders = [
Peer
|| Peer <- Peers, maps:get(<<"session_id">>, Peer, undefined) =:= SessionId
],
Targets = [Peer || Peer <- Peers, maps:get(<<"connection_id">>, Peer, undefined) =:= To],
case {Senders, Targets} of
{[Sender | _], [Target | _]} when Sender =/= Target -> {ok, Sender, Target};
_ -> error
end.
-spec signal_payload(integer() | null, integer(), voice_state(), map()) -> map().
signal_payload(GuildId, ChannelId, Sender, Data) ->
Payload = #{
<<"channel_id">> => integer_to_binary(ChannelId),
<<"from">> => maps:get(<<"connection_id">>, Sender),
<<"user_id">> => maps:get(<<"user_id">>, Sender),
<<"data">> => Data
},
put_guild_id(Payload, GuildId).
-spec put_guild_id(map(), integer() | null) -> map().
put_guild_id(Payload, null) -> Payload;
put_guild_id(Payload, GuildId) -> Payload#{<<"guild_id">> => integer_to_binary(GuildId)}.
@@ -12,6 +12,7 @@
voice_state_channel_id/1,
voice_state_guild_id/1,
voice_state_version/1,
channel_voice_states/2,
initial_voice_state_version/0,
complete_voice_state/1,
external_voice_state/1,
@@ -81,6 +82,13 @@ voice_state_channel_id(VoiceState) ->
voice_state_guild_id(VoiceState) ->
snowflake_field(VoiceState, <<"guild_id">>).
-spec channel_voice_states(integer() | undefined, voice_state_map()) -> voice_state_map().
channel_voice_states(ChannelId, VoiceStates) ->
maps:filter(
fun(_ConnId, VoiceState) -> voice_state_channel_id(VoiceState) =:= ChannelId end,
VoiceStates
).
-spec voice_state_version(voice_state()) -> non_neg_integer().
voice_state_version(VoiceState) ->
case map_utils:get_integer(VoiceState, <<"version">>, undefined) of
@@ -117,6 +125,7 @@ default_voice_state_fields() ->
<<"suppress">> => false,
<<"viewer_stream_keys">> => [],
<<"e2ee_capable">> => false,
<<"p2p">> => false,
<<"region_id">> => null,
<<"server_id">> => null,
<<"version">> => ?INITIAL_VOICE_STATE_VERSION
@@ -389,6 +398,7 @@ complete_voice_state_fills_missing_fields_test() ->
?assertEqual(false, maps:get(<<"suppress">>, Completed)),
?assertEqual([], maps:get(<<"viewer_stream_keys">>, Completed)),
?assertEqual(false, maps:get(<<"e2ee_capable">>, Completed)),
?assertEqual(false, maps:get(<<"p2p">>, Completed)),
?assertEqual(null, maps:get(<<"region_id">>, Completed)),
?assertEqual(null, maps:get(<<"server_id">>, Completed)),
?assertEqual(0, maps:get(<<"version">>, Completed)).
+7
View File
@@ -55,6 +55,7 @@
bot => boolean(),
shard => gateway_sharding:shard() | undefined,
e2ee_capable => boolean(),
geoip_country_code => binary() | undefined,
ignored_events => #{binary() => true},
initial_guild_id => guild_id() | undefined,
collected_guild_states => [map()],
@@ -146,6 +147,12 @@ handle_cast({dispatch, Event, Data}, State) when
session_dispatch:handle_dispatch(Event, Data, State);
handle_cast({initial_global_presences, Presences}, State) ->
handle_cast_presences(Presences, State);
handle_cast({voice_rejected, ErrorAtom}, State) when is_atom(ErrorAtom) ->
ok = session_voice:report_rejection(ErrorAtom, State),
{noreply, State};
handle_cast({voice_signal, Data}, State) when is_map(Data) ->
ok = session_voice:handle_voice_signal(Data, State),
{noreply, State};
handle_cast(Msg, State) ->
handle_cast_guild_or_lifecycle(Msg, State).
+2 -1
View File
@@ -246,7 +246,8 @@ extract_core_fields(
bot => Bot,
shard => maps:get(shard, D, undefined),
is_staff => IsStaff,
e2ee_capable => maps:get(e2ee_capable, D, false)
e2ee_capable => maps:get(e2ee_capable, D, false),
geoip_country_code => maps:get(geoip_country_code, D, undefined)
}.
-spec extract_extra_fields(map(), map() | undefined) -> map().
@@ -604,6 +604,7 @@ serialize_state(State) ->
shard => maps:get(shard, State, undefined),
e2ee_capable => maps:get(e2ee_capable, State, false),
thread_channels_capable => maps:get(thread_channels_capable, State, false),
geoip_country_code => maps:get(geoip_country_code, State, undefined),
guilds => maps:get(guilds, State, #{}),
active_guilds => maps:get(active_guilds, State, sets:new()),
collected_guild_states => maps:get(collected_guild_states, State),
@@ -638,6 +639,7 @@ serialize_transfer_identity(State) ->
bot => maps:get(bot, State, false),
shard => maps:get(shard, State, undefined),
e2ee_capable => maps:get(e2ee_capable, State, false),
geoip_country_code => maps:get(geoip_country_code, State, undefined),
ignored_events => maps:keys(maps:get(ignored_events, State, #{})),
initial_guild_id => maps:get(initial_guild_id, State, undefined),
active_guilds => maps:get(active_guilds, State, sets:new()),
@@ -199,6 +199,7 @@ build_session_data(Data, IdentifyData, Version, SocketPid, SessionId, UserDataMa
),
bot => map_utils:get_safe(UserDataMap, <<"bot">>, false),
e2ee_capable => extract_e2ee_capable(Properties),
geoip_country_code => extract_geoip_country_code(Data),
ignored_events => term_detach:detach(IgnoredEvents),
initial_guild_id => map_utils:get_safe(IdentifyData, initial_guild_id, undefined),
shard => Shard,
@@ -386,6 +387,13 @@ extract_e2ee_capable(Properties) when is_map(Properties) ->
extract_e2ee_capable(_) ->
false.
-spec extract_geoip_country_code(map()) -> binary() | undefined.
extract_geoip_country_code(Data) ->
case maps:get(<<"geoip_country_code">>, Data, null) of
CountryCode when is_binary(CountryCode), byte_size(CountryCode) > 0 -> CountryCode;
_ -> undefined
end.
-spec fetch_rpc_data(map(), term()) -> {ok, map()} | {error, term()}.
fetch_rpc_data(Request, PeerIP) ->
StartTime = erlang:system_time(millisecond),
+137 -2
View File
@@ -7,6 +7,8 @@
init_voice_queue/0,
process_voice_queue/1,
handle_voice_state_update/2,
report_rejection/2,
handle_voice_signal/2,
handle_voice_disconnect/1
]).
@@ -37,7 +39,7 @@ process_voice_queue_item(Item, State) ->
case maps:get(type, Item, undefined) of
voice_state_update ->
Data = maps:get(data, Item),
{reply, _, NewState} = session_voice_connect:handle_voice_state_update(Data, State),
{reply, _, NewState} = handle_voice_state_update(Data, State),
NewState;
_ ->
State
@@ -45,7 +47,64 @@ process_voice_queue_item(Item, State) ->
-spec handle_voice_state_update(map(), session_state()) -> voice_state_reply().
handle_voice_state_update(Data, State) ->
session_voice_connect:handle_voice_state_update(Data, State).
Result = session_voice_connect:handle_voice_state_update(Data, State),
ok = report_rejected_reply(Result, State),
Result.
-spec report_rejected_reply(voice_state_reply(), session_state()) -> ok.
report_rejected_reply({reply, {error, _Category, ErrorAtom}, _NewState}, State) ->
report_rejection(ErrorAtom, State);
report_rejected_reply(_Result, _State) ->
ok.
-spec report_rejection(term(), session_state()) -> ok.
report_rejection(ErrorAtom, State) ->
case {voice_p2p:reports_rejection(ErrorAtom), maps:get(socket_pid, State, undefined)} of
{true, SocketPid} when is_pid(SocketPid) ->
SocketPid ! {gateway_error, ErrorAtom},
ok;
_ ->
ok
end.
-spec handle_voice_signal(map(), session_state()) -> ok.
handle_voice_signal(Data, State) ->
GuildIdResult = validation:validate_optional_snowflake(
maps:get(<<"guild_id">>, Data, null)
),
ChannelIdResult = validation:validate_snowflake(maps:get(<<"channel_id">>, Data, null)),
To = maps:get(<<"to">>, Data, undefined),
Signal = maps:get(<<"data">>, Data, undefined),
case {GuildIdResult, ChannelIdResult} of
{{ok, GuildId}, {ok, ChannelId}} when is_binary(To), is_map(Signal) ->
route_voice_signal(GuildId, ChannelId, To, Signal, State);
_ ->
ok
end.
-spec route_voice_signal(integer() | null, integer(), binary(), map(), session_state()) -> ok.
route_voice_signal(null, ChannelId, To, Signal, State) ->
case maps:get(ChannelId, maps:get(calls, State, #{}), undefined) of
{CallPid, _Ref} when is_pid(CallPid) ->
gen_server:cast(CallPid, {voice_signal, maps:get(id, State), To, Signal});
_ ->
ok
end;
route_voice_signal(GuildId, ChannelId, To, Signal, State) ->
case maps:get(GuildId, maps:get(guilds, State, #{}), undefined) of
{GuildPid, _Ref} when is_pid(GuildPid) ->
gen_server:cast(
GuildPid,
{voice_signal, #{
session_id => maps:get(id, State),
channel_id => ChannelId,
to => To,
data => Signal
}}
);
_ ->
ok
end.
-spec handle_voice_disconnect(session_state()) -> voice_state_reply().
handle_voice_disconnect(State) ->
@@ -88,6 +147,82 @@ init_voice_queue_test() ->
?assert(queue:is_empty(maps:get(voice_queue, Result))),
ok.
report_rejection_sends_p2p_rejections_to_the_socket_test() ->
State = #{socket_pid => self()},
ok = report_rejection(voice_p2p_consent_required, State),
ok = report_rejection(voice_p2p_unavailable, State),
ok = report_rejection(voice_channel_full, State),
ok = report_rejection(voice_token_failed, State),
ok = report_rejection(voice_channel_full, #{socket_pid => undefined}),
?assertEqual(
[voice_p2p_consent_required, voice_p2p_unavailable, voice_channel_full],
drain_gateway_errors()
).
drain_gateway_errors() ->
receive
{gateway_error, ErrorAtom} -> [ErrorAtom | drain_gateway_errors()]
after 50 ->
[]
end.
voice_signal_routes_to_the_guild_test() ->
State = #{id => <<"sess">>, guilds => #{42 => {self(), make_ref()}}, calls => #{}},
Data = #{
<<"guild_id">> => <<"42">>,
<<"channel_id">> => <<"100">>,
<<"to">> => <<"conn-b">>,
<<"data">> => #{<<"type">> => <<"offer">>}
},
ok = handle_voice_signal(Data, State),
receive
{'$gen_cast', {voice_signal, Request}} ->
?assertEqual(
#{
session_id => <<"sess">>,
channel_id => 100,
to => <<"conn-b">>,
data => #{<<"type">> => <<"offer">>}
},
Request
)
after 1000 ->
error(voice_signal_not_routed)
end.
voice_signal_routes_to_the_call_test() ->
State = #{id => <<"sess">>, guilds => #{}, calls => #{100 => {self(), make_ref()}}},
Data = #{<<"channel_id">> => <<"100">>, <<"to">> => <<"conn-b">>, <<"data">> => #{}},
ok = handle_voice_signal(Data, State),
receive
{'$gen_cast', {voice_signal, <<"sess">>, <<"conn-b">>, #{}}} -> ok
after 1000 ->
error(voice_signal_not_routed)
end.
voice_signal_drops_unroutable_frames_test() ->
State = #{id => <<"sess">>, guilds => #{42 => {self(), make_ref()}}, calls => #{}},
Valid = #{
<<"guild_id">> => <<"42">>,
<<"channel_id">> => <<"100">>,
<<"to">> => <<"conn-b">>,
<<"data">> => #{}
},
Dropped = [
Valid#{<<"guild_id">> => <<"43">>},
Valid#{<<"guild_id">> => null},
Valid#{<<"channel_id">> => null},
Valid#{<<"to">> => 5},
Valid#{<<"data">> => <<"sdp">>},
#{}
],
[?assertEqual(ok, handle_voice_signal(Data, State)) || Data <- Dropped],
receive
{'$gen_cast', Unexpected} -> error({unexpected_voice_signal, Unexpected})
after 50 ->
ok
end.
process_voice_queue_empty_test() ->
State = #{voice_queue => queue:new()},
Result = process_voice_queue(State),
@@ -47,6 +47,7 @@ handle_voice_state_update(Data, State) ->
user_id => UserId,
e2ee_capable => E2EECapable,
bot => Bot,
country_code => maps:get(geoip_country_code, State, undefined),
guilds => Guilds
},
dispatch_validated(GuildIdResult, ChannelIdResult, Ctx, State).
@@ -64,7 +65,8 @@ extract_voice_params(Data) ->
viewer_stream_keys => maps:get(<<"viewer_stream_keys">>, Data, undefined),
is_mobile => maps:get(<<"is_mobile">>, Data, false),
latitude => maps:get(<<"latitude">>, Data, null),
longitude => maps:get(<<"longitude">>, Data, null)
longitude => maps:get(<<"longitude">>, Data, null),
p2p => maps:get(<<"p2p">>, Data, undefined)
}.
-spec dispatch_validated(
@@ -148,13 +150,14 @@ handle_dm_channel(ChId, Ctx, State) ->
user_id := UserId,
params := Params,
e2ee_capable := E2EE,
bot := Bot
bot := Bot,
country_code := CountryCode
} = Ctx,
ConnId = maps:get(connection_id, Params),
case is_binary(ConnId) orelse ConnId =:= null of
true ->
handle_dm_connect(
ChId, Params, SId, UserId, E2EE, Bot, State
ChId, Params, SId, UserId, E2EE, Bot, CountryCode, State
);
false ->
invalid_params_reply(UserId, SId, State)
@@ -257,9 +260,10 @@ log_dm_disconnect_err(UserId, SId, ConnId, Cat, Err) ->
user_id(),
boolean(),
boolean(),
binary() | undefined,
session_state()
) -> voice_state_reply().
handle_dm_connect(ChId, Params, SId, UserId, E2EE, Bot, State) ->
handle_dm_connect(ChId, Params, SId, UserId, E2EE, Bot, CountryCode, State) ->
ConnId = maps:get(connection_id, Params),
Request = #{
user_id => UserId,
@@ -275,7 +279,9 @@ handle_dm_connect(ChId, Params, SId, UserId, E2EE, Bot, State) ->
latitude => maps:get(latitude, Params),
longitude => maps:get(longitude, Params),
e2ee_capable => E2EE,
bot => Bot
bot => Bot,
p2p => maps:get(p2p, Params),
country_code => CountryCode
},
StWithPid = State#{session_pid => self()},
Result = dm_voice:voice_state_update(Request, StWithPid),
@@ -403,11 +409,12 @@ guild_voice_queue(GuildPid, GId, ChId, Ctx, State) ->
user_id := UserId,
params := Params,
e2ee_capable := E2EE,
bot := Bot
bot := Bot,
country_code := CountryCode
} = Ctx,
ConnId = maps:get(connection_id, Params),
log_guild_info("guild_queue", UserId, SId, GId, ChId, ConnId),
Req = build_guild_request(ChId, Params, UserId, SId, E2EE, Bot),
Req = build_guild_request(ChId, Params, UserId, SId, E2EE, Bot, CountryCode),
VoiceCtx = #{
guild_pid => GuildPid,
guild_id => GId,
@@ -485,9 +492,10 @@ log_guild_warning(Tag, UserId, SId, GId, ChId, ConnId) ->
user_id(),
binary(),
boolean(),
boolean()
boolean(),
binary() | undefined
) -> map().
build_guild_request(ChId, Params, UserId, SId, E2EE, Bot) ->
build_guild_request(ChId, Params, UserId, SId, E2EE, Bot, CountryCode) ->
#{
user_id => UserId,
session_id => SId,
@@ -502,5 +510,7 @@ build_guild_request(ChId, Params, UserId, SId, E2EE, Bot) ->
latitude => maps:get(latitude, Params),
longitude => maps:get(longitude, Params),
e2ee_capable => E2EE,
bot => Bot
bot => Bot,
p2p => maps:get(p2p, Params),
country_code => CountryCode
}.
@@ -76,7 +76,8 @@ handle_guild_call_result({error, timeout}, Ctx, _SessionPid) ->
log_voice_warning("voice_state_update_guild_call_timeout", Ctx);
handle_guild_call_result({error, noproc}, Ctx, _SessionPid) ->
log_voice_warning("voice_state_update_guild_call_noproc", Ctx);
handle_guild_call_result({error, Cat, Err}, Ctx, _SessionPid) ->
handle_guild_call_result({error, Cat, Err}, Ctx, SessionPid) ->
gen_server:cast(SessionPid, {voice_rejected, Err}),
log_voice_warning_extra(Ctx, " category=~p error=~p", [Cat, Err]);
handle_guild_call_result({error, Reason}, Ctx, _SessionPid) ->
log_voice_warning_extra(Ctx, " reason=~p", [Reason]).
@@ -107,6 +108,15 @@ handle_guild_reply_ok(Reply, Ctx, SessionPid) ->
-spec maybe_dispatch_voice_server_update(
map(), guild_id(), channel_id() | null, pid()
) -> ok.
maybe_dispatch_voice_server_update(
#{p2p := true, ice_servers := IceServers, connection_id := ConnId}, GId, ChId, SessionPid
) when is_integer(ChId) ->
dispatch_to_session(
SessionPid,
voice_server_update,
voice_p2p:voice_server_update(ConnId, ChId, GId, IceServers),
GId
);
maybe_dispatch_voice_server_update(Reply, GId, ChId, SessionPid) ->
Token = maps:get(token, Reply, undefined),
Endpoint = maps:get(endpoint, Reply, undefined),
@@ -371,6 +381,37 @@ join_reply_dispatches_voice_server_update_test() ->
?assertEqual(<<"conn-1">>, maps:get(<<"connection_id">>, Payload)),
?assertNot(maps:is_key(<<"e2ee_key">>, Payload)).
guild_rejection_is_cast_to_the_session_test() ->
ok = handle_guild_call_result(
{error, permission_denied, voice_p2p_consent_required}, test_voice_ctx(456), self()
),
receive
{'$gen_cast', {voice_rejected, voice_p2p_consent_required}} -> ok
after 1000 ->
error(voice_rejection_not_cast)
end.
p2p_join_reply_dispatches_ice_servers_without_a_token_test() ->
IceServers = [#{<<"urls">> => [<<"stun:stun.example:3478">>]}],
Reply = #{
success => true,
p2p => true,
ice_servers => IceServers,
connection_id => <<"conn-1">>,
voice_state => #{<<"channel_id">> => <<"456">>}
},
ok = handle_guild_reply_ok(Reply, test_voice_ctx(456), self()),
?assertEqual(
#{
<<"p2p">> => true,
<<"ice_servers">> => IceServers,
<<"guild_id">> => <<"42">>,
<<"channel_id">> => <<"456">>,
<<"connection_id">> => <<"conn-1">>
},
receive_dispatch(voice_server_update)
).
join_reply_attaches_e2ee_key_test() ->
Reply = #{
success => true,
+4 -2
View File
@@ -47,6 +47,7 @@ gateway_opcode(9) -> invalid_session;
gateway_opcode(10) -> hello;
gateway_opcode(11) -> heartbeat_ack;
gateway_opcode(12) -> gateway_error;
gateway_opcode(13) -> voice_signal;
gateway_opcode(14) -> lazy_request;
gateway_opcode(15) -> request_guild_counts;
gateway_opcode(16) -> request_channel_member_counts;
@@ -67,6 +68,7 @@ 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(voice_signal) -> 13;
opcode_to_num(lazy_request) -> 14;
opcode_to_num(request_guild_counts) -> 15;
opcode_to_num(request_channel_member_counts) -> 16;
@@ -108,7 +110,7 @@ status_type_atom(invisible) -> <<"invisible">>;
status_type_atom(offline) -> <<"offline">>.
-spec max_payload_size() -> pos_integer().
max_payload_size() -> 4096.
max_payload_size() -> 16384.
-spec heartbeat_interval() -> pos_integer().
heartbeat_interval() -> 41250.
@@ -210,7 +212,7 @@ status_type_atom_atom_to_binary_test() ->
?assertEqual(<<"idle">>, status_type_atom(idle)).
constants_values_test() ->
?assertEqual(4096, max_payload_size()),
?assertEqual(16384, max_payload_size()),
?assertEqual(41250, heartbeat_interval()),
?assertEqual(45000, heartbeat_timeout()),
?assertEqual(60000, resume_timeout()),