mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
1561 lines
47 KiB
TypeScript
1561 lines
47 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import type {ChannelID, GuildID, MessageID, RoleID, UserID} from '@app/api/BrandedTypes';
|
|
import {createChannelID, createRoleID, createUserID} from '@app/api/BrandedTypes';
|
|
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
|
import type {GatewayDispatchEvent} from '@app/api/constants/Gateway';
|
|
import {GatewayRpcClient} from '@app/api/infrastructure/GatewayRpcClient';
|
|
import {GatewayRpcMethodError, GatewayRpcMethodErrorCodes} from '@app/api/infrastructure/GatewayRpcError';
|
|
import {
|
|
type CallCaller,
|
|
type CallData,
|
|
callCallerRpcParams,
|
|
type GatewayChannelMention,
|
|
type GatewayGuildMemoryStats,
|
|
type GatewayMentionSourcesPage,
|
|
type GatewayNodeStats,
|
|
type GatewayVoiceStateCounts,
|
|
type GatewayVoiceStateEntry,
|
|
type GuildChannelAuthContext,
|
|
} from '@app/api/infrastructure/IGatewayService';
|
|
import {Logger} from '@app/api/Logger';
|
|
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
|
import {CallAlreadyExistsError} from '@fluxer/errors/src/domains/channel/CallAlreadyExistsError';
|
|
import {InvalidChannelTypeForCallError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeForCallError';
|
|
import {NoActiveCallError} from '@fluxer/errors/src/domains/channel/NoActiveCallError';
|
|
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
|
import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError';
|
|
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
|
import {GatewayTimeoutError} from '@fluxer/errors/src/domains/core/GatewayTimeoutError';
|
|
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
|
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';
|
|
|
|
const USER_PERMISSIONS_BATCH_SIZE = 100;
|
|
|
|
const GATEWAY_ERROR_TO_DOMAIN_ERROR: Record<string, () => Error> = {
|
|
[GatewayRpcMethodErrorCodes.GUILD_NOT_FOUND]: () => new UnknownGuildError(),
|
|
[GatewayRpcMethodErrorCodes.FORBIDDEN]: () => new MissingPermissionsError(),
|
|
[GatewayRpcMethodErrorCodes.CHANNEL_NOT_FOUND]: () => new UnknownChannelError(),
|
|
[GatewayRpcMethodErrorCodes.CHANNEL_NOT_VOICE]: () => new InvalidChannelTypeForCallError(),
|
|
[GatewayRpcMethodErrorCodes.CALL_ALREADY_EXISTS]: () => new CallAlreadyExistsError(),
|
|
[GatewayRpcMethodErrorCodes.CALL_NOT_FOUND]: () => new NoActiveCallError(),
|
|
[GatewayRpcMethodErrorCodes.USER_NOT_IN_VOICE]: () => new UserNotInVoiceError(),
|
|
[GatewayRpcMethodErrorCodes.CONNECTION_NOT_FOUND]: () => new UserNotInVoiceError(),
|
|
[GatewayRpcMethodErrorCodes.MODERATOR_MISSING_CONNECT]: () => new MissingPermissionsError(),
|
|
[GatewayRpcMethodErrorCodes.TARGET_MISSING_CONNECT]: () => new MissingPermissionsError(),
|
|
[GatewayRpcMethodErrorCodes.INVALID_PARAMS]: () => new BadRequestError({code: APIErrorCodes.INVALID_FORM_BODY}),
|
|
[GatewayRpcMethodErrorCodes.BATCH_TOO_LARGE]: () => new BadRequestError({code: APIErrorCodes.INVALID_FORM_BODY}),
|
|
};
|
|
|
|
interface DispatchGuildParams {
|
|
guildId: GuildID;
|
|
event: GatewayDispatchEvent;
|
|
data: unknown;
|
|
}
|
|
|
|
interface DispatchGuildManyParams {
|
|
guildId: GuildID;
|
|
events: Array<{event: GatewayDispatchEvent; data: unknown}>;
|
|
}
|
|
|
|
interface DispatchPresenceParams {
|
|
userId: UserID;
|
|
event: GatewayDispatchEvent;
|
|
data: unknown;
|
|
}
|
|
|
|
interface ClearPushChannelNotificationsParams {
|
|
userId: UserID;
|
|
channelId: ChannelID;
|
|
messageId: MessageID;
|
|
}
|
|
|
|
interface GuildDataParams {
|
|
guildId: GuildID;
|
|
userId: UserID;
|
|
}
|
|
|
|
interface GuildAuthContextParams {
|
|
guildId: GuildID;
|
|
userId: UserID;
|
|
channelId?: ChannelID;
|
|
}
|
|
|
|
interface GuildMemberParams {
|
|
guildId: GuildID;
|
|
userId: UserID;
|
|
}
|
|
|
|
interface HasMemberParams {
|
|
guildId: GuildID;
|
|
userId: UserID;
|
|
}
|
|
|
|
interface UserPermissionsParams {
|
|
guildId: GuildID;
|
|
userId: UserID;
|
|
channelId?: ChannelID;
|
|
}
|
|
|
|
interface CheckPermissionParams {
|
|
guildId: GuildID;
|
|
userId: UserID;
|
|
permission: bigint;
|
|
channelId?: ChannelID;
|
|
}
|
|
|
|
interface CanManageRolesParams {
|
|
guildId: GuildID;
|
|
userId: UserID;
|
|
targetUserId: UserID;
|
|
roleId: RoleID;
|
|
}
|
|
|
|
interface AssignableRolesParams {
|
|
guildId: GuildID;
|
|
userId: UserID;
|
|
}
|
|
|
|
interface MaxRolePositionParams {
|
|
guildId: GuildID;
|
|
userId: UserID;
|
|
}
|
|
|
|
interface MembersWithRoleParams {
|
|
guildId: GuildID;
|
|
roleId: RoleID;
|
|
}
|
|
|
|
interface CheckTargetMemberParams {
|
|
guildId: GuildID;
|
|
userId: UserID;
|
|
targetUserId: UserID;
|
|
}
|
|
|
|
interface ViewableChannelsParams {
|
|
guildId: GuildID;
|
|
userId: UserID;
|
|
}
|
|
|
|
interface CategoryChannelCountParams {
|
|
guildId: GuildID;
|
|
categoryId: ChannelID;
|
|
}
|
|
|
|
interface ChannelCountParams {
|
|
guildId: GuildID;
|
|
}
|
|
|
|
interface ResolveAllMentionsParams {
|
|
guildId: GuildID;
|
|
channelId: ChannelID;
|
|
authorId: UserID;
|
|
mentionEveryone: boolean;
|
|
mentionHere: boolean;
|
|
roleIds: Array<RoleID>;
|
|
userIds: Array<UserID>;
|
|
}
|
|
|
|
interface ResolveMentionSourcesPageParams extends ResolveAllMentionsParams {
|
|
limit: number;
|
|
cursor?: string;
|
|
}
|
|
|
|
interface ResolveChannelMentionsParams {
|
|
guildId: GuildID;
|
|
channelIds: Array<ChannelID>;
|
|
}
|
|
|
|
interface JoinGuildParams {
|
|
userId: UserID;
|
|
guildId: GuildID;
|
|
}
|
|
|
|
interface LeaveGuildParams {
|
|
userId: UserID;
|
|
guildId: GuildID;
|
|
}
|
|
|
|
interface TerminateSessionParams {
|
|
userId: UserID;
|
|
sessionIdHashes: Array<string>;
|
|
}
|
|
|
|
interface TerminateAllSessionsParams {
|
|
userId: UserID;
|
|
}
|
|
|
|
interface UpdateMemberVoiceParams {
|
|
guildId: GuildID;
|
|
userId: UserID;
|
|
mute: boolean;
|
|
deaf: boolean;
|
|
}
|
|
|
|
interface MoveMemberParams {
|
|
guildId: GuildID;
|
|
moderatorId: UserID;
|
|
userId: UserID;
|
|
channelId: ChannelID | null;
|
|
connectionId: string | null;
|
|
}
|
|
|
|
interface GuildMemberRpcResponse {
|
|
success: boolean;
|
|
member_data?: GuildMemberResponse;
|
|
}
|
|
|
|
interface GuildAuthContextRpcResponse {
|
|
guild: GuildResponse;
|
|
parent_channel?: ChannelResponse | null;
|
|
}
|
|
|
|
type PendingRequest<T> = {
|
|
resolve: (value: T) => void;
|
|
reject: (error: Error) => void;
|
|
settled: boolean;
|
|
timeoutId: NodeJS.Timeout | null;
|
|
};
|
|
|
|
export class GatewayService {
|
|
private rpcClient: GatewayRpcClient;
|
|
private pendingGuildDataRequests = new Map<string, Array<PendingRequest<GuildResponse>>>();
|
|
private pendingGuildMemberRequests = new Map<
|
|
string,
|
|
Array<
|
|
PendingRequest<{
|
|
success: boolean;
|
|
memberData?: GuildMemberResponse;
|
|
}>
|
|
>
|
|
>();
|
|
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();
|
|
}
|
|
|
|
private async call<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
|
try {
|
|
return await this.rpcClient.call<T>(method, params);
|
|
} catch (error) {
|
|
throw this.transformGatewayError(error);
|
|
}
|
|
}
|
|
|
|
private transformGatewayError(error: unknown): Error {
|
|
if (error instanceof GatewayRpcMethodError) {
|
|
const createError = GATEWAY_ERROR_TO_DOMAIN_ERROR[error.code];
|
|
if (createError) {
|
|
return createError();
|
|
}
|
|
if (error.code === GatewayRpcMethodErrorCodes.TIMEOUT) {
|
|
return new GatewayTimeoutError();
|
|
}
|
|
if (error.code === GatewayRpcMethodErrorCodes.GUILD_OVERLOADED) {
|
|
return new ServiceUnavailableError({headers: {'Retry-After': '1'}});
|
|
}
|
|
if (error.code === GatewayRpcMethodErrorCodes.OVERLOADED) {
|
|
return new ServiceUnavailableError();
|
|
}
|
|
if (error.code === GatewayRpcMethodErrorCodes.NO_RESPONDERS) {
|
|
return new ServiceUnavailableError();
|
|
}
|
|
if (error.code === GatewayRpcMethodErrorCodes.INTERNAL_ERROR) {
|
|
return new BadGatewayError();
|
|
}
|
|
return new BadGatewayError();
|
|
}
|
|
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
return new GatewayTimeoutError();
|
|
}
|
|
return error instanceof Error ? error : new Error(String(error));
|
|
}
|
|
|
|
private logBatchFailures(method: string, failures: Array<PromiseRejectedResult>): void {
|
|
if (failures.length === 0) {
|
|
return;
|
|
}
|
|
const reasonCounts = new Map<string, number>();
|
|
for (const failure of failures) {
|
|
const reason = failure.reason instanceof Error ? failure.reason.message : String(failure.reason);
|
|
reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1);
|
|
}
|
|
const reasonSummary = Array.from(reasonCounts.entries())
|
|
.sort(([, a], [, b]) => b - a)
|
|
.slice(0, 3)
|
|
.map(([reason, count]) => ({reason, count}));
|
|
Logger.error(
|
|
{method, failureCount: failures.length, reasonSummary},
|
|
`[gateway-batch] ${method} batch operation failed`,
|
|
);
|
|
}
|
|
|
|
private settlePendingRequest<T>(request: PendingRequest<T>, settle: () => void): void {
|
|
if (request.settled) {
|
|
return;
|
|
}
|
|
request.settled = true;
|
|
if (request.timeoutId) {
|
|
clearTimeout(request.timeoutId);
|
|
request.timeoutId = null;
|
|
}
|
|
this.pendingBatchRequestCount = Math.max(0, this.pendingBatchRequestCount - 1);
|
|
settle();
|
|
}
|
|
|
|
private resolvePendingRequests<T>(requests: Array<PendingRequest<T>>, value: T): void {
|
|
for (const request of requests) {
|
|
this.settlePendingRequest(request, () => request.resolve(value));
|
|
}
|
|
}
|
|
|
|
private rejectPendingRequests<T>(requests: Array<PendingRequest<T>>, error: Error): void {
|
|
for (const request of requests) {
|
|
this.settlePendingRequest(request, () => request.reject(error));
|
|
}
|
|
}
|
|
|
|
private scheduleGuildDataBatch(): void {
|
|
if (this.guildDataBatchTimeout || this.activeGuildDataRequests >= this.MAX_BATCH_CONCURRENCY) {
|
|
return;
|
|
}
|
|
this.guildDataBatchTimeout = setTimeout(() => {
|
|
this.guildDataBatchTimeout = null;
|
|
this.processGuildDataQueue();
|
|
}, this.BATCH_DELAY_MS);
|
|
}
|
|
|
|
private scheduleGuildMemberBatch(): void {
|
|
if (this.guildMemberBatchTimeout || this.activeGuildMemberRequests >= this.MAX_BATCH_CONCURRENCY) {
|
|
return;
|
|
}
|
|
this.guildMemberBatchTimeout = setTimeout(() => {
|
|
this.guildMemberBatchTimeout = null;
|
|
this.processGuildMemberQueue();
|
|
}, this.BATCH_DELAY_MS);
|
|
}
|
|
|
|
private schedulePermissionBatch(): void {
|
|
if (this.permissionBatchTimeout || this.activePermissionRequests >= this.MAX_BATCH_CONCURRENCY) {
|
|
return;
|
|
}
|
|
this.permissionBatchTimeout = setTimeout(() => {
|
|
this.permissionBatchTimeout = null;
|
|
this.processPermissionQueue();
|
|
}, 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,
|
|
): Array<[string, Array<PendingRequest<T>>]> {
|
|
const entries: Array<[string, Array<PendingRequest<T>>]> = [];
|
|
for (const [key, pending] of requests) {
|
|
entries.push([key, pending]);
|
|
requests.delete(key);
|
|
if (entries.length >= limit) {
|
|
break;
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
private processGuildDataQueue(): void {
|
|
const availableSlots = this.MAX_BATCH_CONCURRENCY - this.activeGuildDataRequests;
|
|
if (availableSlots <= 0) {
|
|
return;
|
|
}
|
|
const entries = this.takePendingEntries(this.pendingGuildDataRequests, availableSlots);
|
|
if (entries.length === 0) {
|
|
return;
|
|
}
|
|
const totalGuildDataRequests = entries.reduce((sum, [, pending]) => sum + pending.length, 0);
|
|
Logger.debug(
|
|
`[gateway-batch] Processing guild.get_data batch: ${entries.length} unique requests (${totalGuildDataRequests} total)`,
|
|
);
|
|
for (const entry of entries) {
|
|
this.activeGuildDataRequests += 1;
|
|
void this.processGuildDataEntry(entry).finally(() => {
|
|
this.activeGuildDataRequests = Math.max(0, this.activeGuildDataRequests - 1);
|
|
if (this.pendingGuildDataRequests.size > 0) {
|
|
this.scheduleGuildDataBatch();
|
|
}
|
|
});
|
|
}
|
|
if (this.pendingGuildDataRequests.size > 0) {
|
|
this.scheduleGuildDataBatch();
|
|
}
|
|
}
|
|
|
|
private processGuildMemberQueue(): void {
|
|
const availableSlots = this.MAX_BATCH_CONCURRENCY - this.activeGuildMemberRequests;
|
|
if (availableSlots <= 0) {
|
|
return;
|
|
}
|
|
const entries = this.takePendingEntries(this.pendingGuildMemberRequests, availableSlots);
|
|
if (entries.length === 0) {
|
|
return;
|
|
}
|
|
const totalGuildMemberRequests = entries.reduce((sum, [, pending]) => sum + pending.length, 0);
|
|
Logger.debug(
|
|
`[gateway-batch] Processing guild.get_member batch: ${entries.length} unique requests (${totalGuildMemberRequests} total)`,
|
|
);
|
|
for (const entry of entries) {
|
|
this.activeGuildMemberRequests += 1;
|
|
void this.processGuildMemberEntry(entry).finally(() => {
|
|
this.activeGuildMemberRequests = Math.max(0, this.activeGuildMemberRequests - 1);
|
|
if (this.pendingGuildMemberRequests.size > 0) {
|
|
this.scheduleGuildMemberBatch();
|
|
}
|
|
});
|
|
}
|
|
if (this.pendingGuildMemberRequests.size > 0) {
|
|
this.scheduleGuildMemberBatch();
|
|
}
|
|
}
|
|
|
|
private processPermissionQueue(): void {
|
|
const availableSlots = this.MAX_BATCH_CONCURRENCY - this.activePermissionRequests;
|
|
if (availableSlots <= 0) {
|
|
return;
|
|
}
|
|
const entries = this.takePendingEntries(this.pendingPermissionRequests, availableSlots);
|
|
if (entries.length === 0) {
|
|
return;
|
|
}
|
|
const totalPermissionRequests = entries.reduce((sum, [, pending]) => sum + pending.length, 0);
|
|
Logger.debug(
|
|
`[gateway-batch] Processing guild.check_permission batch: ${entries.length} unique requests (${totalPermissionRequests} total)`,
|
|
);
|
|
for (const entry of entries) {
|
|
this.activePermissionRequests += 1;
|
|
void this.processPermissionEntry(entry).finally(() => {
|
|
this.activePermissionRequests = Math.max(0, this.activePermissionRequests - 1);
|
|
if (this.pendingPermissionRequests.size > 0) {
|
|
this.schedulePermissionBatch();
|
|
}
|
|
});
|
|
}
|
|
if (this.pendingPermissionRequests.size > 0) {
|
|
this.schedulePermissionBatch();
|
|
}
|
|
}
|
|
|
|
private rejectAllPendingBatchRequests(error: Error): void {
|
|
for (const pendingRequests of this.pendingGuildDataRequests.values()) {
|
|
this.rejectPendingRequests(pendingRequests, error);
|
|
}
|
|
for (const pendingRequests of this.pendingGuildMemberRequests.values()) {
|
|
this.rejectPendingRequests(pendingRequests, error);
|
|
}
|
|
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('-');
|
|
const guildId = BigInt(guildIdStr) as GuildID;
|
|
const userId = BigInt(userIdStr) as UserID;
|
|
const skipMembershipCheck = skipCheck === 'skip';
|
|
const guildResponse = await this.call<GuildResponse>('guild.get_data', {
|
|
guild_id: guildId.toString(),
|
|
user_id: skipMembershipCheck ? null : userId.toString(),
|
|
});
|
|
this.resolvePendingRequests(pending, guildResponse);
|
|
} catch (error) {
|
|
const transformedError = this.transformGatewayError(error);
|
|
this.rejectPendingRequests(pending, transformedError);
|
|
this.logBatchFailures('guild.get_data', [{status: 'rejected', reason: error}]);
|
|
}
|
|
}
|
|
|
|
private async processGuildMemberEntry([key, pending]: [
|
|
string,
|
|
Array<
|
|
PendingRequest<{
|
|
success: boolean;
|
|
memberData?: GuildMemberResponse;
|
|
}>
|
|
>,
|
|
]): Promise<void> {
|
|
try {
|
|
const [guildIdStr, userIdStr] = key.split('-');
|
|
const guildId = BigInt(guildIdStr) as GuildID;
|
|
const userId = BigInt(userIdStr) as UserID;
|
|
const rpcResult = await this.call<GuildMemberRpcResponse | null>('guild.get_member', {
|
|
guild_id: guildId.toString(),
|
|
user_id: userId.toString(),
|
|
});
|
|
if (rpcResult?.success && rpcResult.member_data) {
|
|
const result = {success: true, memberData: rpcResult.member_data};
|
|
this.resolvePendingRequests(pending, result);
|
|
} else {
|
|
this.resolvePendingRequests(pending, {success: false});
|
|
}
|
|
} catch (error) {
|
|
const transformedError = this.transformGatewayError(error);
|
|
this.rejectPendingRequests(pending, transformedError);
|
|
this.logBatchFailures('guild.get_member', [{status: 'rejected', reason: error}]);
|
|
}
|
|
}
|
|
|
|
private async processPermissionEntry([key, pending]: [string, Array<PendingRequest<boolean>>]): Promise<void> {
|
|
try {
|
|
const [guildIdStr, userIdStr, permissionStr, channelIdStr] = key.split('-');
|
|
const guildId = BigInt(guildIdStr) as GuildID;
|
|
const userId = BigInt(userIdStr) as UserID;
|
|
const permission = BigInt(permissionStr);
|
|
const channelId = channelIdStr !== '0' ? (BigInt(channelIdStr) as ChannelID) : undefined;
|
|
const result = await this.call<{
|
|
has_permission: boolean;
|
|
}>('guild.check_permission', {
|
|
guild_id: guildId.toString(),
|
|
user_id: userId.toString(),
|
|
permission: permission.toString(),
|
|
channel_id: channelId ? channelId.toString() : '0',
|
|
});
|
|
this.resolvePendingRequests(pending, result.has_permission);
|
|
} catch (error) {
|
|
const transformedError = this.transformGatewayError(error);
|
|
this.rejectPendingRequests(pending, transformedError);
|
|
this.logBatchFailures('guild.check_permission', [{status: 'rejected', reason: error}]);
|
|
}
|
|
}
|
|
|
|
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(),
|
|
event,
|
|
data,
|
|
});
|
|
}
|
|
|
|
async dispatchGuildMany({guildId, events}: DispatchGuildManyParams): Promise<void> {
|
|
if (events.length === 0) return;
|
|
await this.call('guild.dispatch_many', {
|
|
guild_id: guildId.toString(),
|
|
events,
|
|
});
|
|
}
|
|
|
|
async dispatchPresence({userId, event, data}: DispatchPresenceParams): Promise<void> {
|
|
if (userId === SYSTEM_USER_ID) {
|
|
return;
|
|
}
|
|
await this.call('presence.dispatch', {
|
|
user_id: userId.toString(),
|
|
event,
|
|
data,
|
|
});
|
|
}
|
|
|
|
async clearPushChannelNotifications({
|
|
userId,
|
|
channelId,
|
|
messageId,
|
|
}: ClearPushChannelNotificationsParams): Promise<void> {
|
|
await this.call('push.clear_channel_notifications', {
|
|
user_id: userId.toString(),
|
|
channel_id: channelId.toString(),
|
|
message_id: messageId.toString(),
|
|
});
|
|
}
|
|
|
|
async syncPushUserGuildSettings({
|
|
userId,
|
|
guildId,
|
|
settings,
|
|
}: {
|
|
userId: UserID;
|
|
guildId: GuildID;
|
|
settings: unknown;
|
|
}): Promise<void> {
|
|
try {
|
|
await this.rpcClient.call('push.sync_user_guild_settings', {
|
|
user_id: userId.toString(),
|
|
guild_id: guildId.toString(),
|
|
user_guild_settings: settings,
|
|
});
|
|
} catch (error) {
|
|
throw this.transformGatewayError(error);
|
|
}
|
|
}
|
|
|
|
async getGuildCounts(guildId: GuildID): Promise<{
|
|
memberCount: number;
|
|
presenceCount: number;
|
|
}> {
|
|
const result = await this.call<{
|
|
member_count?: unknown;
|
|
presence_count?: unknown;
|
|
online_count?: unknown;
|
|
}>('guild.get_counts', {
|
|
guild_id: guildId.toString(),
|
|
});
|
|
if (typeof result.member_count !== 'number') {
|
|
throw new BadGatewayError();
|
|
}
|
|
let presenceCount: number | null = null;
|
|
if (typeof result.presence_count === 'number') {
|
|
presenceCount = result.presence_count;
|
|
} else if (typeof result.online_count === 'number') {
|
|
presenceCount = result.online_count;
|
|
}
|
|
if (presenceCount === null) {
|
|
throw new BadGatewayError();
|
|
}
|
|
return {
|
|
memberCount: result.member_count,
|
|
presenceCount,
|
|
};
|
|
}
|
|
|
|
async getChannelCount({guildId}: ChannelCountParams): Promise<number> {
|
|
const result = await this.call<{
|
|
count: number;
|
|
}>('guild.get_channel_count', {
|
|
guild_id: guildId.toString(),
|
|
});
|
|
return result.count;
|
|
}
|
|
|
|
async getCategoryChannelCount({guildId, categoryId}: CategoryChannelCountParams): Promise<number> {
|
|
const result = await this.call<{
|
|
count: number;
|
|
}>('guild.get_category_channel_count', {
|
|
guild_id: guildId.toString(),
|
|
category_id: categoryId.toString(),
|
|
});
|
|
return result.count;
|
|
}
|
|
|
|
async getGuildData({
|
|
guildId,
|
|
userId,
|
|
skipMembershipCheck,
|
|
}: GuildDataParams & {
|
|
skipMembershipCheck?: boolean;
|
|
}): Promise<GuildResponse> {
|
|
const key = `${guildId.toString()}-${userId.toString()}-${skipMembershipCheck ? 'skip' : 'check'}`;
|
|
return new Promise<GuildResponse>((resolve, reject) => {
|
|
if (this.pendingBatchRequestCount >= this.MAX_PENDING_BATCH_REQUESTS) {
|
|
reject(new ServiceUnavailableError());
|
|
return;
|
|
}
|
|
const pendingRequest: PendingRequest<GuildResponse> = {
|
|
resolve,
|
|
reject,
|
|
settled: false,
|
|
timeoutId: null,
|
|
};
|
|
pendingRequest.timeoutId = setTimeout(() => {
|
|
const error = new GatewayTimeoutError();
|
|
this.rejectPendingRequests([pendingRequest], error);
|
|
this.removePendingGuildDataRequest(key, pendingRequest);
|
|
}, this.PENDING_REQUEST_TIMEOUT_MS);
|
|
const pending = this.pendingGuildDataRequests.get(key) || [];
|
|
pending.push(pendingRequest);
|
|
this.pendingGuildDataRequests.set(key, pending);
|
|
this.pendingBatchRequestCount += 1;
|
|
Logger.debug(
|
|
`[gateway-batch] Queued guild.get_data request for guild ${guildId.toString()}, user ${userId.toString()}, total pending: ${pending.length}`,
|
|
);
|
|
this.scheduleGuildDataBatch();
|
|
});
|
|
}
|
|
|
|
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) {
|
|
const index = pending.indexOf(request);
|
|
if (index >= 0) {
|
|
pending.splice(index, 1);
|
|
if (pending.length === 0) {
|
|
this.pendingGuildDataRequests.delete(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async getGuildMember({guildId, userId}: GuildMemberParams): Promise<{
|
|
success: boolean;
|
|
memberData?: GuildMemberResponse;
|
|
}> {
|
|
const key = `${guildId.toString()}-${userId.toString()}`;
|
|
return new Promise<{
|
|
success: boolean;
|
|
memberData?: GuildMemberResponse;
|
|
}>((resolve, reject) => {
|
|
if (this.pendingBatchRequestCount >= this.MAX_PENDING_BATCH_REQUESTS) {
|
|
reject(new ServiceUnavailableError());
|
|
return;
|
|
}
|
|
const pendingRequest: PendingRequest<{
|
|
success: boolean;
|
|
memberData?: GuildMemberResponse;
|
|
}> = {
|
|
resolve,
|
|
reject,
|
|
settled: false,
|
|
timeoutId: null,
|
|
};
|
|
pendingRequest.timeoutId = setTimeout(() => {
|
|
const error = new GatewayTimeoutError();
|
|
this.rejectPendingRequests([pendingRequest], error);
|
|
this.removePendingGuildMemberRequest(key, pendingRequest);
|
|
}, this.PENDING_REQUEST_TIMEOUT_MS);
|
|
const pending = this.pendingGuildMemberRequests.get(key) || [];
|
|
pending.push(pendingRequest);
|
|
this.pendingGuildMemberRequests.set(key, pending);
|
|
this.pendingBatchRequestCount += 1;
|
|
Logger.debug(
|
|
`[gateway-batch] Queued guild.get_member request for guild ${guildId.toString()}, user ${userId.toString()}, total pending: ${pending.length}`,
|
|
);
|
|
this.scheduleGuildMemberBatch();
|
|
});
|
|
}
|
|
|
|
private removePendingGuildMemberRequest(
|
|
key: string,
|
|
request: PendingRequest<{
|
|
success: boolean;
|
|
memberData?: GuildMemberResponse;
|
|
}>,
|
|
): void {
|
|
const pending = this.pendingGuildMemberRequests.get(key);
|
|
if (pending) {
|
|
const index = pending.indexOf(request);
|
|
if (index >= 0) {
|
|
pending.splice(index, 1);
|
|
if (pending.length === 0) {
|
|
this.pendingGuildMemberRequests.delete(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async hasGuildMember({guildId, userId}: HasMemberParams): Promise<boolean> {
|
|
const result = await this.call<{
|
|
has_member: boolean;
|
|
}>('guild.has_member', {
|
|
guild_id: guildId.toString(),
|
|
user_id: userId.toString(),
|
|
});
|
|
return result.has_member;
|
|
}
|
|
|
|
async listGuildMembers({guildId, limit, offset}: {guildId: GuildID; limit: number; offset: number}): Promise<{
|
|
members: Array<GuildMemberResponse>;
|
|
total: number;
|
|
}> {
|
|
const result = await this.call<{
|
|
members?: Array<GuildMemberResponse>;
|
|
total?: number;
|
|
}>('guild.list_members', {
|
|
guild_id: guildId.toString(),
|
|
limit,
|
|
offset,
|
|
});
|
|
return {
|
|
members: result.members ?? [],
|
|
total: result.total ?? 0,
|
|
};
|
|
}
|
|
|
|
async listGuildMembersCursor({guildId, limit, after}: {guildId: GuildID; limit: number; after?: UserID}): Promise<{
|
|
members: Array<GuildMemberResponse>;
|
|
total: number;
|
|
}> {
|
|
const result = await this.call<{
|
|
members?: Array<GuildMemberResponse>;
|
|
total?: number;
|
|
}>('guild.list_members_cursor', {
|
|
guild_id: guildId.toString(),
|
|
limit,
|
|
...(after !== undefined && {after: after.toString()}),
|
|
});
|
|
return {
|
|
members: result.members ?? [],
|
|
total: result.total ?? 0,
|
|
};
|
|
}
|
|
|
|
async startGuild(guildId: GuildID): Promise<void> {
|
|
await this.call('guild.start', {
|
|
guild_id: guildId.toString(),
|
|
});
|
|
}
|
|
|
|
async stopGuild(guildId: GuildID): Promise<void> {
|
|
await this.call('guild.stop', {
|
|
guild_id: guildId.toString(),
|
|
});
|
|
}
|
|
|
|
async reloadGuild(guildId: GuildID): Promise<void> {
|
|
await this.call('guild.reload', {
|
|
guild_id: guildId.toString(),
|
|
});
|
|
}
|
|
|
|
async reloadAllGuilds(guildIds: Array<GuildID>): Promise<{
|
|
count: number;
|
|
}> {
|
|
const result = await this.call<{
|
|
count: number;
|
|
}>('guild.reload_all', {
|
|
guild_ids: guildIds.map((id) => id.toString()),
|
|
});
|
|
return {count: result.count};
|
|
}
|
|
|
|
async shutdownGuild(guildId: GuildID): Promise<void> {
|
|
await this.call('guild.shutdown', {
|
|
guild_id: guildId.toString(),
|
|
});
|
|
}
|
|
|
|
async getGuildMemoryStats(limit: number): Promise<GatewayGuildMemoryStats> {
|
|
const result = await this.call<GatewayGuildMemoryStats>('process.memory_stats', {
|
|
limit: limit.toString(),
|
|
});
|
|
return {
|
|
guilds: result.guilds,
|
|
};
|
|
}
|
|
|
|
async getVoiceStateCounts(): Promise<GatewayVoiceStateCounts> {
|
|
const result = await this.call<GatewayVoiceStateCounts>('process.voice_state_counts', {});
|
|
return {
|
|
total_voice_states: result.total_voice_states ?? 0,
|
|
regions: result.regions ?? [],
|
|
servers: result.servers ?? [],
|
|
};
|
|
}
|
|
|
|
async getUserPermissions({guildId, userId, channelId}: UserPermissionsParams): Promise<bigint> {
|
|
const result = await this.call<{
|
|
permissions: string;
|
|
}>('guild.get_user_permissions', {
|
|
guild_id: guildId.toString(),
|
|
user_id: userId.toString(),
|
|
channel_id: channelId ? channelId.toString() : '0',
|
|
});
|
|
return BigInt(result.permissions);
|
|
}
|
|
|
|
async getUserPermissionsBatch({
|
|
guildIds,
|
|
userId,
|
|
channelId,
|
|
}: {
|
|
guildIds: Array<GuildID>;
|
|
userId: UserID;
|
|
channelId?: ChannelID;
|
|
}): Promise<Map<GuildID, bigint>> {
|
|
const permissionsMap = new Map<GuildID, bigint>();
|
|
if (guildIds.length === 0) {
|
|
return permissionsMap;
|
|
}
|
|
const batches: Array<Array<GuildID>> = [];
|
|
for (let index = 0; index < guildIds.length; index += USER_PERMISSIONS_BATCH_SIZE) {
|
|
batches.push(guildIds.slice(index, index + USER_PERMISSIONS_BATCH_SIZE));
|
|
}
|
|
const results = await Promise.all(
|
|
batches.map((batch) =>
|
|
this.call<{
|
|
permissions: Array<{
|
|
guild_id: string;
|
|
permissions: string;
|
|
}>;
|
|
}>('guild.get_user_permissions_batch', {
|
|
guild_ids: batch.map((id) => id.toString()),
|
|
user_id: userId.toString(),
|
|
channel_id: channelId ? channelId.toString() : '0',
|
|
}),
|
|
),
|
|
);
|
|
for (const result of results) {
|
|
for (const item of result.permissions) {
|
|
const guildId = BigInt(item.guild_id) as GuildID;
|
|
permissionsMap.set(guildId, BigInt(item.permissions));
|
|
}
|
|
}
|
|
return permissionsMap;
|
|
}
|
|
|
|
async checkPermission({guildId, userId, permission, channelId}: CheckPermissionParams): Promise<boolean> {
|
|
const key = `${guildId.toString()}-${userId.toString()}-${permission.toString()}-${channelId?.toString() || '0'}`;
|
|
return new Promise<boolean>((resolve, reject) => {
|
|
if (this.pendingBatchRequestCount >= this.MAX_PENDING_BATCH_REQUESTS) {
|
|
reject(new ServiceUnavailableError());
|
|
return;
|
|
}
|
|
const pendingRequest: PendingRequest<boolean> = {
|
|
resolve,
|
|
reject,
|
|
settled: false,
|
|
timeoutId: null,
|
|
};
|
|
pendingRequest.timeoutId = setTimeout(() => {
|
|
const error = new GatewayTimeoutError();
|
|
this.rejectPendingRequests([pendingRequest], error);
|
|
this.removePendingPermissionRequest(key, pendingRequest);
|
|
}, this.PENDING_REQUEST_TIMEOUT_MS);
|
|
const pending = this.pendingPermissionRequests.get(key) || [];
|
|
pending.push(pendingRequest);
|
|
this.pendingPermissionRequests.set(key, pending);
|
|
this.pendingBatchRequestCount += 1;
|
|
Logger.debug(
|
|
`[gateway-batch] Queued guild.check_permission request for guild ${guildId.toString()}, user ${userId.toString()}, channel ${channelId?.toString() || 'none'}, permission ${permission.toString()}, total pending: ${pending.length}`,
|
|
);
|
|
this.schedulePermissionBatch();
|
|
});
|
|
}
|
|
|
|
private removePendingPermissionRequest(key: string, request: PendingRequest<boolean>): void {
|
|
const pending = this.pendingPermissionRequests.get(key);
|
|
if (pending) {
|
|
const index = pending.indexOf(request);
|
|
if (index >= 0) {
|
|
pending.splice(index, 1);
|
|
if (pending.length === 0) {
|
|
this.pendingPermissionRequests.delete(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async canManageRoles({guildId, userId, targetUserId, roleId}: CanManageRolesParams): Promise<boolean> {
|
|
const result = await this.call<{
|
|
can_manage: boolean;
|
|
}>('guild.can_manage_roles', {
|
|
guild_id: guildId.toString(),
|
|
user_id: userId.toString(),
|
|
target_user_id: targetUserId.toString(),
|
|
role_id: roleId.toString(),
|
|
});
|
|
return result.can_manage;
|
|
}
|
|
|
|
async canManageRole({guildId, userId, roleId}: {guildId: GuildID; userId: UserID; roleId: RoleID}): Promise<boolean> {
|
|
const result = await this.call<{
|
|
can_manage: boolean;
|
|
}>('guild.can_manage_role', {
|
|
guild_id: guildId.toString(),
|
|
user_id: userId.toString(),
|
|
role_id: roleId.toString(),
|
|
});
|
|
return result.can_manage;
|
|
}
|
|
|
|
async getAssignableRoles({guildId, userId}: AssignableRolesParams): Promise<Array<RoleID>> {
|
|
const result = await this.call<{
|
|
role_ids: Array<string>;
|
|
}>('guild.get_assignable_roles', {
|
|
guild_id: guildId.toString(),
|
|
user_id: userId.toString(),
|
|
});
|
|
return result.role_ids.map((id: string) => createRoleID(BigInt(id)));
|
|
}
|
|
|
|
async getUserMaxRolePosition({guildId, userId}: MaxRolePositionParams): Promise<number> {
|
|
const result = await this.call<{
|
|
position: number;
|
|
}>('guild.get_user_max_role_position', {
|
|
guild_id: guildId.toString(),
|
|
user_id: userId.toString(),
|
|
});
|
|
return result.position;
|
|
}
|
|
|
|
async getMembersWithRole({guildId, roleId}: MembersWithRoleParams): Promise<Array<UserID>> {
|
|
const result = await this.call<{
|
|
user_ids: Array<string>;
|
|
}>('guild.get_members_with_role', {
|
|
guild_id: guildId.toString(),
|
|
role_id: roleId.toString(),
|
|
});
|
|
return result.user_ids.map((id: string) => createUserID(BigInt(id)));
|
|
}
|
|
|
|
async checkTargetMember({guildId, userId, targetUserId}: CheckTargetMemberParams): Promise<boolean> {
|
|
const result = await this.call<{
|
|
can_manage: boolean;
|
|
}>('guild.check_target_member', {
|
|
guild_id: guildId.toString(),
|
|
user_id: userId.toString(),
|
|
target_user_id: targetUserId.toString(),
|
|
});
|
|
return result.can_manage;
|
|
}
|
|
|
|
async getViewableChannels({guildId, userId}: ViewableChannelsParams): Promise<Array<ChannelID>> {
|
|
const result = await this.call<{
|
|
channel_ids: Array<string>;
|
|
}>('guild.get_viewable_channels', {
|
|
guild_id: guildId.toString(),
|
|
user_id: userId.toString(),
|
|
});
|
|
return result.channel_ids.map((id: string) => createChannelID(BigInt(id)));
|
|
}
|
|
|
|
async resolveMentionSourcesPage({
|
|
guildId,
|
|
channelId,
|
|
authorId,
|
|
mentionEveryone,
|
|
mentionHere,
|
|
roleIds,
|
|
userIds,
|
|
limit,
|
|
cursor,
|
|
}: ResolveMentionSourcesPageParams): Promise<GatewayMentionSourcesPage> {
|
|
const result = await this.call<{
|
|
mentions?: Array<{
|
|
user_id: string;
|
|
direct?: boolean;
|
|
role?: boolean;
|
|
everyone?: boolean;
|
|
}>;
|
|
next_cursor?: string | null;
|
|
}>('guild.resolve_mention_sources_page', {
|
|
guild_id: guildId.toString(),
|
|
channel_id: channelId.toString(),
|
|
author_id: authorId.toString(),
|
|
mention_everyone: mentionEveryone,
|
|
mention_here: mentionHere,
|
|
role_ids: roleIds.map((id) => id.toString()),
|
|
user_ids: userIds.map((id) => id.toString()),
|
|
limit,
|
|
...(cursor !== undefined && {cursor}),
|
|
});
|
|
return {
|
|
mentions: (result.mentions ?? []).map((entry) => ({
|
|
userId: createUserID(BigInt(entry.user_id)),
|
|
direct: entry.direct === true,
|
|
role: entry.role === true,
|
|
everyone: entry.everyone === true,
|
|
})),
|
|
nextCursor: result.next_cursor ?? null,
|
|
};
|
|
}
|
|
|
|
async resolveChannelMentions({
|
|
guildId,
|
|
channelIds,
|
|
}: ResolveChannelMentionsParams): Promise<Array<GatewayChannelMention>> {
|
|
if (channelIds.length === 0) {
|
|
return [];
|
|
}
|
|
const result = await this.call<{
|
|
channels?: Array<GatewayChannelMention>;
|
|
}>('guild.resolve_channel_mentions', {
|
|
guild_id: guildId.toString(),
|
|
channel_ids: Array.from(new Set(channelIds)).map((id) => id.toString()),
|
|
});
|
|
return result.channels ?? [];
|
|
}
|
|
|
|
async getFirstViewableTextChannel(guildId: GuildID): Promise<ChannelID | null> {
|
|
const result = await this.call<{
|
|
channel_id: string | null;
|
|
}>('guild.get_first_viewable_text_channel', {
|
|
guild_id: guildId.toString(),
|
|
});
|
|
return result.channel_id ? createChannelID(BigInt(result.channel_id)) : null;
|
|
}
|
|
|
|
async joinGuild({userId, guildId}: JoinGuildParams): Promise<void> {
|
|
await this.call('presence.join_guild', {
|
|
user_id: userId.toString(),
|
|
guild_id: guildId.toString(),
|
|
});
|
|
}
|
|
|
|
async leaveGuild({userId, guildId}: LeaveGuildParams): Promise<void> {
|
|
await this.call('presence.leave_guild', {
|
|
user_id: userId.toString(),
|
|
guild_id: guildId.toString(),
|
|
});
|
|
}
|
|
|
|
async terminateSession({userId, sessionIdHashes}: TerminateSessionParams): Promise<void> {
|
|
await this.call('presence.terminate_sessions', {
|
|
user_id: userId.toString(),
|
|
session_id_hashes: sessionIdHashes,
|
|
});
|
|
}
|
|
|
|
async terminateAllSessionsForUser({userId}: TerminateAllSessionsParams): Promise<void> {
|
|
await this.call('presence.terminate_all_sessions', {
|
|
user_id: userId.toString(),
|
|
});
|
|
}
|
|
|
|
async updateMemberVoice({guildId, userId, mute, deaf}: UpdateMemberVoiceParams): Promise<{
|
|
success: boolean;
|
|
}> {
|
|
const result = await this.call<{
|
|
success: boolean;
|
|
}>('guild.update_member_voice', {
|
|
guild_id: guildId.toString(),
|
|
user_id: userId.toString(),
|
|
mute,
|
|
deaf,
|
|
});
|
|
return {success: result.success};
|
|
}
|
|
|
|
async disconnectVoiceUserIfInChannel({
|
|
guildId,
|
|
userId,
|
|
channelId,
|
|
connectionId,
|
|
}: {
|
|
guildId?: GuildID;
|
|
channelId: ChannelID;
|
|
userId: UserID;
|
|
connectionId?: string;
|
|
}): Promise<{
|
|
success: boolean;
|
|
ignored?: boolean;
|
|
}> {
|
|
const params: Record<string, string> = {
|
|
channel_id: channelId.toString(),
|
|
user_id: userId.toString(),
|
|
};
|
|
if (guildId !== undefined) {
|
|
params['guild_id'] = guildId.toString();
|
|
}
|
|
if (connectionId) {
|
|
params['connection_id'] = connectionId;
|
|
}
|
|
const result = await this.call<{
|
|
success: boolean;
|
|
ignored?: boolean;
|
|
}>('voice.disconnect_user_if_in_channel', params);
|
|
return {
|
|
success: result.success,
|
|
ignored: result.ignored,
|
|
};
|
|
}
|
|
|
|
async getVoiceState({guildId, userId}: {guildId: GuildID; userId: UserID}): Promise<{
|
|
channel_id: string | null;
|
|
} | null> {
|
|
const result = await this.call<{
|
|
voice_state: {
|
|
channel_id: string | null;
|
|
} | null;
|
|
}>('guild.get_voice_state', {
|
|
guild_id: guildId.toString(),
|
|
user_id: userId.toString(),
|
|
});
|
|
return result.voice_state;
|
|
}
|
|
|
|
async moveMember({guildId, moderatorId, userId, channelId, connectionId}: MoveMemberParams): Promise<{
|
|
success?: boolean;
|
|
error?: string;
|
|
}> {
|
|
const result = await this.call<{
|
|
success?: boolean;
|
|
error?: string;
|
|
}>('guild.move_member', {
|
|
guild_id: guildId.toString(),
|
|
moderator_id: moderatorId.toString(),
|
|
user_id: userId.toString(),
|
|
channel_id: channelId ? channelId.toString() : null,
|
|
connection_id: connectionId,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
async hasActivePresence(userId: UserID): Promise<boolean> {
|
|
const result = await this.call<{
|
|
has_active: boolean;
|
|
}>('presence.has_active', {
|
|
user_id: userId.toString(),
|
|
});
|
|
return result.has_active;
|
|
}
|
|
|
|
async addTemporaryGuild({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<void> {
|
|
await this.call('presence.add_temporary_guild', {
|
|
user_id: userId.toString(),
|
|
guild_id: guildId.toString(),
|
|
});
|
|
}
|
|
|
|
async removeTemporaryGuild({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<void> {
|
|
try {
|
|
await this.call('presence.remove_temporary_guild', {
|
|
user_id: userId.toString(),
|
|
guild_id: guildId.toString(),
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
Logger.error(
|
|
`[GatewayService] Failed to remove temporary guild for user ${userId.toString()}, guild ${guildId.toString()}: ${errorMessage}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async syncGroupDmRecipients({
|
|
userId,
|
|
recipientsByChannel,
|
|
}: {
|
|
userId: UserID;
|
|
recipientsByChannel: Record<string, Array<string>>;
|
|
}): Promise<void> {
|
|
try {
|
|
await this.call('presence.sync_group_dm_recipients', {
|
|
user_id: userId.toString(),
|
|
recipients_by_channel: recipientsByChannel,
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
const channelCount = Object.keys(recipientsByChannel).length;
|
|
Logger.error(
|
|
`[GatewayService] Failed to sync group DM recipients for user ${userId.toString()} (${channelCount} channels): ${errorMessage}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async switchVoiceRegion({guildId, channelId}: {guildId: GuildID; channelId: ChannelID}): Promise<void> {
|
|
await this.call('guild.switch_voice_region', {
|
|
guild_id: guildId.toString(),
|
|
channel_id: channelId.toString(),
|
|
});
|
|
}
|
|
|
|
async confirmVoiceConnection({
|
|
guildId,
|
|
channelId,
|
|
connectionId,
|
|
tokenNonce,
|
|
}: {
|
|
guildId?: GuildID;
|
|
channelId: ChannelID;
|
|
connectionId: string;
|
|
tokenNonce?: string;
|
|
}): Promise<{
|
|
success: boolean;
|
|
error?: string;
|
|
}> {
|
|
const params: Record<string, string> = {
|
|
channel_id: channelId.toString(),
|
|
connection_id: connectionId,
|
|
};
|
|
if (guildId !== undefined) {
|
|
params['guild_id'] = guildId.toString();
|
|
}
|
|
if (tokenNonce !== undefined) {
|
|
params['token_nonce'] = tokenNonce;
|
|
}
|
|
const result = await this.call<{
|
|
success: boolean;
|
|
error?: string;
|
|
}>('voice.confirm_connection', params);
|
|
return {
|
|
success: result.success,
|
|
error: result.error,
|
|
};
|
|
}
|
|
|
|
async getVoiceStatesForChannel({guildId, channelId}: {guildId?: GuildID; channelId: ChannelID}): Promise<{
|
|
voiceStates: Array<GatewayVoiceStateEntry>;
|
|
}> {
|
|
const params: Record<string, string> = {channel_id: channelId.toString()};
|
|
if (guildId !== undefined) {
|
|
params['guild_id'] = guildId.toString();
|
|
}
|
|
const result = await this.call<{
|
|
voice_states: Array<{
|
|
connection_id: string;
|
|
user_id: string;
|
|
channel_id: string;
|
|
region_id?: string;
|
|
server_id?: string;
|
|
}>;
|
|
}>('voice.get_voice_states_for_channel', params);
|
|
return {
|
|
voiceStates: (result.voice_states ?? []).map((vs) => ({
|
|
connectionId: vs.connection_id,
|
|
userId: vs.user_id,
|
|
channelId: vs.channel_id,
|
|
regionId: vs.region_id,
|
|
serverId: vs.server_id,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async getCall(channelId: ChannelID): Promise<CallData | null> {
|
|
return this.call<CallData | null>('call.get', {channel_id: channelId.toString()});
|
|
}
|
|
|
|
async createCall(
|
|
channelId: ChannelID,
|
|
messageId: string,
|
|
region: string,
|
|
ringing: Array<string>,
|
|
recipients: Array<string>,
|
|
caller?: CallCaller,
|
|
): Promise<CallData> {
|
|
return this.call<CallData>('call.create', {
|
|
channel_id: channelId.toString(),
|
|
message_id: messageId,
|
|
region,
|
|
ringing,
|
|
recipients,
|
|
...callCallerRpcParams(caller),
|
|
});
|
|
}
|
|
|
|
async updateCallRegion(channelId: ChannelID, region: string | null): Promise<boolean> {
|
|
return this.call<boolean>('call.update_region', {channel_id: channelId.toString(), region});
|
|
}
|
|
|
|
async ringCallRecipients(channelId: ChannelID, recipients: Array<string>, caller?: CallCaller): Promise<boolean> {
|
|
return this.call<boolean>('call.ring', {
|
|
channel_id: channelId.toString(),
|
|
recipients,
|
|
...callCallerRpcParams(caller),
|
|
});
|
|
}
|
|
|
|
async stopRingingCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean> {
|
|
return this.call<boolean>('call.stop_ringing', {channel_id: channelId.toString(), recipients});
|
|
}
|
|
|
|
async getDiscoveryGuildCounts(guildIds: Array<GuildID>): Promise<
|
|
Map<
|
|
GuildID,
|
|
{
|
|
memberCount: number;
|
|
onlineCount: number;
|
|
}
|
|
>
|
|
> {
|
|
const result = await this.call<{
|
|
online_counts: Array<{
|
|
guild_id: string;
|
|
member_count: number;
|
|
online_count: number;
|
|
}>;
|
|
}>('guild.get_online_counts_batch', {
|
|
guild_ids: guildIds.map(String),
|
|
});
|
|
const counts = new Map<
|
|
GuildID,
|
|
{
|
|
memberCount: number;
|
|
onlineCount: number;
|
|
}
|
|
>();
|
|
for (const entry of result.online_counts) {
|
|
counts.set(BigInt(entry.guild_id) as GuildID, {
|
|
memberCount: entry.member_count,
|
|
onlineCount: entry.online_count,
|
|
});
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
async getNodeStats(): Promise<GatewayNodeStats> {
|
|
return this.call<GatewayNodeStats>('process.node_stats', {});
|
|
}
|
|
|
|
destroy(): void {
|
|
if (this.guildDataBatchTimeout) {
|
|
clearTimeout(this.guildDataBatchTimeout);
|
|
this.guildDataBatchTimeout = null;
|
|
}
|
|
if (this.guildMemberBatchTimeout) {
|
|
clearTimeout(this.guildMemberBatchTimeout);
|
|
this.guildMemberBatchTimeout = null;
|
|
}
|
|
if (this.permissionBatchTimeout) {
|
|
clearTimeout(this.permissionBatchTimeout);
|
|
this.permissionBatchTimeout = null;
|
|
}
|
|
if (this.authContextBatchTimeout) {
|
|
clearTimeout(this.authContextBatchTimeout);
|
|
this.authContextBatchTimeout = null;
|
|
}
|
|
this.rejectAllPendingBatchRequests(new ServiceUnavailableError());
|
|
}
|
|
}
|