initial commit
This commit is contained in:
54
fluxer_gateway/src/gateway/fluxer_gateway_app.erl
Normal file
54
fluxer_gateway/src/gateway/fluxer_gateway_app.erl
Normal file
@@ -0,0 +1,54 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(fluxer_gateway_app).
|
||||
-behaviour(application).
|
||||
-export([start/2, stop/1]).
|
||||
|
||||
start(_StartType, _StartArgs) ->
|
||||
fluxer_gateway_env:load(),
|
||||
|
||||
WsPort = fluxer_gateway_env:get(ws_port),
|
||||
RpcPort = fluxer_gateway_env:get(rpc_port),
|
||||
|
||||
Dispatch = cowboy_router:compile([
|
||||
{'_', [
|
||||
{<<"/_health">>, health_handler, []},
|
||||
{<<"/">>, gateway_handler, []}
|
||||
]}
|
||||
]),
|
||||
|
||||
{ok, _} = cowboy:start_clear(http, [{port, WsPort}], #{
|
||||
env => #{dispatch => Dispatch},
|
||||
max_frame_size => 4096
|
||||
}),
|
||||
|
||||
RpcDispatch = cowboy_router:compile([
|
||||
{'_', [
|
||||
{<<"/_rpc">>, gateway_rpc_http_handler, []},
|
||||
{<<"/_admin/reload">>, hot_reload_handler, []}
|
||||
]}
|
||||
]),
|
||||
|
||||
{ok, _} = cowboy:start_clear(rpc_http, [{port, RpcPort}], #{
|
||||
env => #{dispatch => RpcDispatch}
|
||||
}),
|
||||
|
||||
fluxer_gateway_sup:start_link().
|
||||
|
||||
stop(_State) ->
|
||||
ok.
|
||||
268
fluxer_gateway/src/gateway/fluxer_gateway_env.erl
Normal file
268
fluxer_gateway/src/gateway/fluxer_gateway_env.erl
Normal file
@@ -0,0 +1,268 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(fluxer_gateway_env).
|
||||
|
||||
-export([load/0, get/1, get_optional/1, get_map/0, patch/1, update/1]).
|
||||
|
||||
-define(APP, fluxer_gateway).
|
||||
-define(CONFIG_TERM_KEY, {fluxer_gateway, runtime_config}).
|
||||
|
||||
-type config() :: map().
|
||||
|
||||
-spec load() -> config().
|
||||
load() ->
|
||||
set_config(build_config()).
|
||||
|
||||
-spec get(atom()) -> term().
|
||||
get(Key) when is_atom(Key) ->
|
||||
Map = get_map(),
|
||||
maps:get(Key, Map, undefined).
|
||||
|
||||
-spec get_optional(atom()) -> term().
|
||||
get_optional(Key) when is_atom(Key) ->
|
||||
?MODULE:get(Key).
|
||||
|
||||
-spec get_map() -> config().
|
||||
get_map() ->
|
||||
ensure_loaded().
|
||||
|
||||
-spec patch(map()) -> config().
|
||||
patch(Patch) when is_map(Patch) ->
|
||||
Map = get_map(),
|
||||
set_config(maps:merge(Map, Patch)).
|
||||
|
||||
-spec update(fun((config()) -> config())) -> config().
|
||||
update(Fun) when is_function(Fun, 1) ->
|
||||
Map = get_map(),
|
||||
set_config(Fun(Map)).
|
||||
|
||||
-spec set_config(config()) -> config().
|
||||
set_config(Config) when is_map(Config) ->
|
||||
persistent_term:put(?CONFIG_TERM_KEY, Config),
|
||||
Config.
|
||||
|
||||
-spec ensure_loaded() -> config().
|
||||
ensure_loaded() ->
|
||||
case persistent_term:get(?CONFIG_TERM_KEY, undefined) of
|
||||
Map when is_map(Map) ->
|
||||
Map;
|
||||
_ ->
|
||||
load()
|
||||
end.
|
||||
|
||||
-spec build_config() -> config().
|
||||
build_config() ->
|
||||
#{
|
||||
ws_port => env_int("FLUXER_GATEWAY_WS_PORT", ws_port, 8080),
|
||||
rpc_port => env_int("FLUXER_GATEWAY_RPC_PORT", rpc_port, 8081),
|
||||
api_host => env_string("API_HOST", api_host, "api"),
|
||||
api_canary_host => env_optional_string("API_CANARY_HOST", api_canary_host),
|
||||
rpc_secret_key => env_binary("GATEWAY_RPC_SECRET", rpc_secret_key, undefined),
|
||||
identify_rate_limit_enabled => env_bool("FLUXER_GATEWAY_IDENTIFY_RATE_LIMIT_ENABLED", identify_rate_limit_enabled, false),
|
||||
push_enabled => env_bool("FLUXER_GATEWAY_PUSH_ENABLED", push_enabled, true),
|
||||
push_user_guild_settings_cache_mb => env_int("FLUXER_GATEWAY_PUSH_USER_GUILD_SETTINGS_CACHE_MB",
|
||||
push_user_guild_settings_cache_mb, 1024),
|
||||
push_subscriptions_cache_mb => env_int("FLUXER_GATEWAY_PUSH_SUBSCRIPTIONS_CACHE_MB",
|
||||
push_subscriptions_cache_mb, 1024),
|
||||
push_blocked_ids_cache_mb => env_int("FLUXER_GATEWAY_PUSH_BLOCKED_IDS_CACHE_MB",
|
||||
push_blocked_ids_cache_mb, 1024),
|
||||
presence_cache_shards => env_optional_int("FLUXER_GATEWAY_PRESENCE_CACHE_SHARDS", presence_cache_shards),
|
||||
presence_bus_shards => env_optional_int("FLUXER_GATEWAY_PRESENCE_BUS_SHARDS", presence_bus_shards),
|
||||
presence_shards => env_optional_int("FLUXER_GATEWAY_PRESENCE_SHARDS", presence_shards),
|
||||
guild_shards => env_optional_int("FLUXER_GATEWAY_GUILD_SHARDS", guild_shards),
|
||||
metrics_host => env_optional_string("FLUXER_METRICS_HOST", metrics_host),
|
||||
push_badge_counts_cache_mb => app_env_int(push_badge_counts_cache_mb, 256),
|
||||
push_badge_counts_cache_ttl_seconds => app_env_int(push_badge_counts_cache_ttl_seconds, 60),
|
||||
media_proxy_endpoint => env_optional_binary("MEDIA_PROXY_ENDPOINT", media_proxy_endpoint),
|
||||
vapid_email => env_binary("VAPID_EMAIL", vapid_email, <<"support@fluxer.app">>),
|
||||
vapid_public_key => env_binary("VAPID_PUBLIC_KEY", vapid_public_key, undefined),
|
||||
vapid_private_key => env_binary("VAPID_PRIVATE_KEY", vapid_private_key, undefined),
|
||||
gateway_metrics_enabled => app_env_optional_bool(gateway_metrics_enabled),
|
||||
gateway_metrics_report_interval_ms => app_env_optional_int(gateway_metrics_report_interval_ms)
|
||||
}.
|
||||
|
||||
-spec env_int(string(), atom(), integer()) -> integer().
|
||||
env_int(EnvVar, AppKey, Default) when is_atom(AppKey), is_integer(Default) ->
|
||||
case os:getenv(EnvVar) of
|
||||
false ->
|
||||
app_env_int(AppKey, Default);
|
||||
Value ->
|
||||
parse_int(Value, Default)
|
||||
end.
|
||||
|
||||
-spec env_optional_int(string(), atom()) -> integer() | undefined.
|
||||
env_optional_int(EnvVar, AppKey) when is_atom(AppKey) ->
|
||||
case os:getenv(EnvVar) of
|
||||
false ->
|
||||
app_env_optional_int(AppKey);
|
||||
Value ->
|
||||
parse_int(Value, undefined)
|
||||
end.
|
||||
|
||||
-spec env_bool(string(), atom(), boolean()) -> boolean().
|
||||
env_bool(EnvVar, AppKey, Default) when is_atom(AppKey), is_boolean(Default) ->
|
||||
case os:getenv(EnvVar) of
|
||||
false ->
|
||||
app_env_bool(AppKey, Default);
|
||||
Value ->
|
||||
parse_bool(Value, Default)
|
||||
end.
|
||||
|
||||
-spec env_string(string(), atom(), string()) -> string().
|
||||
env_string(EnvVar, AppKey, Default) when is_atom(AppKey) ->
|
||||
case os:getenv(EnvVar) of
|
||||
false ->
|
||||
app_env_string(AppKey, Default);
|
||||
Value ->
|
||||
Value
|
||||
end.
|
||||
|
||||
-spec env_optional_string(string(), atom()) -> string() | undefined.
|
||||
env_optional_string(EnvVar, AppKey) when is_atom(AppKey) ->
|
||||
case os:getenv(EnvVar) of
|
||||
false ->
|
||||
app_env_optional_string(AppKey);
|
||||
Value ->
|
||||
Value
|
||||
end.
|
||||
|
||||
-spec env_binary(string(), atom(), binary() | undefined) -> binary() | undefined.
|
||||
env_binary(EnvVar, AppKey, Default) when is_atom(AppKey) ->
|
||||
case os:getenv(EnvVar) of
|
||||
false ->
|
||||
app_env_binary(AppKey, Default);
|
||||
Value ->
|
||||
to_binary(Value, Default)
|
||||
end.
|
||||
|
||||
-spec env_optional_binary(string(), atom()) -> binary() | undefined.
|
||||
env_optional_binary(EnvVar, AppKey) when is_atom(AppKey) ->
|
||||
case os:getenv(EnvVar) of
|
||||
false ->
|
||||
app_env_optional_binary(AppKey);
|
||||
Value ->
|
||||
to_binary(Value, undefined)
|
||||
end.
|
||||
|
||||
-spec parse_int(string(), integer() | undefined) -> integer() | undefined.
|
||||
parse_int(Value, Default) ->
|
||||
Str = string:trim(Value),
|
||||
try
|
||||
list_to_integer(Str)
|
||||
catch
|
||||
_:_ -> Default
|
||||
end.
|
||||
|
||||
-spec parse_bool(string(), boolean()) -> boolean().
|
||||
parse_bool(Value, Default) ->
|
||||
Str = string:lowercase(string:trim(Value)),
|
||||
case Str of
|
||||
"true" -> true;
|
||||
"1" -> true;
|
||||
"false" -> false;
|
||||
"0" -> false;
|
||||
_ -> Default
|
||||
end.
|
||||
|
||||
-spec to_binary(string(), binary() | undefined) -> binary() | undefined.
|
||||
to_binary(Value, Default) ->
|
||||
try
|
||||
list_to_binary(Value)
|
||||
catch
|
||||
_:_ -> Default
|
||||
end.
|
||||
|
||||
-spec app_env_int(atom(), integer()) -> integer().
|
||||
app_env_int(Key, Default) ->
|
||||
case application:get_env(?APP, Key) of
|
||||
{ok, Value} when is_integer(Value) ->
|
||||
Value;
|
||||
_ ->
|
||||
Default
|
||||
end.
|
||||
|
||||
-spec app_env_optional_int(atom()) -> integer() | undefined.
|
||||
app_env_optional_int(Key) ->
|
||||
case application:get_env(?APP, Key) of
|
||||
{ok, Value} when is_integer(Value) ->
|
||||
Value;
|
||||
_ ->
|
||||
undefined
|
||||
end.
|
||||
|
||||
-spec app_env_bool(atom(), boolean()) -> boolean().
|
||||
app_env_bool(Key, Default) ->
|
||||
case application:get_env(?APP, Key) of
|
||||
{ok, Value} when is_boolean(Value) ->
|
||||
Value;
|
||||
_ ->
|
||||
Default
|
||||
end.
|
||||
|
||||
-spec app_env_optional_bool(atom()) -> boolean() | undefined.
|
||||
app_env_optional_bool(Key) ->
|
||||
case application:get_env(?APP, Key) of
|
||||
{ok, Value} when is_boolean(Value) ->
|
||||
Value;
|
||||
_ ->
|
||||
undefined
|
||||
end.
|
||||
|
||||
-spec app_env_string(atom(), string()) -> string().
|
||||
app_env_string(Key, Default) ->
|
||||
case application:get_env(?APP, Key) of
|
||||
{ok, Value} when is_list(Value) ->
|
||||
Value;
|
||||
{ok, Value} when is_binary(Value) ->
|
||||
binary_to_list(Value);
|
||||
_ ->
|
||||
Default
|
||||
end.
|
||||
|
||||
-spec app_env_optional_string(atom()) -> string() | undefined.
|
||||
app_env_optional_string(Key) ->
|
||||
case application:get_env(?APP, Key) of
|
||||
{ok, Value} when is_list(Value) ->
|
||||
Value;
|
||||
{ok, Value} when is_binary(Value) ->
|
||||
binary_to_list(Value);
|
||||
_ ->
|
||||
undefined
|
||||
end.
|
||||
|
||||
-spec app_env_binary(atom(), binary() | undefined) -> binary() | undefined.
|
||||
app_env_binary(Key, Default) ->
|
||||
case application:get_env(?APP, Key) of
|
||||
{ok, Value} when is_binary(Value) ->
|
||||
Value;
|
||||
{ok, Value} when is_list(Value) ->
|
||||
list_to_binary(Value);
|
||||
_ ->
|
||||
Default
|
||||
end.
|
||||
|
||||
-spec app_env_optional_binary(atom()) -> binary() | undefined.
|
||||
app_env_optional_binary(Key) ->
|
||||
case application:get_env(?APP, Key) of
|
||||
{ok, Value} when is_binary(Value) ->
|
||||
Value;
|
||||
{ok, Value} when is_list(Value) ->
|
||||
list_to_binary(Value);
|
||||
_ ->
|
||||
undefined
|
||||
end.
|
||||
92
fluxer_gateway/src/gateway/fluxer_gateway_sup.erl
Normal file
92
fluxer_gateway/src/gateway/fluxer_gateway_sup.erl
Normal file
@@ -0,0 +1,92 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(fluxer_gateway_sup).
|
||||
-behaviour(supervisor).
|
||||
-export([start_link/0, init/1]).
|
||||
|
||||
start_link() ->
|
||||
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
|
||||
|
||||
init([]) ->
|
||||
SessionManager = #{
|
||||
id => session_manager,
|
||||
start => {session_manager, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker
|
||||
},
|
||||
PresenceManager = #{
|
||||
id => presence_manager,
|
||||
start => {presence_manager, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker
|
||||
},
|
||||
GuildManager = #{
|
||||
id => guild_manager,
|
||||
start => {guild_manager, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker
|
||||
},
|
||||
Push = #{
|
||||
id => push,
|
||||
start => {push, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker
|
||||
},
|
||||
CallManager = #{
|
||||
id => call_manager,
|
||||
start => {call_manager, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker
|
||||
},
|
||||
PresenceBus = #{
|
||||
id => presence_bus,
|
||||
start => {presence_bus, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker
|
||||
},
|
||||
PresenceCache = #{
|
||||
id => presence_cache,
|
||||
start => {presence_cache, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker
|
||||
},
|
||||
GatewayMetricsCollector = #{
|
||||
id => gateway_metrics_collector,
|
||||
start => {gateway_metrics_collector, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker
|
||||
},
|
||||
{ok,
|
||||
{{one_for_one, 5, 10}, [
|
||||
SessionManager,
|
||||
PresenceCache,
|
||||
PresenceBus,
|
||||
PresenceManager,
|
||||
GuildManager,
|
||||
CallManager,
|
||||
Push,
|
||||
GatewayMetricsCollector
|
||||
]}}.
|
||||
77
fluxer_gateway/src/gateway/gateway_codec.erl
Normal file
77
fluxer_gateway/src/gateway/gateway_codec.erl
Normal file
@@ -0,0 +1,77 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(gateway_codec).
|
||||
|
||||
-export([
|
||||
encode/2,
|
||||
decode/2,
|
||||
parse_encoding/1
|
||||
]).
|
||||
|
||||
-type encoding() :: json.
|
||||
-export_type([encoding/0]).
|
||||
|
||||
-spec parse_encoding(binary() | undefined) -> encoding().
|
||||
parse_encoding(_) -> json.
|
||||
|
||||
-spec encode(map(), encoding()) -> {ok, iodata(), text | binary} | {error, term()}.
|
||||
encode(Message, json) ->
|
||||
try
|
||||
Encoded = jsx:encode(Message),
|
||||
{ok, Encoded, text}
|
||||
catch
|
||||
_:Reason ->
|
||||
{error, {encode_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec decode(binary(), encoding()) -> {ok, map()} | {error, term()}.
|
||||
decode(Data, json) ->
|
||||
try
|
||||
Decoded = jsx:decode(Data, [{return_maps, true}]),
|
||||
{ok, Decoded}
|
||||
catch
|
||||
_:Reason ->
|
||||
{error, {decode_failed, Reason}}
|
||||
end.
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
parse_encoding_test() ->
|
||||
?assertEqual(json, parse_encoding(<<"json">>)),
|
||||
?assertEqual(json, parse_encoding(<<"etf">>)),
|
||||
?assertEqual(json, parse_encoding(undefined)),
|
||||
?assertEqual(json, parse_encoding(<<"invalid">>)).
|
||||
|
||||
encode_json_test() ->
|
||||
Message = #{<<"op">> => 0, <<"d">> => #{<<"test">> => true}},
|
||||
{ok, Encoded, text} = encode(Message, json),
|
||||
?assert(is_binary(Encoded)).
|
||||
|
||||
decode_json_test() ->
|
||||
Data = <<"{\"op\":0,\"d\":{\"test\":true}}">>,
|
||||
{ok, Decoded} = decode(Data, json),
|
||||
?assertEqual(0, maps:get(<<"op">>, Decoded)).
|
||||
|
||||
roundtrip_json_test() ->
|
||||
Original = #{<<"op">> => 10, <<"d">> => #{<<"heartbeat_interval">> => 41250}},
|
||||
{ok, Encoded, _} = encode(Original, json),
|
||||
{ok, Decoded} = decode(iolist_to_binary(Encoded), json),
|
||||
?assertEqual(Original, Decoded).
|
||||
|
||||
-endif.
|
||||
106
fluxer_gateway/src/gateway/gateway_compress.erl
Normal file
106
fluxer_gateway/src/gateway/gateway_compress.erl
Normal file
@@ -0,0 +1,106 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(gateway_compress).
|
||||
|
||||
-export([
|
||||
new_context/1,
|
||||
compress/2,
|
||||
decompress/2,
|
||||
parse_compression/1,
|
||||
close_context/1,
|
||||
get_type/1
|
||||
]).
|
||||
|
||||
-type compression() :: none | zstd_stream.
|
||||
-export_type([compression/0]).
|
||||
|
||||
-record(compress_ctx, {type :: compression()}).
|
||||
-type compress_ctx() :: #compress_ctx{}.
|
||||
-export_type([compress_ctx/0]).
|
||||
|
||||
-spec parse_compression(binary() | undefined) -> compression().
|
||||
parse_compression(<<"none">>) -> none;
|
||||
parse_compression(<<"zstd-stream">>) -> zstd_stream;
|
||||
parse_compression(_) -> none.
|
||||
|
||||
-spec new_context(compression()) -> compress_ctx().
|
||||
new_context(none) ->
|
||||
#compress_ctx{type = none};
|
||||
new_context(zstd_stream) ->
|
||||
#compress_ctx{type = zstd_stream}.
|
||||
|
||||
-spec close_context(compress_ctx()) -> ok.
|
||||
close_context(_Ctx) ->
|
||||
ok.
|
||||
|
||||
-spec get_type(compress_ctx()) -> compression().
|
||||
get_type(#compress_ctx{type = Type}) ->
|
||||
Type.
|
||||
|
||||
-spec compress(iodata(), compress_ctx()) -> {ok, binary(), compress_ctx()} | {error, term()}.
|
||||
compress(Data, Ctx = #compress_ctx{type = none}) ->
|
||||
{ok, iolist_to_binary(Data), Ctx};
|
||||
compress(Data, Ctx = #compress_ctx{type = zstd_stream}) ->
|
||||
try
|
||||
Binary = iolist_to_binary(Data),
|
||||
case ezstd:compress(Binary, 3) of
|
||||
Compressed when is_binary(Compressed) ->
|
||||
{ok, Compressed, Ctx};
|
||||
{error, Reason} ->
|
||||
{error, {compress_failed, Reason}}
|
||||
end
|
||||
catch
|
||||
_:Exception ->
|
||||
{error, {compress_failed, Exception}}
|
||||
end.
|
||||
|
||||
-spec decompress(binary(), compress_ctx()) -> {ok, binary(), compress_ctx()} | {error, term()}.
|
||||
decompress(Data, Ctx = #compress_ctx{type = none}) ->
|
||||
{ok, Data, Ctx};
|
||||
decompress(Data, Ctx = #compress_ctx{type = zstd_stream}) ->
|
||||
try
|
||||
case ezstd:decompress(Data) of
|
||||
Decompressed when is_binary(Decompressed) ->
|
||||
{ok, Decompressed, Ctx};
|
||||
{error, Reason} ->
|
||||
{error, {decompress_failed, Reason}}
|
||||
end
|
||||
catch
|
||||
_:Exception ->
|
||||
{error, {decompress_failed, Exception}}
|
||||
end.
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
parse_compression_test() ->
|
||||
?assertEqual(none, parse_compression(undefined)),
|
||||
?assertEqual(none, parse_compression(<<>>)),
|
||||
?assertEqual(zstd_stream, parse_compression(<<"zstd-stream">>)),
|
||||
?assertEqual(none, parse_compression(<<"none">>)).
|
||||
|
||||
zstd_roundtrip_test() ->
|
||||
Ctx = new_context(zstd_stream),
|
||||
Data = <<"hello world, this is a test message for zstd compression">>,
|
||||
{ok, Compressed, Ctx2} = compress(Data, Ctx),
|
||||
?assert(is_binary(Compressed)),
|
||||
{ok, Decompressed, _} = decompress(Compressed, Ctx2),
|
||||
?assertEqual(Data, Decompressed),
|
||||
ok = close_context(Ctx2).
|
||||
|
||||
-endif.
|
||||
143
fluxer_gateway/src/gateway/gateway_errors.erl
Normal file
143
fluxer_gateway/src/gateway/gateway_errors.erl
Normal file
@@ -0,0 +1,143 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(gateway_errors).
|
||||
|
||||
-export([
|
||||
error/1,
|
||||
error_code/1,
|
||||
error_message/1,
|
||||
error_category/1,
|
||||
is_recoverable/1
|
||||
]).
|
||||
|
||||
-spec error(atom()) -> {error, atom(), atom()}.
|
||||
error(ErrorAtom) ->
|
||||
{error, error_category(ErrorAtom), ErrorAtom}.
|
||||
|
||||
-spec error_code(atom()) -> binary().
|
||||
error_code(voice_connection_not_found) -> <<"VOICE_CONNECTION_NOT_FOUND">>;
|
||||
error_code(voice_channel_not_found) -> <<"VOICE_CHANNEL_NOT_FOUND">>;
|
||||
error_code(voice_channel_not_voice) -> <<"VOICE_INVALID_CHANNEL_TYPE">>;
|
||||
error_code(voice_member_not_found) -> <<"VOICE_MEMBER_NOT_FOUND">>;
|
||||
error_code(voice_user_not_in_voice) -> <<"VOICE_USER_NOT_IN_VOICE">>;
|
||||
error_code(voice_guild_not_found) -> <<"VOICE_GUILD_NOT_FOUND">>;
|
||||
error_code(voice_permission_denied) -> <<"VOICE_PERMISSION_DENIED">>;
|
||||
error_code(voice_member_timed_out) -> <<"VOICE_MEMBER_TIMED_OUT">>;
|
||||
error_code(voice_channel_full) -> <<"VOICE_CHANNEL_FULL">>;
|
||||
error_code(voice_missing_connection_id) -> <<"VOICE_MISSING_CONNECTION_ID">>;
|
||||
error_code(voice_invalid_user_id) -> <<"VOICE_INVALID_USER_ID">>;
|
||||
error_code(voice_invalid_channel_id) -> <<"VOICE_INVALID_CHANNEL_ID">>;
|
||||
error_code(voice_invalid_state) -> <<"VOICE_INVALID_STATE">>;
|
||||
error_code(voice_user_mismatch) -> <<"VOICE_USER_MISMATCH">>;
|
||||
error_code(voice_token_failed) -> <<"VOICE_TOKEN_FAILED">>;
|
||||
error_code(voice_guild_id_missing) -> <<"VOICE_GUILD_ID_MISSING">>;
|
||||
error_code(voice_invalid_guild_id) -> <<"VOICE_INVALID_GUILD_ID">>;
|
||||
error_code(voice_moderator_missing_connect) -> <<"VOICE_PERMISSION_DENIED">>;
|
||||
error_code(dm_channel_not_found) -> <<"DM_CHANNEL_NOT_FOUND">>;
|
||||
error_code(dm_not_recipient) -> <<"DM_NOT_RECIPIENT">>;
|
||||
error_code(dm_invalid_channel_type) -> <<"DM_INVALID_CHANNEL_TYPE">>;
|
||||
error_code(validation_invalid_snowflake) -> <<"VALIDATION_INVALID_SNOWFLAKE">>;
|
||||
error_code(validation_null_snowflake) -> <<"VALIDATION_NULL_SNOWFLAKE">>;
|
||||
error_code(validation_invalid_snowflake_list) -> <<"VALIDATION_INVALID_SNOWFLAKE_LIST">>;
|
||||
error_code(validation_expected_list) -> <<"VALIDATION_EXPECTED_LIST">>;
|
||||
error_code(validation_expected_map) -> <<"VALIDATION_EXPECTED_MAP">>;
|
||||
error_code(validation_missing_field) -> <<"VALIDATION_MISSING_FIELD">>;
|
||||
error_code(validation_invalid_params) -> <<"VALIDATION_INVALID_PARAMS">>;
|
||||
error_code(internal_error) -> <<"INTERNAL_ERROR">>;
|
||||
error_code(timeout) -> <<"TIMEOUT">>;
|
||||
error_code(unknown_error) -> <<"UNKNOWN_ERROR">>;
|
||||
error_code(_) -> <<"UNKNOWN_ERROR">>.
|
||||
|
||||
-spec error_message(atom()) -> binary().
|
||||
error_message(voice_connection_not_found) -> <<"Voice connection not found">>;
|
||||
error_message(voice_channel_not_found) -> <<"Voice channel not found">>;
|
||||
error_message(voice_channel_not_voice) -> <<"Channel is not a voice channel">>;
|
||||
error_message(voice_member_not_found) -> <<"Member not found">>;
|
||||
error_message(voice_user_not_in_voice) -> <<"User is not in a voice channel">>;
|
||||
error_message(voice_guild_not_found) -> <<"Guild not found">>;
|
||||
error_message(voice_permission_denied) -> <<"Missing voice permissions">>;
|
||||
error_message(voice_member_timed_out) -> <<"Voice member is timed out">>;
|
||||
error_message(voice_channel_full) -> <<"Voice channel is full">>;
|
||||
error_message(voice_missing_connection_id) -> <<"Connection ID is required">>;
|
||||
error_message(voice_invalid_user_id) -> <<"Invalid user ID">>;
|
||||
error_message(voice_invalid_channel_id) -> <<"Invalid channel ID">>;
|
||||
error_message(voice_invalid_state) -> <<"Invalid voice state">>;
|
||||
error_message(voice_user_mismatch) -> <<"User does not match connection">>;
|
||||
error_message(voice_token_failed) -> <<"Failed to obtain voice token">>;
|
||||
error_message(voice_guild_id_missing) -> <<"Guild ID is required">>;
|
||||
error_message(voice_invalid_guild_id) -> <<"Invalid guild ID">>;
|
||||
error_message(voice_moderator_missing_connect) -> <<"Moderator missing connect permission">>;
|
||||
error_message(dm_channel_not_found) -> <<"DM channel not found">>;
|
||||
error_message(dm_not_recipient) -> <<"Not a recipient of this channel">>;
|
||||
error_message(dm_invalid_channel_type) -> <<"Not a DM or Group DM channel">>;
|
||||
error_message(validation_invalid_snowflake) -> <<"Invalid snowflake ID format">>;
|
||||
error_message(validation_null_snowflake) -> <<"Snowflake ID cannot be null">>;
|
||||
error_message(validation_invalid_snowflake_list) -> <<"Invalid snowflake ID in list">>;
|
||||
error_message(validation_expected_list) -> <<"Expected a list">>;
|
||||
error_message(validation_expected_map) -> <<"Expected a map">>;
|
||||
error_message(validation_missing_field) -> <<"Missing required field">>;
|
||||
error_message(validation_invalid_params) -> <<"Invalid parameters">>;
|
||||
error_message(internal_error) -> <<"Internal server error">>;
|
||||
error_message(timeout) -> <<"Request timed out">>;
|
||||
error_message(unknown_error) -> <<"An unknown error occurred">>;
|
||||
error_message(_) -> <<"An unknown error occurred">>.
|
||||
|
||||
-spec error_category(atom()) -> atom().
|
||||
error_category(voice_connection_not_found) -> not_found;
|
||||
error_category(voice_channel_not_found) -> not_found;
|
||||
error_category(voice_channel_not_voice) -> validation_error;
|
||||
error_category(voice_member_not_found) -> not_found;
|
||||
error_category(voice_user_not_in_voice) -> not_found;
|
||||
error_category(voice_guild_not_found) -> not_found;
|
||||
error_category(voice_permission_denied) -> permission_denied;
|
||||
error_category(voice_member_timed_out) -> permission_denied;
|
||||
error_category(voice_channel_full) -> permission_denied;
|
||||
error_category(voice_missing_connection_id) -> validation_error;
|
||||
error_category(voice_invalid_user_id) -> validation_error;
|
||||
error_category(voice_invalid_channel_id) -> validation_error;
|
||||
error_category(voice_invalid_state) -> validation_error;
|
||||
error_category(voice_user_mismatch) -> validation_error;
|
||||
error_category(voice_token_failed) -> voice_error;
|
||||
error_category(voice_guild_id_missing) -> validation_error;
|
||||
error_category(voice_invalid_guild_id) -> validation_error;
|
||||
error_category(voice_moderator_missing_connect) -> permission_denied;
|
||||
error_category(dm_channel_not_found) -> not_found;
|
||||
error_category(dm_not_recipient) -> permission_denied;
|
||||
error_category(dm_invalid_channel_type) -> validation_error;
|
||||
error_category(validation_invalid_snowflake) -> validation_error;
|
||||
error_category(validation_null_snowflake) -> validation_error;
|
||||
error_category(validation_invalid_snowflake_list) -> validation_error;
|
||||
error_category(validation_expected_list) -> validation_error;
|
||||
error_category(validation_expected_map) -> validation_error;
|
||||
error_category(validation_missing_field) -> validation_error;
|
||||
error_category(validation_invalid_params) -> validation_error;
|
||||
error_category(internal_error) -> unknown;
|
||||
error_category(timeout) -> timeout;
|
||||
error_category(unknown_error) -> unknown;
|
||||
error_category(_) -> unknown.
|
||||
|
||||
-spec is_recoverable(atom()) -> boolean().
|
||||
is_recoverable(not_found) -> true;
|
||||
is_recoverable(permission_denied) -> true;
|
||||
is_recoverable(voice_error) -> true;
|
||||
is_recoverable(validation_error) -> true;
|
||||
is_recoverable(timeout) -> true;
|
||||
is_recoverable(unknown) -> true;
|
||||
is_recoverable(rate_limited) -> false;
|
||||
is_recoverable(auth_failed) -> false;
|
||||
is_recoverable(_) -> true.
|
||||
786
fluxer_gateway/src/gateway/gateway_handler.erl
Normal file
786
fluxer_gateway/src/gateway/gateway_handler.erl
Normal file
@@ -0,0 +1,786 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(gateway_handler).
|
||||
-behaviour(cowboy_websocket).
|
||||
|
||||
-export([init/2, websocket_init/1, websocket_handle/2, websocket_info/2, terminate/3]).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-endif.
|
||||
|
||||
-record(state, {
|
||||
version,
|
||||
encoding = json :: gateway_codec:encoding(),
|
||||
compress_ctx :: gateway_compress:compress_ctx(),
|
||||
session_pid,
|
||||
heartbeat_state = #{},
|
||||
socket_pid,
|
||||
peer_ip,
|
||||
rate_limit_state = #{events => [], window_start => undefined}
|
||||
}).
|
||||
|
||||
init(Req, _Opts) ->
|
||||
QS = cowboy_req:parse_qs(Req),
|
||||
Version =
|
||||
case proplists:get_value(<<"v">>, QS) of
|
||||
<<"1">> -> 1;
|
||||
_ -> undefined
|
||||
end,
|
||||
Encoding = gateway_codec:parse_encoding(proplists:get_value(<<"encoding">>, QS)),
|
||||
Compression = gateway_compress:parse_compression(proplists:get_value(<<"compress">>, QS)),
|
||||
CompressCtx = gateway_compress:new_context(Compression),
|
||||
|
||||
PeerIPBinary = extract_client_ip(Req),
|
||||
|
||||
{cowboy_websocket, Req, #state{
|
||||
version = Version,
|
||||
encoding = Encoding,
|
||||
compress_ctx = CompressCtx,
|
||||
socket_pid = self(),
|
||||
peer_ip = PeerIPBinary
|
||||
}}.
|
||||
|
||||
websocket_init(State = #state{version = Version}) ->
|
||||
gateway_metrics_collector:inc_connections(),
|
||||
case Version of
|
||||
1 ->
|
||||
CompressionType = gateway_compress:get_type(State#state.compress_ctx),
|
||||
FreshCompressCtx = gateway_compress:new_context(CompressionType),
|
||||
FreshState0 = State#state{compress_ctx = FreshCompressCtx},
|
||||
HeartbeatInterval = constants:heartbeat_interval(),
|
||||
HelloMessage = #{
|
||||
<<"op">> => constants:opcode_to_num(hello),
|
||||
<<"d">> => #{
|
||||
<<"heartbeat_interval">> => HeartbeatInterval
|
||||
}
|
||||
},
|
||||
schedule_heartbeat_check(),
|
||||
NewState = FreshState0#state{
|
||||
heartbeat_state = #{
|
||||
last_ack => erlang:system_time(millisecond),
|
||||
waiting_for_ack => false
|
||||
}
|
||||
},
|
||||
case encode_and_compress(HelloMessage, NewState) of
|
||||
{ok, Frame, NewState2} ->
|
||||
{[Frame], NewState2};
|
||||
{error, {compress_failed, CompressionType, Reason}} ->
|
||||
logger:warning(
|
||||
"[gateway_handler] Failed to compress HELLO frame, type=~p, reason=~p",
|
||||
[CompressionType, Reason]
|
||||
),
|
||||
close_with_reason(decode_error, compression_error_reason(CompressionType), NewState);
|
||||
{error, _Reason} ->
|
||||
close_with_reason(decode_error, <<"Encode failed">>, NewState)
|
||||
end;
|
||||
_ ->
|
||||
close_with_reason(invalid_api_version, <<"Invalid API version">>, State)
|
||||
end.
|
||||
|
||||
websocket_handle({text, Text}, State) ->
|
||||
handle_incoming_data(Text, State);
|
||||
websocket_handle({binary, Binary}, State) ->
|
||||
handle_incoming_data(Binary, State);
|
||||
websocket_handle(_, State) ->
|
||||
{ok, State}.
|
||||
|
||||
handle_incoming_data(Data, State = #state{encoding = Encoding, compress_ctx = CompressCtx}) ->
|
||||
MaxSize = constants:max_payload_size(),
|
||||
case byte_size(Data) =< MaxSize of
|
||||
true ->
|
||||
case gateway_codec:decode(Data, Encoding) of
|
||||
{ok, #{<<"op">> := Op} = Payload} ->
|
||||
logger:debug("handle_incoming_data: received op ~p", [Op]),
|
||||
NewState = State#state{compress_ctx = CompressCtx},
|
||||
OpAtom = constants:gateway_opcode(Op),
|
||||
logger:debug("handle_incoming_data: op ~p converted to atom ~p", [Op, OpAtom]),
|
||||
case check_rate_limit(NewState) of
|
||||
{ok, RateLimitedState} ->
|
||||
handle_gateway_payload(OpAtom, Payload, RateLimitedState);
|
||||
rate_limited ->
|
||||
close_with_reason(rate_limited, <<"Rate limited">>, NewState)
|
||||
end;
|
||||
{ok, _} ->
|
||||
close_with_reason(decode_error, <<"Invalid payload">>, State#state{
|
||||
compress_ctx = CompressCtx
|
||||
});
|
||||
{error, _Reason} ->
|
||||
close_with_reason(decode_error, <<"Decode failed">>, State)
|
||||
end;
|
||||
false ->
|
||||
close_with_reason(decode_error, <<"Payload too large">>, State)
|
||||
end.
|
||||
|
||||
websocket_info({heartbeat_check}, State = #state{heartbeat_state = HeartbeatState}) ->
|
||||
Now = erlang:system_time(millisecond),
|
||||
LastAck = maps:get(last_ack, HeartbeatState, Now),
|
||||
WaitingForAck = maps:get(waiting_for_ack, HeartbeatState, false),
|
||||
|
||||
HeartbeatTimeout = constants:heartbeat_timeout(),
|
||||
HeartbeatInterval = constants:heartbeat_interval(),
|
||||
|
||||
if
|
||||
WaitingForAck andalso (Now - LastAck) > HeartbeatTimeout ->
|
||||
gateway_metrics_collector:inc_heartbeat_failure(),
|
||||
close_with_reason(session_timeout, <<"Heartbeat timeout">>, State);
|
||||
(Now - LastAck) >= (HeartbeatInterval * 0.9) ->
|
||||
Message = #{
|
||||
<<"op">> => constants:opcode_to_num(heartbeat),
|
||||
<<"d">> => null
|
||||
},
|
||||
schedule_heartbeat_check(),
|
||||
NewState = State#state{heartbeat_state = HeartbeatState#{waiting_for_ack => true}},
|
||||
case encode_and_compress(Message, NewState) of
|
||||
{ok, Frame, NewState2} ->
|
||||
{[Frame], NewState2};
|
||||
{error, _} ->
|
||||
{ok, NewState}
|
||||
end;
|
||||
true ->
|
||||
schedule_heartbeat_check(),
|
||||
{ok, State}
|
||||
end;
|
||||
websocket_info({dispatch, Event, Data, Seq}, State) ->
|
||||
logger:debug("websocket_info: dispatch event ~p with seq ~p", [Event, Seq]),
|
||||
EventName =
|
||||
if
|
||||
is_binary(Event) -> Event;
|
||||
is_atom(Event) -> constants:dispatch_event_atom(Event);
|
||||
true -> <<"UNKNOWN">>
|
||||
end,
|
||||
|
||||
DataPreview =
|
||||
case is_map(Data) of
|
||||
true -> maps:with([<<"guild_id">>, <<"chunk_index">>, <<"chunk_count">>, <<"nonce">>], Data);
|
||||
false -> Data
|
||||
end,
|
||||
|
||||
logger:debug(
|
||||
"websocket_info: dispatch data preview: ~p",
|
||||
[DataPreview]
|
||||
),
|
||||
|
||||
Message = #{
|
||||
<<"op">> => constants:opcode_to_num(dispatch),
|
||||
<<"t">> => EventName,
|
||||
<<"d">> => Data,
|
||||
<<"s">> => Seq
|
||||
},
|
||||
case encode_and_compress(Message, State) of
|
||||
{ok, Frame, NewState} ->
|
||||
logger:debug(
|
||||
"websocket_info: dispatch ~p (seq ~p) encoded and sent successfully",
|
||||
[EventName, Seq]
|
||||
),
|
||||
{[Frame], NewState};
|
||||
{error, Reason} ->
|
||||
logger:error("websocket_info: encode_and_compress failed for ~p: ~p", [EventName, Reason]),
|
||||
{ok, State}
|
||||
end;
|
||||
websocket_info({'DOWN', _, process, Pid, _}, State = #state{session_pid = SessionPid}) when
|
||||
Pid =:= SessionPid
|
||||
->
|
||||
Message = #{
|
||||
<<"op">> => constants:opcode_to_num(invalid_session),
|
||||
<<"d">> => false
|
||||
},
|
||||
NewState = State#state{session_pid = undefined},
|
||||
case encode_and_compress(Message, NewState) of
|
||||
{ok, Frame, NewState2} ->
|
||||
{[Frame], NewState2};
|
||||
{error, _} ->
|
||||
{ok, NewState}
|
||||
end;
|
||||
websocket_info(_, State) ->
|
||||
{ok, State}.
|
||||
|
||||
terminate(_Reason, _Req, #state{compress_ctx = CompressCtx}) ->
|
||||
gateway_metrics_collector:inc_disconnections(),
|
||||
gateway_compress:close_context(CompressCtx),
|
||||
ok;
|
||||
terminate(_Reason, _Req, _State) ->
|
||||
gateway_metrics_collector:inc_disconnections(),
|
||||
ok.
|
||||
|
||||
validate_identify_data(Data) ->
|
||||
try
|
||||
Token = maps:get(<<"token">>, Data),
|
||||
Properties = maps:get(<<"properties">>, Data),
|
||||
IgnoredEventsRaw = maps:get(<<"ignored_events">>, Data, []),
|
||||
InitialGuildIdRaw = maps:get(<<"initial_guild_id">>, Data, undefined),
|
||||
|
||||
case is_map(Properties) of
|
||||
true ->
|
||||
Os = maps:get(<<"os">>, Properties),
|
||||
Browser = maps:get(<<"browser">>, Properties),
|
||||
Device = maps:get(<<"device">>, Properties),
|
||||
|
||||
case is_binary(Os) andalso is_binary(Browser) andalso is_binary(Device) of
|
||||
true ->
|
||||
Presence = maps:get(<<"presence">>, Data, null),
|
||||
case parse_ignored_events(IgnoredEventsRaw) of
|
||||
{ok, IgnoredEvents} ->
|
||||
FlagsRaw = maps:get(<<"flags">>, Data, 0),
|
||||
case FlagsRaw of
|
||||
Flags when is_integer(Flags), Flags >= 0 ->
|
||||
{ok, Token, Properties, Presence, IgnoredEvents, Flags, parse_initial_guild_id(InitialGuildIdRaw)};
|
||||
_ ->
|
||||
{error, invalid_properties}
|
||||
end;
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
false ->
|
||||
{error, invalid_properties}
|
||||
end;
|
||||
false ->
|
||||
{error, invalid_properties}
|
||||
end
|
||||
catch
|
||||
error:{badkey, _} ->
|
||||
{error, missing_required_field}
|
||||
end.
|
||||
|
||||
parse_ignored_events(undefined) ->
|
||||
{ok, []};
|
||||
parse_ignored_events(null) ->
|
||||
{ok, []};
|
||||
parse_ignored_events(Events) when is_list(Events) ->
|
||||
case lists:all(fun(E) -> is_binary(E) end, Events) of
|
||||
true ->
|
||||
Normalized = lists:usort([normalize_event_name(E) || E <- Events]),
|
||||
{ok, Normalized};
|
||||
false ->
|
||||
{error, invalid_ignored_events}
|
||||
end;
|
||||
parse_ignored_events(_) ->
|
||||
{error, invalid_ignored_events}.
|
||||
|
||||
parse_initial_guild_id(undefined) ->
|
||||
undefined;
|
||||
parse_initial_guild_id(null) ->
|
||||
undefined;
|
||||
parse_initial_guild_id(Value) when is_binary(Value) ->
|
||||
case validation:validate_snowflake(<<"initial_guild_id">>, Value) of
|
||||
{ok, GuildId} ->
|
||||
GuildId;
|
||||
{error, _, Reason} ->
|
||||
logger:warning(
|
||||
"[gateway_handler] Invalid initial_guild_id ~p: ~p",
|
||||
[Value, Reason]
|
||||
),
|
||||
undefined
|
||||
end;
|
||||
parse_initial_guild_id(_) ->
|
||||
undefined.
|
||||
|
||||
normalize_event_name(Event) ->
|
||||
list_to_binary(string:uppercase(binary_to_list(Event))).
|
||||
|
||||
handle_gateway_payload(
|
||||
heartbeat,
|
||||
#{<<"d">> := Seq},
|
||||
State = #state{heartbeat_state = HeartbeatState, session_pid = SessionPid}
|
||||
) ->
|
||||
AckOk =
|
||||
try
|
||||
case {SessionPid, Seq} of
|
||||
{undefined, _} -> true;
|
||||
{_Pid, null} -> true;
|
||||
{Pid, SeqNum} when is_integer(SeqNum) ->
|
||||
case gen_server:call(Pid, {heartbeat_ack, SeqNum}, 5000) of
|
||||
true -> true;
|
||||
ok -> true;
|
||||
_ -> false
|
||||
end;
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
catch
|
||||
exit:_ -> false
|
||||
end,
|
||||
|
||||
case AckOk of
|
||||
true ->
|
||||
NewHeartbeatState = HeartbeatState#{
|
||||
last_ack => erlang:system_time(millisecond),
|
||||
waiting_for_ack => false
|
||||
},
|
||||
gateway_metrics_collector:inc_heartbeat_success(),
|
||||
AckMessage = #{<<"op">> => constants:opcode_to_num(heartbeat_ack)},
|
||||
NewState = State#state{heartbeat_state = NewHeartbeatState},
|
||||
case encode_and_compress(AckMessage, NewState) of
|
||||
{ok, Frame, NewState2} ->
|
||||
{[Frame], NewState2};
|
||||
{error, _} ->
|
||||
{ok, NewState}
|
||||
end;
|
||||
false ->
|
||||
gateway_metrics_collector:inc_heartbeat_failure(),
|
||||
close_with_reason(invalid_seq, <<"Invalid sequence">>, State)
|
||||
end;
|
||||
handle_gateway_payload(
|
||||
identify,
|
||||
#{<<"d">> := Data},
|
||||
State = #state{session_pid = undefined, peer_ip = PeerIP}
|
||||
) ->
|
||||
case validate_identify_data(Data) of
|
||||
{ok, Token, Properties, Presence, IgnoredEvents, Flags, InitialGuildId} ->
|
||||
SessionId = utils:generate_session_id(),
|
||||
SocketPid = self(),
|
||||
IdentifyData0 = #{
|
||||
token => Token,
|
||||
properties => Properties,
|
||||
presence => Presence,
|
||||
ignored_events => IgnoredEvents,
|
||||
flags => Flags
|
||||
},
|
||||
IdentifyData =
|
||||
case InitialGuildId of
|
||||
undefined -> IdentifyData0;
|
||||
Id -> maps:put(initial_guild_id, Id, IdentifyData0)
|
||||
end,
|
||||
Request = #{
|
||||
session_id => SessionId,
|
||||
peer_ip => PeerIP,
|
||||
identify_data => IdentifyData,
|
||||
version => State#state.version
|
||||
},
|
||||
|
||||
case gen_server:call(session_manager, {start, Request, SocketPid}, 10000) of
|
||||
{success, Pid} when is_pid(Pid) ->
|
||||
monitor(process, Pid),
|
||||
{ok, State#state{session_pid = Pid}};
|
||||
{error, invalid_token} ->
|
||||
close_with_reason(authentication_failed, <<"Invalid token">>, State);
|
||||
{error, rate_limited} ->
|
||||
close_with_reason(rate_limited, <<"Rate limited">>, State);
|
||||
{error, identify_rate_limited} ->
|
||||
gateway_metrics_collector:inc_identify_rate_limited(),
|
||||
Message = #{
|
||||
<<"op">> => constants:opcode_to_num(invalid_session),
|
||||
<<"d">> => false
|
||||
},
|
||||
case encode_and_compress(Message, State) of
|
||||
{ok, Frame, NewState} ->
|
||||
{[Frame], NewState};
|
||||
{error, _} ->
|
||||
{ok, State}
|
||||
end;
|
||||
_ ->
|
||||
close_with_reason(unknown_error, <<"Failed to start session">>, State)
|
||||
end;
|
||||
{error, _Reason} ->
|
||||
close_with_reason(decode_error, <<"Invalid identify payload">>, State)
|
||||
end;
|
||||
handle_gateway_payload(identify, _, State = #state{session_pid = _}) ->
|
||||
close_with_reason(already_authenticated, <<"Already authenticated">>, State);
|
||||
handle_gateway_payload(
|
||||
presence_update, #{<<"d">> := _Data}, State = #state{session_pid = undefined}
|
||||
) ->
|
||||
close_with_reason(not_authenticated, <<"Not authenticated">>, State);
|
||||
handle_gateway_payload(presence_update, #{<<"d">> := Data}, State = #state{session_pid = Pid}) when
|
||||
is_pid(Pid)
|
||||
->
|
||||
Status = utils:parse_status(maps:get(<<"status">>, Data)),
|
||||
AdjustedStatus =
|
||||
case Status of
|
||||
offline -> invisible;
|
||||
Other -> Other
|
||||
end,
|
||||
Afk = maps:get(<<"afk">>, Data, false),
|
||||
Mobile = maps:get(<<"mobile">>, Data, false),
|
||||
|
||||
gen_server:cast(
|
||||
Pid, {presence_update, #{status => AdjustedStatus, afk => Afk, mobile => Mobile}}
|
||||
),
|
||||
{ok, State};
|
||||
handle_gateway_payload(resume, #{<<"d">> := Data}, State) ->
|
||||
Token = maps:get(<<"token">>, Data),
|
||||
SessionId = maps:get(<<"session_id">>, Data),
|
||||
Seq = maps:get(<<"seq">>, Data),
|
||||
|
||||
case gen_server:call(session_manager, {lookup, SessionId}, 5000) of
|
||||
{ok, Pid} when is_pid(Pid) ->
|
||||
handle_resume_with_session(Pid, Token, SessionId, Seq, State);
|
||||
{error, not_found} ->
|
||||
handle_resume_session_not_found(SessionId, State)
|
||||
end;
|
||||
handle_gateway_payload(
|
||||
voice_state_update, #{<<"d">> := _Data}, State = #state{session_pid = undefined}
|
||||
) ->
|
||||
close_with_reason(not_authenticated, <<"Not authenticated">>, State);
|
||||
handle_gateway_payload(
|
||||
voice_state_update, #{<<"d">> := Data}, State = #state{session_pid = Pid}
|
||||
) when
|
||||
is_pid(Pid)
|
||||
->
|
||||
logger:debug("[gateway_handler] Processing voice state update: ~p", [Data]),
|
||||
try gen_server:call(Pid, {voice_state_update, Data}, 15000) of
|
||||
ok ->
|
||||
logger:debug("[gateway_handler] Voice state update succeeded"),
|
||||
{ok, State};
|
||||
{error, Category, ErrorAtom} when is_atom(ErrorAtom) ->
|
||||
logger:warning("[gateway_handler] Voice state update failed: Category=~p, Error=~p", [
|
||||
Category, ErrorAtom
|
||||
]),
|
||||
send_gateway_error(ErrorAtom, State);
|
||||
UnexpectedResponse ->
|
||||
logger:error(
|
||||
"[gateway_handler] Voice state update returned unexpected response: ~p, Data: ~p", [
|
||||
UnexpectedResponse, Data
|
||||
]
|
||||
),
|
||||
send_gateway_error(internal_error, State)
|
||||
catch
|
||||
exit:{timeout, _} ->
|
||||
logger:error("[gateway_handler] Voice state update timed out (>15s) for Data: ~p", [
|
||||
Data
|
||||
]),
|
||||
send_gateway_error(timeout, State);
|
||||
Class:ExReason:Stacktrace ->
|
||||
logger:error(
|
||||
"[gateway_handler] Voice state update crashed: ~p:~p~nStacktrace: ~p~nData: ~p", [
|
||||
Class, ExReason, Stacktrace, Data
|
||||
]
|
||||
),
|
||||
send_gateway_error(internal_error, State)
|
||||
end;
|
||||
handle_gateway_payload(call_connect, #{<<"d">> := _Data}, State = #state{session_pid = undefined}) ->
|
||||
close_with_reason(not_authenticated, <<"Not authenticated">>, State);
|
||||
handle_gateway_payload(call_connect, #{<<"d">> := Data}, State = #state{session_pid = Pid}) when
|
||||
is_pid(Pid)
|
||||
->
|
||||
ChannelId = maps:get(<<"channel_id">>, Data),
|
||||
|
||||
gen_server:cast(Pid, {call_connect, ChannelId}),
|
||||
{ok, State};
|
||||
handle_gateway_payload(
|
||||
request_guild_members, #{<<"d">> := _Data}, State = #state{session_pid = undefined}
|
||||
) ->
|
||||
close_with_reason(not_authenticated, <<"Not authenticated">>, State);
|
||||
handle_gateway_payload(
|
||||
request_guild_members, #{<<"d">> := Data}, State = #state{session_pid = Pid}
|
||||
) when
|
||||
is_pid(Pid)
|
||||
->
|
||||
SocketPid = self(),
|
||||
spawn(fun() ->
|
||||
try
|
||||
case gen_server:call(Pid, {get_state}, 5000) of
|
||||
SessionState when is_map(SessionState) ->
|
||||
case guild_request_members:handle_request(Data, SocketPid, SessionState) of
|
||||
ok ->
|
||||
logger:debug("[gateway_handler] Guild members request completed successfully");
|
||||
{error, ErrorReason} ->
|
||||
logger:warning(
|
||||
"[gateway_handler] Guild members request failed: ~p",
|
||||
[ErrorReason]
|
||||
)
|
||||
end;
|
||||
Other ->
|
||||
logger:warning(
|
||||
"[gateway_handler] Failed to get session state for guild members request: ~p",
|
||||
[Other]
|
||||
)
|
||||
end
|
||||
catch
|
||||
Class:ExceptionReason:Stacktrace ->
|
||||
logger:error(
|
||||
"[gateway_handler] Guild members request crashed: ~p:~p~nStacktrace: ~p",
|
||||
[Class, ExceptionReason, Stacktrace]
|
||||
)
|
||||
end
|
||||
end),
|
||||
{ok, State};
|
||||
handle_gateway_payload(
|
||||
lazy_request, #{<<"d">> := _Data}, State = #state{session_pid = undefined}
|
||||
) ->
|
||||
close_with_reason(not_authenticated, <<"Not authenticated">>, State);
|
||||
handle_gateway_payload(
|
||||
lazy_request, #{<<"d">> := Data}, State = #state{session_pid = Pid}
|
||||
) when
|
||||
is_pid(Pid)
|
||||
->
|
||||
logger:debug("lazy_request received with data: ~p", [Data]),
|
||||
SocketPid = self(),
|
||||
spawn(fun() ->
|
||||
try
|
||||
logger:debug("lazy_request: fetching session state"),
|
||||
case gen_server:call(Pid, {get_state}, 5000) of
|
||||
SessionState when is_map(SessionState) ->
|
||||
logger:debug("lazy_request: session state retrieved, calling unified subscriptions handler"),
|
||||
guild_unified_subscriptions:handle_subscriptions(Data, SocketPid, SessionState);
|
||||
Other ->
|
||||
logger:warning("lazy_request: unexpected session state: ~p", [Other])
|
||||
end
|
||||
catch
|
||||
Class:Reason:StackTrace ->
|
||||
logger:error("lazy_request: exception ~p:~p", [Class, Reason], #{stacktrace => StackTrace})
|
||||
end
|
||||
end),
|
||||
{ok, State};
|
||||
handle_gateway_payload(_, _, State) ->
|
||||
close_with_reason(unknown_opcode, <<"Unknown opcode">>, State).
|
||||
|
||||
schedule_heartbeat_check() ->
|
||||
erlang:send_after(constants:heartbeat_interval() div 3, self(), {heartbeat_check}).
|
||||
|
||||
check_rate_limit(State = #state{rate_limit_state = RateLimitState}) ->
|
||||
Now = erlang:system_time(millisecond),
|
||||
Events = maps:get(events, RateLimitState, []),
|
||||
WindowStart = maps:get(window_start, RateLimitState, Now),
|
||||
|
||||
WindowDuration = 60000,
|
||||
MaxEvents = 120,
|
||||
|
||||
EventsInWindow = [T || T <- Events, (Now - T) < WindowDuration],
|
||||
EventsCount = length(EventsInWindow),
|
||||
|
||||
case EventsCount >= MaxEvents of
|
||||
true ->
|
||||
rate_limited;
|
||||
false ->
|
||||
NewEvents = [Now | EventsInWindow],
|
||||
NewRateLimitState = #{
|
||||
events => NewEvents,
|
||||
window_start => WindowStart
|
||||
},
|
||||
{ok, State#state{rate_limit_state = NewRateLimitState}}
|
||||
end.
|
||||
|
||||
extract_client_ip(Req) ->
|
||||
case cowboy_req:header(<<"x-forwarded-for">>, Req) of
|
||||
undefined ->
|
||||
{PeerIP, _Port} = cowboy_req:peer(Req),
|
||||
list_to_binary(inet:ntoa(PeerIP));
|
||||
ForwardedFor ->
|
||||
case parse_forwarded_for(ForwardedFor) of
|
||||
<<>> ->
|
||||
{PeerIP, _Port} = cowboy_req:peer(Req),
|
||||
list_to_binary(inet:ntoa(PeerIP));
|
||||
IP ->
|
||||
IP
|
||||
end
|
||||
end.
|
||||
|
||||
parse_forwarded_for(HeaderValue) ->
|
||||
case binary:split(HeaderValue, <<",">>) of
|
||||
[First | _] ->
|
||||
case normalize_forwarded_ip(First) of
|
||||
{ok, IP} -> IP;
|
||||
error -> <<>>
|
||||
end;
|
||||
[] ->
|
||||
<<>>
|
||||
end.
|
||||
|
||||
normalize_forwarded_ip(Value) ->
|
||||
Trimmed = string:trim(Value),
|
||||
case Trimmed of
|
||||
<<>> ->
|
||||
error;
|
||||
_ ->
|
||||
case Trimmed of
|
||||
<<"[", _/binary>> ->
|
||||
case strip_ipv6_brackets(Trimmed) of
|
||||
{ok, IPv6} ->
|
||||
validate_ip(IPv6);
|
||||
error ->
|
||||
error
|
||||
end;
|
||||
_ ->
|
||||
Cleaned = strip_ipv4_port(Trimmed),
|
||||
validate_ip(Cleaned)
|
||||
end
|
||||
end.
|
||||
|
||||
strip_ipv6_brackets(<<"[", Rest/binary>>) ->
|
||||
case binary:match(Rest, <<"]">>) of
|
||||
{Pos, _Len} when Pos > 0 ->
|
||||
{ok, binary:part(Rest, 0, Pos)};
|
||||
_ ->
|
||||
error
|
||||
end;
|
||||
strip_ipv6_brackets(_) ->
|
||||
error.
|
||||
|
||||
strip_ipv4_port(IP) ->
|
||||
case binary:match(IP, <<".">>) of
|
||||
nomatch ->
|
||||
IP;
|
||||
_ ->
|
||||
case binary:split(IP, <<":">>, [global]) of
|
||||
[Addr, _Port] ->
|
||||
Addr;
|
||||
_ ->
|
||||
IP
|
||||
end
|
||||
end.
|
||||
|
||||
validate_ip(IP) ->
|
||||
case inet:parse_address(binary_to_list(IP)) of
|
||||
{ok, Parsed} ->
|
||||
{ok, list_to_binary(inet:ntoa(Parsed))};
|
||||
{error, _Reason} ->
|
||||
error
|
||||
end.
|
||||
|
||||
handle_resume_with_session(Pid, Token, SessionId, Seq, State) ->
|
||||
case gen_server:call(Pid, {token_verify, Token}, 5000) of
|
||||
true ->
|
||||
handle_resume_with_verified_token(Pid, SessionId, Seq, State);
|
||||
false ->
|
||||
handle_resume_invalid_token(SessionId, State)
|
||||
end.
|
||||
|
||||
handle_resume_with_verified_token(Pid, SessionId, Seq, State) ->
|
||||
SocketPid = self(),
|
||||
case gen_server:call(Pid, {resume, Seq, SocketPid}, 5000) of
|
||||
{ok, MissedEvents} when is_list(MissedEvents) ->
|
||||
handle_resume_success(Pid, SessionId, Seq, MissedEvents, State);
|
||||
invalid_seq ->
|
||||
handle_resume_invalid_seq(Seq, State)
|
||||
end.
|
||||
|
||||
handle_resume_success(Pid, _SessionId, Seq, MissedEvents, State) ->
|
||||
gateway_metrics_collector:inc_resume_success(),
|
||||
SocketPid = self(),
|
||||
monitor(process, Pid),
|
||||
|
||||
lists:foreach(
|
||||
fun(Event) when is_map(Event) ->
|
||||
SocketPid !
|
||||
{dispatch, maps:get(event, Event), maps:get(data, Event), maps:get(seq, Event)}
|
||||
end,
|
||||
MissedEvents
|
||||
),
|
||||
|
||||
SocketPid ! {dispatch, resumed, null, Seq},
|
||||
|
||||
{ok, State#state{
|
||||
session_pid = Pid,
|
||||
heartbeat_state = #{
|
||||
last_ack => erlang:system_time(millisecond),
|
||||
waiting_for_ack => false
|
||||
}
|
||||
}}.
|
||||
|
||||
handle_resume_invalid_seq(_Seq, State) ->
|
||||
gateway_metrics_collector:inc_resume_failure(),
|
||||
close_with_reason(invalid_seq, <<"Invalid sequence">>, State).
|
||||
|
||||
handle_resume_invalid_token(_SessionId, State) ->
|
||||
gateway_metrics_collector:inc_resume_failure(),
|
||||
close_with_reason(authentication_failed, <<"Invalid token">>, State).
|
||||
|
||||
handle_resume_session_not_found(_SessionId, State) ->
|
||||
gateway_metrics_collector:inc_resume_failure(),
|
||||
Message = #{
|
||||
<<"op">> => constants:opcode_to_num(invalid_session),
|
||||
<<"d">> => false
|
||||
},
|
||||
case encode_and_compress(Message, State) of
|
||||
{ok, Frame, NewState} ->
|
||||
{[Frame], NewState};
|
||||
{error, _} ->
|
||||
{ok, State}
|
||||
end.
|
||||
|
||||
send_gateway_error(ErrorAtom, State) when is_atom(ErrorAtom) ->
|
||||
ErrorCode = gateway_errors:error_code(ErrorAtom),
|
||||
ErrorMessage = gateway_errors:error_message(ErrorAtom),
|
||||
Message = #{
|
||||
<<"op">> => constants:opcode_to_num(gateway_error),
|
||||
<<"d">> => #{
|
||||
<<"code">> => ErrorCode,
|
||||
<<"message">> => ErrorMessage
|
||||
}
|
||||
},
|
||||
case encode_and_compress(Message, State) of
|
||||
{ok, Frame, NewState} ->
|
||||
{[Frame], NewState};
|
||||
{error, _} ->
|
||||
{ok, State}
|
||||
end.
|
||||
|
||||
encode_and_compress(Message, State = #state{encoding = Encoding, compress_ctx = CompressCtx}) ->
|
||||
case gateway_codec:encode(Message, Encoding) of
|
||||
{ok, Encoded, FrameType} ->
|
||||
case gateway_compress:compress(Encoded, CompressCtx) of
|
||||
{ok, Compressed, NewCompressCtx} ->
|
||||
Frame = make_frame(Compressed, FrameType, NewCompressCtx),
|
||||
{ok, Frame, State#state{compress_ctx = NewCompressCtx}};
|
||||
{error, Reason} ->
|
||||
{error, {compress_failed, gateway_compress:get_type(CompressCtx), Reason}}
|
||||
end;
|
||||
{error, Reason} ->
|
||||
{error, {encode_failed, Reason}}
|
||||
end.
|
||||
|
||||
compression_error_reason(zstd_stream) ->
|
||||
<<"Compression failed: zstd-stream">>;
|
||||
compression_error_reason(_) ->
|
||||
<<"Encode failed">>.
|
||||
|
||||
close_with_reason(Reason, Message, State) ->
|
||||
gateway_metrics_collector:inc_websocket_close(Reason),
|
||||
CloseCode = constants:close_code_to_num(Reason),
|
||||
{[{close, CloseCode, Message}], State}.
|
||||
|
||||
make_frame(Data, FrameType, CompressCtx) ->
|
||||
case gateway_compress:get_type(CompressCtx) of
|
||||
none -> {FrameType, Data};
|
||||
_ -> {binary, Data}
|
||||
end.
|
||||
|
||||
-ifdef(TEST).
|
||||
|
||||
parse_forwarded_for_ipv4_test() ->
|
||||
?assertEqual(<<"203.0.113.7">>, parse_forwarded_for(<<"203.0.113.7">>)).
|
||||
|
||||
parse_forwarded_for_ipv4_with_port_test() ->
|
||||
?assertEqual(<<"203.0.113.7">>, parse_forwarded_for(<<"203.0.113.7:8080">>)).
|
||||
|
||||
parse_forwarded_for_ipv4_with_port_and_extra_entries_test() ->
|
||||
Header = <<" 203.0.113.7:8080 , 10.0.0.1">>,
|
||||
?assertEqual(<<"203.0.113.7">>, parse_forwarded_for(Header)).
|
||||
|
||||
parse_forwarded_for_ipv6_test() ->
|
||||
?assertEqual(<<"2001:db8::1">>, parse_forwarded_for(<<"2001:db8::1">>)).
|
||||
|
||||
parse_forwarded_for_ipv6_with_brackets_test() ->
|
||||
?assertEqual(<<"2001:db8::1">>, parse_forwarded_for(<<"[2001:db8::1]">>)).
|
||||
|
||||
parse_forwarded_for_ipv6_with_brackets_and_port_test() ->
|
||||
?assertEqual(<<"2001:db8::1">>, parse_forwarded_for(<<"[2001:db8::1]:443">>)).
|
||||
|
||||
parse_forwarded_for_ipv6_with_spaces_test() ->
|
||||
?assertEqual(<<"2001:db8::1">>, parse_forwarded_for(<<" [2001:db8::1] ">>)).
|
||||
|
||||
parse_forwarded_for_invalid_ip_test() ->
|
||||
?assertEqual(<<>>, parse_forwarded_for(<<"not_an_ip">>)).
|
||||
|
||||
parse_forwarded_for_invalid_ipv4_octet_test() ->
|
||||
?assertEqual(<<>>, parse_forwarded_for(<<"203.0.113.300">>)).
|
||||
|
||||
parse_forwarded_for_unterminated_bracket_test() ->
|
||||
?assertEqual(<<>>, parse_forwarded_for(<<"[2001:db8::1">>)).
|
||||
|
||||
-endif.
|
||||
215
fluxer_gateway/src/gateway/gateway_rpc_call.erl
Normal file
215
fluxer_gateway/src/gateway/gateway_rpc_call.erl
Normal file
@@ -0,0 +1,215 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(gateway_rpc_call).
|
||||
|
||||
-export([execute_method/2]).
|
||||
|
||||
execute_method(<<"call.get">>, #{<<"channel_id">> := ChannelIdBin}) ->
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {get_state}, 5000) of
|
||||
{ok, CallData} ->
|
||||
CallData;
|
||||
_ ->
|
||||
throw({error, <<"Failed to get call state">>})
|
||||
end;
|
||||
{error, not_found} ->
|
||||
null;
|
||||
not_found ->
|
||||
null
|
||||
end;
|
||||
execute_method(<<"call.create">>, Params) ->
|
||||
#{
|
||||
<<"channel_id">> := ChannelIdBin,
|
||||
<<"message_id">> := MessageIdBin,
|
||||
<<"region">> := Region,
|
||||
<<"ringing">> := RingingBins,
|
||||
<<"recipients">> := RecipientsBins
|
||||
} = Params,
|
||||
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
MessageId = validation:snowflake_or_throw(<<"message_id">>, MessageIdBin),
|
||||
Ringing = validation:snowflake_list_or_throw(<<"ringing">>, RingingBins),
|
||||
Recipients = validation:snowflake_list_or_throw(<<"recipients">>, RecipientsBins),
|
||||
|
||||
CallData = #{
|
||||
channel_id => ChannelId,
|
||||
message_id => MessageId,
|
||||
region => Region,
|
||||
ringing => Ringing,
|
||||
recipients => Recipients
|
||||
},
|
||||
|
||||
case gen_server:call(call_manager, {create, ChannelId, CallData}, 10000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {get_state}, 5000) of
|
||||
{ok, CallState} ->
|
||||
CallState;
|
||||
_ ->
|
||||
throw({error, <<"Failed to get call state after creation">>})
|
||||
end;
|
||||
{error, already_exists} ->
|
||||
throw({error, <<"Call already exists">>});
|
||||
{error, Reason} ->
|
||||
throw({error, iolist_to_binary(io_lib:format("Failed to create call: ~p", [Reason]))})
|
||||
end;
|
||||
execute_method(<<"call.update_region">>, #{
|
||||
<<"channel_id">> := ChannelIdBin, <<"region">> := Region
|
||||
}) ->
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {update_region, Region}, 5000) of
|
||||
ok ->
|
||||
true;
|
||||
_ ->
|
||||
throw({error, <<"Failed to update region">>})
|
||||
end;
|
||||
not_found ->
|
||||
throw({error, <<"Call not found">>})
|
||||
end;
|
||||
execute_method(<<"call.ring">>, Params) ->
|
||||
#{<<"channel_id">> := ChannelIdBin, <<"recipients">> := RecipientsBin} = Params,
|
||||
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
Recipients = validation:snowflake_list_or_throw(<<"recipients">>, RecipientsBin),
|
||||
|
||||
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {ring_recipients, Recipients}, 5000) of
|
||||
ok ->
|
||||
true;
|
||||
_ ->
|
||||
throw({error, <<"Failed to ring recipients">>})
|
||||
end;
|
||||
not_found ->
|
||||
throw({error, <<"Call not found">>})
|
||||
end;
|
||||
execute_method(<<"call.stop_ringing">>, Params) ->
|
||||
#{<<"channel_id">> := ChannelIdBin, <<"recipients">> := RecipientsBin} = Params,
|
||||
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
Recipients = validation:snowflake_list_or_throw(<<"recipients">>, RecipientsBin),
|
||||
|
||||
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {stop_ringing, Recipients}, 5000) of
|
||||
ok ->
|
||||
true;
|
||||
_ ->
|
||||
throw({error, <<"Failed to stop ringing">>})
|
||||
end;
|
||||
not_found ->
|
||||
throw({error, <<"Call not found">>})
|
||||
end;
|
||||
execute_method(<<"call.join">>, Params) ->
|
||||
#{
|
||||
<<"channel_id">> := ChannelIdBin,
|
||||
<<"user_id">> := UserIdBin,
|
||||
<<"session_id">> := SessionIdBin,
|
||||
<<"voice_state">> := VoiceState
|
||||
} = Params,
|
||||
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
SessionId = SessionIdBin,
|
||||
|
||||
case gen_server:call(session_manager, {lookup, SessionId}, 5000) of
|
||||
{ok, SessionPid} ->
|
||||
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
|
||||
{ok, CallPid} ->
|
||||
case
|
||||
gen_server:call(
|
||||
CallPid, {join, UserId, VoiceState, SessionId, SessionPid}, 5000
|
||||
)
|
||||
of
|
||||
ok ->
|
||||
true;
|
||||
_ ->
|
||||
throw({error, <<"Failed to join call">>})
|
||||
end;
|
||||
not_found ->
|
||||
throw({error, <<"Call not found">>})
|
||||
end;
|
||||
not_found ->
|
||||
throw({error, <<"Session not found">>})
|
||||
end;
|
||||
execute_method(<<"call.leave">>, #{<<"channel_id">> := ChannelIdBin, <<"session_id">> := SessionId}) ->
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
|
||||
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {leave, SessionId}, 5000) of
|
||||
ok ->
|
||||
true;
|
||||
_ ->
|
||||
throw({error, <<"Failed to leave call">>})
|
||||
end;
|
||||
not_found ->
|
||||
throw({error, <<"Call not found">>})
|
||||
end;
|
||||
execute_method(<<"call.delete">>, #{<<"channel_id">> := ChannelIdBin}) ->
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
case gen_server:call(call_manager, {terminate_call, ChannelId}, 5000) of
|
||||
ok ->
|
||||
true;
|
||||
{error, not_found} ->
|
||||
throw({error, <<"Call not found">>});
|
||||
_ ->
|
||||
throw({error, <<"Failed to delete call">>})
|
||||
end;
|
||||
execute_method(<<"call.confirm_connection">>, Params) ->
|
||||
#{<<"channel_id">> := ChannelIdBin, <<"connection_id">> := ConnectionId} = Params,
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
logger:debug(
|
||||
"[gateway_rpc_call] call.confirm_connection channel_id=~p connection_id=~p",
|
||||
[ChannelId, ConnectionId]
|
||||
),
|
||||
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
|
||||
{ok, Pid} ->
|
||||
gen_server:call(Pid, {confirm_connection, ConnectionId}, 5000);
|
||||
{error, not_found} ->
|
||||
logger:debug(
|
||||
"[gateway_rpc_call] call.confirm_connection call not found for channel_id=~p", [
|
||||
ChannelId
|
||||
]
|
||||
),
|
||||
#{success => true, call_not_found => true};
|
||||
not_found ->
|
||||
logger:debug(
|
||||
"[gateway_rpc_call] call.confirm_connection call manager returned not_found for channel_id=~p",
|
||||
[ChannelId]
|
||||
),
|
||||
#{success => true, call_not_found => true}
|
||||
end;
|
||||
execute_method(<<"call.disconnect_user_if_in_channel">>, Params) ->
|
||||
#{<<"channel_id">> := ChannelIdBin, <<"user_id">> := UserIdBin} = Params,
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
ConnectionId = maps:get(<<"connection_id">>, Params, undefined),
|
||||
case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of
|
||||
{ok, Pid} ->
|
||||
gen_server:call(
|
||||
Pid, {disconnect_user_if_in_channel, UserId, ChannelId, ConnectionId}, 5000
|
||||
);
|
||||
{error, not_found} ->
|
||||
#{success => true, call_not_found => true};
|
||||
not_found ->
|
||||
#{success => true, call_not_found => true}
|
||||
end.
|
||||
785
fluxer_gateway/src/gateway/gateway_rpc_guild.erl
Normal file
785
fluxer_gateway/src/gateway/gateway_rpc_guild.erl
Normal file
@@ -0,0 +1,785 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(gateway_rpc_guild).
|
||||
|
||||
-export([execute_method/2]).
|
||||
|
||||
execute_method(<<"guild.dispatch">>, #{
|
||||
<<"guild_id">> := GuildIdBin, <<"event">> := Event, <<"data">> := Data
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
EventAtom = constants:dispatch_event_atom(Event),
|
||||
case gen_server:call(Pid, {dispatch, #{event => EventAtom, data => Data}}, 10000) of
|
||||
ok -> true;
|
||||
_ -> throw({error, <<"Dispatch failed">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.get_counts">>, #{<<"guild_id">> := GuildIdBin}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {get_counts}, 10000) of
|
||||
#{member_count := MemberCount, presence_count := PresenceCount} ->
|
||||
#{
|
||||
<<"member_count">> => MemberCount,
|
||||
<<"presence_count">> => PresenceCount
|
||||
};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get counts">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.get_data">>, #{<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case validation:validate_optional_snowflake(UserIdBin) of
|
||||
{ok, UserId} ->
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{user_id => UserId},
|
||||
case gen_server:call(Pid, {get_guild_data, Request}, 10000) of
|
||||
#{guild_data := null, error_reason := <<"forbidden">>} ->
|
||||
throw({error, <<"forbidden">>});
|
||||
#{guild_data := null} ->
|
||||
throw({error, <<"Guild data not available for user">>});
|
||||
#{guild_data := GuildData} ->
|
||||
GuildData;
|
||||
_ ->
|
||||
throw({error, <<"Failed to get guild data">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"guild_not_found">>})
|
||||
end
|
||||
end;
|
||||
execute_method(<<"guild.get_member">>, #{<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{user_id => UserId},
|
||||
case gen_server:call(Pid, {get_guild_member, Request}, 10000) of
|
||||
#{success := true, member_data := MemberData} ->
|
||||
#{
|
||||
<<"success">> => true,
|
||||
<<"member_data">> => MemberData
|
||||
};
|
||||
#{success := false} ->
|
||||
#{<<"success">> => false};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get guild member">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.has_member">>, #{<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{user_id => UserId},
|
||||
case gen_server:call(Pid, {has_member, Request}, 10000) of
|
||||
#{has_member := HasMember} when is_boolean(HasMember) ->
|
||||
#{<<"has_member">> => HasMember};
|
||||
_ ->
|
||||
throw({error, <<"Failed to determine membership">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.list_members">>, #{
|
||||
<<"guild_id">> := GuildIdBin, <<"limit">> := Limit, <<"offset">> := Offset
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{limit => Limit, offset => Offset},
|
||||
case gen_server:call(Pid, {list_guild_members, Request}, 10000) of
|
||||
#{members := Members, total := Total} ->
|
||||
#{
|
||||
<<"members">> => Members,
|
||||
<<"total">> => Total
|
||||
};
|
||||
_ ->
|
||||
throw({error, <<"Failed to list guild members">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.start">>, #{<<"guild_id">> := GuildIdBin}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, _Pid} ->
|
||||
true;
|
||||
_ ->
|
||||
throw({error, <<"Failed to start guild">>})
|
||||
end;
|
||||
execute_method(<<"guild.stop">>, #{<<"guild_id">> := GuildIdBin}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(guild_manager, {stop_guild, GuildId}, 10000) of
|
||||
ok ->
|
||||
true;
|
||||
_ ->
|
||||
throw({error, <<"Failed to stop guild">>})
|
||||
end;
|
||||
execute_method(<<"guild.reload">>, #{<<"guild_id">> := GuildIdBin}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(guild_manager, {reload_guild, GuildId}, 10000) of
|
||||
ok ->
|
||||
true;
|
||||
{error, not_found} ->
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 20000) of
|
||||
{ok, _Pid} -> true;
|
||||
_ -> throw({error, <<"Failed to reload guild">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Failed to reload guild">>})
|
||||
end;
|
||||
execute_method(<<"guild.reload_all">>, #{<<"guild_ids">> := GuildIdsBin}) ->
|
||||
GuildIds = validation:snowflake_list_or_throw(<<"guild_ids">>, GuildIdsBin),
|
||||
case gen_server:call(guild_manager, {reload_all_guilds, GuildIds}, 60000) of
|
||||
#{count := Count} ->
|
||||
#{<<"count">> => Count};
|
||||
_ ->
|
||||
throw({error, <<"Failed to reload guilds">>})
|
||||
end;
|
||||
execute_method(<<"guild.shutdown">>, #{<<"guild_id">> := GuildIdBin}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(guild_manager, {shutdown_guild, GuildId}, 10000) of
|
||||
ok ->
|
||||
true;
|
||||
{error, timeout} ->
|
||||
case gen_server:call(guild_manager, {stop_guild, GuildId}, 10000) of
|
||||
ok -> true;
|
||||
_ -> throw({error, <<"Failed to shutdown guild">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Failed to shutdown guild">>})
|
||||
end;
|
||||
execute_method(<<"guild.get_user_permissions">>, #{
|
||||
<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin, <<"channel_id">> := ChannelIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
ChannelId =
|
||||
case ChannelIdBin of
|
||||
<<"0">> ->
|
||||
undefined;
|
||||
_ ->
|
||||
validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin)
|
||||
end,
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{user_id => UserId, channel_id => ChannelId},
|
||||
case gen_server:call(Pid, {get_user_permissions, Request}, 10000) of
|
||||
#{permissions := Permissions} ->
|
||||
#{<<"permissions">> => integer_to_binary(Permissions)};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get permissions">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.check_permission">>, #{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"user_id">> := UserIdBin,
|
||||
<<"permission">> := PermissionBin,
|
||||
<<"channel_id">> := ChannelIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
Permission = validation:snowflake_or_throw(<<"permission">>, PermissionBin),
|
||||
ChannelId =
|
||||
case ChannelIdBin of
|
||||
<<"0">> ->
|
||||
undefined;
|
||||
_ ->
|
||||
validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin)
|
||||
end,
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{
|
||||
user_id => UserId,
|
||||
permission => Permission,
|
||||
channel_id => ChannelId
|
||||
},
|
||||
case gen_server:call(Pid, {check_permission, Request}, 10000) of
|
||||
#{has_permission := HasPermission} ->
|
||||
#{<<"has_permission">> => HasPermission};
|
||||
_ ->
|
||||
throw({error, <<"Failed to check permission">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.can_manage_roles">>, #{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"user_id">> := UserIdBin,
|
||||
<<"target_user_id">> := TargetUserIdBin,
|
||||
<<"role_id">> := RoleIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
TargetUserId = validation:snowflake_or_throw(<<"target_user_id">>, TargetUserIdBin),
|
||||
RoleId = validation:snowflake_or_throw(<<"role_id">>, RoleIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{
|
||||
user_id => UserId,
|
||||
target_user_id => TargetUserId,
|
||||
role_id => RoleId
|
||||
},
|
||||
case gen_server:call(Pid, {can_manage_roles, Request}, 10000) of
|
||||
#{can_manage := CanManage} ->
|
||||
#{<<"can_manage">> => CanManage};
|
||||
_ ->
|
||||
throw({error, <<"Failed to check role management">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.can_manage_role">>, #{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"user_id">> := UserIdBin,
|
||||
<<"role_id">> := RoleIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
RoleId = validation:snowflake_or_throw(<<"role_id">>, RoleIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{user_id => UserId, role_id => RoleId},
|
||||
case gen_server:call(Pid, {can_manage_role, Request}, 10000) of
|
||||
#{can_manage := CanManage} ->
|
||||
#{<<"can_manage">> => CanManage};
|
||||
_ ->
|
||||
throw({error, <<"Failed to check role management">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.get_assignable_roles">>, #{
|
||||
<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{user_id => UserId},
|
||||
case gen_server:call(Pid, {get_assignable_roles, Request}, 10000) of
|
||||
#{role_ids := RoleIds} ->
|
||||
#{
|
||||
<<"role_ids">> => [
|
||||
integer_to_binary(RoleId)
|
||||
|| RoleId <- RoleIds
|
||||
]
|
||||
};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get assignable roles">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.get_user_max_role_position">>, #{
|
||||
<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{user_id => UserId},
|
||||
case gen_server:call(Pid, {get_user_max_role_position, Request}, 10000) of
|
||||
#{position := Position} ->
|
||||
#{<<"position">> => Position};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get max role position">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.get_members_with_role">>, #{
|
||||
<<"guild_id">> := GuildIdBin, <<"role_id">> := RoleIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
RoleId = validation:snowflake_or_throw(<<"role_id">>, RoleIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{role_id => RoleId},
|
||||
case gen_server:call(Pid, {get_members_with_role, Request}, 10000) of
|
||||
#{user_ids := UserIds} ->
|
||||
#{
|
||||
<<"user_ids">> => [
|
||||
integer_to_binary(UserId)
|
||||
|| UserId <- UserIds
|
||||
]
|
||||
};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get members with role">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.check_target_member">>, #{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"user_id">> := UserIdBin,
|
||||
<<"target_user_id">> := TargetUserIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
TargetUserId = validation:snowflake_or_throw(<<"target_user_id">>, TargetUserIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{user_id => UserId, target_user_id => TargetUserId},
|
||||
case gen_server:call(Pid, {check_target_member, Request}, 10000) of
|
||||
#{can_manage := CanManage} ->
|
||||
#{<<"can_manage">> => CanManage};
|
||||
_ ->
|
||||
throw({error, <<"Failed to check target member">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.get_viewable_channels">>, #{
|
||||
<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{user_id => UserId},
|
||||
case gen_server:call(Pid, {get_viewable_channels, Request}, 10000) of
|
||||
#{channel_ids := ChannelIds} ->
|
||||
#{
|
||||
<<"channel_ids">> => [
|
||||
integer_to_binary(ChannelId)
|
||||
|| ChannelId <- ChannelIds
|
||||
]
|
||||
};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get viewable channels">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.get_users_to_mention_by_roles">>, #{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"channel_id">> := ChannelIdBin,
|
||||
<<"role_ids">> := RoleIds,
|
||||
<<"author_id">> := AuthorIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
AuthorId = validation:snowflake_or_throw(<<"author_id">>, AuthorIdBin),
|
||||
RoleIdsList = validation:snowflake_list_or_throw(<<"role_ids">>, RoleIds),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{
|
||||
channel_id => ChannelId,
|
||||
role_ids => RoleIdsList,
|
||||
author_id => AuthorId
|
||||
},
|
||||
case gen_server:call(Pid, {get_users_to_mention_by_roles, Request}, 10000) of
|
||||
#{user_ids := UserIds} ->
|
||||
#{
|
||||
<<"user_ids">> => [
|
||||
integer_to_binary(UserId)
|
||||
|| UserId <- UserIds
|
||||
]
|
||||
};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get users">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.get_users_to_mention_by_user_ids">>, #{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"channel_id">> := ChannelIdBin,
|
||||
<<"user_ids">> := UserIds,
|
||||
<<"author_id">> := AuthorIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
AuthorId = validation:snowflake_or_throw(<<"author_id">>, AuthorIdBin),
|
||||
UserIdsList = validation:snowflake_list_or_throw(<<"user_ids">>, UserIds),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{
|
||||
channel_id => ChannelId,
|
||||
user_ids => UserIdsList,
|
||||
author_id => AuthorId
|
||||
},
|
||||
case gen_server:call(Pid, {get_users_to_mention_by_user_ids, Request}, 10000) of
|
||||
#{user_ids := ResultUserIds} ->
|
||||
#{
|
||||
<<"user_ids">> => [
|
||||
integer_to_binary(UserId)
|
||||
|| UserId <- ResultUserIds
|
||||
]
|
||||
};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get users">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.get_all_users_to_mention">>, #{
|
||||
<<"guild_id">> := GuildIdBin, <<"channel_id">> := ChannelIdBin, <<"author_id">> := AuthorIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
AuthorId = validation:snowflake_or_throw(<<"author_id">>, AuthorIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{
|
||||
channel_id => ChannelId,
|
||||
author_id => AuthorId
|
||||
},
|
||||
case gen_server:call(Pid, {get_all_users_to_mention, Request}, 10000) of
|
||||
#{user_ids := UserIds} ->
|
||||
#{
|
||||
<<"user_ids">> => [
|
||||
integer_to_binary(UserId)
|
||||
|| UserId <- UserIds
|
||||
]
|
||||
};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get users">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.resolve_all_mentions">>, #{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"channel_id">> := ChannelIdBin,
|
||||
<<"author_id">> := AuthorIdBin,
|
||||
<<"mention_everyone">> := MentionEveryone,
|
||||
<<"mention_here">> := MentionHere,
|
||||
<<"role_ids">> := RoleIds,
|
||||
<<"user_ids">> := UserIds
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
AuthorId = validation:snowflake_or_throw(<<"author_id">>, AuthorIdBin),
|
||||
RoleIdsList = validation:snowflake_list_or_throw(<<"role_ids">>, RoleIds),
|
||||
UserIdsList = validation:snowflake_list_or_throw(<<"user_ids">>, UserIds),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{
|
||||
channel_id => ChannelId,
|
||||
author_id => AuthorId,
|
||||
mention_everyone => MentionEveryone,
|
||||
mention_here => MentionHere,
|
||||
role_ids => RoleIdsList,
|
||||
user_ids => UserIdsList
|
||||
},
|
||||
case gen_server:call(Pid, {resolve_all_mentions, Request}, 10000) of
|
||||
#{user_ids := ResultUserIds} ->
|
||||
#{
|
||||
<<"user_ids">> => [
|
||||
integer_to_binary(UserId)
|
||||
|| UserId <- ResultUserIds
|
||||
]
|
||||
};
|
||||
_ ->
|
||||
throw({error, <<"Failed to resolve mentions">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.get_vanity_url_channel">>, #{<<"guild_id">> := GuildIdBin}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {get_vanity_url_channel}, 10000) of
|
||||
#{channel_id := ChannelId} when ChannelId =/= null ->
|
||||
#{<<"channel_id">> => integer_to_binary(ChannelId)};
|
||||
#{channel_id := null} ->
|
||||
#{<<"channel_id">> => null};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get vanity URL channel">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.get_first_viewable_text_channel">>, #{<<"guild_id">> := GuildIdBin}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {get_first_viewable_text_channel}, 10000) of
|
||||
#{channel_id := ChannelId} when ChannelId =/= null ->
|
||||
#{<<"channel_id">> => integer_to_binary(ChannelId)};
|
||||
#{channel_id := null} ->
|
||||
#{<<"channel_id">> => null};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get first viewable text channel">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(
|
||||
<<"guild.update_member_voice">>,
|
||||
#{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"user_id">> := UserIdBin,
|
||||
<<"mute">> := Mute,
|
||||
<<"deaf">> := Deaf
|
||||
}
|
||||
) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{user_id => UserId, mute => Mute, deaf => Deaf},
|
||||
case gen_server:call(Pid, {update_member_voice, Request}, 10000) of
|
||||
#{success := true} ->
|
||||
#{<<"success">> => true};
|
||||
#{error := Error} ->
|
||||
throw({error, Error})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(
|
||||
<<"guild.disconnect_voice_user">>,
|
||||
#{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"user_id">> := UserIdBin
|
||||
} = Params
|
||||
) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
ConnectionId = maps:get(<<"connection_id">>, Params, null),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{user_id => UserId, connection_id => ConnectionId},
|
||||
case gen_server:call(Pid, {disconnect_voice_user, Request}, 10000) of
|
||||
#{success := true} ->
|
||||
#{<<"success">> => true};
|
||||
#{error := Error} ->
|
||||
throw({error, Error})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(
|
||||
<<"guild.disconnect_voice_user_if_in_channel">>,
|
||||
#{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"user_id">> := UserIdBin,
|
||||
<<"expected_channel_id">> := ExpectedChannelIdBin
|
||||
} = Params
|
||||
) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
ExpectedChannelId = validation:snowflake_or_throw(
|
||||
<<"expected_channel_id">>, ExpectedChannelIdBin
|
||||
),
|
||||
ConnectionId = maps:get(<<"connection_id">>, Params, undefined),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request =
|
||||
case ConnectionId of
|
||||
undefined ->
|
||||
#{
|
||||
user_id => UserId,
|
||||
expected_channel_id => ExpectedChannelId
|
||||
};
|
||||
ConnId ->
|
||||
#{
|
||||
user_id => UserId,
|
||||
expected_channel_id => ExpectedChannelId,
|
||||
connection_id => ConnId
|
||||
}
|
||||
end,
|
||||
case gen_server:call(Pid, {disconnect_voice_user_if_in_channel, Request}, 10000) of
|
||||
#{success := true, ignored := true} ->
|
||||
#{<<"success">> => true, <<"ignored">> => true};
|
||||
#{success := true} ->
|
||||
#{<<"success">> => true};
|
||||
#{error := Error} ->
|
||||
throw({error, Error})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.disconnect_all_voice_users_in_channel">>, #{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"channel_id">> := ChannelIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{channel_id => ChannelId},
|
||||
case gen_server:call(Pid, {disconnect_all_voice_users_in_channel, Request}, 10000) of
|
||||
#{success := true, disconnected_count := Count} ->
|
||||
#{<<"success">> => true, <<"disconnected_count">> => Count};
|
||||
#{error := Error} ->
|
||||
throw({error, Error})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.confirm_voice_connection_from_livekit">>, Params) ->
|
||||
GuildIdBin = maps:get(<<"guild_id">>, Params),
|
||||
ConnectionId = maps:get(<<"connection_id">>, Params),
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{connection_id => ConnectionId},
|
||||
case gen_server:call(Pid, {confirm_voice_connection_from_livekit, Request}, 10000) of
|
||||
#{success := true} ->
|
||||
#{<<"success">> => true};
|
||||
#{success := false, error := Error} ->
|
||||
#{<<"success">> => false, <<"error">> => Error};
|
||||
#{error := Error} ->
|
||||
throw({error, Error})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.move_member">>, #{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"user_id">> := UserIdBin,
|
||||
<<"moderator_id">> := ModeratorIdBin,
|
||||
<<"channel_id">> := ChannelIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
ModeratorId = validation:snowflake_or_throw(<<"moderator_id">>, ModeratorIdBin),
|
||||
case validation:validate_optional_snowflake(ChannelIdBin) of
|
||||
{ok, ChannelId} ->
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{
|
||||
user_id => UserId,
|
||||
moderator_id => ModeratorId,
|
||||
channel_id => ChannelId
|
||||
},
|
||||
case gen_server:call(Pid, {move_member, Request}, 10000) of
|
||||
#{
|
||||
success := true,
|
||||
needs_token := true,
|
||||
session_data := SessionData,
|
||||
connections_to_move := ConnectionsToMove
|
||||
} when
|
||||
ChannelId =/= null
|
||||
->
|
||||
spawn(fun() ->
|
||||
guild_voice:handle_virtual_channel_access_for_move(
|
||||
UserId, ChannelId, ConnectionsToMove, Pid
|
||||
),
|
||||
guild_voice:send_voice_server_updates_for_move(
|
||||
GuildId, ChannelId, SessionData, Pid
|
||||
)
|
||||
end),
|
||||
#{<<"success">> => true};
|
||||
#{success := true, user_id := DisconnectedUserId} when
|
||||
ChannelId =:= null
|
||||
->
|
||||
spawn(fun() ->
|
||||
guild_voice:cleanup_virtual_access_on_disconnect(
|
||||
DisconnectedUserId, Pid
|
||||
)
|
||||
end),
|
||||
#{<<"success">> => true};
|
||||
#{success := true} ->
|
||||
#{<<"success">> => true};
|
||||
#{error := Error} ->
|
||||
throw({error, Error})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end
|
||||
end;
|
||||
execute_method(<<"guild.get_voice_state">>, #{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"user_id">> := UserIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{user_id => UserId},
|
||||
case gen_server:call(Pid, {get_voice_state, Request}, 10000) of
|
||||
#{voice_state := null} ->
|
||||
#{<<"voice_state">> => null};
|
||||
#{voice_state := VoiceState} ->
|
||||
#{<<"voice_state">> => VoiceState};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get voice state">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.switch_voice_region">>, #{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"channel_id">> := ChannelIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{channel_id => ChannelId},
|
||||
case gen_server:call(Pid, {switch_voice_region, Request}, 10000) of
|
||||
#{success := true} ->
|
||||
spawn(fun() ->
|
||||
guild_voice:switch_voice_region(GuildId, ChannelId, Pid)
|
||||
end),
|
||||
#{<<"success">> => true};
|
||||
#{error := Error} ->
|
||||
throw({error, Error})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.get_category_channel_count">>, #{
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"category_id">> := CategoryIdBin
|
||||
}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
CategoryId = validation:snowflake_or_throw(<<"category_id">>, CategoryIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
Request = #{category_id => CategoryId},
|
||||
case gen_server:call(Pid, {get_category_channel_count, Request}, 10000) of
|
||||
#{count := Count} ->
|
||||
#{<<"count">> => Count};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get category channel count">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end;
|
||||
execute_method(<<"guild.get_channel_count">>, #{<<"guild_id">> := GuildIdBin}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {get_channel_count}, 10000) of
|
||||
#{count := Count} ->
|
||||
#{<<"count">> => Count};
|
||||
_ ->
|
||||
throw({error, <<"Failed to get channel count">>})
|
||||
end;
|
||||
_ ->
|
||||
throw({error, <<"Guild not found">>})
|
||||
end.
|
||||
133
fluxer_gateway/src/gateway/gateway_rpc_http_handler.erl
Normal file
133
fluxer_gateway/src/gateway/gateway_rpc_http_handler.erl
Normal file
@@ -0,0 +1,133 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(gateway_rpc_http_handler).
|
||||
|
||||
-export([init/2]).
|
||||
|
||||
-define(JSON_HEADERS, #{<<"content-type">> => <<"application/json">>}).
|
||||
|
||||
init(Req0, State) ->
|
||||
case cowboy_req:method(Req0) of
|
||||
<<"POST">> ->
|
||||
handle_post(Req0, State);
|
||||
_ ->
|
||||
Req = cowboy_req:reply(405, #{<<"allow">> => <<"POST">>}, <<>>, Req0),
|
||||
{ok, Req, State}
|
||||
end.
|
||||
|
||||
handle_post(Req0, State) ->
|
||||
case authorize(Req0) of
|
||||
ok ->
|
||||
case read_body(Req0) of
|
||||
{ok, Decoded, Req1} ->
|
||||
case maps:get(<<"method">>, Decoded, undefined) of
|
||||
undefined ->
|
||||
respond(400, #{<<"error">> => <<"Missing method">>}, Req1, State);
|
||||
Method when is_binary(Method) ->
|
||||
ParamsValue = maps:get(<<"params">>, Decoded, #{}),
|
||||
case is_map(ParamsValue) of
|
||||
true ->
|
||||
execute_method(Method, ParamsValue, Req1, State);
|
||||
false ->
|
||||
respond(
|
||||
400, #{<<"error">> => <<"Invalid params">>}, Req1, State
|
||||
)
|
||||
end;
|
||||
_ ->
|
||||
respond(400, #{<<"error">> => <<"Invalid method">>}, Req1, State)
|
||||
end;
|
||||
{error, ErrorBody, Req1} ->
|
||||
respond(400, ErrorBody, Req1, State)
|
||||
end;
|
||||
{error, Req1} ->
|
||||
{ok, Req1, State}
|
||||
end.
|
||||
|
||||
authorize(Req0) ->
|
||||
case cowboy_req:header(<<"authorization">>, Req0) of
|
||||
undefined ->
|
||||
Req = cowboy_req:reply(
|
||||
401,
|
||||
?JSON_HEADERS,
|
||||
jsx:encode(#{<<"error">> => <<"Unauthorized">>}),
|
||||
Req0
|
||||
),
|
||||
{error, Req};
|
||||
AuthHeader ->
|
||||
case fluxer_gateway_env:get(rpc_secret_key) of
|
||||
undefined ->
|
||||
Req = cowboy_req:reply(
|
||||
500,
|
||||
?JSON_HEADERS,
|
||||
jsx:encode(#{<<"error">> => <<"RPC secret not configured">>}),
|
||||
Req0
|
||||
),
|
||||
{error, Req};
|
||||
Secret when is_binary(Secret) ->
|
||||
Expected = <<"Bearer ", Secret/binary>>,
|
||||
case AuthHeader of
|
||||
Expected ->
|
||||
ok;
|
||||
_ ->
|
||||
Req = cowboy_req:reply(
|
||||
401,
|
||||
?JSON_HEADERS,
|
||||
jsx:encode(#{<<"error">> => <<"Unauthorized">>}),
|
||||
Req0
|
||||
),
|
||||
{error, Req}
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
read_body(Req0) ->
|
||||
read_body(Req0, <<>>).
|
||||
|
||||
read_body(Req0, Acc) ->
|
||||
case cowboy_req:read_body(Req0) of
|
||||
{ok, Body, Req1} ->
|
||||
FullBody = <<Acc/binary, Body/binary>>,
|
||||
decode_body(FullBody, Req1);
|
||||
{more, Body, Req1} ->
|
||||
read_body(Req1, <<Acc/binary, Body/binary>>)
|
||||
end.
|
||||
|
||||
decode_body(Body, Req0) ->
|
||||
case catch jsx:decode(Body, [return_maps]) of
|
||||
{'EXIT', _Reason} ->
|
||||
{error, #{<<"error">> => <<"Invalid JSON payload">>}, Req0};
|
||||
Decoded when is_map(Decoded) ->
|
||||
{ok, Decoded, Req0};
|
||||
_ ->
|
||||
{error, #{<<"error">> => <<"Invalid request body">>}, Req0}
|
||||
end.
|
||||
|
||||
execute_method(Method, Params, Req0, State) ->
|
||||
try
|
||||
Result = gateway_rpc_router:execute(Method, Params),
|
||||
respond(200, #{<<"result">> => Result}, Req0, State)
|
||||
catch
|
||||
throw:{error, Message} ->
|
||||
respond(400, #{<<"error">> => Message}, Req0, State);
|
||||
_:_ ->
|
||||
respond(500, #{<<"error">> => <<"Internal error">>}, Req0, State)
|
||||
end.
|
||||
|
||||
respond(Status, Body, Req0, State) ->
|
||||
Req = cowboy_req:reply(Status, ?JSON_HEADERS, jsx:encode(Body), Req0),
|
||||
{ok, Req, State}.
|
||||
80
fluxer_gateway/src/gateway/gateway_rpc_misc.erl
Normal file
80
fluxer_gateway/src/gateway/gateway_rpc_misc.erl
Normal file
@@ -0,0 +1,80 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(gateway_rpc_misc).
|
||||
|
||||
-export([execute_method/2, get_local_node_stats/0]).
|
||||
|
||||
execute_method(<<"process.memory_stats">>, Params) ->
|
||||
Limit =
|
||||
case maps:get(<<"limit">>, Params, undefined) of
|
||||
undefined ->
|
||||
100;
|
||||
LimitValue ->
|
||||
validation:snowflake_or_throw(<<"limit">>, LimitValue)
|
||||
end,
|
||||
|
||||
Guilds = process_memory_stats:get_guild_memory_stats(Limit),
|
||||
#{<<"guilds">> => Guilds};
|
||||
execute_method(<<"process.node_stats">>, _Params) ->
|
||||
get_local_node_stats().
|
||||
|
||||
get_local_node_stats() ->
|
||||
SessionCount =
|
||||
case gen_server:call(session_manager, get_global_count, 1000) of
|
||||
{ok, SC} -> SC;
|
||||
_ -> 0
|
||||
end,
|
||||
|
||||
GuildCount =
|
||||
case gen_server:call(guild_manager, get_global_count, 1000) of
|
||||
{ok, GC} -> GC;
|
||||
_ -> 0
|
||||
end,
|
||||
|
||||
PresenceCount =
|
||||
case gen_server:call(presence_manager, get_global_count, 1000) of
|
||||
{ok, PC} -> PC;
|
||||
_ -> 0
|
||||
end,
|
||||
|
||||
CallCount =
|
||||
case gen_server:call(call_manager, get_global_count, 1000) of
|
||||
{ok, CC} -> CC;
|
||||
_ -> 0
|
||||
end,
|
||||
|
||||
MemoryInfo = erlang:memory(),
|
||||
TotalMemory = proplists:get_value(total, MemoryInfo, 0),
|
||||
ProcessMemory = proplists:get_value(processes, MemoryInfo, 0),
|
||||
SystemMemory = proplists:get_value(system, MemoryInfo, 0),
|
||||
|
||||
#{
|
||||
<<"status">> => <<"healthy">>,
|
||||
<<"sessions">> => SessionCount,
|
||||
<<"guilds">> => GuildCount,
|
||||
<<"presences">> => PresenceCount,
|
||||
<<"calls">> => CallCount,
|
||||
<<"memory">> => #{
|
||||
<<"total">> => TotalMemory,
|
||||
<<"processes">> => ProcessMemory,
|
||||
<<"system">> => SystemMemory
|
||||
},
|
||||
<<"process_count">> => erlang:system_info(process_count),
|
||||
<<"process_limit">> => erlang:system_info(process_limit),
|
||||
<<"uptime_seconds">> => element(1, erlang:statistics(wall_clock)) div 1000
|
||||
}.
|
||||
182
fluxer_gateway/src/gateway/gateway_rpc_presence.erl
Normal file
182
fluxer_gateway/src/gateway/gateway_rpc_presence.erl
Normal file
@@ -0,0 +1,182 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(gateway_rpc_presence).
|
||||
|
||||
-export([execute_method/2]).
|
||||
|
||||
execute_method(<<"presence.dispatch">>, #{
|
||||
<<"user_id">> := UserIdBin, <<"event">> := Event, <<"data">> := Data
|
||||
}) ->
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
EventAtom = constants:dispatch_event_atom(Event),
|
||||
case presence_manager:dispatch_to_user(UserId, EventAtom, Data) of
|
||||
ok ->
|
||||
true;
|
||||
{error, not_found} ->
|
||||
handle_offline_dispatch(EventAtom, UserId, Data)
|
||||
end;
|
||||
execute_method(<<"presence.join_guild">>, #{
|
||||
<<"user_id">> := UserIdBin, <<"guild_id">> := GuildIdBin
|
||||
}) ->
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(presence_manager, {lookup, UserId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {join_guild, GuildId}, 10000) of
|
||||
ok -> true;
|
||||
_ -> throw({error, <<"Join guild failed">>})
|
||||
end;
|
||||
not_found ->
|
||||
true;
|
||||
{error, _} ->
|
||||
true;
|
||||
_ ->
|
||||
true
|
||||
end;
|
||||
execute_method(<<"presence.leave_guild">>, #{
|
||||
<<"user_id">> := UserIdBin, <<"guild_id">> := GuildIdBin
|
||||
}) ->
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(presence_manager, {lookup, UserId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {leave_guild, GuildId}, 10000) of
|
||||
ok -> true;
|
||||
_ -> throw({error, <<"Leave guild failed">>})
|
||||
end;
|
||||
not_found ->
|
||||
true;
|
||||
{error, _} ->
|
||||
true;
|
||||
_ ->
|
||||
true
|
||||
end;
|
||||
execute_method(<<"presence.terminate_sessions">>, #{
|
||||
<<"user_id">> := UserIdBin, <<"session_id_hashes">> := SessionIdHashes
|
||||
}) ->
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
case gen_server:call(presence_manager, {lookup, UserId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {terminate_session, SessionIdHashes}, 10000) of
|
||||
ok -> true;
|
||||
_ -> throw({error, <<"Terminate session failed">>})
|
||||
end;
|
||||
not_found ->
|
||||
true;
|
||||
{error, _} ->
|
||||
true;
|
||||
_ ->
|
||||
true
|
||||
end;
|
||||
execute_method(<<"presence.terminate_all_sessions">>, #{
|
||||
<<"user_id">> := UserIdBin
|
||||
}) ->
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
case presence_manager:terminate_all_sessions(UserId) of
|
||||
ok -> true;
|
||||
_ -> throw({error, <<"Terminate all sessions failed">>})
|
||||
end;
|
||||
execute_method(<<"presence.has_active">>, #{<<"user_id">> := UserIdBin}) ->
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
case gen_server:call(presence_manager, {lookup, UserId}, 10000) of
|
||||
{ok, _Pid} ->
|
||||
#{<<"has_active">> => true};
|
||||
_ ->
|
||||
#{<<"has_active">> => false}
|
||||
end;
|
||||
execute_method(<<"presence.add_temporary_guild">>, #{
|
||||
<<"user_id">> := UserIdBin, <<"guild_id">> := GuildIdBin
|
||||
}) ->
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(presence_manager, {lookup, UserId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {add_temporary_guild, GuildId}, 10000) of
|
||||
ok -> true;
|
||||
_ -> throw({error, <<"Add temporary guild failed">>})
|
||||
end;
|
||||
not_found ->
|
||||
true;
|
||||
{error, _} ->
|
||||
true;
|
||||
_ ->
|
||||
true
|
||||
end;
|
||||
execute_method(<<"presence.remove_temporary_guild">>, #{
|
||||
<<"user_id">> := UserIdBin, <<"guild_id">> := GuildIdBin
|
||||
}) ->
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
case gen_server:call(presence_manager, {lookup, UserId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
case gen_server:call(Pid, {remove_temporary_guild, GuildId}, 10000) of
|
||||
ok -> true;
|
||||
_ -> throw({error, <<"Remove temporary guild failed">>})
|
||||
end;
|
||||
not_found ->
|
||||
true;
|
||||
{error, _} ->
|
||||
true;
|
||||
_ ->
|
||||
true
|
||||
end;
|
||||
execute_method(<<"presence.sync_group_dm_recipients">>, #{
|
||||
<<"user_id">> := UserIdBin, <<"recipients_by_channel">> := RecipientsByChannel
|
||||
}) ->
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
NormalizedRecipients =
|
||||
maps:from_list([
|
||||
{
|
||||
validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin),
|
||||
[validation:snowflake_or_throw(<<"recipient_id">>, RBin) || RBin <- Recipients]
|
||||
}
|
||||
|| {ChannelIdBin, Recipients} <- maps:to_list(RecipientsByChannel)
|
||||
]),
|
||||
case gen_server:call(presence_manager, {lookup, UserId}, 10000) of
|
||||
{ok, Pid} ->
|
||||
gen_server:cast(Pid, {sync_group_dm_recipients, NormalizedRecipients}),
|
||||
true;
|
||||
not_found ->
|
||||
true;
|
||||
{error, _} ->
|
||||
true;
|
||||
_ ->
|
||||
true
|
||||
end.
|
||||
|
||||
handle_offline_dispatch(message_create, UserId, Data) ->
|
||||
AuthorIdBin = maps:get(<<"id">>, maps:get(<<"author">>, Data, #{}), <<"0">>),
|
||||
AuthorId = validation:snowflake_or_throw(<<"author_id">>, AuthorIdBin),
|
||||
push:handle_message_create(#{
|
||||
message_data => Data,
|
||||
user_ids => [UserId],
|
||||
guild_id => 0,
|
||||
author_id => AuthorId
|
||||
}),
|
||||
true;
|
||||
handle_offline_dispatch(relationship_add, UserId, _Data) ->
|
||||
sync_blocked_ids_for_user(UserId),
|
||||
true;
|
||||
handle_offline_dispatch(relationship_remove, UserId, _Data) ->
|
||||
sync_blocked_ids_for_user(UserId),
|
||||
true;
|
||||
handle_offline_dispatch(_Event, _UserId, _Data) ->
|
||||
true.
|
||||
|
||||
sync_blocked_ids_for_user(_UserId) ->
|
||||
ok.
|
||||
43
fluxer_gateway/src/gateway/gateway_rpc_push.erl
Normal file
43
fluxer_gateway/src/gateway/gateway_rpc_push.erl
Normal file
@@ -0,0 +1,43 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(gateway_rpc_push).
|
||||
|
||||
-export([execute_method/2]).
|
||||
|
||||
execute_method(<<"push.sync_user_guild_settings">>, #{
|
||||
<<"user_id">> := UserIdBin,
|
||||
<<"guild_id">> := GuildIdBin,
|
||||
<<"user_guild_settings">> := UserGuildSettings
|
||||
}) ->
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
push:sync_user_guild_settings(UserId, GuildId, UserGuildSettings),
|
||||
true;
|
||||
execute_method(<<"push.sync_user_blocked_ids">>, #{
|
||||
<<"user_id">> := UserIdBin, <<"blocked_user_ids">> := BlockedUserIds
|
||||
}) ->
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
BlockedIds = validation:snowflake_list_or_throw(<<"blocked_user_ids">>, BlockedUserIds),
|
||||
push:sync_user_blocked_ids(UserId, BlockedIds),
|
||||
true;
|
||||
execute_method(<<"push.invalidate_badge_count">>, #{
|
||||
<<"user_id">> := UserIdBin
|
||||
}) ->
|
||||
UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin),
|
||||
push:invalidate_user_badge_count(UserId),
|
||||
true.
|
||||
36
fluxer_gateway/src/gateway/gateway_rpc_router.erl
Normal file
36
fluxer_gateway/src/gateway/gateway_rpc_router.erl
Normal file
@@ -0,0 +1,36 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(gateway_rpc_router).
|
||||
|
||||
-export([execute/2]).
|
||||
|
||||
execute(Method, Params) ->
|
||||
case Method of
|
||||
<<"guild.", _/binary>> ->
|
||||
gateway_rpc_guild:execute_method(Method, Params);
|
||||
<<"presence.", _/binary>> ->
|
||||
gateway_rpc_presence:execute_method(Method, Params);
|
||||
<<"push.", _/binary>> ->
|
||||
gateway_rpc_push:execute_method(Method, Params);
|
||||
<<"call.", _/binary>> ->
|
||||
gateway_rpc_call:execute_method(Method, Params);
|
||||
<<"process.", _/binary>> ->
|
||||
gateway_rpc_misc:execute_method(Method, Params);
|
||||
_ ->
|
||||
throw({error, <<"Unknown method: ", Method/binary>>})
|
||||
end.
|
||||
29
fluxer_gateway/src/gateway/health_handler.erl
Normal file
29
fluxer_gateway/src/gateway/health_handler.erl
Normal file
@@ -0,0 +1,29 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(health_handler).
|
||||
|
||||
-export([init/2]).
|
||||
|
||||
init(Req0, State) ->
|
||||
Req = cowboy_req:reply(
|
||||
200,
|
||||
#{<<"content-type">> => <<"text/plain">>},
|
||||
<<"OK">>,
|
||||
Req0
|
||||
),
|
||||
{ok, Req, State}.
|
||||
357
fluxer_gateway/src/gateway/hot_reload.erl
Normal file
357
fluxer_gateway/src/gateway/hot_reload.erl
Normal file
@@ -0,0 +1,357 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(hot_reload).
|
||||
|
||||
-export([
|
||||
reload_module/1,
|
||||
reload_modules/1,
|
||||
reload_modules/2,
|
||||
reload_beams/2,
|
||||
reload_all_changed/0,
|
||||
reload_all_changed/1,
|
||||
get_loaded_modules/0,
|
||||
get_module_info/1
|
||||
]).
|
||||
|
||||
-define(CRITICAL_MODULES, [
|
||||
code,
|
||||
kernel,
|
||||
erlang,
|
||||
init,
|
||||
erl_prim_loader,
|
||||
prim_file,
|
||||
prim_inet,
|
||||
prim_zip,
|
||||
zlib,
|
||||
otp_ring0,
|
||||
erts_internal,
|
||||
erts_code_purger,
|
||||
application,
|
||||
application_controller,
|
||||
application_master,
|
||||
supervisor,
|
||||
gen_server,
|
||||
gen_event,
|
||||
gen_statem,
|
||||
proc_lib,
|
||||
error_handler,
|
||||
heart,
|
||||
logger,
|
||||
logger_handler_watcher,
|
||||
logger_server,
|
||||
logger_config,
|
||||
logger_simple_h
|
||||
]).
|
||||
|
||||
-type purge_mode() :: none | soft | hard.
|
||||
-type reload_opts() :: #{purge => purge_mode()}.
|
||||
|
||||
-spec reload_module(atom()) -> {ok, map()} | {error, term()}.
|
||||
reload_module(Module) when is_atom(Module) ->
|
||||
case is_critical_module(Module) of
|
||||
true ->
|
||||
{error, {critical_module, Module}};
|
||||
false ->
|
||||
{ok, Result} = reload_modules([Module], #{purge => soft}),
|
||||
case Result of
|
||||
[One] ->
|
||||
case maps:get(status, One) of
|
||||
ok -> {ok, One};
|
||||
error -> {error, maps:get(reason, One, unknown)}
|
||||
end;
|
||||
_ ->
|
||||
{error, unexpected_result}
|
||||
end
|
||||
end.
|
||||
|
||||
-spec reload_modules([atom()]) -> {ok, [map()]}.
|
||||
reload_modules(Modules) when is_list(Modules) ->
|
||||
reload_modules(Modules, #{purge => soft}).
|
||||
|
||||
-spec reload_modules([atom()], reload_opts()) -> {ok, [map()]}.
|
||||
reload_modules(Modules, Opts) when is_list(Modules), is_map(Opts) ->
|
||||
Purge = maps:get(purge, Opts, soft),
|
||||
Results = lists:map(
|
||||
fun(Module) ->
|
||||
reload_one(Module, Purge)
|
||||
end,
|
||||
Modules
|
||||
),
|
||||
{ok, Results}.
|
||||
|
||||
-spec reload_beams([{atom(), binary()}], reload_opts()) -> {ok, [map()]}.
|
||||
reload_beams(Pairs, Opts) when is_list(Pairs), is_map(Opts) ->
|
||||
Purge = maps:get(purge, Opts, soft),
|
||||
Results =
|
||||
lists:map(
|
||||
fun({Module, BeamBin}) ->
|
||||
reload_one_beam(Module, BeamBin, Purge)
|
||||
end,
|
||||
Pairs
|
||||
),
|
||||
{ok, Results}.
|
||||
|
||||
-spec reload_all_changed() -> {ok, [map()]}.
|
||||
reload_all_changed() ->
|
||||
reload_all_changed(soft).
|
||||
|
||||
-spec reload_all_changed(purge_mode()) -> {ok, [map()]}.
|
||||
reload_all_changed(Purge) ->
|
||||
ChangedModules = get_changed_modules(),
|
||||
reload_modules(ChangedModules, #{purge => Purge}).
|
||||
|
||||
-spec get_loaded_modules() -> [atom()].
|
||||
get_loaded_modules() ->
|
||||
[M || {M, _} <- code:all_loaded(), is_fluxer_module(M)].
|
||||
|
||||
-spec get_module_info(atom()) -> {ok, map()} | {error, not_loaded}.
|
||||
get_module_info(Module) when is_atom(Module) ->
|
||||
case code:is_loaded(Module) of
|
||||
false ->
|
||||
{error, not_loaded};
|
||||
{file, BeamPath} ->
|
||||
LoadedTime = get_loaded_time(Module),
|
||||
DiskTime = get_disk_time(BeamPath),
|
||||
LoadedMd5 = loaded_md5(Module),
|
||||
DiskMd5 = disk_md5(BeamPath),
|
||||
{ok, #{
|
||||
module => Module,
|
||||
beam_path => BeamPath,
|
||||
loaded_time => LoadedTime,
|
||||
disk_time => DiskTime,
|
||||
loaded_md5 => hex_or_null(LoadedMd5),
|
||||
disk_md5 => hex_or_null(DiskMd5),
|
||||
changed => (code:module_status(Module) =:= modified),
|
||||
is_critical => is_critical_module(Module)
|
||||
}}
|
||||
end.
|
||||
|
||||
reload_one(Module, Purge) ->
|
||||
case is_critical_module(Module) of
|
||||
true ->
|
||||
#{module => Module, status => error, reason => {critical_module, Module}};
|
||||
false ->
|
||||
do_reload_one(Module, Purge)
|
||||
end.
|
||||
|
||||
reload_one_beam(Module, BeamBin, Purge) ->
|
||||
case is_critical_module(Module) of
|
||||
true ->
|
||||
#{module => Module, status => error, reason => {critical_module, Module}};
|
||||
false ->
|
||||
do_reload_one_beam(Module, BeamBin, Purge)
|
||||
end.
|
||||
|
||||
do_reload_one(Module, Purge) ->
|
||||
OldLoadedMd5 = loaded_md5(Module),
|
||||
OldBeamPath = code:which(Module),
|
||||
OldDiskMd5 = disk_md5(OldBeamPath),
|
||||
|
||||
ok = maybe_purge_before_load(Module, Purge),
|
||||
|
||||
case code:load_file(Module) of
|
||||
{module, Module} ->
|
||||
NewLoadedMd5 = loaded_md5(Module),
|
||||
NewBeamPath = code:which(Module),
|
||||
NewDiskMd5 = disk_md5(NewBeamPath),
|
||||
Verified = (NewLoadedMd5 =/= undefined) andalso (NewDiskMd5 =/= undefined) andalso (NewLoadedMd5 =:= NewDiskMd5),
|
||||
{PurgedOld, LingeringCount} = maybe_purge_old_after_load(Module, Purge),
|
||||
#{
|
||||
module => Module,
|
||||
status => ok,
|
||||
old_loaded_md5 => hex_or_null(OldLoadedMd5),
|
||||
old_disk_md5 => hex_or_null(OldDiskMd5),
|
||||
new_loaded_md5 => hex_or_null(NewLoadedMd5),
|
||||
new_disk_md5 => hex_or_null(NewDiskMd5),
|
||||
verified => Verified,
|
||||
purged_old_code => PurgedOld,
|
||||
lingering_count => LingeringCount
|
||||
};
|
||||
{error, Reason} ->
|
||||
#{
|
||||
module => Module,
|
||||
status => error,
|
||||
reason => Reason,
|
||||
old_loaded_md5 => hex_or_null(OldLoadedMd5),
|
||||
old_disk_md5 => hex_or_null(OldDiskMd5),
|
||||
verified => false,
|
||||
purged_old_code => false,
|
||||
lingering_count => 0
|
||||
}
|
||||
end.
|
||||
|
||||
do_reload_one_beam(Module, BeamBin, Purge) ->
|
||||
OldLoadedMd5 = loaded_md5(Module),
|
||||
|
||||
ExpectedMd5 =
|
||||
case beam_lib:md5(BeamBin) of
|
||||
{ok, {Module, Md5}} ->
|
||||
Md5;
|
||||
{ok, {Other, _}} ->
|
||||
erlang:error({beam_module_mismatch, Module, Other});
|
||||
_ ->
|
||||
erlang:error(invalid_beam)
|
||||
end,
|
||||
|
||||
ok = maybe_purge_before_load(Module, Purge),
|
||||
|
||||
Filename = atom_to_list(Module) ++ ".beam(hot)",
|
||||
case code:load_binary(Module, Filename, BeamBin) of
|
||||
{module, Module} ->
|
||||
NewLoadedMd5 = loaded_md5(Module),
|
||||
Verified = (NewLoadedMd5 =:= ExpectedMd5),
|
||||
{PurgedOld, LingeringCount} = maybe_purge_old_after_load(Module, Purge),
|
||||
#{
|
||||
module => Module,
|
||||
status => ok,
|
||||
old_loaded_md5 => hex_or_null(OldLoadedMd5),
|
||||
expected_md5 => hex_or_null(ExpectedMd5),
|
||||
new_loaded_md5 => hex_or_null(NewLoadedMd5),
|
||||
verified => Verified,
|
||||
purged_old_code => PurgedOld,
|
||||
lingering_count => LingeringCount
|
||||
};
|
||||
{error, Reason} ->
|
||||
#{
|
||||
module => Module,
|
||||
status => error,
|
||||
reason => Reason,
|
||||
old_loaded_md5 => hex_or_null(OldLoadedMd5),
|
||||
expected_md5 => hex_or_null(ExpectedMd5),
|
||||
verified => false,
|
||||
purged_old_code => false,
|
||||
lingering_count => 0
|
||||
}
|
||||
end.
|
||||
|
||||
maybe_purge_before_load(_Module, none) ->
|
||||
ok;
|
||||
maybe_purge_before_load(_Module, soft) ->
|
||||
ok;
|
||||
maybe_purge_before_load(Module, hard) ->
|
||||
_ = code:purge(Module),
|
||||
ok.
|
||||
|
||||
maybe_purge_old_after_load(_Module, none) ->
|
||||
{false, 0};
|
||||
maybe_purge_old_after_load(Module, hard) ->
|
||||
_ = code:soft_purge(Module),
|
||||
Purged = code:purge(Module),
|
||||
{Purged, case Purged of true -> 0; false -> count_lingering(Module) end};
|
||||
maybe_purge_old_after_load(Module, soft) ->
|
||||
Purged = wait_soft_purge(Module, 40, 50),
|
||||
{Purged, case Purged of true -> 0; false -> count_lingering(Module) end}.
|
||||
|
||||
wait_soft_purge(_Module, 0, _SleepMs) ->
|
||||
false;
|
||||
wait_soft_purge(Module, N, SleepMs) ->
|
||||
case code:soft_purge(Module) of
|
||||
true ->
|
||||
true;
|
||||
false ->
|
||||
receive after SleepMs -> ok end,
|
||||
wait_soft_purge(Module, N - 1, SleepMs)
|
||||
end.
|
||||
|
||||
count_lingering(Module) ->
|
||||
lists:foldl(
|
||||
fun(Pid, Acc) ->
|
||||
case erlang:check_process_code(Pid, Module) of
|
||||
true -> Acc + 1;
|
||||
false -> Acc
|
||||
end
|
||||
end,
|
||||
0,
|
||||
processes()
|
||||
).
|
||||
|
||||
get_changed_modules() ->
|
||||
Modified = code:modified_modules(),
|
||||
[M || M <- Modified, is_fluxer_module(M), not is_critical_module(M)].
|
||||
|
||||
is_critical_module(Module) ->
|
||||
lists:member(Module, ?CRITICAL_MODULES).
|
||||
|
||||
is_fluxer_module(Module) ->
|
||||
ModuleStr = atom_to_list(Module),
|
||||
lists:prefix("fluxer_", ModuleStr) orelse
|
||||
lists:prefix("gateway", ModuleStr) orelse
|
||||
lists:prefix("session", ModuleStr) orelse
|
||||
lists:prefix("guild", ModuleStr) orelse
|
||||
lists:prefix("presence", ModuleStr) orelse
|
||||
lists:prefix("push", ModuleStr) orelse
|
||||
lists:prefix("call", ModuleStr) orelse
|
||||
lists:prefix("health", ModuleStr) orelse
|
||||
lists:prefix("hot_reload", ModuleStr) orelse
|
||||
lists:prefix("rpc_client", ModuleStr) orelse
|
||||
lists:prefix("rendezvous", ModuleStr) orelse
|
||||
lists:prefix("process_", ModuleStr) orelse
|
||||
lists:prefix("metrics_", ModuleStr) orelse
|
||||
lists:prefix("dm_voice", ModuleStr) orelse
|
||||
lists:prefix("voice_", ModuleStr) orelse
|
||||
lists:prefix("constants", ModuleStr) orelse
|
||||
lists:prefix("validation", ModuleStr) orelse
|
||||
lists:prefix("backoff_", ModuleStr) orelse
|
||||
lists:prefix("list_ops", ModuleStr) orelse
|
||||
lists:prefix("map_utils", ModuleStr) orelse
|
||||
lists:prefix("type_conv", ModuleStr) orelse
|
||||
lists:prefix("utils", ModuleStr) orelse
|
||||
lists:prefix("user_utils", ModuleStr) orelse
|
||||
lists:prefix("custom_status", ModuleStr).
|
||||
|
||||
loaded_md5(Module) ->
|
||||
try
|
||||
Module:module_info(md5)
|
||||
catch
|
||||
_:_ -> undefined
|
||||
end.
|
||||
|
||||
disk_md5(Path) when is_list(Path) ->
|
||||
case beam_lib:md5(Path) of
|
||||
{ok, {_M, Md5}} -> Md5;
|
||||
_ -> undefined
|
||||
end;
|
||||
disk_md5(_) ->
|
||||
undefined.
|
||||
|
||||
hex_or_null(undefined) ->
|
||||
null;
|
||||
hex_or_null(Bin) when is_binary(Bin) ->
|
||||
binary:encode_hex(Bin, lowercase).
|
||||
|
||||
get_loaded_time(Module) ->
|
||||
try
|
||||
case Module:module_info(compile) of
|
||||
CompileInfo when is_list(CompileInfo) ->
|
||||
proplists:get_value(time, CompileInfo, undefined);
|
||||
_ ->
|
||||
undefined
|
||||
end
|
||||
catch
|
||||
_:_ -> undefined
|
||||
end.
|
||||
|
||||
get_disk_time(BeamPath) when is_list(BeamPath) ->
|
||||
case file:read_file_info(BeamPath) of
|
||||
{ok, FileInfo} ->
|
||||
element(6, FileInfo);
|
||||
_ ->
|
||||
undefined
|
||||
end;
|
||||
get_disk_time(_) ->
|
||||
undefined.
|
||||
266
fluxer_gateway/src/gateway/hot_reload_handler.erl
Normal file
266
fluxer_gateway/src/gateway/hot_reload_handler.erl
Normal file
@@ -0,0 +1,266 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(hot_reload_handler).
|
||||
|
||||
-export([init/2]).
|
||||
|
||||
-define(JSON_HEADERS, #{<<"content-type">> => <<"application/json">>}).
|
||||
-define(MAX_MODULES, 600).
|
||||
-define(MAX_BODY_BYTES, 26214400).
|
||||
|
||||
init(Req0, State) ->
|
||||
case cowboy_req:method(Req0) of
|
||||
<<"POST">> ->
|
||||
handle_post(Req0, State);
|
||||
_ ->
|
||||
Req = cowboy_req:reply(405, #{<<"allow">> => <<"POST">>}, <<>>, Req0),
|
||||
{ok, Req, State}
|
||||
end.
|
||||
|
||||
handle_post(Req0, State) ->
|
||||
case authorize(Req0) of
|
||||
ok ->
|
||||
case read_body(Req0) of
|
||||
{ok, Decoded, Req1} ->
|
||||
handle_reload(Decoded, Req1, State);
|
||||
{error, Status, ErrorBody, Req1} ->
|
||||
respond(Status, ErrorBody, Req1, State)
|
||||
end;
|
||||
{error, Req1} ->
|
||||
{ok, Req1, State}
|
||||
end.
|
||||
|
||||
authorize(Req0) ->
|
||||
case cowboy_req:header(<<"authorization">>, Req0) of
|
||||
undefined ->
|
||||
Req = cowboy_req:reply(
|
||||
401,
|
||||
?JSON_HEADERS,
|
||||
jsx:encode(#{<<"error">> => <<"Unauthorized">>}),
|
||||
Req0
|
||||
),
|
||||
{error, Req};
|
||||
AuthHeader ->
|
||||
case os:getenv("GATEWAY_ADMIN_SECRET") of
|
||||
false ->
|
||||
Req = cowboy_req:reply(
|
||||
500,
|
||||
?JSON_HEADERS,
|
||||
jsx:encode(#{<<"error">> => <<"GATEWAY_ADMIN_SECRET not configured">>}),
|
||||
Req0
|
||||
),
|
||||
{error, Req};
|
||||
Secret ->
|
||||
Expected = <<"Bearer ", (list_to_binary(Secret))/binary>>,
|
||||
case AuthHeader of
|
||||
Expected ->
|
||||
ok;
|
||||
_ ->
|
||||
Req = cowboy_req:reply(
|
||||
401,
|
||||
?JSON_HEADERS,
|
||||
jsx:encode(#{<<"error">> => <<"Unauthorized">>}),
|
||||
Req0
|
||||
),
|
||||
{error, Req}
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
read_body(Req0) ->
|
||||
case cowboy_req:body_length(Req0) of
|
||||
Length when is_integer(Length), Length > ?MAX_BODY_BYTES ->
|
||||
{error, 413, #{<<"error">> => <<"Request body too large">>}, Req0};
|
||||
_ ->
|
||||
read_body(Req0, <<>>)
|
||||
end.
|
||||
|
||||
read_body(Req0, Acc) ->
|
||||
case cowboy_req:read_body(Req0, #{length => 1048576}) of
|
||||
{ok, Body, Req1} ->
|
||||
FullBody = <<Acc/binary, Body/binary>>,
|
||||
decode_body(FullBody, Req1);
|
||||
{more, Body, Req1} ->
|
||||
NewAcc = <<Acc/binary, Body/binary>>,
|
||||
case byte_size(NewAcc) > ?MAX_BODY_BYTES of
|
||||
true ->
|
||||
{error, 413, #{<<"error">> => <<"Request body too large">>}, Req1};
|
||||
false ->
|
||||
read_body(Req1, NewAcc)
|
||||
end
|
||||
end.
|
||||
|
||||
decode_body(<<>>, Req0) ->
|
||||
{ok, #{}, Req0};
|
||||
decode_body(Body, Req0) ->
|
||||
case catch jsx:decode(Body, [return_maps]) of
|
||||
{'EXIT', _Reason} ->
|
||||
{error, 400, #{<<"error">> => <<"Invalid JSON payload">>}, Req0};
|
||||
Decoded when is_map(Decoded) ->
|
||||
{ok, Decoded, Req0};
|
||||
_ ->
|
||||
{error, 400, #{<<"error">> => <<"Invalid request body">>}, Req0}
|
||||
end.
|
||||
|
||||
handle_reload(Params, Req0, State) ->
|
||||
try
|
||||
Purge = parse_purge(maps:get(<<"purge">>, Params, <<"soft">>)),
|
||||
case maps:get(<<"beams">>, Params, undefined) of
|
||||
undefined ->
|
||||
handle_modules_reload(Params, Purge, Req0, State);
|
||||
Beams when is_list(Beams) ->
|
||||
case length(Beams) =< ?MAX_MODULES of
|
||||
true ->
|
||||
Pairs = decode_beams(Beams),
|
||||
{ok, Results} = hot_reload:reload_beams(Pairs, #{purge => Purge}),
|
||||
respond(200, #{<<"results">> => Results}, Req0, State);
|
||||
false ->
|
||||
respond(400, #{<<"error">> => <<"Too many modules">>}, Req0, State)
|
||||
end;
|
||||
_ ->
|
||||
respond(400, #{<<"error">> => <<"beams must be an array">>}, Req0, State)
|
||||
end
|
||||
catch
|
||||
error:badarg ->
|
||||
respond(400, #{<<"error">> => <<"Invalid module name or beam payload">>}, Req0, State);
|
||||
error:invalid_beam ->
|
||||
respond(400, #{<<"error">> => <<"Invalid module name or beam payload">>}, Req0, State);
|
||||
error:{beam_module_mismatch, _, _} ->
|
||||
respond(400, #{<<"error">> => <<"Invalid module name or beam payload">>}, Req0, State);
|
||||
_:Reason ->
|
||||
logger:error("hot_reload_handler: Error during reload: ~p", [Reason]),
|
||||
respond(500, #{<<"error">> => <<"Internal error">>}, Req0, State)
|
||||
end.
|
||||
|
||||
handle_modules_reload(Params, Purge, Req0, State) ->
|
||||
case maps:get(<<"modules">>, Params, []) of
|
||||
[] ->
|
||||
{ok, Results} = hot_reload:reload_all_changed(Purge),
|
||||
respond(200, #{<<"results">> => Results}, Req0, State);
|
||||
Modules when is_list(Modules) ->
|
||||
case length(Modules) =< ?MAX_MODULES of
|
||||
true ->
|
||||
ModuleAtoms = lists:map(fun to_module_atom/1, Modules),
|
||||
{ok, Results} = hot_reload:reload_modules(ModuleAtoms, #{purge => Purge}),
|
||||
respond(200, #{<<"results">> => Results}, Req0, State);
|
||||
false ->
|
||||
respond(400, #{<<"error">> => <<"Too many modules">>}, Req0, State)
|
||||
end;
|
||||
_ ->
|
||||
respond(400, #{<<"error">> => <<"modules must be an array">>}, Req0, State)
|
||||
end.
|
||||
|
||||
decode_beams(Beams) ->
|
||||
lists:map(
|
||||
fun(Elem) ->
|
||||
case Elem of
|
||||
#{<<"module">> := Mod0, <<"beam_b64">> := B640} ->
|
||||
ModBin = to_binary(Mod0),
|
||||
Module = to_module_atom(ModBin),
|
||||
B64Bin = to_binary(B640),
|
||||
BeamBin = base64:decode(B64Bin),
|
||||
case beam_lib:md5(BeamBin) of
|
||||
{ok, {Module, _}} -> ok;
|
||||
{ok, {Other, _}} -> erlang:error({beam_module_mismatch, Module, Other});
|
||||
_ -> erlang:error(invalid_beam)
|
||||
end,
|
||||
{Module, BeamBin};
|
||||
_ ->
|
||||
erlang:error(badarg)
|
||||
end
|
||||
end,
|
||||
Beams
|
||||
).
|
||||
|
||||
to_binary(B) when is_binary(B) ->
|
||||
B;
|
||||
to_binary(L) when is_list(L) ->
|
||||
list_to_binary(L);
|
||||
to_binary(_) ->
|
||||
erlang:error(badarg).
|
||||
|
||||
parse_purge(<<"none">>) -> none;
|
||||
parse_purge(<<"soft">>) -> soft;
|
||||
parse_purge(<<"hard">>) -> hard;
|
||||
parse_purge(none) -> none;
|
||||
parse_purge(soft) -> soft;
|
||||
parse_purge(hard) -> hard;
|
||||
parse_purge(_) -> soft.
|
||||
|
||||
to_module_atom(B) when is_binary(B) ->
|
||||
case is_allowed_module_name(B) of
|
||||
true -> erlang:binary_to_atom(B, utf8);
|
||||
false -> erlang:error(badarg)
|
||||
end;
|
||||
to_module_atom(L) when is_list(L) ->
|
||||
to_module_atom(list_to_binary(L));
|
||||
to_module_atom(_) ->
|
||||
erlang:error(badarg).
|
||||
|
||||
is_allowed_module_name(Bin) when is_binary(Bin) ->
|
||||
byte_size(Bin) > 0 andalso byte_size(Bin) < 128 andalso
|
||||
is_safe_chars(Bin) andalso has_allowed_prefix(Bin).
|
||||
|
||||
is_safe_chars(Bin) ->
|
||||
lists:all(
|
||||
fun(C) ->
|
||||
(C >= $a andalso C =< $z) orelse
|
||||
(C >= $0 andalso C =< $9) orelse
|
||||
(C =:= $_)
|
||||
end,
|
||||
binary_to_list(Bin)
|
||||
).
|
||||
|
||||
has_allowed_prefix(Bin) ->
|
||||
Prefixes = [
|
||||
<<"fluxer_">>,
|
||||
<<"gateway">>,
|
||||
<<"session">>,
|
||||
<<"guild">>,
|
||||
<<"presence">>,
|
||||
<<"push">>,
|
||||
<<"call">>,
|
||||
<<"health">>,
|
||||
<<"hot_reload">>,
|
||||
<<"rpc_client">>,
|
||||
<<"rendezvous">>,
|
||||
<<"process_">>,
|
||||
<<"metrics_">>,
|
||||
<<"dm_voice">>,
|
||||
<<"voice_">>,
|
||||
<<"constants">>,
|
||||
<<"validation">>,
|
||||
<<"backoff_">>,
|
||||
<<"list_ops">>,
|
||||
<<"map_utils">>,
|
||||
<<"type_conv">>,
|
||||
<<"utils">>,
|
||||
<<"user_utils">>,
|
||||
<<"custom_status">>
|
||||
],
|
||||
lists:any(
|
||||
fun(P) ->
|
||||
Sz = byte_size(P),
|
||||
byte_size(Bin) >= Sz andalso binary:part(Bin, 0, Sz) =:= P
|
||||
end,
|
||||
Prefixes
|
||||
).
|
||||
|
||||
respond(Status, Body, Req0, State) ->
|
||||
Req = cowboy_req:reply(Status, ?JSON_HEADERS, jsx:encode(Body), Req0),
|
||||
{ok, Req, State}.
|
||||
101
fluxer_gateway/src/gateway/rendezvous_router.erl
Normal file
101
fluxer_gateway/src/gateway/rendezvous_router.erl
Normal file
@@ -0,0 +1,101 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(rendezvous_router).
|
||||
|
||||
-export([select/2, group_keys/2]).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-endif.
|
||||
|
||||
-define(HASH_LIMIT, 16#FFFFFFFF).
|
||||
|
||||
-spec select(term(), pos_integer()) -> non_neg_integer().
|
||||
select(Key, ShardCount) when ShardCount > 0 ->
|
||||
Indices = lists:seq(0, ShardCount - 1),
|
||||
{Index, _Weight} =
|
||||
lists:foldl(
|
||||
fun(CurrentIndex, {BestIndex, BestWeight}) ->
|
||||
Weight = weight(Key, CurrentIndex),
|
||||
case
|
||||
(Weight > BestWeight) orelse
|
||||
(Weight =:= BestWeight andalso CurrentIndex < BestIndex)
|
||||
of
|
||||
true ->
|
||||
{CurrentIndex, Weight};
|
||||
false ->
|
||||
{BestIndex, BestWeight}
|
||||
end
|
||||
end,
|
||||
{0, -1},
|
||||
Indices
|
||||
),
|
||||
Index;
|
||||
select(_Key, _ShardCount) ->
|
||||
0.
|
||||
|
||||
-spec group_keys([term()], pos_integer()) -> [{non_neg_integer(), [term()]}].
|
||||
group_keys(Keys, ShardCount) when is_list(Keys), ShardCount > 0 ->
|
||||
Sorted =
|
||||
maps:to_list(
|
||||
lists:foldl(
|
||||
fun(Key, Acc) ->
|
||||
Index = select(Key, ShardCount),
|
||||
Existing = maps:get(Index, Acc, []),
|
||||
maps:put(Index, [Key | Existing], Acc)
|
||||
end,
|
||||
#{},
|
||||
Keys
|
||||
)
|
||||
),
|
||||
lists:sort(
|
||||
fun({IdxA, _}, {IdxB, _}) -> IdxA =< IdxB end,
|
||||
[{Index, lists:usort(Group)} || {Index, Group} <- Sorted]
|
||||
);
|
||||
group_keys(_Keys, _ShardCount) ->
|
||||
[].
|
||||
|
||||
-spec weight(term(), non_neg_integer()) -> non_neg_integer().
|
||||
weight(Key, Index) ->
|
||||
erlang:phash2({Key, Index}, ?HASH_LIMIT).
|
||||
|
||||
-ifdef(TEST).
|
||||
select_returns_valid_index_test() ->
|
||||
?assertEqual(0, select(test_key, 1)),
|
||||
Index = select(test_key, 5),
|
||||
?assert(Index >= 0),
|
||||
?assert(Index < 5).
|
||||
|
||||
select_is_stable_for_same_inputs_test() ->
|
||||
?assertEqual(select(<<"abc">>, 8), select(<<"abc">>, 8)),
|
||||
?assertEqual(select(12345, 3), select(12345, 3)).
|
||||
|
||||
group_keys_sorts_and_deduplicates_test() ->
|
||||
Keys = [1, 2, 3, 1, 2],
|
||||
Groups = group_keys(Keys, 2),
|
||||
?assertMatch([{_, _}, {_, _}], Groups),
|
||||
lists:foreach(
|
||||
fun({_Index, GroupKeys}) ->
|
||||
?assertEqual(GroupKeys, lists:usort(GroupKeys))
|
||||
end,
|
||||
Groups
|
||||
).
|
||||
|
||||
group_keys_handles_empty_test() ->
|
||||
?assertEqual([], group_keys([], 4)).
|
||||
-endif.
|
||||
85
fluxer_gateway/src/gateway/rpc_client.erl
Normal file
85
fluxer_gateway/src/gateway/rpc_client.erl
Normal file
@@ -0,0 +1,85 @@
|
||||
%% Copyright (C) 2026 Fluxer Contributors
|
||||
%%
|
||||
%% This file is part of Fluxer.
|
||||
%%
|
||||
%% Fluxer is free software: you can redistribute it and/or modify
|
||||
%% it under the terms of the GNU Affero General Public License as published by
|
||||
%% the Free Software Foundation, either version 3 of the License, or
|
||||
%% (at your option) any later version.
|
||||
%%
|
||||
%% Fluxer is distributed in the hope that it will be useful,
|
||||
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
%% GNU Affero General Public License for more details.
|
||||
%%
|
||||
%% You should have received a copy of the GNU Affero General Public License
|
||||
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-module(rpc_client).
|
||||
|
||||
-export([
|
||||
call/1,
|
||||
call/2,
|
||||
get_rpc_url/0,
|
||||
get_rpc_url/1,
|
||||
get_rpc_headers/0
|
||||
]).
|
||||
|
||||
-type rpc_request() :: map().
|
||||
-type rpc_response() :: {ok, map()} | {error, term()}.
|
||||
|
||||
-spec call(rpc_request()) -> rpc_response().
|
||||
call(Request) ->
|
||||
call(Request, #{}).
|
||||
|
||||
-spec call(rpc_request(), map()) -> rpc_response().
|
||||
call(Request, _Options) ->
|
||||
Url = get_rpc_url(),
|
||||
Headers = get_rpc_headers(),
|
||||
Body = jsx:encode(Request),
|
||||
|
||||
case
|
||||
hackney:request(post, Url, Headers, Body, [{recv_timeout, 30000}, {connect_timeout, 5000}])
|
||||
of
|
||||
{ok, 200, _RespHeaders, ClientRef} ->
|
||||
case hackney:body(ClientRef) of
|
||||
{ok, RespBody} ->
|
||||
Response = jsx:decode(RespBody, [return_maps]),
|
||||
Data = maps:get(<<"data">>, Response, #{}),
|
||||
{ok, Data};
|
||||
{error, Reason} ->
|
||||
logger:error("[rpc_client] Failed to read response body: ~p", [Reason]),
|
||||
{error, {body_read_failed, Reason}}
|
||||
end;
|
||||
{ok, StatusCode, _RespHeaders, ClientRef} ->
|
||||
case hackney:body(ClientRef) of
|
||||
{ok, RespBody} ->
|
||||
hackney:close(ClientRef),
|
||||
logger:error("[rpc_client] RPC request failed with status ~p: ~s", [
|
||||
StatusCode, RespBody
|
||||
]),
|
||||
{error, {http_error, StatusCode, RespBody}};
|
||||
{error, Reason} ->
|
||||
hackney:close(ClientRef),
|
||||
logger:error(
|
||||
"[rpc_client] Failed to read error response body (status ~p): ~p", [
|
||||
StatusCode, Reason
|
||||
]
|
||||
),
|
||||
{error, {http_error, StatusCode, body_read_failed}}
|
||||
end;
|
||||
{error, Reason} ->
|
||||
logger:error("[rpc_client] RPC request failed: ~p", [Reason]),
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
get_rpc_url() ->
|
||||
ApiHost = fluxer_gateway_env:get(api_host),
|
||||
get_rpc_url(ApiHost).
|
||||
|
||||
get_rpc_url(ApiHost) ->
|
||||
"http://" ++ ApiHost ++ "/_rpc".
|
||||
|
||||
get_rpc_headers() ->
|
||||
RpcSecretKey = fluxer_gateway_env:get(rpc_secret_key),
|
||||
[{<<"Authorization">>, <<"Bearer ", RpcSecretKey/binary>>}].
|
||||
Reference in New Issue
Block a user