refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -30,3 +30,29 @@ calculate(Attempt) ->
calculate(Attempt, MaxMs) ->
BackoffMs = round(1000 * math:pow(2, Attempt)),
min(BackoffMs, MaxMs).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
calculate_default_max_test() ->
?assertEqual(1000, calculate(0)),
?assertEqual(2000, calculate(1)),
?assertEqual(4000, calculate(2)),
?assertEqual(8000, calculate(3)),
?assertEqual(16000, calculate(4)),
?assertEqual(30000, calculate(5)),
?assertEqual(30000, calculate(10)).
calculate_custom_max_test() ->
?assertEqual(1000, calculate(0, 5000)),
?assertEqual(2000, calculate(1, 5000)),
?assertEqual(4000, calculate(2, 5000)),
?assertEqual(5000, calculate(3, 5000)),
?assertEqual(5000, calculate(10, 5000)).
calculate_small_max_test() ->
?assertEqual(1000, calculate(0, 1000)),
?assertEqual(1000, calculate(1, 1000)),
?assertEqual(1000, calculate(5, 1000)).
-endif.

View File

@@ -35,10 +35,12 @@
speak_permission/0,
stream_permission/0,
use_vad_permission/0,
read_message_history_permission/0,
kick_members_permission/0,
ban_members_permission/0
]).
-spec gateway_opcode(integer()) -> atom().
gateway_opcode(0) -> dispatch;
gateway_opcode(1) -> heartbeat;
gateway_opcode(2) -> identify;
@@ -52,10 +54,10 @@ gateway_opcode(9) -> invalid_session;
gateway_opcode(10) -> hello;
gateway_opcode(11) -> heartbeat_ack;
gateway_opcode(12) -> gateway_error;
gateway_opcode(13) -> call_connect;
gateway_opcode(14) -> lazy_request;
gateway_opcode(_) -> unknown.
-spec opcode_to_num(atom()) -> integer().
opcode_to_num(dispatch) -> 0;
opcode_to_num(heartbeat) -> 1;
opcode_to_num(identify) -> 2;
@@ -69,9 +71,9 @@ opcode_to_num(invalid_session) -> 9;
opcode_to_num(hello) -> 10;
opcode_to_num(heartbeat_ack) -> 11;
opcode_to_num(gateway_error) -> 12;
opcode_to_num(call_connect) -> 13;
opcode_to_num(lazy_request) -> 14.
-spec close_code_to_num(atom()) -> integer().
close_code_to_num(unknown_error) -> 4000;
close_code_to_num(unknown_opcode) -> 4001;
close_code_to_num(decode_error) -> 4002;
@@ -83,264 +85,16 @@ close_code_to_num(rate_limited) -> 4008;
close_code_to_num(session_timeout) -> 4009;
close_code_to_num(invalid_shard) -> 4010;
close_code_to_num(sharding_required) -> 4011;
close_code_to_num(invalid_api_version) -> 4012.
close_code_to_num(invalid_api_version) -> 4012;
close_code_to_num(ack_backpressure) -> 4013.
dispatch_event_atom(<<"READY">>) ->
ready;
dispatch_event_atom(<<"RESUMED">>) ->
resumed;
dispatch_event_atom(<<"SESSIONS_REPLACE">>) ->
sessions_replace;
dispatch_event_atom(<<"USER_UPDATE">>) ->
user_update;
dispatch_event_atom(<<"USER_SETTINGS_UPDATE">>) ->
user_settings_update;
dispatch_event_atom(<<"USER_GUILD_SETTINGS_UPDATE">>) ->
user_guild_settings_update;
dispatch_event_atom(<<"USER_PINNED_DMS_UPDATE">>) ->
user_pinned_dms_update;
dispatch_event_atom(<<"USER_NOTE_UPDATE">>) ->
user_note_update;
dispatch_event_atom(<<"RECENT_MENTION_DELETE">>) ->
recent_mention_delete;
dispatch_event_atom(<<"SAVED_MESSAGE_CREATE">>) ->
saved_message_create;
dispatch_event_atom(<<"SAVED_MESSAGE_DELETE">>) ->
saved_message_delete;
dispatch_event_atom(<<"AUTH_SESSION_CHANGE">>) ->
auth_session_change;
dispatch_event_atom(<<"PRESENCE_UPDATE">>) ->
presence_update;
dispatch_event_atom(<<"GUILD_CREATE">>) ->
guild_create;
dispatch_event_atom(<<"GUILD_UPDATE">>) ->
guild_update;
dispatch_event_atom(<<"GUILD_DELETE">>) ->
guild_delete;
dispatch_event_atom(<<"GUILD_MEMBER_ADD">>) ->
guild_member_add;
dispatch_event_atom(<<"GUILD_MEMBER_UPDATE">>) ->
guild_member_update;
dispatch_event_atom(<<"GUILD_MEMBER_REMOVE">>) ->
guild_member_remove;
dispatch_event_atom(<<"GUILD_ROLE_CREATE">>) ->
guild_role_create;
dispatch_event_atom(<<"GUILD_ROLE_UPDATE">>) ->
guild_role_update;
dispatch_event_atom(<<"GUILD_ROLE_UPDATE_BULK">>) ->
guild_role_update_bulk;
dispatch_event_atom(<<"GUILD_ROLE_DELETE">>) ->
guild_role_delete;
dispatch_event_atom(<<"GUILD_EMOJIS_UPDATE">>) ->
guild_emojis_update;
dispatch_event_atom(<<"GUILD_STICKERS_UPDATE">>) ->
guild_stickers_update;
dispatch_event_atom(<<"GUILD_BAN_ADD">>) ->
guild_ban_add;
dispatch_event_atom(<<"GUILD_BAN_REMOVE">>) ->
guild_ban_remove;
dispatch_event_atom(<<"GUILD_MEMBERS_CHUNK">>) ->
guild_members_chunk;
dispatch_event_atom(<<"CHANNEL_CREATE">>) ->
channel_create;
dispatch_event_atom(<<"CHANNEL_UPDATE">>) ->
channel_update;
dispatch_event_atom(<<"CHANNEL_UPDATE_BULK">>) ->
channel_update_bulk;
dispatch_event_atom(<<"PASSIVE_UPDATES">>) ->
passive_updates;
dispatch_event_atom(<<"CHANNEL_DELETE">>) ->
channel_delete;
dispatch_event_atom(<<"CHANNEL_RECIPIENT_ADD">>) ->
channel_recipient_add;
dispatch_event_atom(<<"CHANNEL_RECIPIENT_REMOVE">>) ->
channel_recipient_remove;
dispatch_event_atom(<<"CHANNEL_PINS_UPDATE">>) ->
channel_pins_update;
dispatch_event_atom(<<"CHANNEL_PINS_ACK">>) ->
channel_pins_ack;
dispatch_event_atom(<<"INVITE_CREATE">>) ->
invite_create;
dispatch_event_atom(<<"INVITE_DELETE">>) ->
invite_delete;
dispatch_event_atom(<<"MESSAGE_CREATE">>) ->
message_create;
dispatch_event_atom(<<"MESSAGE_UPDATE">>) ->
message_update;
dispatch_event_atom(<<"MESSAGE_DELETE">>) ->
message_delete;
dispatch_event_atom(<<"MESSAGE_DELETE_BULK">>) ->
message_delete_bulk;
dispatch_event_atom(<<"MESSAGE_REACTION_ADD">>) ->
message_reaction_add;
dispatch_event_atom(<<"MESSAGE_REACTION_REMOVE">>) ->
message_reaction_remove;
dispatch_event_atom(<<"MESSAGE_REACTION_REMOVE_ALL">>) ->
message_reaction_remove_all;
dispatch_event_atom(<<"MESSAGE_REACTION_REMOVE_EMOJI">>) ->
message_reaction_remove_emoji;
dispatch_event_atom(<<"MESSAGE_ACK">>) ->
message_ack;
dispatch_event_atom(<<"TYPING_START">>) ->
typing_start;
dispatch_event_atom(<<"WEBHOOKS_UPDATE">>) ->
webhooks_update;
dispatch_event_atom(<<"RELATIONSHIP_ADD">>) ->
relationship_add;
dispatch_event_atom(<<"RELATIONSHIP_UPDATE">>) ->
relationship_update;
dispatch_event_atom(<<"RELATIONSHIP_REMOVE">>) ->
relationship_remove;
dispatch_event_atom(<<"VOICE_STATE_UPDATE">>) ->
voice_state_update;
dispatch_event_atom(<<"VOICE_SERVER_UPDATE">>) ->
voice_server_update;
dispatch_event_atom(<<"FAVORITE_MEME_CREATE">>) ->
favorite_meme_create;
dispatch_event_atom(<<"FAVORITE_MEME_UPDATE">>) ->
favorite_meme_update;
dispatch_event_atom(<<"FAVORITE_MEME_DELETE">>) ->
favorite_meme_delete;
dispatch_event_atom(<<"CALL_CREATE">>) ->
call_create;
dispatch_event_atom(<<"CALL_UPDATE">>) ->
call_update;
dispatch_event_atom(<<"CALL_DELETE">>) ->
call_delete;
dispatch_event_atom(<<"GUILD_MEMBER_LIST_UPDATE">>) ->
guild_member_list_update;
dispatch_event_atom(<<"GUILD_SYNC">>) ->
guild_sync;
dispatch_event_atom(ready) ->
<<"READY">>;
dispatch_event_atom(resumed) ->
<<"RESUMED">>;
dispatch_event_atom(sessions_replace) ->
<<"SESSIONS_REPLACE">>;
dispatch_event_atom(user_update) ->
<<"USER_UPDATE">>;
dispatch_event_atom(user_settings_update) ->
<<"USER_SETTINGS_UPDATE">>;
dispatch_event_atom(user_guild_settings_update) ->
<<"USER_GUILD_SETTINGS_UPDATE">>;
dispatch_event_atom(user_pinned_dms_update) ->
<<"USER_PINNED_DMS_UPDATE">>;
dispatch_event_atom(user_note_update) ->
<<"USER_NOTE_UPDATE">>;
dispatch_event_atom(recent_mention_delete) ->
<<"RECENT_MENTION_DELETE">>;
dispatch_event_atom(saved_message_create) ->
<<"SAVED_MESSAGE_CREATE">>;
dispatch_event_atom(saved_message_delete) ->
<<"SAVED_MESSAGE_DELETE">>;
dispatch_event_atom(auth_session_change) ->
<<"AUTH_SESSION_CHANGE">>;
dispatch_event_atom(presence_update) ->
<<"PRESENCE_UPDATE">>;
dispatch_event_atom(guild_create) ->
<<"GUILD_CREATE">>;
dispatch_event_atom(guild_update) ->
<<"GUILD_UPDATE">>;
dispatch_event_atom(guild_delete) ->
<<"GUILD_DELETE">>;
dispatch_event_atom(guild_member_add) ->
<<"GUILD_MEMBER_ADD">>;
dispatch_event_atom(guild_member_update) ->
<<"GUILD_MEMBER_UPDATE">>;
dispatch_event_atom(guild_member_remove) ->
<<"GUILD_MEMBER_REMOVE">>;
dispatch_event_atom(guild_role_create) ->
<<"GUILD_ROLE_CREATE">>;
dispatch_event_atom(guild_role_update) ->
<<"GUILD_ROLE_UPDATE">>;
dispatch_event_atom(guild_role_update_bulk) ->
<<"GUILD_ROLE_UPDATE_BULK">>;
dispatch_event_atom(guild_role_delete) ->
<<"GUILD_ROLE_DELETE">>;
dispatch_event_atom(guild_emojis_update) ->
<<"GUILD_EMOJIS_UPDATE">>;
dispatch_event_atom(guild_stickers_update) ->
<<"GUILD_STICKERS_UPDATE">>;
dispatch_event_atom(guild_ban_add) ->
<<"GUILD_BAN_ADD">>;
dispatch_event_atom(guild_ban_remove) ->
<<"GUILD_BAN_REMOVE">>;
dispatch_event_atom(guild_members_chunk) ->
<<"GUILD_MEMBERS_CHUNK">>;
dispatch_event_atom(channel_create) ->
<<"CHANNEL_CREATE">>;
dispatch_event_atom(channel_update) ->
<<"CHANNEL_UPDATE">>;
dispatch_event_atom(channel_update_bulk) ->
<<"CHANNEL_UPDATE_BULK">>;
dispatch_event_atom(passive_updates) ->
<<"PASSIVE_UPDATES">>;
dispatch_event_atom(channel_delete) ->
<<"CHANNEL_DELETE">>;
dispatch_event_atom(channel_recipient_add) ->
<<"CHANNEL_RECIPIENT_ADD">>;
dispatch_event_atom(channel_recipient_remove) ->
<<"CHANNEL_RECIPIENT_REMOVE">>;
dispatch_event_atom(channel_pins_update) ->
<<"CHANNEL_PINS_UPDATE">>;
dispatch_event_atom(channel_pins_ack) ->
<<"CHANNEL_PINS_ACK">>;
dispatch_event_atom(invite_create) ->
<<"INVITE_CREATE">>;
dispatch_event_atom(invite_delete) ->
<<"INVITE_DELETE">>;
dispatch_event_atom(message_create) ->
<<"MESSAGE_CREATE">>;
dispatch_event_atom(message_update) ->
<<"MESSAGE_UPDATE">>;
dispatch_event_atom(message_delete) ->
<<"MESSAGE_DELETE">>;
dispatch_event_atom(message_delete_bulk) ->
<<"MESSAGE_DELETE_BULK">>;
dispatch_event_atom(message_reaction_add) ->
<<"MESSAGE_REACTION_ADD">>;
dispatch_event_atom(message_reaction_remove) ->
<<"MESSAGE_REACTION_REMOVE">>;
dispatch_event_atom(message_reaction_remove_all) ->
<<"MESSAGE_REACTION_REMOVE_ALL">>;
dispatch_event_atom(message_reaction_remove_emoji) ->
<<"MESSAGE_REACTION_REMOVE_EMOJI">>;
dispatch_event_atom(message_ack) ->
<<"MESSAGE_ACK">>;
dispatch_event_atom(typing_start) ->
<<"TYPING_START">>;
dispatch_event_atom(webhooks_update) ->
<<"WEBHOOKS_UPDATE">>;
dispatch_event_atom(relationship_add) ->
<<"RELATIONSHIP_ADD">>;
dispatch_event_atom(relationship_update) ->
<<"RELATIONSHIP_UPDATE">>;
dispatch_event_atom(relationship_remove) ->
<<"RELATIONSHIP_REMOVE">>;
dispatch_event_atom(voice_state_update) ->
<<"VOICE_STATE_UPDATE">>;
dispatch_event_atom(voice_server_update) ->
<<"VOICE_SERVER_UPDATE">>;
dispatch_event_atom(favorite_meme_create) ->
<<"FAVORITE_MEME_CREATE">>;
dispatch_event_atom(favorite_meme_update) ->
<<"FAVORITE_MEME_UPDATE">>;
dispatch_event_atom(favorite_meme_delete) ->
<<"FAVORITE_MEME_DELETE">>;
dispatch_event_atom(call_create) ->
<<"CALL_CREATE">>;
dispatch_event_atom(call_update) ->
<<"CALL_UPDATE">>;
dispatch_event_atom(call_delete) ->
<<"CALL_DELETE">>;
dispatch_event_atom(guild_member_list_update) ->
<<"GUILD_MEMBER_LIST_UPDATE">>;
dispatch_event_atom(guild_sync) ->
<<"GUILD_SYNC">>;
dispatch_event_atom(EventBinary) when is_binary(EventBinary) -> EventBinary;
dispatch_event_atom(EventAtom) when is_atom(EventAtom) ->
list_to_binary(string:uppercase(atom_to_list(EventAtom))).
-spec dispatch_event_atom(atom() | binary()) -> atom() | binary().
dispatch_event_atom(Event) when is_atom(Event) ->
list_to_binary(string:uppercase(atom_to_list(Event)));
dispatch_event_atom(EventBinary) when is_binary(EventBinary) ->
event_atoms:normalize(EventBinary).
-spec status_type_atom(binary() | atom()) -> atom() | binary().
status_type_atom(<<"online">>) -> online;
status_type_atom(<<"dnd">>) -> dnd;
status_type_atom(<<"idle">>) -> idle;
@@ -352,17 +106,89 @@ status_type_atom(idle) -> <<"idle">>;
status_type_atom(invisible) -> <<"invisible">>;
status_type_atom(offline) -> <<"offline">>.
-spec max_payload_size() -> pos_integer().
max_payload_size() -> 4096.
-spec heartbeat_interval() -> pos_integer().
heartbeat_interval() -> 41250.
-spec heartbeat_timeout() -> pos_integer().
heartbeat_timeout() -> 45000.
-spec random_session_bytes() -> pos_integer().
random_session_bytes() -> 16.
-spec view_channel_permission() -> pos_integer().
view_channel_permission() -> 1024.
-spec administrator_permission() -> pos_integer().
administrator_permission() -> 8.
-spec manage_roles_permission() -> pos_integer().
manage_roles_permission() -> 268435456.
-spec manage_channels_permission() -> pos_integer().
manage_channels_permission() -> 16.
-spec connect_permission() -> pos_integer().
connect_permission() -> 1048576.
-spec speak_permission() -> pos_integer().
speak_permission() -> 2097152.
-spec stream_permission() -> pos_integer().
stream_permission() -> 512.
-spec use_vad_permission() -> pos_integer().
use_vad_permission() -> 33554432.
-spec read_message_history_permission() -> pos_integer().
read_message_history_permission() -> 65536.
-spec kick_members_permission() -> pos_integer().
kick_members_permission() -> 2.
-spec ban_members_permission() -> pos_integer().
ban_members_permission() -> 4.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
gateway_opcode_test() ->
?assertEqual(dispatch, gateway_opcode(0)),
?assertEqual(heartbeat, gateway_opcode(1)),
?assertEqual(identify, gateway_opcode(2)),
?assertEqual(unknown, gateway_opcode(999)).
opcode_to_num_test() ->
?assertEqual(0, opcode_to_num(dispatch)),
?assertEqual(1, opcode_to_num(heartbeat)),
?assertEqual(2, opcode_to_num(identify)).
close_code_to_num_test() ->
?assertEqual(4000, close_code_to_num(unknown_error)),
?assertEqual(4004, close_code_to_num(authentication_failed)),
?assertEqual(4008, close_code_to_num(rate_limited)),
?assertEqual(4013, close_code_to_num(ack_backpressure)).
status_type_atom_binary_to_atom_test() ->
?assertEqual(online, status_type_atom(<<"online">>)),
?assertEqual(dnd, status_type_atom(<<"dnd">>)),
?assertEqual(idle, status_type_atom(<<"idle">>)),
?assertEqual(invisible, status_type_atom(<<"invisible">>)),
?assertEqual(offline, status_type_atom(<<"offline">>)).
status_type_atom_atom_to_binary_test() ->
?assertEqual(<<"online">>, status_type_atom(online)),
?assertEqual(<<"dnd">>, status_type_atom(dnd)),
?assertEqual(<<"idle">>, status_type_atom(idle)).
constants_values_test() ->
?assertEqual(4096, max_payload_size()),
?assertEqual(41250, heartbeat_interval()),
?assertEqual(45000, heartbeat_timeout()),
?assertEqual(16, random_session_bytes()),
?assertEqual(1024, view_channel_permission()),
?assertEqual(8, administrator_permission()).
-endif.

