// SPDX-License-Identifier: AGPL-3.0-or-later import {createHash} from 'node:crypto'; import type {ApiContext} from '@app/api/ApiContext'; import * as AuthSession from '@app/api/auth/AuthSession'; import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes'; import { createChannelID, createGuildID, createMessageID, createUserID, userIdToChannelId, vanityCodeToInviteCode, } from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; import {mapChannelToResponse} from '@app/api/channel/ChannelMappers'; import type {IChannelRepository} from '@app/api/channel/IChannelRepository'; import {buildBroadcastMessageData} from '@app/api/channel/services/message/MessageGatewayDispatch'; import {ensurePersonalNotesChannelExists} from '@app/api/channel/services/PersonalNotesChannelRepair'; import {mapFavoriteMemeToResponse} from '@app/api/favorite_meme/FavoriteMemeModel'; import type {IFavoriteMemeRepository} from '@app/api/favorite_meme/IFavoriteMemeRepository'; import { mapGuildEmojiToResponse, mapGuildMemberToResponse, mapGuildRoleToResponse, mapGuildStickerToResponse, mapGuildToGuildResponse, } from '@app/api/guild/GuildModel'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import type {AvatarService} from '@app/api/infrastructure/AvatarService'; import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {ListParticipantsResult} from '@app/api/infrastructure/ILiveKitService'; import type {IStorageService} from '@app/api/infrastructure/IStorageService'; import type {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import type {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository'; import type {IInviteRepository} from '@app/api/invite/IInviteRepository'; import {Logger} from '@app/api/Logger'; import type {LimitConfigService} from '@app/api/limits/LimitConfigService'; import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils'; import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {AuthSession as AuthSessionModel} from '@app/api/models/AuthSession'; import type {Channel} from '@app/api/models/Channel'; import type {FavoriteMeme} from '@app/api/models/FavoriteMeme'; import type {Guild} from '@app/api/models/Guild'; import type {GuildMember} from '@app/api/models/GuildMember'; import type {GuildSticker} from '@app/api/models/GuildSticker'; import type {ReadState} from '@app/api/models/ReadState'; import type {Relationship} from '@app/api/models/Relationship'; import type {User} from '@app/api/models/User'; import type {UserGuildSettings} from '@app/api/models/UserGuildSettings'; import {UserSettings} from '@app/api/models/UserSettings'; import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential'; import type {BotAuthService} from '@app/api/oauth/BotAuthService'; import {sendApnsPush} from '@app/api/push/ApnsPushService'; import {mapReadStateResponse} from '@app/api/read_state/ReadStateResponseMapper'; import type {ReadStateService} from '@app/api/read_state/ReadStateService'; import {RpcSessionStartService} from '@app/api/rpc/RpcSessionStartService'; import { createRpcTimingNode, RpcTimingRecorder, type RpcTimingSteps, startRpcTiming, timeRpcStep, timeRpcStepSync, } from '@app/api/rpc/RpcTimings'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import {PaymentRepository} from '@app/api/user/repositories/PaymentRepository'; import {CustomStatusValidator} from '@app/api/user/services/CustomStatusValidator'; import {getCachedUserPartialResponse} from '@app/api/user/UserCacheHelpers'; import { mapRelationshipToResponse, mapUserGuildSettingsToResponse, mapUserSettingsToResponse, mapUserToPrivateResponse, } from '@app/api/user/UserMappers'; import {isUserAdult} from '@app/api/utils/AgeUtils'; import {deriveDominantAvatarColor} from '@app/api/utils/AvatarColorUtils'; import {calculateDistance, parseCoordinate} from '@app/api/utils/GeoUtils'; import {lookupGeoip} from '@app/api/utils/IpUtils'; import type {VoiceAccessContext, VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService'; import type {VoiceService} from '@app/api/voice/VoiceService'; import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository'; import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig'; import {AUTOMATIC_VOICE_REGION_ID, ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata'; import {MAX_PRIVATE_CHANNELS_PER_USER} from '@fluxer/constants/src/LimitConstants'; import { GroupDmAddPermissionFlags, IncomingCallFlags, UserFlags, UserPremiumTypes, } from '@fluxer/constants/src/UserConstants'; import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError'; import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError'; import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError'; import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; import type {VoiceStateResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type { RpcGuildCollectionType, RpcRequest, RpcResponse, RpcResponseGuildCollectionData, RpcResponseSessionData, } from '@fluxer/schema/src/domains/rpc/RpcSchemas'; import type {RelationshipResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider'; import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService'; import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService'; import {ms} from 'itty-time'; import sharp from 'sharp'; import {uint8ArrayToBase64} from 'uint8array-extras'; interface HandleRpcRequestParams { request: RpcRequest; requestCache: RequestCache; } interface HandleSessionRequestParams { token: string; version: number; requestCache: RequestCache; ip?: string; latitude?: string; longitude?: string; } interface HandleGuildCollectionRequestParams { guildId: GuildID; collection: RpcGuildCollectionType; requestCache: RequestCache; afterUserId?: UserID; limit?: number; } interface GetUserDataParams { userId: UserID; includePrivateChannels?: boolean; timingSteps?: RpcTimingSteps; } interface UserData { user: User; settings: UserSettings | null; guildSettings: Array; notes: Map; readStates: Array; guildIds: Array; privateChannels: Array; relationships: Array; favoriteMemes: Array; pinnedDMs: Array; webAuthnCredentials: Array; } interface RpcVoiceParticipantSnapshot { identity: string; user_id: string; connection_id: string; } const GUILD_COLLECTION_DEFAULT_LIMIT = 250; const GUILD_COLLECTION_MAX_LIMIT = 1000; const RPC_RESPONSE_MAP_CONCURRENCY = 32; const VOICE_STATE_KV_KEY_PREFIX = 'voice:guild:states:'; const PREMIUM_RECONCILE_ENQUEUE_COOLDOWN_SECONDS = 10 * 60; const PREMIUM_RECONCILE_ENQUEUE_COOLDOWN_KEY_PREFIX = 'rpc:premium:reconcile:enqueue:'; const PAYMENT_RECONCILE_ENQUEUE_COOLDOWN_SECONDS = 30 * 60; const PAYMENT_RECONCILE_ENQUEUE_COOLDOWN_KEY_PREFIX = 'rpc:payment:reconcile:enqueue:'; function serializeCoordinate(value: number | null | undefined): string | undefined { if (value === null || value === undefined || !Number.isFinite(value)) { return undefined; } return value.toString(); } function parseVoiceParticipantIdentity(identity: string): RpcVoiceParticipantSnapshot | null { const match = /^user_(\d+)_(.+)$/.exec(identity); if (!match) { return null; } const [, userId, connectionId] = match; if (!userId || !connectionId) { return null; } return { identity, user_id: userId, connection_id: connectionId, }; } function mapVoiceParticipantSnapshot(result: ListParticipantsResult): { status: 'ok' | 'error'; participants: Array; error_code?: string; retryable?: boolean; server_missing?: boolean; } { if (result.status === 'error') { const errorSnapshot: { status: 'error'; participants: Array; error_code: string; retryable: boolean; server_missing?: boolean; } = { status: 'error', participants: [], error_code: result.errorCode, retryable: result.retryable, }; if (result.serverMissing !== undefined) { errorSnapshot.server_missing = result.serverMissing; } return errorSnapshot; } return { status: 'ok', participants: result.participants.flatMap((participant) => { const parsed = parseVoiceParticipantIdentity(participant.identity); return parsed ? [parsed] : []; }), }; } export class RpcService { private readonly customStatusValidator: CustomStatusValidator; private readonly sessionStartService: RpcSessionStartService; constructor( private userRepository: IUserRepository, private guildRepository: IGuildRepositoryAggregate, private channelRepository: IChannelRepository, private userCacheService: UserCacheService, private readStateService: ReadStateService, private apiContext: ApiContext, private gatewayService: IGatewayService, private discriminatorService: IDiscriminatorService, private favoriteMemeRepository: IFavoriteMemeRepository, private botAuthService: BotAuthService, private inviteRepository: IInviteRepository, private webhookRepository: IWebhookRepository, private storageService: IStorageService, private avatarService: AvatarService, private rateLimitService: IRateLimitService, private readonly limitConfigService: LimitConfigService, private readonly kvClient: IKVProvider, private readonly workerService: IWorkerService, private readonly premiumStateReconciliationQueueService: PremiumStateReconciliationQueueService, private readonly instanceConfigRepository: InstanceConfigRepository, private voiceService: VoiceService | null, private voiceAvailabilityService: VoiceAvailabilityService | null, ) { this.customStatusValidator = new CustomStatusValidator( this.userRepository, this.guildRepository, this.limitConfigService, ); this.sessionStartService = new RpcSessionStartService({ userRepository: this.userRepository, guildRepository: this.guildRepository, userCacheService: this.userCacheService, gatewayService: this.gatewayService, discriminatorService: this.discriminatorService, paymentRepository: new PaymentRepository(), }); } private async ensurePersonalNotesChannel(user: User): Promise { const personalNotesChannelId = userIdToChannelId(user.id); const existingChannel = await this.channelRepository.findUnique(personalNotesChannelId); if (existingChannel) { if (existingChannel.type !== ChannelTypes.DM_PERSONAL_NOTES) { Logger.warn( {channelId: personalNotesChannelId.toString(), type: existingChannel.type}, 'Unexpected channel type already exists for personal notes channel', ); } return; } await ensurePersonalNotesChannelExists({channelRepository: this.channelRepository, userId: user.id}); } private async updateGuildMemberCount(guild: Guild, actualMemberCount: number): Promise { if (guild.memberCount === actualMemberCount) { return guild; } return await this.guildRepository.upsertPartial(guild.id, {member_count: actualMemberCount}, guild.toRow()); } private async migrateStickerAnimated(sticker: GuildSticker): Promise { if (sticker.animated !== null && sticker.animated !== undefined) { return sticker; } try { const animated = await this.avatarService.checkStickerAnimated(sticker.id); if (animated !== null) { const updatedSticker = await this.guildRepository.upsertSticker({ guild_id: sticker.guildId, sticker_id: sticker.id, name: sticker.name, description: sticker.description, animated, tags: sticker.tags, creator_id: sticker.creatorId, version: sticker.version, }); Logger.debug({stickerId: sticker.id, animated}, 'Migrated sticker animated field'); return updatedSticker; } } catch (error) { Logger.warn({stickerId: sticker.id, error}, 'Failed to migrate sticker animated field'); } return sticker; } async handleRpcRequest({request, requestCache}: HandleRpcRequestParams): Promise { switch (request.type) { case 'session': return { type: 'session', data: await this.handleSessionRequest({ token: request.token, version: request.version, requestCache, ip: request.ip, latitude: request.latitude, longitude: request.longitude, }), }; case 'log_guild_crash': { Logger.error( { guildId: request.guild_id.toString(), stacktrace: request.stacktrace, }, 'Guild crash reported by gateway', ); return { type: 'log_guild_crash', data: {success: true}, }; } case 'guild_collection': return { type: 'guild_collection', data: await this.handleGuildCollectionRequest({ guildId: createGuildID(request.guild_id), collection: request.collection, requestCache, afterUserId: request.after_user_id ? createUserID(request.after_user_id) : undefined, limit: request.limit, }), }; case 'voice_state_upsert': await this.persistGuildVoiceState({ guildId: createGuildID(request.guild_id), voiceState: request.voice_state, }); return { type: 'voice_state_upsert', data: {success: true}, }; case 'voice_state_remove': await this.removeGuildVoiceState({ guildId: createGuildID(request.guild_id), connectionId: request.connection_id, }); return { type: 'voice_state_remove', data: {success: true}, }; case 'get_user_guild_settings': { const result = await this.getUserGuildSettings({ userIds: request.user_ids.map(createUserID), guildId: createGuildID(request.guild_id), }); return { type: 'get_user_guild_settings', data: { user_guild_settings: result.user_guild_settings.map((settings) => settings ? mapUserGuildSettingsToResponse(settings) : null, ), }, }; } case 'get_push_subscriptions': return { type: 'get_push_subscriptions', data: await this.getPushSubscriptions({ userIds: request.user_ids.map(createUserID), }), }; case 'get_badge_counts': { const badgeCounts = await this.getBadgeCounts({ userIds: request.user_ids.map(createUserID), }); return { type: 'get_badge_counts', data: { badge_counts: badgeCounts, }, }; } case 'geoip_lookup': { const geoip = await lookupGeoip(request.ip); return { type: 'geoip_lookup', data: { country_code: geoip.countryCode ?? 'US', latitude: serializeCoordinate(geoip.latitude), longitude: serializeCoordinate(geoip.longitude), }, }; } case 'delete_push_subscriptions': return { type: 'delete_push_subscriptions', data: await this.deletePushSubscriptions({ subscriptions: request.subscriptions.map((sub) => ({ userId: createUserID(sub.user_id), subscriptionId: sub.subscription_id, })), }), }; case 'send_apns_push': { const result = await sendApnsPush({ userId: request.user_id.toString(), subscriptionId: request.subscription_id, deviceToken: request.device_token, appId: request.app_id, providerEnvironment: request.provider_environment, payload: request.payload, }); return { type: 'send_apns_push', data: { success: result.success, should_delete: result.shouldDelete, reason: result.reason, status_code: result.statusCode, }, }; } case 'get_user_blocked_ids': return { type: 'get_user_blocked_ids', data: await this.getUserBlockedIds({ userIds: request.user_ids.map(createUserID), }), }; case 'voice_get_token': { Logger.debug( {type: 'voice_get_token', guildId: request.guild_id, channelId: request.channel_id, userId: request.user_id}, 'RPC voice_get_token received', ); if (this.voiceService === null) { throw new Error('Voice is not enabled on this server'); } const result = await this.voiceService.getVoiceToken({ guildId: request.guild_id !== undefined ? createGuildID(request.guild_id) : undefined, channelId: createChannelID(request.channel_id), userId: createUserID(request.user_id), connectionId: request.connection_id, region: request.rtc_region, latitude: request.latitude, longitude: request.longitude, canSpeak: request.can_speak, canStream: request.can_stream, canVideo: request.can_video, tokenNonce: request.token_nonce, }); return { type: 'voice_get_token', data: result, }; } case 'voice_force_disconnect_participant': { if (this.voiceService === null) { throw new Error('Voice is not enabled on this server'); } await this.voiceService.disconnectParticipant({ guildId: request.guild_id !== undefined ? createGuildID(request.guild_id) : undefined, channelId: createChannelID(request.channel_id), userId: createUserID(request.user_id), connectionId: request.connection_id, }); return { type: 'voice_force_disconnect_participant', data: {success: true}, }; } case 'voice_update_participant': { if (this.voiceService === null) { throw new Error('Voice is not enabled on this server'); } await this.voiceService.updateParticipant({ guildId: request.guild_id !== undefined ? createGuildID(request.guild_id) : undefined, channelId: createChannelID(request.channel_id), userId: createUserID(request.user_id), mute: request.mute, deaf: request.deaf, canSpeak: request.can_speak, canStream: request.can_stream, canVideo: request.can_video, }); return { type: 'voice_update_participant', data: {success: true}, }; } case 'voice_force_disconnect_channel': { if (this.voiceService === null) { throw new Error('Voice is not enabled on this server'); } const result = await this.voiceService.disconnectChannel({ guildId: request.guild_id !== undefined ? createGuildID(request.guild_id) : undefined, channelId: createChannelID(request.channel_id), }); return { type: 'voice_force_disconnect_channel', data: { success: result.success, disconnected_count: result.disconnectedCount, }, }; } case 'voice_list_participants': { if (this.voiceService === null) { return { type: 'voice_list_participants', data: { status: 'error', participants: [], error_code: 'voice_disabled', retryable: false, }, }; } const result = await this.voiceService.listParticipantsOnServer({ guildId: request.guild_id !== undefined ? createGuildID(request.guild_id) : undefined, channelId: createChannelID(request.channel_id), regionId: request.region_id, serverId: request.server_id, }); return { type: 'voice_list_participants', data: mapVoiceParticipantSnapshot(result), }; } case 'voice_update_participant_permissions': { if (this.voiceService === null) { throw new Error('Voice is not enabled on this server'); } await this.voiceService.updateParticipantPermissions({ guildId: request.guild_id !== undefined ? createGuildID(request.guild_id) : undefined, channelId: createChannelID(request.channel_id), userId: createUserID(request.user_id), connectionId: request.connection_id, canSpeak: request.can_speak, canStream: request.can_stream, canVideo: request.can_video, deaf: request.deaf, }); return { type: 'voice_update_participant_permissions', data: {success: true}, }; } case 'kick_temporary_member': { const success = await this.kickTemporaryMember({ userId: createUserID(request.user_id), guildIds: request.guild_ids.map(createGuildID), }); return { type: 'kick_temporary_member', data: {success}, }; } case 'call_ended': { await this.handleCallEnded({ channelId: createChannelID(request.channel_id), messageId: createMessageID(request.message_id), participants: request.participants.map(createUserID), endedTimestamp: new Date(request.ended_timestamp), requestCache, }); return { type: 'call_ended', data: {success: true}, }; } case 'validate_custom_status': { const userId = createUserID(request.user_id); const validatedCustomStatus = request.custom_status === null || request.custom_status === undefined ? null : await this.customStatusValidator.validate(userId, request.custom_status); return { type: 'validate_custom_status', data: { custom_status: validatedCustomStatus ? { text: validatedCustomStatus.text, expires_at: validatedCustomStatus.expiresAt?.toISOString() ?? null, emoji_id: validatedCustomStatus.emojiId?.toString() ?? null, emoji_name: validatedCustomStatus.emojiName, emoji_animated: validatedCustomStatus.emojiAnimated, } : null, }, }; } case 'get_dm_channel': { const channel = await this.getDmChannel({ channelId: createChannelID(request.channel_id), userId: createUserID(request.user_id), requestCache, }); return { type: 'get_dm_channel', data: {channel}, }; } case 'get_gateway_rollout_config': { const rolloutConfig = await this.instanceConfigRepository.getGatewayRolloutConfig(); return { type: 'get_gateway_rollout_config', data: {config: rolloutConfig}, }; } default: { const exhaustiveCheck: never = request; throw new Error( `Unknown RPC request type: ${String( ( exhaustiveCheck as { type?: string; } ).type ?? 'unknown', )}`, ); } } } private parseTokenType(token: string): 'user' | 'bot' | 'unknown' { if (token.startsWith('flx_')) { return 'user'; } const dotIndex = token.indexOf('.'); if (dotIndex > 0 && dotIndex < token.length - 1) { const beforeDot = token.slice(0, dotIndex); if (/^\d+$/.test(beforeDot)) { return 'bot'; } } return 'unknown'; } private normalizeSessionToken(token: string): string { if (token.startsWith('Bot ')) { return token.slice('Bot '.length); } return token; } private isUnknownUserError(error: unknown): error is UnknownUserError { return error instanceof UnknownUserError; } private async mapRpcSessionPrivateChannels(params: { channels: Array; userId: UserID; requestCache: RequestCache; }): Promise> { const {channels, userId, requestCache} = params; const mappedChannels = await allSettledWithConcurrency(channels, RPC_RESPONSE_MAP_CONCURRENCY, (channel) => mapChannelToResponse({ channel, currentUserId: userId, userCacheService: this.userCacheService, requestCache, }), ); const validChannels: Array = []; for (const [index, mappedChannel] of mappedChannels.entries()) { if (mappedChannel.status === 'fulfilled') { validChannels.push(mappedChannel.value); continue; } const channel = channels[index]; if (this.isUnknownUserError(mappedChannel.reason)) { Logger.warn( { userId: userId.toString(), channelId: channel?.id.toString(), }, 'Skipping RPC session private channel with unknown user reference', ); continue; } throw mappedChannel.reason; } return validChannels; } private async mapRpcSessionRelationships(params: { relationships: Array; userId: UserID; requestCache: RequestCache; }): Promise> { const {relationships, userId, requestCache} = params; const userPartialResolver = (targetUserId: UserID) => getCachedUserPartialResponse({ userId: targetUserId, userCacheService: this.userCacheService, requestCache, }); const mappedRelationships = await allSettledWithConcurrency( relationships, RPC_RESPONSE_MAP_CONCURRENCY, (relationship) => mapRelationshipToResponse({relationship, userPartialResolver}), ); const validRelationships: Array = []; for (const [index, mappedRelationship] of mappedRelationships.entries()) { if (mappedRelationship.status === 'fulfilled') { validRelationships.push(mappedRelationship.value); continue; } const relationship = relationships[index]; if (this.isUnknownUserError(mappedRelationship.reason)) { Logger.warn( { userId: userId.toString(), targetUserId: relationship?.targetUserId.toString(), type: relationship?.type, }, 'Skipping RPC session relationship with unknown user reference', ); continue; } throw mappedRelationship.reason; } return validRelationships; } private async preloadRpcSessionUserPartials(params: { channels: Array; relationships: Array; userId: UserID; requestCache: RequestCache; steps?: RpcTimingSteps; }): Promise { const {channels, relationships, userId, requestCache, steps} = params; const userIds = timeRpcStepSync(steps ?? {}, 'collect_user_partial_ids', () => { const collected = new Set(); for (const channel of channels) { if ( channel.guildId != null || channel.type === ChannelTypes.DM_PERSONAL_NOTES || !channel.recipientIds || channel.recipientIds.size === 0 ) { continue; } for (const recipientId of channel.recipientIds) { if (recipientId !== userId) { collected.add(recipientId); } } } for (const relationship of relationships) { collected.add(relationship.targetUserId); } return Array.from(collected); }); if (userIds.length === 0) { return; } await this.userCacheService.getUserPartialResponses(userIds, requestCache, { timeStep: (name, operation) => timeRpcStep(steps ?? {}, name, operation), }); } private async mapRpcGuildMembers(params: { guildId: GuildID; members: Array; requestCache: RequestCache; }): Promise> { const {guildId, members, requestCache} = params; await this.userCacheService.getUserPartialResponses( members.map((member) => member.userId), requestCache, ); const mappedMembers = await allSettledWithConcurrency(members, RPC_RESPONSE_MAP_CONCURRENCY, (member) => mapGuildMemberToResponse(member, this.userCacheService, requestCache), ); const validMembers: Array = []; for (const [index, mappedMember] of mappedMembers.entries()) { if (mappedMember.status === 'fulfilled') { validMembers.push(mappedMember.value); continue; } const member = members[index]; if (this.isUnknownUserError(mappedMember.reason)) { Logger.warn( { guildId: guildId.toString(), userId: member?.userId.toString(), }, 'Skipping RPC guild member with unknown user reference', ); continue; } throw mappedMember.reason; } return validMembers; } private queueStripePremiumStateReconciliation(user: User): void { if (user.isBot || user.premiumType === UserPremiumTypes.LIFETIME) { return; } if (!user.stripeSubscriptionId && !user.stripeCustomerId) { return; } const userIdString = user.id.toString(); const cooldownKey = `${PREMIUM_RECONCILE_ENQUEUE_COOLDOWN_KEY_PREFIX}${userIdString}`; void this.kvClient .setnx(cooldownKey, new Date().toISOString(), PREMIUM_RECONCILE_ENQUEUE_COOLDOWN_SECONDS) .then(async (queueAllowed) => { if (!queueAllowed) { return; } await this.premiumStateReconciliationQueueService.enqueueUser(user.id); }) .catch((error) => { Logger.warn( {userId: userIdString, error}, 'Failed to queue user for premium reconciliation during RPC session handling', ); }); } private queuePaymentReconciliation(user: User): void { if (user.isBot) { return; } if (!user.stripeCustomerId) { return; } const userIdString = user.id.toString(); const cooldownKey = `${PAYMENT_RECONCILE_ENQUEUE_COOLDOWN_KEY_PREFIX}${userIdString}`; void this.kvClient .setnx(cooldownKey, new Date().toISOString(), PAYMENT_RECONCILE_ENQUEUE_COOLDOWN_SECONDS) .then(async (queueAllowed) => { if (!queueAllowed) { return; } await this.workerService.addJob('reconcileUserPayments', {userId: userIdString}); }) .catch((error) => { Logger.warn( {userId: userIdString, error}, 'Failed to queue user for payment reconciliation during RPC session handling', ); }); } private async handleSessionRequest({ token, version, requestCache, ip, latitude, longitude, }: HandleSessionRequestParams): Promise { const timings = new RpcTimingRecorder(); const normalizedToken = timings.timeSync('normalize_session_token', () => this.normalizeSessionToken(token)); const tokenHash = timings.timeSync('hash_session_token', () => createHash('sha256').update(normalizedToken).digest('hex'), ); const tokenType = timings.timeSync('parse_token_type', () => this.parseTokenType(normalizedToken)); const tokenHashPrefix = timings.timeSync('compute_token_hash_prefix', () => tokenHash.slice(0, 12)); timings.timeSync('log_session_handling_started', () => { Logger.debug( { tokenType, tokenHashPrefix, version, hasIp: ip !== undefined, hasLatitude: latitude !== undefined, hasLongitude: longitude !== undefined, }, 'RPC session handling started', ); }); const bucketKey = timings.timeSync( 'build_rate_limit_bucket_key', () => `gateway:rpc:session:${tokenType}:${tokenHash}`, ); const rateLimitResult = await timings.time('check_session_rate_limit', async () => this.rateLimitService.checkLimit({ identifier: bucketKey, maxAttempts: 20, windowMs: ms('1 minute'), }), ); if (!rateLimitResult.allowed) { Logger.warn( { tokenType, tokenHashPrefix, retryAfter: rateLimitResult.retryAfter, limit: rateLimitResult.limit, resetTime: rateLimitResult.resetTime, }, 'RPC session request rate limited', ); throw new RateLimitError({ retryAfter: rateLimitResult.retryAfter!, limit: rateLimitResult.limit, resetTime: rateLimitResult.resetTime, }); } let userId: UserID | null = null; let authSession: AuthSessionModel | null = null; if (tokenType === 'user') { authSession = await timings.time('validate_user_session_token', async () => AuthSession.getAuthSessionByToken(this.apiContext, normalizedToken), ); if (authSession) { userId = authSession.userId; } } else if (tokenType === 'bot') { userId = await timings.time('validate_bot_token', async () => this.botAuthService.validateBotToken(normalizedToken), ); } if (!userId) { Logger.warn( { tokenType, tokenHashPrefix, }, 'RPC session token validation failed', ); throw new UnauthorizedError(); } const loadUserDataSteps: RpcTimingSteps = {}; const loadUserDataStartedAtNs = startRpcTiming(); const userData = await this.getUserData({userId, includePrivateChannels: true, timingSteps: loadUserDataSteps}); timings.record('load_user_data', loadUserDataStartedAtNs, loadUserDataSteps); if (!userData?.user) { Logger.warn( { tokenType, tokenHashPrefix, userId: userId.toString(), }, 'RPC session user lookup failed', ); throw new UnauthorizedError(); } let user = userData.user; if (user.avatarHash && user.avatarColor == null) { const avatarRepairSteps: RpcTimingSteps = {}; const avatarRepairStartedAtNs = startRpcTiming(); try { const avatarKey = timeRpcStepSync( avatarRepairSteps, 'build_avatar_storage_key', () => `avatars/${user.id}/${this.stripAnimationPrefix(user.avatarHash!)}`, ); const object = await timeRpcStep(avatarRepairSteps, 'read_avatar_object', async () => this.storageService.readObject(Config.s3.buckets.cdn, avatarKey), ); const avatarColor = await timeRpcStep(avatarRepairSteps, 'derive_avatar_color', async () => deriveDominantAvatarColor(object), ); const updatedUser = await timeRpcStep(avatarRepairSteps, 'persist_avatar_color', async () => this.userRepository.patchUpsert(user.id, {avatar_color: avatarColor}, user.toRow()), ); if (updatedUser) { user = updatedUser; this.userCacheService.setUserPartialResponseFromUserInBackground(updatedUser, requestCache); } } catch (error) { Logger.warn({userId: user.id, error}, 'Failed to repair user avatar color'); } finally { timings.record('repair_avatar_color', avatarRepairStartedAtNs, avatarRepairSteps); } } if (user.bannerHash && user.bannerColor == null) { const bannerRepairSteps: RpcTimingSteps = {}; const bannerRepairStartedAtNs = startRpcTiming(); try { const bannerKey = timeRpcStepSync( bannerRepairSteps, 'build_banner_storage_key', () => `banners/${user.id}/${this.stripAnimationPrefix(user.bannerHash!)}`, ); const object = await timeRpcStep(bannerRepairSteps, 'read_banner_object', async () => this.storageService.readObject(Config.s3.buckets.cdn, bannerKey), ); const bannerColor = await timeRpcStep(bannerRepairSteps, 'derive_banner_color', async () => deriveDominantAvatarColor(object), ); const updatedUser = await timeRpcStep(bannerRepairSteps, 'persist_banner_color', async () => this.userRepository.patchUpsert(user.id, {banner_color: bannerColor}, user.toRow()), ); if (updatedUser) { user = updatedUser; } } catch (error) { Logger.warn({userId: user.id, error}, 'Failed to repair user banner color'); } finally { timings.record('repair_banner_color', bannerRepairStartedAtNs, bannerRepairSteps); } } timings.timeSync('sync_repaired_user_data', () => { userData.user = user; }); let countryCode = 'US'; let geoipCountryIso: string | null = null; let geoipLatitude: string | undefined; let geoipLongitude: string | undefined; if (ip) { const geoip = await timings.time('lookup_geoip', async () => lookupGeoip(ip)); timings.timeSync('map_geoip_result', () => { geoipCountryIso = geoip.countryCode ?? null; countryCode = geoip.countryCode ?? countryCode; geoipLatitude = serializeCoordinate(geoip.latitude); geoipLongitude = serializeCoordinate(geoip.longitude); }); } else { timings.timeSync('log_missing_geoip_ip', () => { Logger.warn({context: 'rpc_geoip', reason: 'ip_missing'}, 'RPC session request missing IP for GeoIP'); }); } const sessionStartStartedAtNs = startRpcTiming(); const sessionStartResult = await this.sessionStartService.processSessionStart({ userData, requestCache, geoipCountryIso, clientIp: ip ?? null, }); timings.record('process_session_start', sessionStartStartedAtNs, sessionStartResult.timings.steps); user = sessionStartResult.user; timings.timeSync('queue_stripe_premium_state_reconciliation', () => this.queueStripePremiumStateReconciliation(user), ); timings.timeSync('queue_payment_reconciliation', () => this.queuePaymentReconciliation(user)); await timings.time('ensure_personal_notes_channel', async () => this.ensurePersonalNotesChannel(user)); const privateChannelLimitSteps: RpcTimingSteps = {}; const privateChannelLimitStartedAtNs = startRpcTiming(); userData.privateChannels = await this.ensurePrivateChannelsWithinLimit({ user, channels: userData.privateChannels, steps: privateChannelLimitSteps, }); timings.record('ensure_private_channels_within_limit', privateChannelLimitStartedAtNs, privateChannelLimitSteps); const readyPayloadSteps: RpcTimingSteps = {}; const readyPayloadStartedAtNs = startRpcTiming(); const preloadUserPartialsSteps: RpcTimingSteps = {}; const preloadUserPartialsStartedAtNs = startRpcTiming(); await this.preloadRpcSessionUserPartials({ channels: userData.privateChannels, relationships: userData.relationships, userId: user.id, requestCache, steps: preloadUserPartialsSteps, }); readyPayloadSteps.preload_user_partials = createRpcTimingNode( preloadUserPartialsStartedAtNs, preloadUserPartialsSteps, ); const [privateChannels, relationships, guilds] = await Promise.all([ timeRpcStep(readyPayloadSteps, 'map_private_channels', async () => this.mapRpcSessionPrivateChannels({ channels: userData.privateChannels, userId: user.id, requestCache, }), ), timeRpcStep(readyPayloadSteps, 'map_relationships', async () => this.mapRpcSessionRelationships({ relationships: userData.relationships, userId: user.id, requestCache, }), ), timeRpcStepSync(readyPayloadSteps, 'map_guild_ids', () => userData.guildIds.map((guildId) => ({id: guildId.toString()})), ), ]); timings.record('map_ready_payloads', readyPayloadStartedAtNs, readyPayloadSteps); const rtcRegions = timings.timeSync('build_rtc_regions', () => { const voiceAccessContext: VoiceAccessContext = {requestingUserId: user.id}; const availableRegions = this.voiceAvailabilityService === null ? [] : this.voiceAvailabilityService.getAvailableRegions(voiceAccessContext); const accessibleRegions = availableRegions.filter((region) => region.isAccessible); const sortedRegions = accessibleRegions.slice(); const userLatitude = parseCoordinate(latitude); const userLongitude = parseCoordinate(longitude); const hasLocation = userLatitude !== null && userLongitude !== null; if (hasLocation) { sortedRegions.sort((a, b) => { const distanceA = calculateDistance(userLatitude, userLongitude, a.latitude, a.longitude); const distanceB = calculateDistance(userLatitude, userLongitude, b.latitude, b.longitude); if (distanceA !== distanceB) { return distanceA - distanceB; } return a.id.localeCompare(b.id); }); } else { sortedRegions.sort((a, b) => a.id.localeCompare(b.id)); } return [ {id: AUTOMATIC_VOICE_REGION_ID, name: 'Automatic', emoji: '🌐'}, ...sortedRegions.map((region) => ({ id: region.id, name: region.name, emoji: region.emoji, })), ]; }); timings.timeSync('log_session_handling_completed', () => { Logger.debug( { tokenType, tokenHashPrefix, userId: user.id.toString(), guildCount: guilds.length, privateChannelCount: privateChannels.length, relationshipCount: relationships.length, countryCode, rtcRegionCount: rtcRegions.length, }, 'RPC session handling completed', ); }); const responseBuildSteps: RpcTimingSteps = {}; const responseBuildStartedAtNs = startRpcTiming(); const responsePayload = { auth_session_id_hash: timeRpcStepSync(responseBuildSteps, 'map_auth_session_id_hash', () => authSession ? uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true}) : undefined, ), user: timeRpcStepSync(responseBuildSteps, 'map_user', () => mapUserToPrivateResponse(user)), user_settings: timeRpcStepSync(responseBuildSteps, 'map_user_settings', () => userData.settings ? mapUserSettingsToResponse({ settings: userData.settings, }) : null, ), user_guild_settings: timeRpcStepSync(responseBuildSteps, 'map_user_guild_settings', () => userData.guildSettings.map(mapUserGuildSettingsToResponse), ), notes: timeRpcStepSync(responseBuildSteps, 'map_notes', () => Object.fromEntries(Array.from(userData.notes.entries()).map(([userId, note]) => [userId.toString(), note])), ), read_states: timeRpcStepSync(responseBuildSteps, 'map_read_states', () => userData.readStates.map(mapReadStateResponse), ), guilds, private_channels: privateChannels, relationships, favorite_memes: timeRpcStepSync(responseBuildSteps, 'map_favorite_memes', () => userData.favoriteMemes.map(mapFavoriteMemeToResponse), ), pinned_dms: timeRpcStepSync(responseBuildSteps, 'map_pinned_dms', () => userData.pinnedDMs?.map(String) ?? []), country_code: countryCode, latitude: geoipLatitude, longitude: geoipLongitude, rtc_regions: rtcRegions, webauthn_credentials: timeRpcStepSync(responseBuildSteps, 'map_webauthn_credentials', () => userData.webAuthnCredentials.map((cred) => ({ id: cred.credentialId, name: cred.name, created_at: cred.createdAt.toISOString(), last_used_at: cred.lastUsedAt?.toISOString() ?? null, })), ), version, }; timings.record('build_session_response_payload', responseBuildStartedAtNs, responseBuildSteps); if ((user.flags & UserFlags.STAFF) === 0n) { return responsePayload; } return { _timings: timings.finalize(), ...responsePayload, }; } private async handleGuildCollectionRequest({ guildId, collection, requestCache, afterUserId, limit, }: HandleGuildCollectionRequestParams): Promise { switch (collection) { case 'guild': return await this.handleGuildCollectionGuildRequest({guildId}); case 'roles': return await this.handleGuildCollectionRolesRequest({guildId}); case 'channels': return await this.handleGuildCollectionChannelsRequest({guildId, requestCache}); case 'emojis': return await this.handleGuildCollectionEmojisRequest({guildId}); case 'stickers': return await this.handleGuildCollectionStickersRequest({guildId}); case 'members': return await this.handleGuildCollectionMembersRequest({guildId, requestCache, afterUserId, limit}); case 'voice_states': return await this.handleGuildCollectionVoiceStatesRequest({guildId}); default: { const exhaustiveCheck: never = collection; throw new Error(`Unknown guild collection: ${String(exhaustiveCheck)}`); } } } private createGuildCollectionResponse(collection: RpcGuildCollectionType): RpcResponseGuildCollectionData { return { collection, guild: undefined, roles: undefined, channels: undefined, emojis: undefined, stickers: undefined, members: undefined, voice_states: undefined, has_more: false, next_after_user_id: null, }; } private async getGuildOrThrow(guildId: GuildID): Promise { const guild = await this.guildRepository.findUnique(guildId); if (!guild) { throw new UnknownGuildError(); } return guild; } private resolveGuildCollectionLimit(limit?: number): number { if (!limit || !Number.isInteger(limit) || limit < 1) { return GUILD_COLLECTION_DEFAULT_LIMIT; } return Math.min(limit, GUILD_COLLECTION_MAX_LIMIT); } private async handleGuildCollectionGuildRequest({ guildId, }: { guildId: GuildID; }): Promise { const guild = await this.getGuildOrThrow(guildId); const memberCount = await this.guildRepository.countMembers(guildId); const repairedMemberCountGuild = await this.updateGuildMemberCount(guild, memberCount); const repairedBannerGuild = await this.repairGuildBannerHeight(repairedMemberCountGuild); const repairedSplashGuild = await this.repairGuildSplashDimensions(repairedBannerGuild); const repairedEmbedSplashGuild = await this.repairGuildEmbedSplashDimensions(repairedSplashGuild); const response = mapGuildToGuildResponse(repairedEmbedSplashGuild); return { ...this.createGuildCollectionResponse('guild'), guild: response, }; } private async handleGuildCollectionRolesRequest({ guildId, }: { guildId: GuildID; }): Promise { await this.getGuildOrThrow(guildId); const roles = await this.guildRepository.listRoles(guildId); return { ...this.createGuildCollectionResponse('roles'), roles: roles.map(mapGuildRoleToResponse), }; } private async handleGuildCollectionChannelsRequest({ guildId, requestCache, }: { guildId: GuildID; requestCache: RequestCache; }): Promise { const guild = await this.getGuildOrThrow(guildId); const channels = await this.channelRepository.listGuildChannels(guildId); const repairedGuild = await this.repairDanglingChannelReferences({guild, channels}); this.repairOrphanedInvitesAndWebhooks({guild: repairedGuild, channels}).catch((error) => { Logger.warn({guildId: guildId.toString(), error}, 'Failed to repair orphaned invites/webhooks'); }); const mappedChannels = await Promise.all( channels.map((channel) => mapChannelToResponse({ channel, currentUserId: null, userCacheService: this.userCacheService, requestCache, }), ), ); return { ...this.createGuildCollectionResponse('channels'), channels: mappedChannels, }; } private async handleGuildCollectionEmojisRequest({ guildId, }: { guildId: GuildID; }): Promise { await this.getGuildOrThrow(guildId); const emojis = await this.guildRepository.listEmojis(guildId); return { ...this.createGuildCollectionResponse('emojis'), emojis: emojis.map(mapGuildEmojiToResponse), }; } private async handleGuildCollectionStickersRequest({ guildId, }: { guildId: GuildID; }): Promise { await this.getGuildOrThrow(guildId); const stickers = await this.guildRepository.listStickers(guildId); const migratedStickers = await this.migrateGuildStickersForRpc(guildId, stickers); return { ...this.createGuildCollectionResponse('stickers'), stickers: migratedStickers.map(mapGuildStickerToResponse), }; } private async handleGuildCollectionMembersRequest({ guildId, requestCache, afterUserId, limit, }: { guildId: GuildID; requestCache: RequestCache; afterUserId?: UserID; limit?: number; }): Promise { if (!afterUserId) { await this.getGuildOrThrow(guildId); } const chunkSize = this.resolveGuildCollectionLimit(limit); const members = await this.guildRepository.listMembersPaginated(guildId, chunkSize + 1, afterUserId); const hasMore = members.length > chunkSize; const pageMembers = hasMore ? members.slice(0, chunkSize) : members; const mappedMembers = await this.mapRpcGuildMembers({guildId, members: pageMembers, requestCache}); let nextAfterUserId: string | null = null; if (hasMore) { const lastMember = pageMembers[pageMembers.length - 1]; if (!lastMember) { throw new Error('Failed to build next member collection cursor'); } nextAfterUserId = lastMember.userId.toString(); } return { ...this.createGuildCollectionResponse('members'), members: mappedMembers, has_more: hasMore, next_after_user_id: nextAfterUserId, }; } private async handleGuildCollectionVoiceStatesRequest({ guildId, }: { guildId: GuildID; }): Promise { await this.getGuildOrThrow(guildId); const persistedVoiceStates = await this.kvClient.hgetall(this.guildVoiceStatesKey(guildId)); const voiceStates = this.parsePersistedGuildVoiceStates(guildId, persistedVoiceStates); return { ...this.createGuildCollectionResponse('voice_states'), voice_states: voiceStates, }; } private guildVoiceStatesKey(guildId: GuildID): string { return `${VOICE_STATE_KV_KEY_PREFIX}${guildId.toString()}`; } private isPersistedVoiceState(value: unknown): value is VoiceStateResponse { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return false; } const candidate = value as Record; const hasGuildId = typeof candidate.guild_id === 'string' || candidate.guild_id === null; const hasChannelId = typeof candidate.channel_id === 'string' || candidate.channel_id === null; return ( hasGuildId && hasChannelId && typeof candidate.user_id === 'string' && typeof candidate.mute === 'boolean' && typeof candidate.deaf === 'boolean' && typeof candidate.self_mute === 'boolean' && typeof candidate.self_deaf === 'boolean' ); } private parsePersistedGuildVoiceStates( guildId: GuildID, persistedVoiceStates: Record, ): Array { const voiceStates: Array = []; for (const [connectionId, serializedVoiceState] of Object.entries(persistedVoiceStates)) { try { const parsed = JSON.parse(serializedVoiceState); if (!this.isPersistedVoiceState(parsed)) { Logger.warn( {guildId: guildId.toString(), connectionId}, 'Dropping malformed persisted guild voice state payload', ); continue; } voiceStates.push({ ...parsed, connection_id: typeof parsed.connection_id === 'string' && parsed.connection_id.length > 0 ? parsed.connection_id : connectionId, guild_id: guildId.toString(), }); } catch (error) { Logger.warn( {guildId: guildId.toString(), connectionId, error}, 'Failed to decode persisted guild voice state payload', ); } } return voiceStates; } private async persistGuildVoiceState(params: {guildId: GuildID; voiceState: VoiceStateResponse}): Promise { const {guildId, voiceState} = params; const connectionId = voiceState.connection_id; if (!connectionId || connectionId.length === 0) { return; } const normalizedVoiceState: VoiceStateResponse = { ...voiceState, guild_id: guildId.toString(), connection_id: connectionId, }; await this.kvClient.hset(this.guildVoiceStatesKey(guildId), connectionId, JSON.stringify(normalizedVoiceState)); } private async removeGuildVoiceState(params: {guildId: GuildID; connectionId: string}): Promise { const {guildId, connectionId} = params; await this.kvClient.hdel(this.guildVoiceStatesKey(guildId), connectionId); } private async migrateGuildStickersForRpc( guildId: GuildID, stickers: Array, ): Promise> { const needsMigration = stickers.filter((sticker) => sticker.animated === null || sticker.animated === undefined); if (needsMigration.length === 0) { return stickers; } Logger.info({count: needsMigration.length, guildId}, 'Migrating sticker animated fields'); const migrated = await Promise.all(needsMigration.map((sticker) => this.migrateStickerAnimated(sticker))); return stickers.map((sticker) => { const migratedSticker = migrated.find((candidate) => candidate.id === sticker.id); return migratedSticker ?? sticker; }); } private async getUserData({ userId, includePrivateChannels = true, timingSteps, }: GetUserDataParams): Promise { const timeUserDataStep = (name: string, operation: () => Promise): Promise => timingSteps ? timeRpcStep(timingSteps, name, operation) : operation(); const user = await timeUserDataStep('find_user', async () => this.userRepository.findUnique(userId)); if (!user) return null; if (user.isBot) { const guildIds = await timeUserDataStep('get_user_guild_ids', async () => this.userRepository.getUserGuildIds(userId), ); return { user, settings: null, guildSettings: [], notes: new Map(), readStates: [], guildIds, privateChannels: [], relationships: [], favoriteMemes: [], pinnedDMs: [], webAuthnCredentials: [], }; } const [ settingsResult, notes, readStates, guildIds, relationships, favoriteMemes, pinnedDMs, webAuthnCredentials, guildSettings, privateChannels, ] = await Promise.all([ timeUserDataStep('find_settings', async () => this.userRepository.findSettings(userId)), timeUserDataStep('get_user_notes', async () => this.userRepository.getUserNotes(userId)), timeUserDataStep('get_read_states', async () => this.readStateService.getReadStates(userId)), timeUserDataStep('get_user_guild_ids', async () => this.userRepository.getUserGuildIds(userId)), timeUserDataStep('list_relationships', async () => this.userRepository.listRelationships(userId)), timeUserDataStep('list_favorite_memes', async () => this.favoriteMemeRepository.findByUserId(userId)), timeUserDataStep('get_pinned_dms', async () => this.userRepository.getPinnedDms(userId)), timeUserDataStep('list_webauthn_credentials', async () => this.userRepository.listWebAuthnCredentials(userId)), timeUserDataStep('find_all_guild_settings', async () => this.userRepository.findAllGuildSettings(userId)), includePrivateChannels ? timeUserDataStep('list_private_channels', async () => this.userRepository.listPrivateChannels(userId)) : Promise.resolve>([]), ]); let settings = settingsResult; if (settings) { const needsIncomingCallRepair = settings.incomingCallFlags === 0; const needsGroupDmRepair = settings.groupDmAddPermissionFlags === 0; if (needsIncomingCallRepair || needsGroupDmRepair) { const isAdult = isUserAdult(user.dateOfBirth); const updatedRow = { ...settings.toRow(), ...(needsIncomingCallRepair && { incoming_call_flags: isAdult ? IncomingCallFlags.EVERYONE : IncomingCallFlags.FRIENDS_ONLY, }), ...(needsGroupDmRepair && {group_dm_add_permission_flags: GroupDmAddPermissionFlags.FRIENDS_ONLY}), }; await timeUserDataStep('repair_user_settings_defaults', async () => this.userRepository.upsertSettings(updatedRow), ); settings = new UserSettings(updatedRow); } } return { user, settings, guildSettings, notes, readStates, guildIds, privateChannels, relationships, favoriteMemes, pinnedDMs, webAuthnCredentials, }; } private async ensurePrivateChannelsWithinLimit(params: { user: User; channels: Array; steps?: RpcTimingSteps; }): Promise> { const {user, channels, steps} = params; if (user.isBot) { return []; } const totalPrivateChannels = channels.length; const limit = timeRpcStepSync(steps ?? {}, 'resolve_private_channel_limit', () => this.resolveLimitForUser(user, 'max_private_channels_per_user', MAX_PRIVATE_CHANNELS_PER_USER), ); if (totalPrivateChannels <= limit) { return channels; } const pinnedDmIds = new Set( await timeRpcStep(steps ?? {}, 'get_pinned_dms_for_limit', async () => this.userRepository.getPinnedDms(user.id)), ); const toClose = totalPrivateChannels - limit; const channelsToClose = timeRpcStepSync(steps ?? {}, 'select_private_channels_to_close', () => channels .filter((channel) => channel.type === ChannelTypes.DM && !pinnedDmIds.has(channel.id)) .sort((a, b) => { const aValue = a.lastMessageId ?? 0n; const bValue = b.lastMessageId ?? 0n; if (aValue < bValue) return -1; if (aValue > bValue) return 1; return 0; }) .slice(0, toClose), ); await timeRpcStep(steps ?? {}, 'close_private_channels_over_limit', async () => { await Promise.all(channelsToClose.map((channel) => this.userRepository.closeDmForUser(user.id, channel.id))); }); if (channelsToClose.length < toClose) { Logger.warn( { user_id: user.id.toString(), total_private_channels: totalPrivateChannels, required_closures: toClose, actual_closures: channelsToClose.length, }, 'Unable to close enough DMs to satisfy private channel limit', ); } if (channelsToClose.length === 0) { return channels; } const closedChannelIds = new Set(channelsToClose.map((channel) => channel.id)); return channels.filter((channel) => !closedChannelIds.has(channel.id)); } private resolveLimitForUser(user: User | null, key: LimitKey, fallback: number): number { const ctx = createLimitMatchContext({user}); return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback); } private async getUserGuildSettings(params: {userIds: Array; guildId: GuildID}): Promise<{ user_guild_settings: Array; }> { const {userIds, guildId} = params; const actualGuildId = guildId === createGuildID(0n) ? null : guildId; const settled = await allSettledWithConcurrency(userIds, RPC_RESPONSE_MAP_CONCURRENCY, (userId) => this.userRepository.findGuildSettings(userId, actualGuildId), ); const userGuildSettings: Array = []; for (const result of settled) { if (result.status === 'rejected') { throw result.reason; } userGuildSettings.push(result.value); } return {user_guild_settings: userGuildSettings}; } private async getPushSubscriptions(params: {userIds: Array}): Promise< Record< string, Array<{ subscription_id: string; endpoint: string; p256dh_key: string | null; auth_key: string | null; platform: string; app_id: string | null; provider_environment: string | null; }> > > { const {userIds} = params; const subscriptionsMap = await this.userRepository.getBulkPushSubscriptions(userIds); const result: Record< string, Array<{ subscription_id: string; endpoint: string; p256dh_key: string | null; auth_key: string | null; platform: string; app_id: string | null; provider_environment: string | null; }> > = {}; for (const [userId, subscriptions] of subscriptionsMap.entries()) { result[userId.toString()] = subscriptions.map((sub) => ({ subscription_id: sub.subscriptionId, endpoint: sub.endpoint, p256dh_key: sub.p256dhKey, auth_key: sub.authKey, platform: sub.platform, app_id: sub.appId, provider_environment: sub.providerEnvironment, })); } return result; } private async getBadgeCounts(params: {userIds: Array}): Promise> { const {userIds} = params; const uniqueUserIds = Array.from(new Set(userIds)) as Array; const badgeCounts: Record = {}; await Promise.all( uniqueUserIds.map(async (userId) => { const readStates = await this.readStateService.getReadStates(userId); const totalMentions = readStates.reduce((sum, state) => sum + state.mentionCount, 0); badgeCounts[userId.toString()] = totalMentions; }), ); return badgeCounts; } private async deletePushSubscriptions(params: { subscriptions: Array<{ userId: UserID; subscriptionId: string; }>; }): Promise<{ success: boolean; }> { const {subscriptions} = params; await Promise.all( subscriptions.map((sub) => this.userRepository.deletePushSubscription(sub.userId, sub.subscriptionId)), ); return {success: true}; } private stripAnimationPrefix(hash: string): string { return hash.startsWith('a_') ? hash.slice(2) : hash; } private async repairDanglingChannelReferences(params: {guild: Guild; channels: Array}): Promise { const {guild, channels} = params; const channelIds = new Set(channels.map((channel) => channel.id)); const danglingSystemChannel = guild.systemChannelId != null && !channelIds.has(guild.systemChannelId); const danglingRulesChannel = guild.rulesChannelId != null && !channelIds.has(guild.rulesChannelId); const danglingAfkChannel = guild.afkChannelId != null && !channelIds.has(guild.afkChannelId); if (!danglingSystemChannel && !danglingRulesChannel && !danglingAfkChannel) { return guild; } Logger.info( { guildId: guild.id.toString(), danglingSystemChannel, danglingRulesChannel, danglingAfkChannel, }, 'Repairing dangling guild channel references', ); const patch: { system_channel_id?: null; rules_channel_id?: null; afk_channel_id?: null; } = {}; if (danglingSystemChannel) patch.system_channel_id = null; if (danglingRulesChannel) patch.rules_channel_id = null; if (danglingAfkChannel) patch.afk_channel_id = null; return this.guildRepository.upsertPartial(guild.id, patch, guild.toRow()); } private async repairGuildBannerHeight(guild: Guild): Promise { if (!guild.bannerHash || (guild.bannerHeight != null && guild.bannerWidth != null)) { return guild; } const s3Key = `banners/${guild.id}/${this.stripAnimationPrefix(guild.bannerHash)}`; try { const object = await this.storageService.readObject(Config.s3.buckets.cdn, s3Key); const metadata = await sharp(object).metadata(); const bannerHeight = metadata.height ?? null; const bannerWidth = metadata.width ?? null; if (bannerHeight == null || bannerWidth == null) { return guild; } const repairedGuild = await this.guildRepository.upsertPartial( guild.id, {banner_height: bannerHeight, banner_width: bannerWidth}, guild.toRow(), ); return repairedGuild; } catch (error) { Logger.warn({guildId: guild.id, error}, 'Failed to repair guild banner height'); return guild; } } private async repairGuildSplashDimensions(guild: Guild): Promise { if (!guild.splashHash || (guild.splashWidth != null && guild.splashHeight != null)) { return guild; } const s3Key = `splashes/${guild.id}/${this.stripAnimationPrefix(guild.splashHash)}`; try { const object = await this.storageService.readObject(Config.s3.buckets.cdn, s3Key); const metadata = await sharp(object).metadata(); const splashHeight = metadata.height ?? null; const splashWidth = metadata.width ?? null; if (splashHeight == null || splashWidth == null) { return guild; } const repairedGuild = await this.guildRepository.upsertPartial( guild.id, {splash_height: splashHeight, splash_width: splashWidth}, guild.toRow(), ); return repairedGuild; } catch (error) { Logger.warn({guildId: guild.id, error}, 'Failed to repair guild splash dimensions'); return guild; } } private async repairGuildEmbedSplashDimensions(guild: Guild): Promise { if (!guild.embedSplashHash || (guild.embedSplashWidth != null && guild.embedSplashHeight != null)) { return guild; } const s3Key = `embed-splashes/${guild.id}/${this.stripAnimationPrefix(guild.embedSplashHash)}`; try { const object = await this.storageService.readObject(Config.s3.buckets.cdn, s3Key); const metadata = await sharp(object).metadata(); const embedSplashHeight = metadata.height ?? null; const embedSplashWidth = metadata.width ?? null; if (embedSplashHeight == null || embedSplashWidth == null) { return guild; } const repairedGuild = await this.guildRepository.upsertPartial( guild.id, {embed_splash_height: embedSplashHeight, embed_splash_width: embedSplashWidth}, guild.toRow(), ); return repairedGuild; } catch (error) { Logger.warn({guildId: guild.id, error}, 'Failed to repair guild embed splash dimensions'); return guild; } } private async repairOrphanedInvitesAndWebhooks(params: {guild: Guild; channels: Array}): Promise { const {guild, channels} = params; const channelIds = new Set(channels.map((channel) => channel.id)); const vanityInviteCode = guild.vanityUrlCode ? vanityCodeToInviteCode(guild.vanityUrlCode) : null; const [invites, webhooks] = await Promise.all([ this.inviteRepository.listGuildInvites(guild.id), this.webhookRepository.listByGuild(guild.id), ]); const orphanedInvites = invites.filter((invite) => { if (!invite.channelId) { return false; } if (vanityInviteCode && invite.code === vanityInviteCode) { return false; } return !channelIds.has(invite.channelId); }); const orphanedWebhooks = webhooks.filter((webhook) => { if (!webhook.channelId) { return false; } return !channelIds.has(webhook.channelId); }); if (orphanedInvites.length > 0) { Logger.info( { guildId: guild.id.toString(), count: orphanedInvites.length, codes: orphanedInvites.map((i) => i.code), }, 'Repairing orphaned invites', ); await Promise.all(orphanedInvites.map((invite) => this.inviteRepository.delete(invite.code))); } if (orphanedWebhooks.length > 0) { Logger.info( { guildId: guild.id.toString(), count: orphanedWebhooks.length, webhookIds: orphanedWebhooks.map((w) => w.id.toString()), }, 'Repairing orphaned webhooks', ); await Promise.all(orphanedWebhooks.map((webhook) => this.webhookRepository.delete(webhook.id))); } } private async getUserBlockedIds(params: {userIds: Array}): Promise>> { const {userIds} = params; const settled = await allSettledWithConcurrency(userIds, RPC_RESPONSE_MAP_CONCURRENCY, (userId) => this.userRepository.listBlockedUserIds(userId), ); const result: Record> = {}; for (const [index, settledBlockedIds] of settled.entries()) { if (settledBlockedIds.status === 'rejected') { throw settledBlockedIds.reason; } result[userIds[index]!.toString()] = settledBlockedIds.value.map((blockedUserId) => blockedUserId.toString()); } return result; } private async kickTemporaryMember(params: {userId: UserID; guildIds: Array}): Promise { const {userId, guildIds} = params; try { await Promise.all( guildIds.map(async (guildId) => { try { const [member, guild] = await Promise.all([ this.guildRepository.getMember(guildId, userId), this.guildRepository.findUnique(guildId), ]); if (member?.isTemporary && guild) { await this.guildRepository.deleteMember(guildId, userId); await this.updateGuildMemberCount(guild, Math.max(0, guild.memberCount - 1)); await this.gatewayService.dispatchGuild({ guildId, event: 'GUILD_MEMBER_REMOVE', data: {user: {id: userId.toString()}}, }); await this.gatewayService.leaveGuild({userId, guildId}); } } catch (error) { Logger.error( {userId: userId.toString(), guildId: guildId.toString(), error}, 'Failed to kick temporary member from guild', ); throw error; } }), ); return true; } catch (error) { Logger.error( {userId: userId.toString(), guildIds: guildIds.map(String), error}, 'Failed to kick temporary member from multiple guilds', ); return false; } } private async handleCallEnded(params: { channelId: ChannelID; messageId: bigint; participants: Array; endedTimestamp: Date; requestCache: RequestCache; }): Promise { const {channelId, messageId, participants, endedTimestamp} = params; const [message, channel] = await Promise.all([ this.channelRepository.getMessage(channelId, createMessageID(messageId)), this.channelRepository.findUnique(channelId), ]); if (!message || !channel) { return; } if (message.type !== MessageTypes.CALL) { return; } const messageRow = message.toRow(); const updatedMessage = await this.channelRepository.upsertMessage({ ...messageRow, call: { participant_ids: new Set(participants), ended_timestamp: endedTimestamp, }, }); if (!updatedMessage) { return; } const participantIds = participants.filter((id) => channel.recipientIds.has(id)); if (participantIds.length > 0) { await Promise.all( participantIds.map((participantId) => this.readStateService .ackMessage({ userId: participantId, channelId, messageId: createMessageID(messageId), mentionCount: 0, silent: true, }) .catch((error) => { Logger.error( { userId: participantId.toString(), channelId: channelId.toString(), messageId: messageId.toString(), error, }, 'Failed to ack call message for participant on call end', ); return null; }), ), ); } const messageResponse = await buildBroadcastMessageData({ channel, message: updatedMessage, }); for (const recipientId of channel.recipientIds) { await this.gatewayService.dispatchPresence({ userId: recipientId, event: 'MESSAGE_UPDATE', data: messageResponse, }); } } private async getDmChannel(params: { channelId: ChannelID; userId: UserID; requestCache: RequestCache; }): Promise { const {channelId, userId, requestCache} = params; const channel = await this.channelRepository.findUnique(channelId); if (!channel) { return null; } if (!channel.recipientIds.has(userId)) { return null; } try { return await mapChannelToResponse({ channel, currentUserId: userId, userCacheService: this.userCacheService, requestCache, }); } catch (error) { if (this.isUnknownUserError(error)) { Logger.warn( { userId: userId.toString(), channelId: channelId.toString(), }, 'Skipping RPC get_dm_channel response with unknown user reference', ); return null; } throw error; } } } async function allSettledWithConcurrency( items: ReadonlyArray, concurrency: number, mapper: (item: T, index: number) => Promise, ): Promise>> { const results = new Array>(items.length); let nextIndex = 0; async function worker(): Promise { for (;;) { const index = nextIndex++; if (index >= items.length) return; try { results[index] = { status: 'fulfilled', value: await mapper(items[index]!, index), }; } catch (reason) { results[index] = { status: 'rejected', reason, }; } } } await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, () => worker())); return results; }