%% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_voice_p2p_tests). -typing([eqwalizer]). -include_lib("eunit/include/eunit.hrl"). -define(GUILD_ID, 999). -define(CHANNEL_ID, 100). -define(OTHER_CHANNEL_ID, 200). -define(PINNED_CHANNEL_ID, 300). p2p_start_in_an_empty_channel_test() -> with_stubs(grant, fun(State0) -> {Reply, State1} = join(10, true, State0), ConnectionId = maps:get(connection_id, Reply), ?assertMatch(#{success := true, p2p := true, ice_servers := [_]}, Reply), ?assertNot(maps:is_key(token, Reply)), ?assertNot(maps:is_key(endpoint, Reply)), ?assertEqual(true, p2p_flag(ConnectionId, State1)), ?assertEqual(#{}, maps:get(pending_voice_connections, State1)), ?assertMatch( #{ <<"p2p">> := true, <<"p2p_initiator">> := true, <<"p2p_participant_count">> := 1, <<"country_code">> := <<"SE">> }, last_token_request() ), ?assertEqual([{ConnectionId, true}], drain_voice_state_updates()) end). agreed_joiner_joins_the_mesh_test() -> with_stubs(grant, fun(State0) -> {ReplyA, State1} = join(10, true, State0), {ReplyB, State2} = join(11, true, State1), ?assertMatch(#{p2p := true}, ReplyB), ?assertMatch( #{ <<"p2p">> := true, <<"p2p_initiator">> := false, <<"p2p_participant_count">> := 2 }, last_token_request() ), ?assertEqual(true, p2p_flag(maps:get(connection_id, ReplyA), State2)), ?assertEqual(true, p2p_flag(maps:get(connection_id, ReplyB), State2)), ?assertEqual([], served_voice_server_updates(State2)) end). non_agreeing_joiner_is_rejected_and_nobody_is_converted_test() -> with_stubs(grant, fun(State0) -> {Mesh, State1} = join_mesh([10, 11], State0), Requests = token_request_count(), {Reply, State2} = join(12, undefined, State1), ?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply), ?assertEqual(Requests, token_request_count()), assert_untouched(Mesh, State1, State2) end). bot_is_rejected_like_a_non_agreeing_joiner_test() -> with_stubs(grant, fun(State0) -> {Mesh, State1} = join_mesh([10, 11], State0), {reply, Reply, State2} = guild_voice_connection:voice_state_update( (request(12, true))#{bot => true}, State1 ), ?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply), assert_untouched(Mesh, State1, State2) end). fifth_joiner_is_rejected_as_full_test() -> with_stubs(grant, fun(State0) -> {Mesh, State1} = join_mesh([10, 11, 12, 13], State0), ?assertEqual(4, length(Mesh)), Requests = token_request_count(), {Reply, State2} = join(14, true, State1), ?assertEqual({error, permission_denied, voice_channel_full}, Reply), ?assertEqual(Requests, token_request_count()), assert_untouched(Mesh, State1, State2) end). joiner_past_the_configured_cap_is_rejected_as_full_test() -> with_stubs({cap, 2}, fun(State0) -> {Mesh, State1} = join_mesh([10, 11], State0), {Reply, State2} = join(12, true, State1), ?assertMatch( #{ <<"p2p">> := true, <<"p2p_initiator">> := false, <<"p2p_participant_count">> := 3 }, last_token_request() ), ?assertEqual({error, permission_denied, voice_channel_full}, Reply), assert_untouched(Mesh, State1, State2) end). mesh_fills_up_to_the_configured_cap_test() -> with_stubs({cap, 3}, fun(State0) -> {[_, _, _] = Mesh, State1} = join_mesh([10, 11, 12], State0), [?assertEqual(true, p2p_flag(ConnectionId, State1)) || ConnectionId <- Mesh], {Reply, _State2} = join(13, true, State1), ?assertEqual({error, permission_denied, voice_channel_full}, Reply) end). declined_grant_into_a_live_mesh_is_rejected_test() -> with_stubs(grant, fun(State0) -> {Mesh, State1} = join_mesh([10, 11], State0), erlang:put(p2p_stub_mode, decline), {Reply, State2} = join(12, true, State1), ?assertMatch(#{<<"p2p">> := true, <<"p2p_initiator">> := false}, last_token_request()), ?assertEqual({error, voice_error, voice_p2p_unavailable}, Reply), assert_untouched(Mesh, State1, State2) end). declined_grant_on_an_empty_channel_joins_sfu_test() -> with_stubs(decline, fun(State0) -> {Reply, State1} = join(10, true, State0), ?assertMatch(#{<<"p2p">> := true, <<"p2p_initiator">> := true}, last_token_request()), ?assertMatch(#{success := true, token := _, endpoint := _}, Reply), ?assertNot(maps:is_key(p2p, Reply)), ?assertEqual(#{}, maps:get(voice_states, State1)), ?assertEqual( [maps:get(connection_id, Reply)], maps:keys(maps:get(pending_voice_connections, State1)) ) end). pinned_empty_channel_joins_a_non_agreeing_joiner_on_sfu_test() -> with_stubs(grant, fun(State0) -> {reply, Reply, _State1} = guild_voice_connection:voice_state_update( (request(10, undefined))#{channel_id => ?PINNED_CHANNEL_ID}, State0 ), ?assertMatch(#{success := true, token := _}, Reply), ?assertNot(maps:is_key(<<"p2p">>, last_token_request())) end). pinned_channel_joins_sfu_when_the_grant_is_declined_test() -> with_stubs(decline, fun(State0) -> {reply, Reply, _State1} = guild_voice_connection:voice_state_update( (request(10, true))#{channel_id => ?PINNED_CHANNEL_ID}, State0 ), ?assertMatch(#{success := true, token := _}, Reply) end). pending_join_blocks_a_p2p_start_test() -> with_stubs(grant, fun(State0) -> {_Reply, State1} = join(10, undefined, State0), ?assertEqual(1, maps:size(maps:get(pending_voice_connections, State1))), {Reply, _State2} = join(11, true, State1), ?assertMatch(#{token := _}, Reply), ?assertNot(maps:is_key(<<"p2p">>, last_token_request())) end). explicit_p2p_false_update_converts_test() -> with_stubs(grant, fun(State0) -> {[ConnectionA | _] = Mesh, State1} = join_mesh([10, 11], State0), {reply, #{success := true}, State2} = guild_voice_connection:voice_state_update( (request(10, false))#{connection_id => ConnectionA}, State1 ), assert_converted(Mesh, State2) end). explicit_p2p_false_update_is_ignored_in_a_pinned_channel_test() -> with_stubs(grant, fun(State0) -> {[ConnectionA | _] = Mesh, State1} = join_mesh([10, 11], ?PINNED_CHANNEL_ID, State0), _ = drain_voice_state_updates(), {reply, #{success := true}, State2} = guild_voice_connection:voice_state_update( (request(10, false))#{ channel_id => ?PINNED_CHANNEL_ID, connection_id => ConnectionA }, State1 ), assert_untouched(Mesh, State1, State2) end). update_without_p2p_keeps_the_mesh_test() -> with_stubs(grant, fun(State0) -> {[ConnectionA, ConnectionB], State1} = join_mesh([10, 11], State0), {reply, #{success := true}, State2} = guild_voice_connection:voice_state_update( (request(10, undefined))#{connection_id => ConnectionA, self_mute => true}, State1 ), ?assertEqual(true, p2p_flag(ConnectionA, State2)), ?assertEqual(true, p2p_flag(ConnectionB, State2)), ?assertEqual( true, maps:get(<<"self_mute">>, maps:get(ConnectionA, maps:get(voice_states, State2))) ) end). region_switch_converts_test() -> with_stubs(grant, fun(State0) -> {Mesh, State1} = join_mesh([10, 11], State0), {reply, #{success := true}, State2} = guild_voice_region:switch_voice_region_handler( #{channel_id => ?CHANNEL_ID}, State1 ), assert_marked_sfu(Mesh, State2), GuildPid = maps:get(guild_pid, State2), GuildPid ! {serve, State2}, ok = guild_voice_region:switch_voice_region(?GUILD_ID, ?CHANNEL_ID, GuildPid), ?assertEqual(lists:sort(Mesh), lists:sort(drain_voice_server_updates(length(Mesh)))) end). region_switch_keeps_a_pinned_channel_p2p_test() -> with_stubs(grant, fun(State0) -> {Mesh, State1} = join_mesh([10, 11], ?PINNED_CHANNEL_ID, State0), _ = drain_voice_state_updates(), {reply, #{success := true}, State2} = guild_voice_region:switch_voice_region_handler( #{channel_id => ?PINNED_CHANNEL_ID}, State1 ), GuildPid = maps:get(guild_pid, State2), GuildPid ! {serve, State2}, ok = guild_voice_region:switch_voice_region(?GUILD_ID, ?PINNED_CHANNEL_ID, GuildPid), assert_untouched(Mesh, State1, State2) end). livekit_restore_into_a_mesh_is_refused_test() -> with_stubs(grant, fun(State0) -> {Mesh, State1} = join_mesh([10, 11], State0), Stale = (maps:get(hd(Mesh), maps:get(voice_states, State1)))#{ <<"connection_id">> => <<"stale">>, <<"user_id">> => <<"12">>, <<"p2p">> => false }, State2 = State1#{ recently_disconnected_voice_states => #{ <<"stale">> => #{ voice_state => Stale, disconnected_at => erlang:system_time(millisecond) } } }, {reply, Reply, State3} = guild_voice_connection_confirm:confirm_voice_connection_from_livekit( #{connection_id => <<"stale">>}, State2 ), ?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply), ?assertNot(maps:is_key(<<"stale">>, maps:get(voice_states, State3))), assert_untouched(Mesh, State2, State3) end). livekit_activation_into_a_mesh_is_refused_test() -> with_stubs(grant, fun(State0) -> {Mesh, State1} = join_mesh([10, 11], State0), State2 = guild_voice_connection_pending:store_pending( <<"late">>, #{ user_id => 12, guild_id => ?GUILD_ID, channel_id => ?CHANNEL_ID, session_id => session_id(12), token_nonce => <<"nonce">>, expires_at => erlang:system_time(millisecond) + 30000 }, State1 ), {reply, Reply, State3} = guild_voice_connection_confirm:confirm_voice_connection_from_livekit( #{connection_id => <<"late">>, token_nonce => <<"nonce">>}, State2 ), ?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply), ?assertEqual(lists:sort(Mesh), lists:sort(maps:keys(maps:get(voice_states, State3)))), ?assertEqual(#{}, maps:get(pending_voice_connections, State3)), assert_untouched(Mesh, State1, State3) end). explicit_switch_converts_without_a_voice_server_test() -> with_stubs(grant, fun(State0) -> {[ConnectionA | _] = Mesh, State1} = join_mesh([10, 11], State0), Self = self(), Guild = spawn(fun() -> {reply, Reply, State2} = guild_voice_connection:voice_state_update( (request(10, false))#{connection_id => ConnectionA}, maps:remove(guild_pid, State1) ), Self ! {converted, Reply, State2}, guild_stub_loop(State2) end), receive {converted, Reply, State2} -> ?assertMatch(#{success := true}, Reply), assert_marked_sfu(Mesh, State2), ?assertEqual(lists:sort(Mesh), lists:sort(drain_voice_server_updates(2))) after 2000 -> error(explicit_switch_did_not_convert) end, Guild ! stop end). moderator_move_into_a_mesh_is_refused_test() -> with_stubs(grant, fun(State0) -> {Mesh, State1} = join_mesh([10, 11], State0), State2 = with_sfu_voice_state(<<"moved">>, 12, ?OTHER_CHANNEL_ID, State1), {reply, Reply, State3} = guild_voice_move:move_member( move_request(12, ?CHANNEL_ID), State2 ), ?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply), ?assert(maps:is_key(<<"moved">>, maps:get(voice_states, State3))), assert_untouched(Mesh, State2, State3) end). moderator_move_into_an_empty_pinned_channel_moves_on_sfu_test() -> with_stubs(grant, fun(State0) -> State1 = with_sfu_voice_state(<<"moved">>, 12, ?OTHER_CHANNEL_ID, State0), {reply, Reply, _State2} = guild_voice_move:move_member( move_request(12, ?PINNED_CHANNEL_ID), State1 ), ?assertMatch(#{success := true, needs_token := true}, Reply) end). moderator_move_out_of_a_mesh_gets_an_sfu_grant_test() -> with_stubs(grant, fun(State0) -> {[ConnectionA, ConnectionB], State1} = join_mesh([10, 11], State0), {reply, #{success := true, needs_token := true, session_data := SessionData}, State2} = guild_voice_move:move_member(move_request(11, ?OTHER_CHANNEL_ID), State1), ?assertEqual([ConnectionA], maps:keys(maps:get(voice_states, State2))), ?assertEqual(true, p2p_flag(ConnectionA, State2)), GuildPid = maps:get(guild_pid, State2), GuildPid ! {serve, State2}, ok = guild_voice_move_execute:send_single_voice_server_update( ?GUILD_ID, ?OTHER_CHANNEL_ID, hd(SessionData), GuildPid ), ?assertEqual([ConnectionB], drain_voice_server_updates(1)), ?assertNot(maps:is_key(<<"p2p">>, last_token_request())) end). client_move_into_a_mesh_joins_it_test() -> with_stubs(grant, fun(State0) -> {[ConnectionA], State1} = join_mesh([10], State0), State2 = with_sfu_voice_state(<<"moved">>, 12, ?OTHER_CHANNEL_ID, State1), {reply, Reply, State3} = guild_voice_connection:voice_state_update( (request(12, true))#{connection_id => <<"moved">>}, State2 ), ?assertMatch(#{success := true, p2p := true, connection_id := <<"moved">>}, Reply), ?assertEqual(true, p2p_flag(<<"moved">>, State3)), ?assertEqual(true, p2p_flag(ConnectionA, State3)), ?assertEqual(#{}, maps:get(pending_voice_connections, State3)) end). client_move_into_a_mesh_without_agreement_is_rejected_test() -> with_stubs(grant, fun(State0) -> {Mesh, State1} = join_mesh([10], State0), State2 = with_sfu_voice_state(<<"moved">>, 12, ?OTHER_CHANNEL_ID, State1), {reply, Reply, State3} = guild_voice_connection:voice_state_update( (request(12, undefined))#{connection_id => <<"moved">>}, State2 ), ?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply), ?assertEqual( <<"200">>, maps:get(<<"channel_id">>, maps:get(<<"moved">>, maps:get(voice_states, State3))) ), assert_untouched(Mesh, State2, State3) end). signal_relay_allowed_test() -> with_stubs(grant, fun(State0) -> {[ConnectionA, ConnectionB], State1} = join_mesh([10, 11], State0), Data = #{<<"type">> => <<"offer">>, <<"sdp">> => <<"v=0">>, <<"id">> => 7}, {noreply, State1} = guild_voice_handler:handle_cast( {voice_signal, signal(<<"sess-10">>, ConnectionB, Data)}, State1 ), ?assertEqual( #{ <<"guild_id">> => <<"999">>, <<"channel_id">> => <<"100">>, <<"from">> => ConnectionA, <<"user_id">> => <<"10">>, <<"data">> => Data }, receive_voice_signal() ) end). signal_relay_denied_test() -> with_stubs(grant, fun(State0) -> {[ConnectionA, ConnectionB], State1} = join_mesh([10, 11], State0), Data = #{<<"type">> => <<"candidate">>}, Denied = [ signal(<<"sess-12">>, ConnectionB, Data), signal(<<"sess-10">>, ConnectionA, Data), signal(<<"sess-10">>, <<"unknown">>, Data), (signal(<<"sess-10">>, ConnectionB, Data))#{channel_id => 200} ], [ {noreply, State1} = guild_voice_handler:handle_cast({voice_signal, Request}, State1) || Request <- Denied ], State2 = guild_voice_region:switch_to_sfu(?CHANNEL_ID, State1), {noreply, State2} = guild_voice_handler:handle_cast( {voice_signal, signal(<<"sess-10">>, ConnectionB, Data)}, State2 ), assert_no_voice_signal(), ?assertEqual(2, length(served_voice_server_updates(State2))) end). signal(SessionId, To, Data) -> #{session_id => SessionId, channel_id => ?CHANNEL_ID, to => To, data => Data}. with_sfu_voice_state(ConnectionId, UserId, ChannelId, State) -> VoiceState = voice_state_utils:complete_voice_state(#{ <<"guild_id">> => <<"999">>, <<"channel_id">> => integer_to_binary(ChannelId), <<"user_id">> => integer_to_binary(UserId), <<"connection_id">> => ConnectionId, <<"session_id">> => session_id(UserId), <<"member">> => member(UserId) }), VoiceStates = maps:get(voice_states, State), State#{voice_states => VoiceStates#{ConnectionId => VoiceState}}. move_request(UserId, ChannelId) -> #{ user_id => UserId, moderator_id => 10, channel_id => ChannelId, mute => false, deaf => false }. assert_converted(Mesh, State) -> assert_marked_sfu(Mesh, State), ?assertEqual(lists:sort(Mesh), lists:sort(served_voice_server_updates(State))). assert_marked_sfu(Mesh, State) -> [?assertEqual(false, p2p_flag(ConnectionId, State)) || ConnectionId <- Mesh], Broadcasts = drain_voice_state_updates(), [?assert(lists:member({ConnectionId, false}, Broadcasts)) || ConnectionId <- Mesh]. assert_untouched(Mesh, Before, After) -> VoiceStates = maps:get(voice_states, Before), [ ?assertEqual( maps:get(ConnectionId, VoiceStates), maps:get(ConnectionId, maps:get(voice_states, After)) ) || ConnectionId <- Mesh ], ?assertEqual( maps:get(pending_voice_connections, Before), maps:get(pending_voice_connections, After) ), Broadcasts = drain_voice_state_updates(), [?assertNot(lists:member({ConnectionId, false}, Broadcasts)) || ConnectionId <- Mesh], ?assertEqual([], served_voice_server_updates(After, 0)). served_voice_server_updates(State) -> served_voice_server_updates( State, maps:size( voice_state_utils:channel_voice_states(?CHANNEL_ID, maps:get(voice_states, State)) ) ). served_voice_server_updates(State, Expected) -> maps:get(guild_pid, State) ! {serve, State}, drain_voice_server_updates(Expected). drain_voice_server_updates(0) -> receive {'$gen_cast', {dispatch, voice_server_update, Payload}} -> error({unexpected_voice_server_update, Payload}) after 100 -> [] end; drain_voice_server_updates(Remaining) -> receive {'$gen_cast', {dispatch, voice_server_update, Payload}} -> ?assertEqual(<<"tok">>, maps:get(<<"token">>, Payload)), ?assertEqual(<<"wss://voice.example">>, maps:get(<<"endpoint">>, Payload)), ?assertNot(maps:is_key(<<"p2p">>, Payload)), [maps:get(<<"connection_id">>, Payload) | drain_voice_server_updates(Remaining - 1)] after 300 -> [] end. drain_voice_state_updates() -> receive {'$gen_cast', {dispatch, voice_state_update, {pre_encoded, Encoded}}} -> #{<<"connection_id">> := ConnectionId, <<"p2p">> := P2p} = json:decode(Encoded), [{ConnectionId, P2p} | drain_voice_state_updates()] after 200 -> [] end. receive_voice_signal() -> receive {'$gen_cast', {dispatch, voice_signal, Payload}} -> Payload after 1000 -> error(voice_signal_not_dispatched) end. assert_no_voice_signal() -> receive {'$gen_cast', {dispatch, voice_signal, Payload}} -> error({unexpected_voice_signal, Payload}) after 100 -> ok end. p2p_flag(ConnectionId, State) -> maps:get(<<"p2p">>, maps:get(ConnectionId, maps:get(voice_states, State))). join_mesh(UserIds, State0) -> join_mesh(UserIds, ?CHANNEL_ID, State0). join_mesh(UserIds, ChannelId, State0) -> lists:foldl( fun(UserId, {ConnectionIds, State}) -> {reply, #{p2p := true, connection_id := ConnectionId}, NewState} = guild_voice_connection:voice_state_update( (request(UserId, true))#{channel_id => ChannelId}, State ), {ConnectionIds ++ [ConnectionId], NewState} end, {[], State0}, UserIds ). join(UserId, P2p, State) -> {reply, Reply, NewState} = guild_voice_connection:voice_state_update( request(UserId, P2p), State ), {Reply, NewState}. request(UserId, P2p) -> #{ user_id => UserId, channel_id => ?CHANNEL_ID, session_id => session_id(UserId), p2p => P2p, country_code => <<"SE">> }. session_id(UserId) -> <<"sess-", (integer_to_binary(UserId))/binary>>. last_token_request() -> lists:last(token_requests()). token_request_count() -> length(token_requests()). token_requests() -> [ Request || {_Pid, {rpc_client, call, [#{<<"type">> := <<"voice_get_token">>} = Request]}, _Result} <- meck:history(rpc_client) ]. with_stubs(Mode, Fun) -> erlang:put(p2p_stub_mode, Mode), meck:new(rpc_client, [passthrough, no_link, non_strict]), meck:expect(rpc_client, call, fun rpc_reply/1), meck:new(guild_voice_server, [passthrough, no_link]), meck:expect(guild_voice_server, resolve, fun(_GuildId, GuildPid) -> GuildPid end), GuildPid = spawn(fun guild_stub/0), Test = self(), Sink = spawn(fun() -> sink(Test) end), try Fun(base_state(GuildPid, Sink)) after GuildPid ! stop, meck:unload(guild_voice_server), meck:unload(rpc_client), stop_sink(Sink) end. sink(Test) -> receive Message -> Test ! Message, sink(Test) after 30000 -> ok end. stop_sink(Sink) -> exit(Sink, kill), flush_mailbox(). flush_mailbox() -> receive _Message -> flush_mailbox() after 0 -> ok end. rpc_reply(#{<<"type">> := <<"voice_get_token">>} = Request) -> ConnectionId = maps:get( <<"connection_id">>, Request, <<"conn-", (integer_to_binary(erlang:unique_integer([positive])))/binary>> ), case {maps:get(<<"p2p">>, Request, false), erlang:get(p2p_stub_mode)} of {true, grant} -> p2p_grant(ConnectionId); {true, {cap, Max}} -> case maps:get(<<"p2p_participant_count">>, Request) > Max of true -> {ok, #{<<"p2p">> => false, <<"declineReason">> => <<"channel_full">>}}; false -> p2p_grant(ConnectionId) end; _ -> {ok, #{ <<"token">> => <<"tok">>, <<"endpoint">> => <<"wss://voice.example">>, <<"connectionId">> => ConnectionId, <<"regionId">> => <<"us-east">>, <<"serverId">> => <<"voice-1">> }} end; rpc_reply(_Request) -> {ok, #{}}. p2p_grant(ConnectionId) -> {ok, #{ <<"p2p">> => true, <<"connectionId">> => ConnectionId, <<"iceServers">> => [#{<<"urls">> => [<<"stun:stun.example:3478">>]}] }}. guild_stub() -> receive {serve, State} -> guild_stub_loop(State); stop -> ok after 30000 -> ok end. guild_stub_loop(State) -> receive {serve, NewState} -> guild_stub_loop(NewState); {'$gen_call', From, {get_voice_guild_state}} -> gen_server:reply(From, State), guild_stub_loop(State); {'$gen_call', From, _Request} -> gen_server:reply(From, ok), guild_stub_loop(State); stop -> ok; _Other -> guild_stub_loop(State) after 30000 -> ok end. base_state(GuildPid, Sink) -> UserIds = [10, 11, 12, 13, 14], #{ id => ?GUILD_ID, guild_pid => GuildPid, data => #{ <<"id">> => <<"999">>, <<"guild">> => #{<<"owner_id">> => <<"10">>}, <<"channels">> => [ #{<<"id">> => <<"100">>, <<"type">> => 2, <<"user_limit">> => 0}, #{<<"id">> => <<"200">>, <<"type">> => 2, <<"user_limit">> => 0}, #{ <<"id">> => <<"300">>, <<"type">> => 2, <<"user_limit">> => 0, <<"rtc_p2p">> => true } ], <<"members">> => [member(UserId) || UserId <- UserIds] }, sessions => maps:from_list([ {session_id(UserId), #{user_id => UserId, pid => Sink, pending_connect => false}} || UserId <- UserIds ]), voice_states => #{}, pending_voice_connections => #{}, test_perm_fun => fun(_UserId) -> constants:view_channel_permission() bor constants:connect_permission() end }. member(UserId) -> #{ <<"user">> => #{<<"id">> => integer_to_binary(UserId)}, <<"mute">> => false, <<"deaf">> => false }.