View File

@@ -17,17 +17,16 @@
-module(custom_status_validation).
-export([
validate/2
]).
-export([validate/2]).
-spec validate(integer(), map() | null) -> {ok, map()} | {error, term()}.
-spec validate(integer(), map() | null) -> {ok, map() | null} | {error, term()}.
validate(_UserId, null) ->
{ok, null};
validate(UserId, CustomStatus) when is_map(CustomStatus) ->
Request = build_request(UserId, CustomStatus),
rpc_client:call(Request).
-spec build_request(integer(), map()) -> map().
build_request(UserId, CustomStatus) ->
#{
<<"type">> => <<"validate_custom_status">>,
@@ -35,29 +34,56 @@ build_request(UserId, CustomStatus) ->
<<"custom_status">> => build_custom_status_payload(CustomStatus)
}.
-spec build_custom_status_payload(map()) -> map().
build_custom_status_payload(CustomStatus) ->
Field1 = put_optional_field(
maps:new(),
<<"text">>,
maps:get(<<"text">>, CustomStatus, undefined)
),
Field2 = put_optional_field(
Field1,
<<"expires_at">>,
maps:get(<<"expires_at">>, CustomStatus, undefined)
),
Field3 = put_optional_field(
Field2,
<<"emoji_id">>,
maps:get(<<"emoji_id">>, CustomStatus, undefined)
),
put_optional_field(
Field3,
<<"emoji_name">>,
maps:get(<<"emoji_name">>, CustomStatus, undefined)
Fields = [
{<<"text">>, maps:get(<<"text">>, CustomStatus, undefined)},
{<<"expires_at">>, maps:get(<<"expires_at">>, CustomStatus, undefined)},
{<<"emoji_id">>, maps:get(<<"emoji_id">>, CustomStatus, undefined)},
{<<"emoji_name">>, maps:get(<<"emoji_name">>, CustomStatus, undefined)}
],
lists:foldl(
fun
({_Key, undefined}, Acc) -> Acc;
({Key, Value}, Acc) -> maps:put(Key, Value, Acc)
end,
#{},
Fields
).
put_optional_field(Map, _Key, undefined) ->
Map;
put_optional_field(Map, Key, Value) ->
maps:put(Key, Value, Map).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
validate_null_test() ->
?assertEqual({ok, null}, validate(123, null)).
build_custom_status_payload_all_fields_test() ->
Input = #{
<<"text">> => <<"Hello">>,
<<"expires_at">> => <<"2024-01-01T00:00:00Z">>,
<<"emoji_id">> => <<"123">>,
<<"emoji_name">> => <<"smile">>
},
Result = build_custom_status_payload(Input),
?assertEqual(<<"Hello">>, maps:get(<<"text">>, Result)),
?assertEqual(<<"2024-01-01T00:00:00Z">>, maps:get(<<"expires_at">>, Result)),
?assertEqual(<<"123">>, maps:get(<<"emoji_id">>, Result)),
?assertEqual(<<"smile">>, maps:get(<<"emoji_name">>, Result)).
build_custom_status_payload_partial_test() ->
Input = #{<<"text">> => <<"Hello">>},
Result = build_custom_status_payload(Input),
?assertEqual(1, maps:size(Result)),
?assertEqual(<<"Hello">>, maps:get(<<"text">>, Result)).
build_custom_status_payload_empty_test() ->
?assertEqual(#{}, build_custom_status_payload(#{})).
build_request_test() ->
CustomStatus = #{<<"text">> => <<"Test">>},
Result = build_request(123, CustomStatus),
?assertEqual(<<"validate_custom_status">>, maps:get(<<"type">>, Result)),
?assertEqual(<<"123">>, maps:get(<<"user_id">>, Result)),
?assert(is_map(maps:get(<<"custom_status">>, Result))).
-endif.

View File

@@ -0,0 +1,50 @@
%% 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(event_atoms).
-export([normalize/1]).
-spec normalize(binary() | atom()) -> atom() | binary().
normalize(Event) when is_atom(Event) ->
Event;
normalize(EventBinary) when is_binary(EventBinary) ->
Lowercase = string:lowercase(EventBinary),
try
binary_to_existing_atom(Lowercase, utf8)
catch
error:badarg ->
EventBinary
end.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
normalize_atom_test() ->
?assertEqual(test_event, normalize(test_event)),
?assertEqual(message_create, normalize(message_create)).
normalize_binary_existing_atom_test() ->
_ = message_create,
?assertEqual(message_create, normalize(<<"MESSAGE_CREATE">>)),
?assertEqual(message_create, normalize(<<"message_create">>)).
normalize_binary_unknown_test() ->
Result = normalize(<<"UNKNOWN_EVENT_XYZ_12345">>),
?assertEqual(<<"UNKNOWN_EVENT_XYZ_12345">>, Result).
-endif.

View File

@@ -0,0 +1,56 @@
%% 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(snowflake_util).
-export([extract_timestamp/1]).
-define(FLUXER_EPOCH, 1420070400000).
-define(TIMESTAMP_SHIFT, 22).
-spec extract_timestamp(binary() | integer()) -> integer().
extract_timestamp(SnowflakeBin) when is_binary(SnowflakeBin) ->
Snowflake = binary_to_integer(SnowflakeBin),
extract_timestamp(Snowflake);
extract_timestamp(Snowflake) when is_integer(Snowflake) ->
(Snowflake bsr ?TIMESTAMP_SHIFT) + ?FLUXER_EPOCH.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
extract_timestamp_from_integer_test() ->
Timestamp = 1704067200000,
RelativeTs = Timestamp - ?FLUXER_EPOCH,
Snowflake = RelativeTs bsl ?TIMESTAMP_SHIFT,
?assertEqual(Timestamp, extract_timestamp(Snowflake)).
extract_timestamp_from_binary_test() ->
Timestamp = 1704067200000,
RelativeTs = Timestamp - ?FLUXER_EPOCH,
Snowflake = RelativeTs bsl ?TIMESTAMP_SHIFT,
SnowflakeBin = integer_to_binary(Snowflake),
?assertEqual(Timestamp, extract_timestamp(SnowflakeBin)).
extract_timestamp_with_worker_and_sequence_test() ->
Timestamp = 1704067200000,
RelativeTs = Timestamp - ?FLUXER_EPOCH,
WorkerId = 5,
Sequence = 100,
Snowflake = (RelativeTs bsl ?TIMESTAMP_SHIFT) bor (WorkerId bsl 12) bor Sequence,
?assertEqual(Timestamp, extract_timestamp(Snowflake)).
-endif.

View File

@@ -17,10 +17,11 @@
-module(user_utils).
-export([normalize_user/1]).
-export([normalize_user/1, partial_user_fields/0]).
normalize_user(User) when is_map(User) ->
AllowedKeys = [
-spec partial_user_fields() -> [binary()].
partial_user_fields() ->
[
<<"id">>,
<<"username">>,
<<"discriminator">>,
@@ -29,25 +30,82 @@ normalize_user(User) when is_map(User) ->
<<"avatar_color">>,
<<"bot">>,
<<"system">>,
<<"flags">>,
<<"banner">>,
<<"banner_color">>
],
<<"flags">>
].
-spec normalize_user(map() | term()) -> map().
normalize_user(User) when is_map(User) ->
CleanPairs =
lists:foldl(
fun(Key, Acc) ->
Value = maps:get(Key, User, undefined),
case is_undefined(Value) of
true -> Acc;
false -> [{Key, Value} | Acc]
case maps:get(Key, User, undefined) of
undefined -> Acc;
Value -> [{Key, Value} | Acc]
end
end,
[],
AllowedKeys
partial_user_fields()
),
maps:from_list(lists:reverse(CleanPairs));
normalize_user(_) ->
#{}.
is_undefined(undefined) -> true;
is_undefined(_) -> false.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
normalize_user_valid_test() ->
User = #{
<<"id">> => <<"123">>,
<<"username">> => <<"testuser">>,
<<"discriminator">> => <<"0001">>,
<<"email">> => <<"test@example.com">>
},
Result = normalize_user(User),
?assertEqual(<<"123">>, maps:get(<<"id">>, Result)),
?assertEqual(<<"testuser">>, maps:get(<<"username">>, Result)),
?assertEqual(<<"0001">>, maps:get(<<"discriminator">>, Result)),
?assertEqual(error, maps:find(<<"email">>, Result)).
normalize_user_all_fields_test() ->
User = #{
<<"id">> => <<"123">>,
<<"username">> => <<"test">>,
<<"discriminator">> => <<"0">>,
<<"global_name">> => <<"Test User">>,
<<"avatar">> => <<"abc123">>,
<<"avatar_color">> => <<"#ff0000">>,
<<"bot">> => false,
<<"system">> => false,
<<"flags">> => 0
},
Result = normalize_user(User),
?assertEqual(9, maps:size(Result)).
normalize_user_undefined_values_test() ->
User = #{
<<"id">> => <<"123">>,
<<"username">> => <<"test">>,
<<"avatar">> => undefined
},
Result = normalize_user(User),
?assertEqual(2, maps:size(Result)),
?assertEqual(error, maps:find(<<"avatar">>, Result)).
normalize_user_not_map_test() ->
?assertEqual(#{}, normalize_user(not_a_map)),
?assertEqual(#{}, normalize_user(123)),
?assertEqual(#{}, normalize_user(<<"binary">>)),
?assertEqual(#{}, normalize_user(undefined)).
normalize_user_empty_map_test() ->
?assertEqual(#{}, normalize_user(#{})).
partial_user_fields_test() ->
Fields = partial_user_fields(),
?assert(is_list(Fields)),
?assertEqual(9, length(Fields)),
?assert(lists:member(<<"id">>, Fields)),
?assert(lists:member(<<"username">>, Fields)),
?assert(lists:member(<<"flags">>, Fields)).
-endif.

View File

@@ -16,7 +16,7 @@
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(utils).
-import(type_conv, [to_integer/1]).
-export([
binary_to_integer_safe/1,
generate_session_id/0,
@@ -25,10 +25,12 @@
parse_status/1,
safe_json_decode/1,
check_user_data_differs/2,
partial_user_fields/0,
parse_iso8601_to_unix_ms/1
]).
-spec binary_to_integer_safe(binary() | integer() | term()) -> integer() | undefined.
binary_to_integer_safe(Int) when is_integer(Int) ->
Int;
binary_to_integer_safe(Bin) when is_binary(Bin) ->
try
binary_to_integer(Bin)
@@ -40,21 +42,24 @@ binary_to_integer_safe(Bin) when is_binary(Bin) ->
_:_ -> undefined
end
end;
binary_to_integer_safe(Int) when is_integer(Int) -> Int;
binary_to_integer_safe(_) ->
undefined.
-spec generate_session_id() -> binary().
generate_session_id() ->
Bytes = crypto:strong_rand_bytes(constants:random_session_bytes()),
binary:encode_hex(Bytes).
-spec generate_resume_token() -> binary().
generate_resume_token() ->
Bytes = crypto:strong_rand_bytes(32),
base64url:encode(Bytes).
-spec hash_token(binary()) -> binary().
hash_token(Token) ->
crypto:hash(sha256, Token).
-spec parse_status(binary() | atom() | term()) -> atom().
parse_status(Status) when is_binary(Status) ->
constants:status_type_atom(Status);
parse_status(Status) when is_atom(Status) ->
@@ -62,29 +67,35 @@ parse_status(Status) when is_atom(Status) ->
parse_status(_) ->
online.
-spec safe_json_decode(binary()) -> map().
safe_json_decode(Bin) ->
try
jsx:decode(Bin, [{return_maps, true}])
json:decode(Bin)
catch
_:_ -> #{}
end.
-spec parse_iso8601_to_unix_ms(binary() | term()) -> integer() | undefined.
parse_iso8601_to_unix_ms(Binary) when is_binary(Binary) ->
Pattern =
<<"^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,9}))?Z$">>,
case re:run(Binary, Pattern, [{capture, [1, 2, 3, 4, 5, 6, 7], list}]) of
{match, [YearBin, MonthBin, DayBin, HourBin, MinuteBin, SecondBin, FractionBin]} ->
Year = to_integer(YearBin),
Month = to_integer(MonthBin),
Day = to_integer(DayBin),
Hour = to_integer(HourBin),
Minute = to_integer(MinuteBin),
Second = to_integer(SecondBin),
Year = type_conv:to_integer(YearBin),
Month = type_conv:to_integer(MonthBin),
Day = type_conv:to_integer(DayBin),
Hour = type_conv:to_integer(HourBin),
Minute = type_conv:to_integer(MinuteBin),
Second = type_conv:to_integer(SecondBin),
FractionMs = fractional_ms(FractionBin),
case {Year, Month, Day, Hour, Minute, Second} of
{Y, M, D, H, Min, S} when
is_integer(Y) and is_integer(M) and is_integer(D) and is_integer(H) and
is_integer(Min) and is_integer(S)
is_integer(Y),
is_integer(M),
is_integer(D),
is_integer(H),
is_integer(Min),
is_integer(S)
->
Seconds = calendar:datetime_to_gregorian_seconds({{Y, M, D}, {H, Min, S}}),
Seconds * 1000 + FractionMs;
@@ -97,6 +108,9 @@ parse_iso8601_to_unix_ms(Binary) when is_binary(Binary) ->
parse_iso8601_to_unix_ms(_) ->
undefined.
-spec fractional_ms(list()) -> non_neg_integer().
fractional_ms([]) ->
0;
fractional_ms(Fraction) when is_list(Fraction) ->
Normalized =
case length(Fraction) of
@@ -104,41 +118,121 @@ fractional_ms(Fraction) when is_list(Fraction) ->
Len when Len > 0 -> Fraction ++ lists:duplicate(3 - Len, $0);
_ -> "000"
end,
case Normalized of
[] ->
0;
_ ->
case catch list_to_integer(Normalized) of
{'EXIT', _} -> 0;
Value -> Value
end
case catch list_to_integer(Normalized) of
{'EXIT', _} -> 0;
Value -> Value
end;
fractional_ms(_) ->
0.
partial_user_fields() ->
[
<<"id">>,
<<"username">>,
<<"discriminator">>,
<<"global_name">>,
<<"avatar">>,
<<"avatar_color">>,
<<"bot">>,
<<"system">>,
<<"flags">>,
<<"banner">>,
<<"banner_color">>
].
-spec check_user_data_differs(map(), map()) -> boolean().
check_user_data_differs(CurrentUserData, NewUserData) ->
CheckedFields = partial_user_fields(),
CheckedFields = user_utils:partial_user_fields(),
lists:any(
fun(Field) ->
CurrentValue = maps:get(Field, CurrentUserData, undefined),
NewValue = maps:get(Field, NewUserData, undefined),
CurrentValue =/= NewValue orelse
(maps:is_key(Field, CurrentUserData) andalso not maps:is_key(Field, NewUserData))
case maps:is_key(Field, NewUserData) of
false ->
false;
true ->
CurrentValue = maps:get(Field, CurrentUserData, undefined),
NewValue = maps:get(Field, NewUserData, undefined),
CurrentValue =/= NewValue
end
end,
CheckedFields
).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
binary_to_integer_safe_integer_test() ->
?assertEqual(42, binary_to_integer_safe(42)),
?assertEqual(0, binary_to_integer_safe(0)),
?assertEqual(-100, binary_to_integer_safe(-100)).
binary_to_integer_safe_binary_test() ->
?assertEqual(123, binary_to_integer_safe(<<"123">>)),
?assertEqual(0, binary_to_integer_safe(<<"0">>)),
?assertEqual(-456, binary_to_integer_safe(<<"-456">>)).
binary_to_integer_safe_invalid_test() ->
?assertEqual(undefined, binary_to_integer_safe(<<"not_a_number">>)),
?assertEqual(undefined, binary_to_integer_safe(<<"12.34">>)),
?assertEqual(undefined, binary_to_integer_safe(<<"">>)),
?assertEqual(undefined, binary_to_integer_safe(atom)),
?assertEqual(undefined, binary_to_integer_safe(#{})).
generate_session_id_test() ->
SessionId = generate_session_id(),
?assert(is_binary(SessionId)),
?assertEqual(32, byte_size(SessionId)).
generate_resume_token_test() ->
Token = generate_resume_token(),
?assert(is_binary(Token)),
?assert(byte_size(Token) > 0).
hash_token_test() ->
Hash = hash_token(<<"test_token">>),
?assert(is_binary(Hash)),
?assertEqual(32, byte_size(Hash)).
parse_status_binary_test() ->
?assertEqual(online, parse_status(<<"online">>)),
?assertEqual(dnd, parse_status(<<"dnd">>)),
?assertEqual(idle, parse_status(<<"idle">>)),
?assertEqual(invisible, parse_status(<<"invisible">>)),
?assertEqual(offline, parse_status(<<"offline">>)).
parse_status_atom_test() ->
?assertEqual(online, parse_status(online)),
?assertEqual(dnd, parse_status(dnd)),
?assertEqual(idle, parse_status(idle)).
parse_status_default_test() ->
?assertEqual(online, parse_status(123)),
?assertEqual(online, parse_status(#{})).
safe_json_decode_valid_test() ->
Result = safe_json_decode(<<"{\"key\": \"value\"}">>),
?assertEqual(#{<<"key">> => <<"value">>}, Result).
safe_json_decode_invalid_test() ->
?assertEqual(#{}, safe_json_decode(<<"not json">>)),
?assertEqual(#{}, safe_json_decode(<<"">>)).
parse_iso8601_to_unix_ms_valid_test() ->
Result = parse_iso8601_to_unix_ms(<<"2024-01-15T12:30:45Z">>),
?assert(is_integer(Result)),
?assert(Result > 0).
parse_iso8601_to_unix_ms_with_fraction_test() ->
Result = parse_iso8601_to_unix_ms(<<"2024-01-15T12:30:45.123Z">>),
?assert(is_integer(Result)),
?assertEqual(123, Result rem 1000).
parse_iso8601_to_unix_ms_invalid_test() ->
?assertEqual(undefined, parse_iso8601_to_unix_ms(<<"invalid">>)),
?assertEqual(undefined, parse_iso8601_to_unix_ms(<<"2024-01-15">>)),
?assertEqual(undefined, parse_iso8601_to_unix_ms(123)).
check_user_data_differs_same_test() ->
User = #{<<"id">> => <<"123">>, <<"username">> => <<"test">>},
?assertEqual(false, check_user_data_differs(User, User)).
check_user_data_differs_different_test() ->
Current = #{<<"id">> => <<"123">>, <<"username">> => <<"test">>},
New = #{<<"id">> => <<"123">>, <<"username">> => <<"changed">>},
?assertEqual(true, check_user_data_differs(Current, New)).
check_user_data_differs_missing_field_test() ->
Current = #{<<"id">> => <<"123">>, <<"username">> => <<"test">>},
New = #{<<"id">> => <<"123">>},
?assertEqual(false, check_user_data_differs(Current, New)).
check_user_data_differs_null_field_test() ->
Current = #{<<"id">> => <<"123">>, <<"username">> => <<"test">>},
New = #{<<"username">> => null},
?assertEqual(true, check_user_data_differs(Current, New)).
-endif.

View File

@@ -138,6 +138,8 @@ extract_snowflake(FieldName, Map, Default) ->
extract_snowflakes(FieldSpecs, Map) ->
extract_snowflakes_loop(FieldSpecs, Map, #{}).
-spec extract_snowflakes_loop(list({atom(), binary()}), map(), map()) ->
{ok, #{atom() => integer()}} | {error, atom(), atom()}.
extract_snowflakes_loop([], _Map, Acc) ->
{ok, Acc};
extract_snowflakes_loop([{KeyAtom, FieldName} | Rest], Map, Acc) ->
@@ -192,3 +194,87 @@ error_category_to_close_code(auth_failed) ->
constants:close_code_to_num(authentication_failed);
error_category_to_close_code(_) ->
constants:close_code_to_num(unknown_error).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
validate_snowflake_integer_test() ->
?assertEqual({ok, 123}, validate_snowflake(123)),
?assertEqual({ok, 0}, validate_snowflake(0)),
?assertEqual({ok, -1}, validate_snowflake(-1)).
validate_snowflake_binary_test() ->
?assertEqual({ok, 123}, validate_snowflake(<<"123">>)),
?assertEqual({ok, 0}, validate_snowflake(<<"0">>)).
validate_snowflake_invalid_test() ->
?assertMatch({error, _, _}, validate_snowflake(null)),
?assertMatch({error, _, _}, validate_snowflake(<<"abc">>)),
?assertMatch({error, _, _}, validate_snowflake(1.5)).
validate_optional_snowflake_test() ->
?assertEqual({ok, null}, validate_optional_snowflake(null)),
?assertEqual({ok, 123}, validate_optional_snowflake(123)),
?assertEqual({ok, 456}, validate_optional_snowflake(<<"456">>)).
validate_snowflake_list_test() ->
?assertEqual({ok, [1, 2, 3]}, validate_snowflake_list([1, 2, 3])),
?assertEqual({ok, [1, 2]}, validate_snowflake_list([<<"1">>, <<"2">>])),
?assertEqual({ok, []}, validate_snowflake_list([])).
validate_snowflake_list_invalid_test() ->
?assertMatch({error, _, _}, validate_snowflake_list([1, <<"abc">>])),
?assertMatch({error, _, _}, validate_snowflake_list(not_a_list)).
snowflake_or_default_test() ->
?assertEqual(123, snowflake_or_default(123, 0)),
?assertEqual(456, snowflake_or_default(<<"456">>, 0)),
?assertEqual(0, snowflake_or_default(<<"abc">>, 0)),
?assertEqual(99, snowflake_or_default(null, 99)).
get_field_test() ->
Map = #{<<"key">> => <<"value">>},
?assertEqual({ok, <<"value">>}, get_field(<<"key">>, Map)),
?assertMatch({error, _, _}, get_field(<<"missing">>, Map)).
get_field_with_default_test() ->
Map = #{<<"key">> => <<"value">>},
?assertEqual(<<"value">>, get_field(<<"key">>, Map, <<"default">>)),
?assertEqual(<<"default">>, get_field(<<"missing">>, Map, <<"default">>)),
?assertEqual(<<"default">>, get_field(<<"key">>, not_a_map, <<"default">>)).
extract_snowflake_test() ->
Map = #{<<"id">> => <<"123">>},
?assertEqual({ok, 123}, extract_snowflake(<<"id">>, Map)),
?assertMatch({error, _, _}, extract_snowflake(<<"missing">>, Map)).
extract_snowflake_with_default_test() ->
Map = #{<<"id">> => <<"123">>},
?assertEqual(123, extract_snowflake(<<"id">>, Map, 0)),
?assertEqual(0, extract_snowflake(<<"missing">>, Map, 0)).
extract_snowflakes_test() ->
Map = #{<<"user_id">> => <<"123">>, <<"guild_id">> => <<"456">>},
Specs = [{user, <<"user_id">>}, {guild, <<"guild_id">>}],
{ok, Result} = extract_snowflakes(Specs, Map),
?assertEqual(123, maps:get(user, Result)),
?assertEqual(456, maps:get(guild, Result)).
get_required_field_test() ->
Map = #{<<"id">> => <<"123">>},
Validator = fun(V) -> validate_snowflake(V) end,
?assertEqual({ok, 123}, get_required_field(<<"id">>, Map, Validator)),
?assertMatch({error, _, _}, get_required_field(<<"missing">>, Map, Validator)).
get_optional_field_test() ->
Map = #{<<"id">> => <<"123">>},
Validator = fun(V) -> validate_snowflake(V) end,
?assertEqual({ok, 123}, get_optional_field(<<"id">>, Map, Validator)),
?assertEqual({ok, undefined}, get_optional_field(<<"missing">>, Map, Validator)).
error_category_to_close_code_test() ->
?assertEqual(4008, error_category_to_close_code(rate_limited)),
?assertEqual(4004, error_category_to_close_code(auth_failed)),
?assertEqual(4000, error_category_to_close_code(unknown)).
-endif.