initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,510 @@
%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
-module(guild_dispatch).
-export([
handle_dispatch/3,
extract_and_remove_session_id/1,
decorate_member_data/3,
extract_member_for_event/3,
collect_and_send_push_notifications/3,
normalize_event/1
]).
-import(guild_permissions, [find_member_by_user_id/2]).
-import(guild_state, [update_state/3]).
-import(guild_sessions, [
filter_sessions_for_channel/4,
filter_sessions_for_manage_channels/4,
filter_sessions_exclude_session/2
]).
-import(session_passive, [should_receive_event/5]).
normalize_event(Event) when is_atom(Event) -> Event;
normalize_event(<<"MESSAGE_CREATE">>) -> message_create;
normalize_event(<<"MESSAGE_UPDATE">>) -> message_update;
normalize_event(<<"MESSAGE_DELETE">>) -> message_delete;
normalize_event(<<"MESSAGE_DELETE_BULK">>) -> message_delete_bulk;
normalize_event(<<"MESSAGE_REACTION_ADD">>) -> message_reaction_add;
normalize_event(<<"MESSAGE_REACTION_REMOVE">>) -> message_reaction_remove;
normalize_event(<<"MESSAGE_REACTION_REMOVE_ALL">>) -> message_reaction_remove_all;
normalize_event(<<"MESSAGE_REACTION_REMOVE_EMOJI">>) -> message_reaction_remove_emoji;
normalize_event(<<"CHANNEL_CREATE">>) -> channel_create;
normalize_event(<<"CHANNEL_UPDATE">>) -> channel_update;
normalize_event(<<"CHANNEL_UPDATE_BULK">>) -> channel_update_bulk;
normalize_event(<<"CHANNEL_DELETE">>) -> channel_delete;
normalize_event(<<"CHANNEL_PINS_UPDATE">>) -> channel_pins_update;
normalize_event(<<"TYPING_START">>) -> typing_start;
normalize_event(<<"INVITE_CREATE">>) -> invite_create;
normalize_event(<<"INVITE_DELETE">>) -> invite_delete;
normalize_event(<<"GUILD_UPDATE">>) -> guild_update;
normalize_event(EventBinary) when is_binary(EventBinary) -> EventBinary.
handle_dispatch(Event, EventData, State) ->
case should_skip_dispatch(Event, State) of
true ->
{noreply, State};
false ->
NormalizedEvent = normalize_event(Event),
process_dispatch(NormalizedEvent, EventData, State)
end.
should_skip_dispatch(guild_update, _State) ->
false;
should_skip_dispatch(_Event, State) ->
Data = maps:get(data, State),
Guild = maps:get(<<"guild">>, Data),
Features = maps:get(<<"features">>, Guild, []),
lists:member(<<"UNAVAILABLE_FOR_EVERYONE">>, Features) orelse
lists:member(<<"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF">>, Features).
process_dispatch(Event, EventData, State) ->
GuildId = maps:get(id, State),
{SessionIdOpt, CleanData} = extract_session_id_if_needed(Event, EventData),
DecoratedData = maps:put(<<"guild_id">>, integer_to_binary(GuildId), CleanData),
FinalData = decorate_member_data(Event, DecoratedData, State),
UpdatedState = update_state(Event, FinalData, State),
Sessions = maps:get(sessions, UpdatedState, #{}),
FilteredSessions = filter_sessions_for_event(
Event, FinalData, SessionIdOpt, Sessions, UpdatedState
),
dispatch_to_sessions(FilteredSessions, Event, FinalData, UpdatedState),
maybe_send_push_notifications(Event, FinalData, GuildId, UpdatedState),
maybe_broadcast_member_list_update(Event, FinalData, State, UpdatedState),
{noreply, UpdatedState}.
extract_session_id_if_needed(Event, EventData) ->
case Event of
message_reaction_add -> extract_and_remove_session_id(EventData);
message_reaction_remove -> extract_and_remove_session_id(EventData);
_ -> {undefined, EventData}
end.
filter_sessions_for_event(Event, FinalData, SessionIdOpt, Sessions, UpdatedState) ->
case is_channel_scoped_event(Event) of
true ->
ChannelId = extract_channel_id(Event, FinalData),
filter_sessions_for_channel(Sessions, ChannelId, SessionIdOpt, UpdatedState);
false ->
case is_invite_event(Event) of
true ->
ChannelIdBin = maps:get(<<"channel_id">>, FinalData, <<"0">>),
ChannelId = validation:snowflake_or_default(<<"channel_id">>, ChannelIdBin, 0),
filter_sessions_for_manage_channels(
Sessions, ChannelId, SessionIdOpt, UpdatedState
);
false ->
case is_bulk_update_event(Event) of
true ->
filter_sessions_exclude_session(Sessions, SessionIdOpt);
false ->
filter_sessions_exclude_session(Sessions, SessionIdOpt)
end
end
end.
is_channel_scoped_event(channel_create) -> true;
is_channel_scoped_event(channel_update) -> true;
is_channel_scoped_event(message_create) -> true;
is_channel_scoped_event(message_update) -> true;
is_channel_scoped_event(message_delete) -> true;
is_channel_scoped_event(message_delete_bulk) -> true;
is_channel_scoped_event(message_reaction_add) -> true;
is_channel_scoped_event(message_reaction_remove) -> true;
is_channel_scoped_event(message_reaction_remove_all) -> true;
is_channel_scoped_event(message_reaction_remove_emoji) -> true;
is_channel_scoped_event(typing_start) -> true;
is_channel_scoped_event(channel_pins_update) -> true;
is_channel_scoped_event(_) -> false.
is_invite_event(invite_create) -> true;
is_invite_event(invite_delete) -> true;
is_invite_event(_) -> false.
is_bulk_update_event(channel_update_bulk) -> true;
is_bulk_update_event(_) -> false.
extract_channel_id(Event, FinalData) ->
case Event of
channel_create ->
ChannelIdBin = maps:get(<<"id">>, FinalData, <<"0">>),
validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0);
channel_update ->
ChannelIdBin = maps:get(<<"id">>, FinalData, <<"0">>),
validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0);
_ ->
ChannelIdBin = maps:get(<<"channel_id">>, FinalData, <<"0">>),
validation:snowflake_or_default(<<"channel_id">>, ChannelIdBin, 0)
end.
dispatch_to_sessions(FilteredSessions, Event, FinalData, UpdatedState) ->
GuildId = maps:get(id, UpdatedState),
case is_bulk_update_event(Event) of
true ->
dispatch_bulk_update(FilteredSessions, Event, FinalData, UpdatedState);
false ->
dispatch_standard(FilteredSessions, Event, FinalData, GuildId, UpdatedState)
end.
dispatch_bulk_update(FilteredSessions, Event, FinalData, UpdatedState) ->
GuildId = maps:get(id, UpdatedState),
BulkChannels = maps:get(<<"channels">>, FinalData, []),
lists:foreach(
fun({_Sid, SessionData}) ->
Pid = maps:get(pid, SessionData),
UserId = maps:get(user_id, SessionData),
Member = find_member_by_user_id(UserId, UpdatedState),
case should_receive_event(Event, FinalData, GuildId, SessionData, UpdatedState) of
false ->
ok;
true ->
FilteredChannels = lists:filter(
fun(Channel) ->
ChannelIdBin = maps:get(<<"id">>, Channel, <<"0">>),
ChannelId = validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0),
case Member of
undefined ->
false;
_ ->
guild_permissions:can_view_channel(
UserId, ChannelId, Member, UpdatedState
)
end
end,
BulkChannels
),
case FilteredChannels of
[] ->
ok;
_ when is_pid(Pid) ->
CustomData = maps:put(<<"channels">>, FilteredChannels, FinalData),
gen_server:cast(Pid, {dispatch, Event, CustomData})
end
end
end,
FilteredSessions
).
dispatch_standard(FilteredSessions, Event, FinalData, GuildId, State) ->
lists:foreach(
fun({_Sid, SessionData}) ->
Pid = maps:get(pid, SessionData),
case is_pid(Pid) andalso should_receive_event(Event, FinalData, GuildId, SessionData, State) of
true ->
gen_server:cast(Pid, {dispatch, Event, FinalData});
false ->
ok
end
end,
FilteredSessions
).
maybe_send_push_notifications(message_create, FinalData, GuildId, UpdatedState) ->
spawn(fun() ->
collect_and_send_push_notifications(FinalData, GuildId, UpdatedState)
end);
maybe_send_push_notifications(_Event, _FinalData, _GuildId, _UpdatedState) ->
ok.
maybe_broadcast_member_list_update(guild_member_add, EventData, OldState, UpdatedState) ->
UserId = extract_user_id_from_event(EventData),
guild_member_list:broadcast_member_list_updates(UserId, OldState, UpdatedState);
maybe_broadcast_member_list_update(guild_member_remove, EventData, OldState, UpdatedState) ->
UserId = extract_user_id_from_event(EventData),
guild_member_list:broadcast_member_list_updates(UserId, OldState, UpdatedState);
maybe_broadcast_member_list_update(guild_member_update, EventData, OldState, UpdatedState) ->
UserId = extract_user_id_from_event(EventData),
guild_member_list:broadcast_member_list_updates(UserId, OldState, UpdatedState);
maybe_broadcast_member_list_update(guild_role_create, _EventData, _OldState, UpdatedState) ->
guild_member_list:broadcast_all_member_list_updates(UpdatedState);
maybe_broadcast_member_list_update(guild_role_update, _EventData, _OldState, UpdatedState) ->
guild_member_list:broadcast_all_member_list_updates(UpdatedState);
maybe_broadcast_member_list_update(guild_role_update_bulk, _EventData, _OldState, UpdatedState) ->
guild_member_list:broadcast_all_member_list_updates(UpdatedState);
maybe_broadcast_member_list_update(guild_role_delete, _EventData, _OldState, UpdatedState) ->
guild_member_list:broadcast_all_member_list_updates(UpdatedState);
maybe_broadcast_member_list_update(channel_update, EventData, _OldState, UpdatedState) ->
ChannelIdBin = maps:get(<<"id">>, EventData, <<"0">>),
ChannelId = validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0),
guild_member_list:broadcast_member_list_updates_for_channel(ChannelId, UpdatedState);
maybe_broadcast_member_list_update(channel_update_bulk, EventData, _OldState, UpdatedState) ->
Channels = maps:get(<<"channels">>, EventData, []),
lists:foreach(
fun(Channel) ->
ChannelIdBin = maps:get(<<"id">>, Channel, <<"0">>),
ChannelId = validation:snowflake_or_default(<<"id">>, ChannelIdBin, 0),
guild_member_list:broadcast_member_list_updates_for_channel(ChannelId, UpdatedState)
end,
Channels
);
maybe_broadcast_member_list_update(_Event, _FinalData, _OldState, _UpdatedState) ->
ok.
extract_user_id_from_event(EventData) ->
MUser = maps:get(<<"user">>, EventData, #{}),
utils:binary_to_integer_safe(maps:get(<<"id">>, MUser, <<"0">>)).
extract_and_remove_session_id(Data) ->
case maps:get(<<"session_id">>, Data, undefined) of
undefined -> {undefined, Data};
SessionId -> {SessionId, maps:remove(<<"session_id">>, Data)}
end.
decorate_member_data(Event, Data, State) ->
case extract_member_for_event(Event, Data, State) of
undefined ->
Data;
MemberData ->
add_member_to_data(Event, Data, MemberData)
end.
add_member_to_data(Event, Data, MemberData) ->
case is_message_event(Event) of
true ->
case maps:is_key(<<"author">>, Data) of
true ->
CleanMemberData = maps:remove(<<"user">>, MemberData),
maps:put(<<"member">>, CleanMemberData, Data);
false ->
Data
end;
false ->
case is_user_event(Event) of
true ->
case maps:is_key(<<"user_id">>, Data) of
true -> maps:put(<<"member">>, MemberData, Data);
false -> Data
end;
false ->
Data
end
end.
is_message_event(message_create) -> true;
is_message_event(message_update) -> true;
is_message_event(_) -> false.
is_user_event(typing_start) -> true;
is_user_event(message_reaction_add) -> true;
is_user_event(message_reaction_remove) -> true;
is_user_event(_) -> false.
extract_member_for_event(Event, Data, State) ->
UserId = extract_user_id_for_event(Event, Data),
case UserId of
undefined -> undefined;
Id -> find_member_by_user_id(Id, State)
end.
extract_user_id_for_event(Event, Data) ->
case is_message_event(Event) of
true ->
AuthorId = maps:get(<<"id">>, maps:get(<<"author">>, Data, #{}), undefined),
case AuthorId of
undefined ->
undefined;
_ ->
case validation:validate_snowflake(<<"author.id">>, AuthorId) of
{ok, Id} ->
Id;
{error, _, Reason} ->
logger:warning("[guild_dispatch] Invalid field: ~p", [Reason]),
undefined
end
end;
false ->
case is_user_event(Event) of
true ->
UserId = maps:get(<<"user_id">>, Data, undefined),
case UserId of
undefined ->
undefined;
_ ->
case validation:validate_snowflake(<<"user_id">>, UserId) of
{ok, Id} ->
Id;
{error, _, Reason} ->
logger:warning("[guild_dispatch] Invalid field: ~p", [Reason]),
undefined
end
end;
false ->
undefined
end
end.
collect_and_send_push_notifications(MessageData, GuildId, State) ->
case should_send_push_notifications(State) of
false ->
ok;
true ->
send_push_notifications(MessageData, GuildId, State)
end.
should_send_push_notifications(State) ->
Data = maps:get(data, State),
Guild = maps:get(<<"guild">>, Data),
DisabledOperationsBin = maps:get(<<"disabled_operations">>, Guild, <<"0">>),
DisabledOperations = validation:snowflake_or_default(
<<"disabled_operations">>, DisabledOperationsBin, 0
),
(DisabledOperations band 1) =:= 0.
send_push_notifications(MessageData, GuildId, State) ->
Data = maps:get(data, State),
Members = maps:get(<<"members">>, Data, []),
Sessions = maps:get(sessions, State, #{}),
ChannelIdBin = maps:get(<<"channel_id">>, MessageData),
ChannelId = validation:snowflake_or_default(<<"channel_id">>, ChannelIdBin, 0),
case find_eligible_users_for_push(Members, Sessions, ChannelId, State) of
[] ->
ok;
EligibleUserIds ->
UserRolesMap = build_user_roles_map(Members, EligibleUserIds),
send_push_to_eligible_users(MessageData, GuildId, EligibleUserIds, UserRolesMap, Data)
end.
find_eligible_users_for_push(Members, Sessions, ChannelId, State) ->
lists:filtermap(
fun(Member) ->
is_user_eligible_for_push(Member, Sessions, ChannelId, State)
end,
Members
).
is_user_eligible_for_push(Member, Sessions, ChannelId, State) ->
MUser = maps:get(<<"user">>, Member, #{}),
UserIdBin = maps:get(<<"id">>, MUser, <<"0">>),
UserId = validation:snowflake_or_default(<<"user.id">>, UserIdBin, 0),
case guild_permissions:can_view_channel(UserId, ChannelId, Member, State) of
false ->
false;
true ->
check_user_session_eligibility(UserId, Sessions)
end.
check_user_session_eligibility(UserId, Sessions) ->
UserSessions = maps:filter(
fun(_Sid, Session) ->
maps:get(user_id, Session) =:= UserId
end,
Sessions
),
case map_size(UserSessions) of
0 ->
{true, UserId};
_ ->
HasMobile = lists:any(
fun(Session) -> maps:get(mobile, Session, false) end,
maps:values(UserSessions)
),
AllAfk = lists:all(
fun(Session) -> maps:get(afk, Session, false) end,
maps:values(UserSessions)
),
case (not HasMobile) andalso AllAfk of
true -> {true, UserId};
false -> false
end
end.
send_push_to_eligible_users(MessageData, GuildId, EligibleUserIds, UserRolesMap, Data) ->
Guild = maps:get(<<"guild">>, Data),
AuthorIdBin = maps:get(<<"id">>, maps:get(<<"author">>, MessageData, #{}), <<"0">>),
AuthorId = validation:snowflake_or_default(<<"author.id">>, AuthorIdBin, 0),
DefaultMessageNotifications = maps:get(<<"default_message_notifications">>, Guild, 0),
GuildName = maps:get(<<"name">>, Guild, <<"Unknown">>),
ChannelIdBin = maps:get(<<"channel_id">>, MessageData),
ChannelName = find_channel_name(ChannelIdBin, Data),
push:handle_message_create(#{
message_data => MessageData,
user_ids => EligibleUserIds,
guild_id => GuildId,
author_id => AuthorId,
guild_default_notifications => DefaultMessageNotifications,
guild_name => GuildName,
channel_name => ChannelName,
user_roles => UserRolesMap
}).
find_channel_name(ChannelIdBin, Data) ->
Channels = maps:get(<<"channels">>, Data, []),
lists:foldl(
fun(Channel, Acc) ->
case maps:get(<<"id">>, Channel, <<"">>) of
ChannelIdBin -> maps:get(<<"name">>, Channel, <<"unknown">>);
_ -> Acc
end
end,
<<"unknown">>,
Channels
).
build_user_roles_map(Members, EligibleUserIds) ->
EligibleSet = sets:from_list(EligibleUserIds),
lists:foldl(
fun(Member, Acc) ->
case get_member_user_id(Member) of
0 -> Acc;
UserId ->
case sets:is_element(UserId, EligibleSet) of
true ->
Roles = extract_role_ids(Member),
maps:put(UserId, Roles, Acc);
false ->
Acc
end
end
end,
#{},
Members
).
get_member_user_id(Member) ->
User = maps:get(<<"user">>, Member, #{}),
case maps:get(<<"id">>, User, undefined) of
undefined ->
0;
Id ->
validation:snowflake_or_default(<<"member.user.id">>, Id, 0)
end.
extract_role_ids(Member) ->
Roles = maps:get(<<"roles">>, Member, []),
lists:foldl(
fun(Role, Acc) ->
case validation:validate_snowflake(<<"role">>, Role) of
{ok, RoleId} -> [RoleId | Acc];
_ -> Acc
end
end,
[],
Roles
).