/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {createHash} from 'node:crypto'; import type {AlertService} from '@fluxer/api/src/alert/AlertService'; import type {AuthService} from '@fluxer/api/src/auth/AuthService'; import type {ChannelID, GuildID, UserID} from '@fluxer/api/src/BrandedTypes'; import { createChannelID, createGuildID, createMessageID, createUserID, userIdToChannelId, vanityCodeToInviteCode, } from '@fluxer/api/src/BrandedTypes'; import {Config} from '@fluxer/api/src/Config'; import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers'; import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository'; import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers'; import {mapFavoriteMemeToResponse} from '@fluxer/api/src/favorite_meme/FavoriteMemeModel'; import type {IFavoriteMemeRepository} from '@fluxer/api/src/favorite_meme/IFavoriteMemeRepository'; import { mapGuildEmojiToResponse, mapGuildMemberToResponse, mapGuildRoleToResponse, mapGuildStickerToResponse, mapGuildToGuildResponse, } from '@fluxer/api/src/guild/GuildModel'; import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate'; import type {AvatarService} from '@fluxer/api/src/infrastructure/AvatarService'; import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService'; import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService'; import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService'; import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService'; import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService'; import type {IInviteRepository} from '@fluxer/api/src/invite/IInviteRepository'; import {Logger} from '@fluxer/api/src/Logger'; import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService'; import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils'; import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder'; import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware'; import type {AuthSession} from '@fluxer/api/src/models/AuthSession'; import type {Channel} from '@fluxer/api/src/models/Channel'; import type {FavoriteMeme} from '@fluxer/api/src/models/FavoriteMeme'; import type {Guild} from '@fluxer/api/src/models/Guild'; import type {GuildEmoji} from '@fluxer/api/src/models/GuildEmoji'; import type {GuildMember} from '@fluxer/api/src/models/GuildMember'; import type {GuildRole} from '@fluxer/api/src/models/GuildRole'; import type {GuildSticker} from '@fluxer/api/src/models/GuildSticker'; import type {ReadState} from '@fluxer/api/src/models/ReadState'; import type {Relationship} from '@fluxer/api/src/models/Relationship'; import type {User} from '@fluxer/api/src/models/User'; import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings'; import {UserSettings} from '@fluxer/api/src/models/UserSettings'; import type {BotAuthService} from '@fluxer/api/src/oauth/BotAuthService'; import type {IApplicationRepository} from '@fluxer/api/src/oauth/repositories/IApplicationRepository'; import type {PackService} from '@fluxer/api/src/pack/PackService'; import type {ReadStateService} from '@fluxer/api/src/read_state/ReadStateService'; import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository'; import {CustomStatusValidator} from '@fluxer/api/src/user/services/CustomStatusValidator'; import {getCachedUserPartialResponse} from '@fluxer/api/src/user/UserCacheHelpers'; import {createPremiumClearPatch, shouldStripExpiredPremium} from '@fluxer/api/src/user/UserHelpers'; import { mapRelationshipToResponse, mapUserGuildSettingsToResponse, mapUserSettingsToResponse, mapUserToPrivateResponse, } from '@fluxer/api/src/user/UserMappers'; import {isUserAdult} from '@fluxer/api/src/utils/AgeUtils'; import {deriveDominantAvatarColor} from '@fluxer/api/src/utils/AvatarColorUtils'; import {calculateDistance, parseCoordinate} from '@fluxer/api/src/utils/GeoUtils'; import {lookupGeoip} from '@fluxer/api/src/utils/IpUtils'; import type {VoiceAccessContext, VoiceAvailabilityService} from '@fluxer/api/src/voice/VoiceAvailabilityService'; import type {VoiceService} from '@fluxer/api/src/voice/VoiceService'; import type {IWebhookRepository} from '@fluxer/api/src/webhook/IWebhookRepository'; import {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 {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService'; import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type { RpcRequest, RpcResponse, RpcResponseGuildData, RpcResponseSessionData, } from '@fluxer/schema/src/domains/rpc/RpcSchemas'; import type {RelationshipResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; 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 HandleGuildRequestParams { guildId: GuildID; requestCache: RequestCache; } interface GetGuildDataParams { guildId: GuildID; } interface GetUserDataParams { userId: UserID; includePrivateChannels?: boolean; } interface GuildData { guild: Guild; channels: Array; emojis: Array; stickers: Array; members: Array; roles: Array; } interface UserData { user: User; settings: UserSettings | null; guildSettings: Array; notes: Map; readStates: Array; guildIds: Array; privateChannels: Array; relationships: Array; favoriteMemes: Array; pinnedDMs: Array; } export class RpcService { private readonly customStatusValidator: CustomStatusValidator; constructor( private userRepository: IUserRepository, private applicationRepository: IApplicationRepository, private guildRepository: IGuildRepositoryAggregate, private channelRepository: IChannelRepository, private userCacheService: UserCacheService, private readStateService: ReadStateService, private authService: AuthService, private gatewayService: IGatewayService, private alertService: AlertService, private discriminatorService: IDiscriminatorService, private favoriteMemeRepository: IFavoriteMemeRepository, private packService: PackService, private botAuthService: BotAuthService, private inviteRepository: IInviteRepository, private webhookRepository: IWebhookRepository, private storageService: IStorageService, private avatarService: AvatarService, private rateLimitService: IRateLimitService, private mediaService: IMediaService, private readonly limitConfigService: LimitConfigService, private voiceService?: VoiceService, private voiceAvailabilityService?: VoiceAvailabilityService, ) { this.customStatusValidator = new CustomStatusValidator( this.userRepository, this.guildRepository, this.packService, this.limitConfigService, ); } private async sanitizeOwnedBotDiscriminators(owner: User): Promise { // Only reroll bot discriminators that are invalid (#0000). This is a one-time migration guarded by UserFlags.BOT_SANITIZED. const applications = await this.applicationRepository.listApplicationsByOwner(owner.id); const botUserIds: Array = []; for (const app of applications) { if (!app.hasBotUser()) continue; const botUserId = app.getBotUserId(); if (botUserId) { botUserIds.push(botUserId); } } const uniqueBotUserIds = Array.from(new Set(botUserIds)); let allSucceeded = true; let rerolledCount = 0; for (const botUserId of uniqueBotUserIds) { const botUser = await this.userRepository.findUnique(botUserId); if (!botUser) { Logger.warn( {ownerUserId: owner.id.toString(), botUserId: botUserId.toString()}, 'Owned bot user missing during discriminator sanitization', ); allSucceeded = false; continue; } if (!botUser.isBot) { Logger.warn( {ownerUserId: owner.id.toString(), botUserId: botUserId.toString()}, 'Owned bot user record is not marked as bot during discriminator sanitization', ); allSucceeded = false; continue; } const isDeleted = (botUser.flags & UserFlags.DELETED) === UserFlags.DELETED; if (isDeleted) { continue; } if (botUser.discriminator !== 0) { continue; } try { const discriminatorResult = await this.discriminatorService.generateDiscriminator({ username: botUser.username, }); if ( !discriminatorResult.available || discriminatorResult.discriminator === -1 || discriminatorResult.discriminator === 0 ) { Logger.warn( { ownerUserId: owner.id.toString(), botUserId: botUserId.toString(), username: botUser.username, discriminatorResult, }, 'Failed to reroll invalid bot discriminator during sanitization', ); allSucceeded = false; continue; } const updatedBotUser = await this.userRepository.patchUpsert( botUserId, { discriminator: discriminatorResult.discriminator, }, botUser.toRow(), ); await this.userCacheService.setUserPartialResponseFromUser(updatedBotUser); rerolledCount += 1; } catch (error) { Logger.warn( {ownerUserId: owner.id.toString(), botUserId: botUserId.toString(), error}, 'Failed to sanitize owned bot discriminator', ); allSucceeded = false; } } Logger.info( { ownerUserId: owner.id.toString(), botCount: uniqueBotUserIds.length, rerolledCount, allSucceeded, }, 'Completed owned bot discriminator sanitization pass', ); return allSucceeded; } 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 this.channelRepository.upsert({ channel_id: personalNotesChannelId, guild_id: null, type: ChannelTypes.DM_PERSONAL_NOTES, name: '', topic: null, icon_hash: null, url: null, parent_id: null, position: 0, owner_id: null, recipient_ids: new Set(), nsfw: false, rate_limit_per_user: 0, bitrate: null, user_limit: null, rtc_region: null, last_message_id: null, last_pin_timestamp: null, permission_overwrites: null, nicks: null, soft_deleted: false, indexed_at: null, version: 1, }); } private async updateGuildMemberCount(guild: Guild, actualMemberCount: number): Promise { if (guild.memberCount === actualMemberCount) { return guild; } const guildRow = guild.toRow(); return await this.guildRepository.upsert({ ...guildRow, member_count: actualMemberCount, }); } 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': { await this.alertService.logGuildCrash({ guildId: request.guild_id.toString(), stacktrace: request.stacktrace, }); return { type: 'log_guild_crash', data: {success: true}, }; } case 'guild': return { type: 'guild', data: await this.handleGuildRequest({ guildId: createGuildID(request.guild_id), requestCache, }), }; 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', }, }; } 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 '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) { 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) { 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) { 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, }); return { type: 'voice_update_participant', data: {success: true}, }; } case 'voice_force_disconnect_channel': { if (!this.voiceService) { 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, message: result.message, }, }; } case 'voice_update_participant_permissions': { if (!this.voiceService) { 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, }); 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}, }; } 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 Promise.allSettled( channels.map((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 Promise.allSettled( relationships.map((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 mapRpcGuildMembers(params: { guildId: GuildID; members: Array; requestCache: RequestCache; }): Promise> { const {guildId, members, requestCache} = params; const mappedMembers = await Promise.allSettled( members.map((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 async handleSessionRequest({ token, version, requestCache, ip, latitude, longitude, }: HandleSessionRequestParams): Promise { const normalizedToken = this.normalizeSessionToken(token); const tokenHash = createHash('sha256').update(normalizedToken).digest('hex'); const tokenType = this.parseTokenType(normalizedToken); const tokenHashPrefix = tokenHash.slice(0, 12); Logger.debug( { tokenType, tokenHashPrefix, version, hasIp: ip !== undefined, hasLatitude: latitude !== undefined, hasLongitude: longitude !== undefined, }, 'RPC session handling started', ); const bucketKey = `gateway:rpc:session:${tokenType}:${tokenHash}`; const rateLimitResult = await 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: AuthSession | null = null; if (tokenType === 'user') { authSession = await this.authService.getAuthSessionByToken(normalizedToken); if (authSession) { userId = authSession.userId; } } else if (tokenType === 'bot') { userId = await this.botAuthService.validateBotToken(normalizedToken); } if (!userId) { Logger.warn( { tokenType, tokenHashPrefix, }, 'RPC session token validation failed', ); throw new UnauthorizedError(); } const userData = await this.getUserData({userId, includePrivateChannels: false}); if (!userData || !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) { try { const avatarKey = `avatars/${user.id}/${this.stripAnimationPrefix(user.avatarHash)}`; const object = await this.storageService.readObject(Config.s3.buckets.cdn, avatarKey); const avatarColor = await deriveDominantAvatarColor(object); const updatedUser = await 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'); } } if (user.bannerHash && user.bannerColor == null) { try { const bannerKey = `banners/${user.id}/${this.stripAnimationPrefix(user.bannerHash)}`; const object = await this.storageService.readObject(Config.s3.buckets.cdn, bannerKey); const bannerColor = await deriveDominantAvatarColor(object); const updatedUser = await 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'); } } userData.user = user; let userSettings = userData.settings; if (userSettings?.customStatus?.isExpired()) { userSettings = Object.assign(Object.create(Object.getPrototypeOf(userSettings)), userSettings, { customStatus: null, }); userData.settings = userSettings; } let flagsToUpdate: bigint | null = null; if (!(user.flags & UserFlags.HAS_SESSION_STARTED)) { flagsToUpdate = (flagsToUpdate ?? user.flags) | UserFlags.HAS_SESSION_STARTED; } const hadPremium = user.premiumType != null && user.premiumType > 0; const isPremium = user.isPremium(); const needsPremiumStrip = shouldStripExpiredPremium(user); const hasBeenSanitized = !!(user.flags & UserFlags.PREMIUM_PERKS_SANITIZED); const hasInvalidNonLifetimeDiscriminator = user.discriminator === 0 && user.premiumType !== UserPremiumTypes.LIFETIME; if (needsPremiumStrip) { try { const strippedUser = await this.userRepository.patchUpsert(user.id, createPremiumClearPatch(), user.toRow()); if (strippedUser) { user = strippedUser; userData.user = strippedUser; this.userCacheService.setUserPartialResponseFromUserInBackground(strippedUser, requestCache); } } catch (error) { Logger.warn({userId: user.id.toString(), error}, 'Failed to strip expired premium on RPC session start'); } } if (hasInvalidNonLifetimeDiscriminator) { try { const discriminatorResult = await this.discriminatorService.generateDiscriminator({ username: user.username, user, }); if (discriminatorResult.available && discriminatorResult.discriminator !== -1) { const updatedUser = await this.userRepository.patchUpsert( user.id, { discriminator: discriminatorResult.discriminator, }, user.toRow(), ); if (updatedUser) { Object.assign(user, updatedUser); userData.user = user; this.userCacheService.setUserPartialResponseFromUserInBackground(user, requestCache); } } } catch (error) { Logger.error( {userId: user.id.toString(), error}, 'Failed to reset invalid non-lifetime discriminator on RPC session start', ); } flagsToUpdate = (flagsToUpdate ?? user.flags) & ~UserFlags.PREMIUM_DISCRIMINATOR; } if (hadPremium && !isPremium && !hasBeenSanitized) { if (user.flags & UserFlags.PREMIUM_DISCRIMINATOR) { try { const discriminatorResult = await this.discriminatorService.generateDiscriminator({ username: user.username, user, }); if (discriminatorResult.available && discriminatorResult.discriminator !== -1) { const updatedUser = await this.userRepository.patchUpsert( user.id, { discriminator: discriminatorResult.discriminator, }, user.toRow(), ); if (updatedUser) { Object.assign(user, updatedUser); this.userCacheService.setUserPartialResponseFromUserInBackground(user, requestCache); } } } catch (error) { Logger.error({userId: user.id.toString(), error}, 'Failed to reset discriminator after premium expired'); } flagsToUpdate = (flagsToUpdate ?? user.flags) & ~UserFlags.PREMIUM_DISCRIMINATOR; } const guildIdsToProcess = userData.guildIds; try { const members = await Promise.all( guildIdsToProcess.map(async (guildId) => { try { const member = await this.guildRepository.getMember(guildId, user.id); return {guildId, member, error: null}; } catch (error) { Logger.error( {userId: user.id.toString(), guildId: guildId.toString(), error}, 'Failed to fetch guild member for premium sanitization', ); return {guildId, member: null, error}; } }), ); const membersToSanitize = members.filter( ({member, error}) => !error && member && !member.isPremiumSanitized && (member.avatarHash || member.bannerHash || member.bio || member.accentColor !== null), ); if (membersToSanitize.length > 0) { const updatePromises = membersToSanitize.map(({guildId, member}) => this.guildRepository .upsertMember({ ...member!.toRow(), is_premium_sanitized: true, }) .then((updatedMember) => ({guildId, updatedMember, error: null})), ); const updatedResults = await Promise.all(updatePromises); const dispatchPromises = updatedResults.map(async ({guildId, updatedMember, error}) => { if (error) return; try { await this.gatewayService.dispatchGuild({ guildId, event: 'GUILD_MEMBER_UPDATE', data: await mapGuildMemberToResponse(updatedMember!, this.userCacheService, requestCache), }); } catch (error) { Logger.error( {userId: user.id.toString(), guildId: guildId.toString(), error}, 'Failed to dispatch guild member update after premium sanitization', ); } }); await Promise.all(dispatchPromises); } } catch (error) { Logger.error( {userId: user.id.toString(), guildIds: guildIdsToProcess.map(String), error}, 'Failed to sanitize guild member premium perks for multiple guilds', ); } flagsToUpdate = (flagsToUpdate ?? user.flags) | UserFlags.PREMIUM_PERKS_SANITIZED; await this.gatewayService.dispatchPresence({ userId: user.id, event: 'USER_UPDATE', data: {user: mapUserToPrivateResponse(user)}, }); } if (!user.isBot && (user.flags & UserFlags.BOT_SANITIZED) !== UserFlags.BOT_SANITIZED) { try { const sanitized = await this.sanitizeOwnedBotDiscriminators(user); if (sanitized) { flagsToUpdate = (flagsToUpdate ?? user.flags) | UserFlags.BOT_SANITIZED; } } catch (error) { Logger.warn( {userId: user.id.toString(), error}, 'Failed to run owned bot discriminator sanitization on RPC session start', ); } } if (!(user.flags & UserFlags.HAS_RELATIONSHIPS_INDEXED)) { try { const relationships = userData.relationships; await this.userRepository.backfillRelationshipsIndex(user.id, relationships); flagsToUpdate = (flagsToUpdate ?? user.flags) | UserFlags.HAS_RELATIONSHIPS_INDEXED; } catch (error) { Logger.warn({userId: user.id, error}, 'Failed to backfill relationships index'); } } if (!(user.flags & UserFlags.MESSAGES_BY_AUTHOR_BACKFILLED)) { try { await this.channelRepository.backfillMessagesByAuthorIndex(user.id); flagsToUpdate = (flagsToUpdate ?? user.flags) | UserFlags.MESSAGES_BY_AUTHOR_BACKFILLED; } catch (error) { Logger.warn({userId: user.id, error}, 'Failed to backfill messages by author index'); } } if (flagsToUpdate !== null && flagsToUpdate !== user.flags) { await this.userRepository.patchUpsert( user.id, { flags: flagsToUpdate, }, user.toRow(), ); } await this.ensurePersonalNotesChannel(user); const cachedChannels = await this.ensurePrivateChannelsWithinLimit(user); userData.privateChannels = cachedChannels ?? (await this.userRepository.listPrivateChannels(user.id)); const mapReadState = (readState: ReadState) => ({ id: readState.channelId.toString(), mention_count: readState.mentionCount, last_message_id: readState.lastMessageId?.toString() ?? null, last_pin_timestamp: readState.lastPinTimestamp?.toISOString() ?? null, }); let countryCode = 'US'; if (ip) { const geoip = await lookupGeoip(ip); countryCode = geoip.countryCode ?? countryCode; } else { Logger.warn({context: 'rpc_geoip', reason: 'ip_missing'}, 'RPC session request missing IP for GeoIP'); } const [privateChannels, relationships] = await Promise.all([ this.mapRpcSessionPrivateChannels({ channels: userData.privateChannels, userId: user.id, requestCache, }), this.mapRpcSessionRelationships({ relationships: userData.relationships, userId: user.id, requestCache, }), ]); const voiceAccessContext: VoiceAccessContext = {requestingUserId: user.id}; const availableRegions = 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)); } const rtcRegions = sortedRegions.map((region) => ({ id: region.id, name: region.name, emoji: region.emoji, })); Logger.debug( { tokenType, tokenHashPrefix, userId: user.id.toString(), guildCount: userData.guildIds.length, privateChannelCount: privateChannels.length, relationshipCount: relationships.length, countryCode, rtcRegionCount: rtcRegions.length, }, 'RPC session handling completed', ); return { auth_session_id_hash: authSession ? uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true}) : undefined, user: mapUserToPrivateResponse(user), user_settings: userData.settings ? mapUserSettingsToResponse({ settings: userData.settings, memberGuildIds: userData.guildIds, }) : null, user_guild_settings: userData.guildSettings.map(mapUserGuildSettingsToResponse), notes: Object.fromEntries( Array.from(userData.notes.entries()).map(([userId, note]) => [userId.toString(), note]), ), read_states: userData.readStates.map(mapReadState), guild_ids: userData.guildIds.map(String), private_channels: privateChannels, relationships, favorite_memes: userData.favoriteMemes.map(mapFavoriteMemeToResponse), pinned_dms: userData.pinnedDMs?.map(String) ?? [], country_code: countryCode, rtc_regions: rtcRegions, version, }; } private async handleGuildRequest({guildId, requestCache}: HandleGuildRequestParams): Promise { const guildData = await this.getGuildData({guildId}); if (!guildData) { throw new UnknownGuildError(); } const [channels, members] = await Promise.all([ Promise.all( guildData.channels.map((channel) => mapChannelToResponse({ channel, currentUserId: null, userCacheService: this.userCacheService, requestCache, }), ), ), this.mapRpcGuildMembers({guildId, members: guildData.members, requestCache}), ]); return { guild: mapGuildToGuildResponse(guildData.guild), roles: guildData.roles.map(mapGuildRoleToResponse), channels, emojis: guildData.emojis.map(mapGuildEmojiToResponse), stickers: guildData.stickers.map(mapGuildStickerToResponse), members, }; } private async getGuildData({guildId}: GetGuildDataParams): Promise { const [guildResult, channels, emojis, stickers, members, roles] = await Promise.all([ this.guildRepository.findUnique(guildId), this.channelRepository.listGuildChannels(guildId), this.guildRepository.listEmojis(guildId), this.guildRepository.listStickers(guildId), this.guildRepository.listMembers(guildId), this.guildRepository.listRoles(guildId), ]); if (!guildResult) return null; let migratedStickers = stickers; const needsMigration = stickers.filter((s) => s.animated === null || s.animated === undefined); if (needsMigration.length > 0) { Logger.info({count: needsMigration.length, guildId}, 'Migrating sticker animated fields'); const migrated = await Promise.all(needsMigration.map((s) => this.migrateStickerAnimated(s))); migratedStickers = stickers.map((s) => { const migratedSticker = migrated.find((m) => m.id === s.id); return migratedSticker ?? s; }); } const repairedChannelRefsGuild = await this.repairDanglingChannelReferences({guild: guildResult, channels}); const repairedBannerGuild = await this.repairGuildBannerHeight(repairedChannelRefsGuild); const repairedSplashGuild = await this.repairGuildSplashDimensions(repairedBannerGuild); const repairedEmbedSplashGuild = await this.repairGuildEmbedSplashDimensions(repairedSplashGuild); const updatedGuild = await this.updateGuildMemberCount(repairedEmbedSplashGuild, members.length); this.repairOrphanedInvitesAndWebhooks({guild: updatedGuild, channels}).catch((error) => { Logger.warn({guildId: guildId.toString(), error}, 'Failed to repair orphaned invites/webhooks'); }); return { guild: updatedGuild, channels, emojis, stickers: migratedStickers, members, roles, }; } private async getUserData({userId, includePrivateChannels = true}: GetUserDataParams): Promise { const user = await this.userRepository.findUnique(userId); if (!user) return null; if (user.isBot) { const guildIds = await this.userRepository.getUserGuildIds(userId); return { user, settings: null, guildSettings: [], notes: new Map(), readStates: [], guildIds, privateChannels: [], relationships: [], favoriteMemes: [], pinnedDMs: [], }; } const [settingsResult, notes, readStates, guildIds, relationships, favoriteMemes, pinnedDMs] = await Promise.all([ this.userRepository.findSettings(userId), this.userRepository.getUserNotes(userId), this.readStateService.getReadStates(userId), this.userRepository.getUserGuildIds(userId), this.userRepository.listRelationships(userId), this.favoriteMemeRepository.findByUserId(userId), this.userRepository.getPinnedDms(userId), ]); const privateChannels = includePrivateChannels ? await this.userRepository.listPrivateChannels(userId) : []; 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 this.userRepository.upsertSettings(updatedRow); settings = new UserSettings(updatedRow); } } const guildSettings = await this.userRepository.findAllGuildSettings(userId); return { user, settings, guildSettings, notes, readStates, guildIds, privateChannels, relationships, favoriteMemes, pinnedDMs, }; } private async ensurePrivateChannelsWithinLimit(user: User): Promise | null> { if (user.isBot) { return []; } const channels = await this.userRepository.listPrivateChannels(user.id); const totalPrivateChannels = channels.length; const limit = this.resolveLimitForUser( user ?? null, 'max_private_channels_per_user', MAX_PRIVATE_CHANNELS_PER_USER, ); if (totalPrivateChannels <= limit) { return channels; } const closableDms = channels .filter((channel) => channel.type === ChannelTypes.DM) .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; }); const toClose = totalPrivateChannels - limit; let closed = 0; for (const channel of closableDms) { if (closed >= toClose) { break; } await this.userRepository.closeDmForUser(user.id, channel.id); closed += 1; } if (closed < toClose) { Logger.warn( { user_id: user.id.toString(), total_private_channels: totalPrivateChannels, required_closures: toClose, actual_closures: closed, }, 'Unable to close enough DMs to satisfy private channel limit', ); } return null; } 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 userGuildSettings = await Promise.all( userIds.map((userId) => this.userRepository.findGuildSettings(userId, actualGuildId)), ); return {user_guild_settings: userGuildSettings}; } private async getPushSubscriptions(params: { userIds: Array; }): Promise< Record> > { const {userIds} = params; const subscriptionsMap = await this.userRepository.getBulkPushSubscriptions(userIds); const result: Record< string, Array<{subscription_id: string; endpoint: string; p256dh_key: string; auth_key: string}> > = {}; 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, })); } return result; } private async getBadgeCounts(params: {userIds: Array}): Promise> { const {userIds} = params; const uniqueUserIds = Array.from(new Set(userIds)) as Array; const allReadStates = await Promise.all(uniqueUserIds.map((userId) => this.readStateService.getReadStates(userId))); const badgeCounts: Record = {}; uniqueUserIds.forEach((userId, index) => { const readStates = allReadStates[index]; 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', ); return this.guildRepository.upsert({ ...guild.toRow(), system_channel_id: danglingSystemChannel ? null : guild.systemChannelId, rules_channel_id: danglingRulesChannel ? null : guild.rulesChannelId, afk_channel_id: danglingAfkChannel ? null : guild.afkChannelId, }); } 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.upsert({ ...guild.toRow(), banner_height: bannerHeight, banner_width: bannerWidth, }); 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.upsert({ ...guild.toRow(), splash_height: splashHeight, splash_width: splashWidth, }); 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.upsert({ ...guild.toRow(), embed_splash_height: embedSplashHeight, embed_splash_width: embedSplashWidth, }); 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 result: Record> = {}; const relationshipsPromises = userIds.map(async (userId) => { const relationships = await this.userRepository.listRelationships(userId); const blockedIds = relationships.filter((rel) => rel.type === 2).map((rel) => rel.targetUserId.toString()); return {userId, blockedIds}; }); const results = await Promise.all(relationshipsPromises); for (const {userId, blockedIds} of results) { result[userId.toString()] = blockedIds; } 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, requestCache} = 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 messageResponse = await mapMessageToResponse({ message: updatedMessage, userCacheService: this.userCacheService, requestCache, mediaService: this.mediaService, getReferencedMessage: (channelId, messageId) => this.channelRepository.getMessage(channelId, messageId), }); 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; } } }