mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
2065 lines
69 KiB
TypeScript
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;
|
|
}
|