Files
fluxer/fluxer_gateway/src/guild/guild_data.erl
Hampus Kraft 2f557eda8c initial commit
2026-01-01 21:05:54 +00:00

314 lines
12 KiB
Erlang

%% 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_data).
-export([get_guild_data/2]).
-export([get_guild_member/2]).
-export([has_member/2]).
-export([list_guild_members/2]).
-export([get_vanity_url_channel/1]).
-export([get_first_viewable_text_channel/1]).
-export([get_guild_state/2]).
-export([find_everyone_viewable_text_channel/2]).
-type guild_state() :: map().
-type guild_reply(T) :: {reply, T, guild_state()}.
-type guild_data_map() :: map().
-type guild_member() :: map().
-type channel_list() :: [map()].
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-spec get_guild_data(map(), guild_state()) -> guild_reply(map()).
get_guild_data(#{user_id := UserId}, State) ->
Data = guild_data_map(State),
case UserId of
null ->
GuildData = build_complete_guild_data(Data, State),
Reply = #{guild_data => GuildData},
{reply, Reply, State};
_ ->
Members = map_utils:ensure_list(maps:get(<<"members">>, Data, [])),
case member_in_list(UserId, Members) of
false ->
{reply, #{guild_data => null, error_reason => <<"forbidden">>}, State};
true ->
GuildData = build_complete_guild_data(Data, State),
{reply, #{guild_data => GuildData}, State}
end
end.
-spec get_guild_member(map(), guild_state()) -> guild_reply(map()).
get_guild_member(#{user_id := UserId}, State) ->
case find_member_by_user_id(UserId, State) of
undefined ->
{reply, #{success => false, member_data => null}, State};
Member ->
{reply, #{success => true, member_data => Member}, State}
end.
-spec has_member(map(), guild_state()) -> guild_reply(map()).
has_member(#{user_id := UserId}, State) ->
case find_member_by_user_id(UserId, State) of
undefined ->
{reply, #{has_member => false}, State};
_ ->
{reply, #{has_member => true}, State}
end.
-spec list_guild_members(map(), guild_state()) -> guild_reply(map()).
list_guild_members(#{limit := Limit, offset := Offset}, State) ->
Data = guild_data_map(State),
AllMembers = map_utils:ensure_list(maps:get(<<"members">>, Data, [])),
TotalCount = length(AllMembers),
PaginatedMembers = paginate_members(AllMembers, Limit, Offset),
{reply, #{members => PaginatedMembers, total => TotalCount}, State}.
-spec get_vanity_url_channel(guild_state()) -> guild_reply(map()).
get_vanity_url_channel(State) ->
Channels = channels_from_state(State),
EveryoneChannelId = find_everyone_viewable_text_channel(Channels, State),
{reply, #{channel_id => EveryoneChannelId}, State}.
-spec get_first_viewable_text_channel(guild_state()) -> guild_reply(map()).
get_first_viewable_text_channel(State) ->
Channels = channels_from_state(State),
EveryoneChannelId = find_everyone_viewable_text_channel(Channels, State),
{reply, #{channel_id => EveryoneChannelId}, State}.
-spec get_guild_state(integer(), guild_state()) -> map().
get_guild_state(UserId, State) ->
Data = guild_data_map(State),
GuildId = map_utils:get_integer(State, id, 0),
AllChannels = channels_from_data(Data),
AllMembers = map_utils:ensure_list(maps:get(<<"members">>, Data, [])),
Member = find_member_by_user_id(UserId, State),
{ViewableChannels, JoinedAt} = derive_member_view(UserId, Member, State, AllChannels),
OnlineCount = guild_member_list:get_online_count(State),
OwnMemberList = case Member of
undefined -> [];
M -> [M]
end,
#{
<<"id">> => integer_to_binary(GuildId),
<<"properties">> => maps:get(<<"guild">>, Data, #{}),
<<"roles">> => map_utils:ensure_list(maps:get(<<"roles">>, Data, [])),
<<"channels">> => ViewableChannels,
<<"emojis">> => maps:get(<<"emojis">>, Data, []),
<<"stickers">> => maps:get(<<"stickers">>, Data, []),
<<"members">> => OwnMemberList,
<<"member_count">> => length(AllMembers),
<<"online_count">> => OnlineCount,
<<"presences">> => [],
<<"voice_states">> => guild_voice:get_voice_states_list(State),
<<"joined_at">> => JoinedAt
}.
-spec find_everyone_viewable_text_channel(channel_list(), guild_state()) -> integer() | null.
find_everyone_viewable_text_channel(Channels, State) ->
GuildId = map_utils:get_integer(State, id, 0),
Data = guild_data_map(State),
Roles = map_utils:ensure_list(maps:get(<<"roles">>, Data, [])),
EveryonePerms = role_permissions_for_id(Roles, GuildId),
lists:foldl(
fun(Channel, Acc) ->
case Acc of
null ->
select_first_viewable(Channel, GuildId, EveryonePerms);
_ ->
Acc
end
end,
null,
map_utils:ensure_list(Channels)
).
find_member_by_user_id(UserId, State) ->
guild_permissions:find_member_by_user_id(UserId, State).
-spec guild_data_map(guild_state()) -> guild_data_map().
guild_data_map(State) ->
map_utils:ensure_map(map_utils:get_safe(State, data, #{})).
-spec build_complete_guild_data(guild_data_map(), guild_state()) -> map().
build_complete_guild_data(Data, _State) ->
GuildProperties = maps:get(<<"guild">>, Data, #{}),
maps:merge(GuildProperties, #{
<<"roles">> => map_utils:ensure_list(maps:get(<<"roles">>, Data, [])),
<<"channels">> => map_utils:ensure_list(maps:get(<<"channels">>, Data, [])),
<<"emojis">> => map_utils:ensure_list(maps:get(<<"emojis">>, Data, [])),
<<"stickers">> => map_utils:ensure_list(maps:get(<<"stickers">>, Data, []))
}).
-spec channels_from_state(guild_state()) -> channel_list().
channels_from_state(State) ->
Data = guild_data_map(State),
channels_from_data(Data).
-spec channels_from_data(guild_data_map()) -> channel_list().
channels_from_data(Data) ->
map_utils:ensure_list(maps:get(<<"channels">>, Data, [])).
-spec member_in_list(integer(), [guild_member()]) -> boolean().
member_in_list(UserId, Members) ->
lists:any(fun(Member) -> member_matches(UserId, Member) end, Members).
-spec member_matches(integer(), guild_member()) -> boolean().
member_matches(UserId, Member) ->
MemberUser = map_utils:ensure_map(maps:get(<<"user">>, Member, #{})),
case map_utils:get_integer(MemberUser, <<"id">>, undefined) of
undefined -> false;
Id -> Id =:= UserId
end.
-spec paginate_members([guild_member()], non_neg_integer(), non_neg_integer()) -> [guild_member()].
paginate_members(Members, Limit, Offset) ->
case Offset >= length(Members) of
true ->
[];
false ->
Remaining = lists:nthtail(Offset, Members),
case length(Remaining) > Limit of
true -> lists:sublist(Remaining, Limit);
false -> Remaining
end
end.
-spec derive_member_view(integer(), guild_member() | undefined, guild_state(), channel_list()) ->
{channel_list(), term()}.
derive_member_view(_UserId, undefined, _State, _Channels) ->
{[], null};
derive_member_view(UserId, Member, State, Channels) ->
Filtered =
lists:filter(
fun(Channel) ->
ChannelId = map_utils:get_integer(Channel, <<"id">>, undefined),
case ChannelId of
undefined -> false;
_ -> guild_permissions:can_view_channel(UserId, ChannelId, Member, State)
end
end,
Channels
),
JoinedAt = maps:get(<<"joined_at">>, Member, null),
{Filtered, JoinedAt}.
-spec role_permissions_for_id(list(), integer()) -> integer().
role_permissions_for_id(Roles, GuildId) ->
lists:foldl(
fun(Role, Acc) ->
case map_utils:get_integer(Role, <<"id">>, undefined) of
GuildId -> map_utils:get_integer(Role, <<"permissions">>, 0);
_ -> Acc
end
end,
0,
map_utils:ensure_list(Roles)
).
-spec select_first_viewable(map(), integer(), integer()) -> integer() | null.
select_first_viewable(Channel, GuildId, BasePerms) ->
ChannelType = map_utils:get_integer(Channel, <<"type">>, undefined),
ChannelId = map_utils:get_integer(Channel, <<"id">>, undefined),
select_first_viewable(ChannelType, ChannelId, Channel, GuildId, BasePerms).
select_first_viewable(0, ChannelId, Channel, GuildId, BasePerms) when is_integer(ChannelId) ->
case (BasePerms band constants:administrator_permission()) =/= 0 of
true ->
ChannelId;
false ->
FinalPerms = guild_permissions:apply_channel_overwrites(
BasePerms, GuildId, [GuildId], Channel, GuildId
),
case (FinalPerms band constants:view_channel_permission()) =/= 0 of
true -> ChannelId;
false -> null
end
end;
select_first_viewable(_, _, _, _, _) ->
null.
-ifdef(TEST).
get_guild_data_membership_gate_test() ->
State = test_state(),
{reply, Reply1, _} = get_guild_data(#{user_id => 999}, State),
?assertEqual(null, maps:get(guild_data, Reply1)),
?assertEqual(<<"forbidden">>, maps:get(error_reason, Reply1)),
{reply, Reply2, _} = get_guild_data(#{user_id => 200}, State),
Guild = maps:get(guild_data, Reply2),
?assertEqual(<<"Fluxer">>, maps:get(<<"name">>, Guild)),
Roles = maps:get(<<"roles">>, Guild, []),
?assert(length(Roles) > 0).
get_guild_state_filters_channels_test() ->
State = test_state(),
GuildState = get_guild_state(200, State),
Channels = maps:get(<<"channels">>, GuildState),
?assert(lists:any(fun(Chan) -> maps:get(<<"id">>, Chan) =:= <<"500">> end, Channels)),
?assertEqual(<<"2024-01-01T00:00:00Z">>, maps:get(<<"joined_at">>, GuildState)).
find_everyone_viewable_text_channel_test() ->
State = test_state(),
Data = guild_data_map(State),
Channels = maps:get(<<"channels">>, Data),
ChannelId = find_everyone_viewable_text_channel(Channels, State),
?assertEqual(500, ChannelId).
test_state() ->
GuildId = 100,
ViewPerm = constants:view_channel_permission(),
#{
id => GuildId,
data => #{
<<"guild">> => #{<<"name">> => <<"Fluxer">>},
<<"roles">> => [
#{
<<"id">> => integer_to_binary(GuildId),
<<"permissions">> => integer_to_binary(ViewPerm)
}
],
<<"channels">> => [
#{
<<"id">> => <<"500">>,
<<"type">> => 0,
<<"permission_overwrites">> => []
},
#{
<<"id">> => <<"501">>,
<<"type">> => 2,
<<"permission_overwrites">> => []
}
],
<<"members">> => [
#{
<<"user">> => #{<<"id">> => <<"200">>},
<<"roles">> => [integer_to_binary(GuildId)],
<<"joined_at">> => <<"2024-01-01T00:00:00Z">>
}
],
<<"emojis">> => [],
<<"stickers">> => []
}
}.
-endif.