perf(guild): add a lean channel auth context rpc (#2226)

This commit is contained in:
Hampus
2026-08-31 01:04:55 +02:00
committed by GitHub
parent 8dc2bad843
commit ba54b61dcf
11 changed files with 383 additions and 25 deletions
@@ -9,12 +9,11 @@ import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildEr
import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {ChannelID, GuildID, UserID} from '../../BrandedTypes';
import {SYSTEM_USER_ID} from '../../constants/Core';
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
import {createGuildMfaEnforcer} from '../../guild/services/GuildMfaEnforcement';
import type {IGatewayService} from '../../infrastructure/IGatewayService';
import type {GuildChannelAuthContext, IGatewayService} from '../../infrastructure/IGatewayService';
import type {Channel} from '../../models/Channel';
import type {GuildMember} from '../../models/GuildMember';
import type {User} from '../../models/User';
@@ -173,13 +172,14 @@ export abstract class BaseChannelAuthService {
skipNsfwValidation?: boolean;
}): Promise<AuthenticatedChannel> {
const guildId = channel.guildId!;
const [guildDataResult, guildMemberResult] = await Promise.all([
this.fetchGuildDataOrThrow({guildId, userId}),
const [authContextResult, guildMemberResult] = await Promise.all([
this.fetchGuildAuthContextOrThrow({guildId, userId, channelId: this.parentLookupChannelId(channel)}),
this.gatewayService.getGuildMember({guildId, userId}),
]);
if (!guildDataResult) {
if (!authContextResult) {
this.throwGuildAccessError();
}
const guildDataResult = authContextResult.guild;
if (!guildMemberResult.success || !guildMemberResult.memberData) {
this.throwGuildAccessError();
}
@@ -190,7 +190,7 @@ export abstract class BaseChannelAuthService {
});
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: this.userRepository,
guildData: guildDataResult!,
guildData: guildDataResult,
userId,
});
const channelPermissions = await this.gatewayService.getUserPermissions({
@@ -210,12 +210,12 @@ export abstract class BaseChannelAuthService {
await checkPermission(Permissions.VIEW_CHANNEL);
const parentCategory = await this.getParentCategoryContentWarningView({
channel,
guild: guildDataResult!,
parentChannel: authContextResult.parentChannel,
});
const requiresAgeVerification = computeEffectiveChannelNsfw(
channelToContentWarningView(channel),
parentCategory,
guildResponseToContentWarningView(guildDataResult!),
guildResponseToContentWarningView(guildDataResult),
);
if (
this.options.validateNsfw &&
@@ -233,27 +233,32 @@ export abstract class BaseChannelAuthService {
}
return {
channel,
guild: guildDataResult!,
guild: guildDataResult,
member,
hasPermission,
checkPermission,
};
}
private parentLookupChannelId(channel: Channel): ChannelID | undefined {
if (!channel.parentId || channel.type === ChannelTypes.GUILD_CATEGORY) {
return undefined;
}
return channel.parentId;
}
private async getParentCategoryContentWarningView({
channel,
guild,
parentChannel,
}: {
channel: Channel;
guild: GuildResponse;
parentChannel: GuildChannelAuthContext['parentChannel'];
}): Promise<ContentWarningChannelLike | null> {
if (!channel.parentId || channel.type === ChannelTypes.GUILD_CATEGORY) {
return null;
}
const parentId = channel.parentId.toString();
const parentFromGateway = guild.channels?.find((guildChannel) => guildChannel.id === parentId);
if (parentFromGateway) {
return channelResponseToContentWarningView(parentFromGateway);
if (parentChannel) {
return channelResponseToContentWarningView(parentChannel);
}
const parentCategory = await this.channelRepository.channelData.findUnique(channel.parentId);
return parentCategory ? channelToContentWarningView(parentCategory) : null;
@@ -266,10 +271,14 @@ export abstract class BaseChannelAuthService {
throw new UnknownChannelError();
}
private async fetchGuildDataOrThrow(params: {guildId: GuildID; userId: UserID}): Promise<GuildResponse | null> {
const {guildId, userId} = params;
private async fetchGuildAuthContextOrThrow(params: {
guildId: GuildID;
userId: UserID;
channelId?: ChannelID;
}): Promise<GuildChannelAuthContext | null> {
const {guildId, userId, channelId} = params;
try {
return await this.gatewayService.getGuildData({guildId, userId});
return await this.gatewayService.getGuildAuthContext({guildId, userId, channelId});
} catch (error) {
await this.handleGuildAccessError(error, guildId);
return null;
@@ -111,7 +111,7 @@ describe('Message send permissions', () => {
});
await ensureSessionStarted(harness, member.token);
const gatewayService = getGatewayService();
const getGuildData = vi.spyOn(gatewayService, 'getGuildData');
const getGuildAuthContext = vi.spyOn(gatewayService, 'getGuildAuthContext');
const getGuildMember = vi.spyOn(gatewayService, 'getGuildMember');
const getUserPermissions = vi.spyOn(gatewayService, 'getUserPermissions');
@@ -121,11 +121,11 @@ describe('Message send permissions', () => {
.body({content: 'authenticate me once'})
.execute();
const sendCounts = {
guildData: getGuildData.mock.calls.length,
authContext: getGuildAuthContext.mock.calls.length,
guildMember: getGuildMember.mock.calls.length,
userPermissions: getUserPermissions.mock.calls.length,
};
getGuildData.mockClear();
getGuildAuthContext.mockClear();
getGuildMember.mockClear();
getUserPermissions.mockClear();
await createBuilder<MessageResponse>(harness, member.token)
@@ -133,15 +133,15 @@ describe('Message send permissions', () => {
.body({content: 'authenticate me once again'})
.execute();
const editCounts = {
guildData: getGuildData.mock.calls.length,
authContext: getGuildAuthContext.mock.calls.length,
guildMember: getGuildMember.mock.calls.length,
userPermissions: getUserPermissions.mock.calls.length,
};
expect(sendCounts).toEqual({guildData: 1, guildMember: 1, userPermissions: 1});
expect(editCounts).toEqual({guildData: 1, guildMember: 1, userPermissions: 1});
expect(sendCounts).toEqual({authContext: 1, guildMember: 1, userPermissions: 1});
expect(editCounts).toEqual({authContext: 1, guildMember: 1, userPermissions: 1});
} finally {
getGuildData.mockRestore();
getGuildAuthContext.mockRestore();
getGuildMember.mockRestore();
getUserPermissions.mockRestore();
}
@@ -10,6 +10,7 @@ import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPe
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UserNotInVoiceError} from '@fluxer/errors/src/domains/user/UserNotInVoiceError';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {ms} from 'itty-time';
@@ -30,6 +31,7 @@ import type {
GatewayNodeStats,
GatewayVoiceStateCounts,
GatewayVoiceStateEntry,
GuildChannelAuthContext,
} from './IGatewayService';
const PUSH_BADGE_COUNT_BATCH_SIZE = 100;
@@ -82,6 +84,12 @@ interface GuildDataParams {
userId: UserID;
}
interface GuildAuthContextParams {
guildId: GuildID;
userId: UserID;
channelId?: ChannelID;
}
interface GuildMemberParams {
guildId: GuildID;
userId: UserID;
@@ -232,6 +240,11 @@ interface GuildMemberRpcResponse {
member_data?: GuildMemberResponse;
}
interface GuildAuthContextRpcResponse {
guild: GuildResponse;
parent_channel?: ChannelResponse | null;
}
type PendingRequest<T> = {
resolve: (value: T) => void;
reject: (error: Error) => void;
@@ -252,17 +265,22 @@ export class GatewayService {
>
>();
private pendingPermissionRequests = new Map<string, Array<PendingRequest<boolean>>>();
private pendingAuthContextRequests = new Map<string, Array<PendingRequest<GuildChannelAuthContext>>>();
private pendingBatchRequestCount = 0;
private guildDataBatchTimeout: NodeJS.Timeout | null = null;
private guildMemberBatchTimeout: NodeJS.Timeout | null = null;
private permissionBatchTimeout: NodeJS.Timeout | null = null;
private authContextBatchTimeout: NodeJS.Timeout | null = null;
private activeGuildDataRequests = 0;
private activeGuildMemberRequests = 0;
private activePermissionRequests = 0;
private activeAuthContextRequests = 0;
private authContextUnsupportedUntil = 0;
private readonly BATCH_DELAY_MS = ms('5 milliseconds');
private readonly MAX_PENDING_BATCH_REQUESTS = 2000;
private readonly MAX_BATCH_CONCURRENCY = 50;
private readonly PENDING_REQUEST_TIMEOUT_MS = ms('30 seconds');
private readonly AUTH_CONTEXT_FALLBACK_MS = ms('5 minutes');
constructor() {
this.rpcClient = GatewayRpcClient.getInstance();
@@ -376,6 +394,16 @@ export class GatewayService {
}, this.BATCH_DELAY_MS);
}
private scheduleAuthContextBatch(): void {
if (this.authContextBatchTimeout || this.activeAuthContextRequests >= this.MAX_BATCH_CONCURRENCY) {
return;
}
this.authContextBatchTimeout = setTimeout(() => {
this.authContextBatchTimeout = null;
this.processAuthContextQueue();
}, this.BATCH_DELAY_MS);
}
private takePendingEntries<T>(
requests: Map<string, Array<PendingRequest<T>>>,
limit: number,
@@ -482,12 +510,43 @@ export class GatewayService {
for (const pendingRequests of this.pendingPermissionRequests.values()) {
this.rejectPendingRequests(pendingRequests, error);
}
for (const pendingRequests of this.pendingAuthContextRequests.values()) {
this.rejectPendingRequests(pendingRequests, error);
}
this.pendingGuildDataRequests.clear();
this.pendingGuildMemberRequests.clear();
this.pendingPermissionRequests.clear();
this.pendingAuthContextRequests.clear();
this.pendingBatchRequestCount = 0;
}
private processAuthContextQueue(): void {
const availableSlots = this.MAX_BATCH_CONCURRENCY - this.activeAuthContextRequests;
if (availableSlots <= 0) {
return;
}
const entries = this.takePendingEntries(this.pendingAuthContextRequests, availableSlots);
if (entries.length === 0) {
return;
}
const totalAuthContextRequests = entries.reduce((sum, [, pending]) => sum + pending.length, 0);
Logger.debug(
`[gateway-batch] Processing guild.get_auth_context batch: ${entries.length} unique requests (${totalAuthContextRequests} total)`,
);
for (const entry of entries) {
this.activeAuthContextRequests += 1;
void this.processAuthContextEntry(entry).finally(() => {
this.activeAuthContextRequests = Math.max(0, this.activeAuthContextRequests - 1);
if (this.pendingAuthContextRequests.size > 0) {
this.scheduleAuthContextBatch();
}
});
}
if (this.pendingAuthContextRequests.size > 0) {
this.scheduleAuthContextBatch();
}
}
private async processGuildDataEntry([key, pending]: [string, Array<PendingRequest<GuildResponse>>]): Promise<void> {
try {
const [guildIdStr, userIdStr, skipCheck] = key.split('-');
@@ -559,6 +618,65 @@ export class GatewayService {
}
}
private async processAuthContextEntry([key, pending]: [
string,
Array<PendingRequest<GuildChannelAuthContext>>,
]): Promise<void> {
const [guildIdStr, userIdStr, channelIdStr] = key.split('-');
const guildId = BigInt(guildIdStr) as GuildID;
const userId = BigInt(userIdStr) as UserID;
const channelId = channelIdStr !== '0' ? (BigInt(channelIdStr) as ChannelID) : undefined;
try {
const result = await this.call<GuildAuthContextRpcResponse>('guild.get_auth_context', {
guild_id: guildId.toString(),
user_id: userId.toString(),
channel_id: channelId ? channelId.toString() : null,
});
this.resolvePendingRequests(pending, {
guild: result.guild,
parentChannel: result.parent_channel ?? null,
});
} catch (error) {
const transformedError = this.transformGatewayError(error);
if (!this.isAuthContextUnsupportedError(transformedError)) {
this.rejectPendingRequests(pending, transformedError);
this.logBatchFailures('guild.get_auth_context', [{status: 'rejected', reason: error}]);
return;
}
this.authContextUnsupportedUntil = Date.now() + this.AUTH_CONTEXT_FALLBACK_MS;
Logger.warn({error}, '[gateway-rpc] guild.get_auth_context unavailable, falling back to guild.get_data');
await this.settleAuthContextFromGuildData({guildId, userId, channelId}, pending);
}
}
private isAuthContextUnsupportedError(error: Error): boolean {
return error instanceof BadGatewayError || error instanceof GatewayTimeoutError;
}
private async settleAuthContextFromGuildData(
params: GuildAuthContextParams,
pending: Array<PendingRequest<GuildChannelAuthContext>>,
): Promise<void> {
try {
this.resolvePendingRequests(pending, await this.getGuildAuthContextFromGuildData(params));
} catch (error) {
const transformedError = this.transformGatewayError(error);
this.rejectPendingRequests(pending, transformedError);
this.logBatchFailures('guild.get_data', [{status: 'rejected', reason: error}]);
}
}
private async getGuildAuthContextFromGuildData({
guildId,
userId,
channelId,
}: GuildAuthContextParams): Promise<GuildChannelAuthContext> {
const guild = await this.getGuildData({guildId, userId});
const parentId = channelId?.toString();
const parentChannel = parentId ? (guild.channels?.find((channel) => channel.id === parentId) ?? null) : null;
return {guild, parentChannel};
}
async dispatchGuild({guildId, event, data}: DispatchGuildParams): Promise<void> {
await this.call('guild.dispatch', {
guild_id: guildId.toString(),
@@ -713,6 +831,48 @@ export class GatewayService {
});
}
async getGuildAuthContext({guildId, userId, channelId}: GuildAuthContextParams): Promise<GuildChannelAuthContext> {
if (Date.now() < this.authContextUnsupportedUntil) {
return await this.getGuildAuthContextFromGuildData({guildId, userId, channelId});
}
const key = `${guildId.toString()}-${userId.toString()}-${channelId ? channelId.toString() : '0'}`;
return new Promise<GuildChannelAuthContext>((resolve, reject) => {
if (this.pendingBatchRequestCount >= this.MAX_PENDING_BATCH_REQUESTS) {
reject(new ServiceUnavailableError());
return;
}
const pendingRequest: PendingRequest<GuildChannelAuthContext> = {
resolve,
reject,
settled: false,
timeoutId: null,
};
pendingRequest.timeoutId = setTimeout(() => {
const error = new GatewayTimeoutError();
this.rejectPendingRequests([pendingRequest], error);
this.removePendingAuthContextRequest(key, pendingRequest);
}, this.PENDING_REQUEST_TIMEOUT_MS);
const pending = this.pendingAuthContextRequests.get(key) || [];
pending.push(pendingRequest);
this.pendingAuthContextRequests.set(key, pending);
this.pendingBatchRequestCount += 1;
this.scheduleAuthContextBatch();
});
}
private removePendingAuthContextRequest(key: string, request: PendingRequest<GuildChannelAuthContext>): void {
const pending = this.pendingAuthContextRequests.get(key);
if (pending) {
const index = pending.indexOf(request);
if (index >= 0) {
pending.splice(index, 1);
if (pending.length === 0) {
this.pendingAuthContextRequests.delete(key);
}
}
}
}
private removePendingGuildDataRequest(key: string, request: PendingRequest<GuildResponse>): void {
const pending = this.pendingGuildDataRequests.get(key);
if (pending) {
@@ -1649,6 +1809,10 @@ export class GatewayService {
clearTimeout(this.permissionBatchTimeout);
this.permissionBatchTimeout = null;
}
if (this.authContextBatchTimeout) {
clearTimeout(this.authContextBatchTimeout);
this.authContextBatchTimeout = null;
}
this.rejectAllPendingBatchRequests(new ServiceUnavailableError());
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {ChannelID, GuildID, MessageID, RoleID, UserID} from '../BrandedTypes';
@@ -106,6 +107,11 @@ export interface GatewayVoiceStateEntry {
serverId?: string;
}
export interface GuildChannelAuthContext {
guild: GuildResponse;
parentChannel: ChannelResponse | null;
}
export interface GatewayChannelMention {
id: string;
name: string;
@@ -250,6 +256,12 @@ export abstract class IGatewayService {
skipMembershipCheck?: boolean;
}): Promise<GuildResponse>;
abstract getGuildAuthContext(params: {
guildId: GuildID;
userId: UserID;
channelId?: ChannelID;
}): Promise<GuildChannelAuthContext>;
abstract getGuildMember(params: {guildId: GuildID; userId: UserID}): Promise<{
success: boolean;
memberData?: GuildMemberResponse;
@@ -25,6 +25,7 @@ import {
type GatewayNodeStats,
type GatewayVoiceStateCounts,
type GatewayVoiceStateEntry,
type GuildChannelAuthContext,
IGatewayService,
} from '../infrastructure/IGatewayService';
import {UserRepository} from '../user/repositories/UserRepository';
@@ -562,6 +563,17 @@ export class NoopGatewayService extends IGatewayService {
return createDummyGuildResponse({guildId: params.guildId, userId: params.userId});
}
async getGuildAuthContext(params: {
guildId: GuildID;
userId: UserID;
channelId?: ChannelID;
}): Promise<GuildChannelAuthContext> {
const guild = await this.getGuildData({guildId: params.guildId, userId: params.userId});
const parentId = params.channelId?.toString();
const parentChannel = parentId ? (guild.channels?.find((channel) => channel.id === parentId) ?? null) : null;
return {guild, parentChannel};
}
async getGuildMember(params: {guildId: GuildID; userId: UserID}): Promise<{
success: boolean;
memberData?: GuildMemberResponse;
@@ -37,6 +37,7 @@ route(M, P) ->
-define(HANDLER_MAP, #{
<<"guild.dispatch">> => fun gateway_rpc_guild_lifecycle:handle/2,
<<"guild.get_data">> => fun gateway_rpc_guild_lifecycle:handle/2,
<<"guild.get_auth_context">> => fun gateway_rpc_guild_lifecycle:handle/2,
<<"guild.start">> => fun gateway_rpc_guild_lifecycle:handle/2,
<<"guild.stop">> => fun gateway_rpc_guild_lifecycle:handle/2,
<<"guild.reload">> => fun gateway_rpc_guild_lifecycle:handle/2,
@@ -15,6 +15,7 @@
-spec handle(binary(), map()) -> term().
handle(<<"guild.dispatch">>, P) -> handle_dispatch(P);
handle(<<"guild.get_data">>, P) -> handle_get_data(P);
handle(<<"guild.get_auth_context">>, P) -> handle_get_auth_context(P);
handle(<<"guild.start">>, P) -> handle_start(P);
handle(<<"guild.stop">>, P) -> handle_stop(P);
handle(<<"guild.reload">>, P) -> handle_reload(P);
@@ -47,6 +48,39 @@ handle_get_data(#{<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin}) ->
<<"guild_not_found">>
).
-spec handle_get_auth_context(map()) -> term().
handle_get_auth_context(#{<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin} = Params) ->
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
UserId = optional_user_id(UserIdBin),
ChannelId = optional_channel_id(maps:get(<<"channel_id">>, Params, null)),
gateway_rpc_guild_infra:with_guild(
GuildId,
fun(Pid) ->
get_auth_context_from_guild(Pid, UserId, ChannelId)
end,
<<"guild_not_found">>
).
-spec optional_channel_id(term()) -> integer() | null.
optional_channel_id(Value) ->
case validation:validate_optional_snowflake(Value) of
{ok, null} -> null;
{ok, ChannelId} when is_integer(ChannelId) -> ChannelId;
_ -> gateway_rpc_error:raise(validation_invalid_params)
end.
-spec get_auth_context_from_guild(pid(), integer() | null, integer() | null) -> term().
get_auth_context_from_guild(Pid, UserId, ChannelId) ->
Request = {get_guild_auth_context, #{user_id => UserId, channel_id => ChannelId}},
case gen_server:call(Pid, Request, ?GUILD_CALL_TIMEOUT) of
#{auth_context := null} ->
gateway_rpc_error:raise(<<"forbidden">>);
#{auth_context := AuthContext} ->
AuthContext;
_ ->
gateway_rpc_error:raise(<<"guild_data_error">>)
end.
-spec optional_user_id(term()) -> integer() | null.
optional_user_id(Value) ->
case validation:validate_optional_snowflake(Value) of
@@ -194,4 +228,13 @@ optional_user_id_preserves_null_for_skip_membership_check_test() ->
optional_user_id_accepts_snowflake_test() ->
?assertEqual(123, optional_user_id(<<"123">>)).
optional_channel_id_defaults_to_null_test() ->
?assertEqual(null, optional_channel_id(null)).
optional_channel_id_accepts_snowflake_test() ->
?assertEqual(456, optional_channel_id(<<"456">>)).
optional_channel_id_rejects_garbage_test() ->
?assertError({gateway_rpc_error, _}, optional_channel_id(<<"nope">>)).
-endif.
+1
View File
@@ -89,6 +89,7 @@ query_call_handler(get_user_permissions) -> query;
query_call_handler(can_manage_roles) -> query;
query_call_handler(can_manage_role) -> query;
query_call_handler(get_guild_data) -> query;
query_call_handler(get_guild_auth_context) -> query;
query_call_handler(get_assignable_roles) -> query;
query_call_handler(get_user_max_role_position) -> query;
query_call_handler(check_target_member) -> query;
+45
View File
@@ -4,6 +4,7 @@
-typing([eqwalizer]).
-export([get_guild_data/2]).
-export([get_auth_context/2]).
-export([get_guild_member/2]).
-export([get_guild_members_batch/2]).
-export([has_member/2]).
@@ -33,6 +34,50 @@ get_guild_data(#{user_id := UserId}, State) ->
get_guild_data_for_user(UserId, Data, State)
end.
-spec get_auth_context(map(), guild_state()) -> guild_reply(map()).
get_auth_context(#{user_id := UserId, channel_id := ChannelId}, State) ->
Data = guild_data_index:ensure_data_map(State),
case UserId of
null ->
{reply, #{auth_context => build_auth_context(ChannelId, Data, State)}, State};
_ ->
get_auth_context_for_member(UserId, ChannelId, Data, State)
end.
-spec get_auth_context_for_member(user_id(), integer() | null, map(), guild_state()) ->
guild_reply(map()).
get_auth_context_for_member(UserId, ChannelId, Data, State) ->
case guild_data_index:get_member(UserId, Data) of
undefined ->
{reply, #{auth_context => null, error_reason => <<"forbidden">>}, State};
_Member ->
{reply, #{auth_context => build_auth_context(ChannelId, Data, State)}, State}
end.
-spec build_auth_context(integer() | null, map(), guild_state()) -> map().
build_auth_context(ChannelId, Data, State) ->
#{
<<"guild">> => build_auth_guild(Data, State),
<<"parent_channel">> => find_channel(ChannelId, Data)
}.
-spec build_auth_guild(map(), guild_state()) -> map().
build_auth_guild(Data, State) ->
GuildProperties = map_utils:ensure_map(maps:get(<<"guild">>, Data, #{})),
GuildProperties#{
<<"roles">> => map_utils:ensure_list(maps:get(<<"roles">>, Data, [])),
<<"member_count">> => maps:get(
member_count, State, guild_data_index:member_count(Data)
),
<<"online_count">> => guild_member_list:get_online_count(State)
}.
-spec find_channel(integer() | null, map()) -> map() | null.
find_channel(null, _Data) ->
null;
find_channel(ChannelId, Data) ->
maps:get(ChannelId, guild_data_index:channel_index(Data), null).
-spec get_guild_member(map(), guild_state()) -> guild_reply(map()).
get_guild_member(Request, State) ->
guild_data_members:get_guild_member(Request, State).
@@ -241,6 +241,8 @@ handle_max_role_position(#{user_id := UserId}, State) ->
-spec handle_call_data(term(), guild_state()) -> {reply, term(), guild_state()}.
handle_call_data({get_guild_data, Req}, State) ->
guild_data:get_guild_data(request_map(Req), State);
handle_call_data({get_guild_auth_context, Req}, State) ->
guild_data:get_auth_context(request_map(Req), State);
handle_call_data({get_guild_member, Req}, State) ->
guild_data:get_guild_member(request_map(Req), State);
handle_call_data({get_guild_members_batch, Req}, State) ->
+69
View File
@@ -16,6 +16,75 @@ get_guild_data_membership_gate_test() ->
Roles = maps:get(<<"roles">>, Guild, []),
?assertMatch([_ | _], Roles).
get_auth_context_membership_gate_test() ->
State = test_state(),
{reply, Reply1, _} = guild_data:get_auth_context(
#{user_id => 999, channel_id => null}, State
),
?assertEqual(null, maps:get(auth_context, Reply1)),
?assertEqual(<<"forbidden">>, maps:get(error_reason, Reply1)),
{reply, Reply2, _} = guild_data:get_auth_context(
#{user_id => 200, channel_id => null}, State
),
Context = maps:get(auth_context, Reply2),
Guild = maps:get(<<"guild">>, Context),
?assertEqual(<<"Fluxer">>, maps:get(<<"name">>, Guild)),
?assertMatch([_ | _], maps:get(<<"roles">>, Guild, [])),
?assertEqual(null, maps:get(<<"parent_channel">>, Context)).
get_auth_context_omits_collections_test() ->
State = test_state(),
{reply, Reply, _} = guild_data:get_auth_context(
#{user_id => 200, channel_id => null}, State
),
Guild = maps:get(<<"guild">>, maps:get(auth_context, Reply)),
?assertNot(maps:is_key(<<"channels">>, Guild)),
?assertNot(maps:is_key(<<"emojis">>, Guild)),
?assertNot(maps:is_key(<<"stickers">>, Guild)).
get_auth_context_resolves_requested_channel_test() ->
State = test_state(),
{reply, Reply, _} = guild_data:get_auth_context(
#{user_id => 200, channel_id => 500}, State
),
Parent = maps:get(<<"parent_channel">>, maps:get(auth_context, Reply)),
?assertEqual(500, maps:get(<<"id">>, Parent)),
?assertEqual(0, maps:get(<<"type">>, Parent)).
get_auth_context_unknown_channel_is_null_test() ->
State = test_state(),
{reply, Reply, _} = guild_data:get_auth_context(
#{user_id => 200, channel_id => 999}, State
),
?assertEqual(null, maps:get(<<"parent_channel">>, maps:get(auth_context, Reply))).
get_auth_context_null_user_skips_membership_test() ->
State = test_state(),
{reply, Reply, _} = guild_data:get_auth_context(
#{user_id => null, channel_id => null}, State
),
Guild = maps:get(<<"guild">>, maps:get(auth_context, Reply)),
?assertEqual(<<"Fluxer">>, maps:get(<<"name">>, Guild)).
get_auth_context_matches_get_data_scalars_test() ->
State = test_state(),
{reply, DataReply, _} = guild_data:get_guild_data(#{user_id => 200}, State),
{reply, ContextReply, _} = guild_data:get_auth_context(
#{user_id => 200, channel_id => null}, State
),
Full = maps:get(guild_data, DataReply),
Lean = maps:get(<<"guild">>, maps:get(auth_context, ContextReply)),
Collections = [<<"channels">>, <<"emojis">>, <<"stickers">>],
Expected = maps:without(Collections, Full),
?assertEqual(Expected, Lean).
get_auth_context_is_reachable_through_the_query_handler_test() ->
State = test_state(),
Request = {get_guild_auth_context, #{user_id => 200, channel_id => 500}},
{reply, Reply, _} = guild_query_handler:handle_call(Request, {self(), make_ref()}, State),
Context = maps:get(auth_context, Reply),
?assertEqual(500, maps:get(<<"id">>, maps:get(<<"parent_channel">>, Context))).
get_guild_state_filters_channels_test() ->
State = test_state(),
GuildState = guild_data:get_guild_state(200, State),