Files
fluxer/fluxer_api/src/api/rpc/RpcService.ts
T

2065 lines
69 KiB
TypeScript

// 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<UserGuildSettings>;
notes: Map<UserID, string>;
readStates: Array<ReadState>;
guildIds: Array<GuildID>;
privateChannels: Array<Channel>;
relationships: Array<Relationship>;
favoriteMemes: Array<FavoriteMeme>;
pinnedDMs: Array<ChannelID>;
webAuthnCredentials: Array<WebAuthnCredential>;
}
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<RpcVoiceParticipantSnapshot>;
error_code?: string;
retryable?: boolean;
server_missing?: boolean;
} {
if (result.status === 'error') {
const errorSnapshot: {
status: 'error';
participants: Array<RpcVoiceParticipantSnapshot>;
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<WorkerTaskName>,
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<void> {
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<Guild> {
if (guild.memberCount === actualMemberCount) {
return guild;
}
return await this.guildRepository.upsertPartial(guild.id, {member_count: actualMemberCount}, guild.toRow());
}
private async migrateStickerAnimated(sticker: GuildSticker): Promise<GuildSticker> {
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<RpcResponse> {
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<Channel>;
userId: UserID;
requestCache: RequestCache;
}): Promise<Array<ChannelResponse>> {
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<ChannelResponse> = [];
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<Relationship>;
userId: UserID;
requestCache: RequestCache;
}): Promise<Array<RelationshipResponse>> {
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<RelationshipResponse> = [];
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<Channel>;
relationships: Array<Relationship>;
userId: UserID;
requestCache: RequestCache;
steps?: RpcTimingSteps;
}): Promise<void> {
const {channels, relationships, userId, requestCache, steps} = params;
const userIds = timeRpcStepSync(steps ?? {}, 'collect_user_partial_ids', () => {
const collected = new Set<UserID>();
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<GuildMember>;
requestCache: RequestCache;
}): Promise<Array<GuildMemberResponse>> {
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<GuildMemberResponse> = [];
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<RpcResponseSessionData> {
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<RpcResponseGuildCollectionData> {
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<Guild> {
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<RpcResponseGuildCollectionData> {
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<RpcResponseGuildCollectionData> {
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<RpcResponseGuildCollectionData> {
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<RpcResponseGuildCollectionData> {
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<RpcResponseGuildCollectionData> {
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<RpcResponseGuildCollectionData> {
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<RpcResponseGuildCollectionData> {
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<string, unknown>;
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<string, string>,
): Array<VoiceStateResponse> {
const voiceStates: Array<VoiceStateResponse> = [];
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<void> {
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<void> {
const {guildId, connectionId} = params;
await this.kvClient.hdel(this.guildVoiceStatesKey(guildId), connectionId);
}
private async migrateGuildStickersForRpc(
guildId: GuildID,
stickers: Array<GuildSticker>,
): Promise<Array<GuildSticker>> {
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<UserData | null> {
const timeUserDataStep = <T>(name: string, operation: () => Promise<T>): Promise<T> =>
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<Array<Channel>>([]),
]);
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<Channel>;
steps?: RpcTimingSteps;
}): Promise<Array<Channel>> {
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<UserID>; guildId: GuildID}): Promise<{
user_guild_settings: Array<UserGuildSettings | null>;
}> {
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<UserGuildSettings | null> = [];
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<UserID>}): 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<UserID>}): Promise<Record<string, number>> {
const {userIds} = params;
const uniqueUserIds = Array.from(new Set(userIds)) as Array<UserID>;
const badgeCounts: Record<string, number> = {};
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<Channel>}): Promise<Guild> {
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<Guild> {
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<Guild> {
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<Guild> {
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<Channel>}): Promise<void> {
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<UserID>}): Promise<Record<string, Array<string>>> {
const {userIds} = params;
const settled = await allSettledWithConcurrency(userIds, RPC_RESPONSE_MAP_CONCURRENCY, (userId) =>
this.userRepository.listBlockedUserIds(userId),
);
const result: Record<string, Array<string>> = {};
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<GuildID>}): Promise<boolean> {
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<UserID>;
endedTimestamp: Date;
requestCache: RequestCache;
}): Promise<void> {
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<ChannelResponse | null> {
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<T, TResult>(
items: ReadonlyArray<T>,
concurrency: number,
mapper: (item: T, index: number) => Promise<TResult>,
): Promise<Array<PromiseSettledResult<TResult>>> {
const results = new Array<PromiseSettledResult<TResult>>(items.length);
let nextIndex = 0;
async function worker(): Promise<void> {
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;
}