mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
perf(guild): add a lean channel auth context rpc (#2226)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user