fluxer/packages/api/src/rpc/RpcService.tsx
2026-02-19 01:23:38 +00:00

1886 lines
59 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createHash} from 'node:crypto';
import type {AlertService} from '@fluxer/api/src/alert/AlertService';
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {ChannelID, GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import {
createChannelID,
createGuildID,
createMessageID,
createUserID,
userIdToChannelId,
vanityCodeToInviteCode,
} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
import {mapFavoriteMemeToResponse} from '@fluxer/api/src/favorite_meme/FavoriteMemeModel';
import type {IFavoriteMemeRepository} from '@fluxer/api/src/favorite_meme/IFavoriteMemeRepository';
import {
mapGuildEmojiToResponse,
mapGuildMemberToResponse,
mapGuildRoleToResponse,
mapGuildStickerToResponse,
mapGuildToGuildResponse,
} from '@fluxer/api/src/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {AvatarService} from '@fluxer/api/src/infrastructure/AvatarService';
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {IInviteRepository} from '@fluxer/api/src/invite/IInviteRepository';
import {Logger} from '@fluxer/api/src/Logger';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {Channel} from '@fluxer/api/src/models/Channel';
import type {FavoriteMeme} from '@fluxer/api/src/models/FavoriteMeme';
import type {Guild} from '@fluxer/api/src/models/Guild';
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
import type {GuildSticker} from '@fluxer/api/src/models/GuildSticker';
import type {ReadState} from '@fluxer/api/src/models/ReadState';
import type {Relationship} from '@fluxer/api/src/models/Relationship';
import type {User} from '@fluxer/api/src/models/User';
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import {UserSettings} from '@fluxer/api/src/models/UserSettings';
import type {BotAuthService} from '@fluxer/api/src/oauth/BotAuthService';
import type {IApplicationRepository} from '@fluxer/api/src/oauth/repositories/IApplicationRepository';
import type {PackService} from '@fluxer/api/src/pack/PackService';
import type {ReadStateService} from '@fluxer/api/src/read_state/ReadStateService';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import {CustomStatusValidator} from '@fluxer/api/src/user/services/CustomStatusValidator';
import {getCachedUserPartialResponse} from '@fluxer/api/src/user/UserCacheHelpers';
import {createPremiumClearPatch, shouldStripExpiredPremium} from '@fluxer/api/src/user/UserHelpers';
import {
mapRelationshipToResponse,
mapUserGuildSettingsToResponse,
mapUserSettingsToResponse,
mapUserToPrivateResponse,
} from '@fluxer/api/src/user/UserMappers';
import {isUserAdult} from '@fluxer/api/src/utils/AgeUtils';
import {deriveDominantAvatarColor} from '@fluxer/api/src/utils/AvatarColorUtils';
import {calculateDistance, parseCoordinate} from '@fluxer/api/src/utils/GeoUtils';
import {lookupGeoip} from '@fluxer/api/src/utils/IpUtils';
import type {VoiceAccessContext, VoiceAvailabilityService} from '@fluxer/api/src/voice/VoiceAvailabilityService';
import type {VoiceService} from '@fluxer/api/src/voice/VoiceService';
import type {IWebhookRepository} from '@fluxer/api/src/webhook/IWebhookRepository';
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {MAX_PRIVATE_CHANNELS_PER_USER} from '@fluxer/constants/src/LimitConstants';
import {
GroupDmAddPermissionFlags,
IncomingCallFlags,
UserFlags,
UserPremiumTypes,
} from '@fluxer/constants/src/UserConstants';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {
RpcGuildCollectionType,
RpcRequest,
RpcResponse,
RpcResponseGuildCollectionData,
RpcResponseSessionData,
} from '@fluxer/schema/src/domains/rpc/RpcSchemas';
import type {RelationshipResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {ms} from 'itty-time';
import sharp from 'sharp';
import {uint8ArrayToBase64} from 'uint8array-extras';
interface HandleRpcRequestParams {
request: RpcRequest;
requestCache: RequestCache;
}
interface HandleSessionRequestParams {
token: string;
version: number;
requestCache: RequestCache;
ip?: string;
latitude?: string;
longitude?: string;
}
interface HandleGuildCollectionRequestParams {
guildId: GuildID;
collection: RpcGuildCollectionType;
requestCache: RequestCache;
afterUserId?: UserID;
limit?: number;
}
interface GetUserDataParams {
userId: UserID;
includePrivateChannels?: boolean;
}
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>;
}
const GUILD_COLLECTION_DEFAULT_LIMIT = 250;
const GUILD_COLLECTION_MAX_LIMIT = 1000;
export class RpcService {
private readonly customStatusValidator: CustomStatusValidator;
constructor(
private userRepository: IUserRepository,
private applicationRepository: IApplicationRepository,
private guildRepository: IGuildRepositoryAggregate,
private channelRepository: IChannelRepository,
private userCacheService: UserCacheService,
private readStateService: ReadStateService,
private authService: AuthService,
private gatewayService: IGatewayService,
private alertService: AlertService,
private discriminatorService: IDiscriminatorService,
private favoriteMemeRepository: IFavoriteMemeRepository,
private packService: PackService,
private botAuthService: BotAuthService,
private inviteRepository: IInviteRepository,
private webhookRepository: IWebhookRepository,
private storageService: IStorageService,
private avatarService: AvatarService,
private rateLimitService: IRateLimitService,
private mediaService: IMediaService,
private readonly limitConfigService: LimitConfigService,
private voiceService?: VoiceService,
private voiceAvailabilityService?: VoiceAvailabilityService,
) {
this.customStatusValidator = new CustomStatusValidator(
this.userRepository,
this.guildRepository,
this.packService,
this.limitConfigService,
);
}
private async sanitizeOwnedBotDiscriminators(owner: User): Promise<boolean> {
// Only reroll bot discriminators that are invalid (#0000). This is a one-time migration guarded by UserFlags.BOT_SANITIZED.
const applications = await this.applicationRepository.listApplicationsByOwner(owner.id);
const botUserIds: Array<UserID> = [];
for (const app of applications) {
if (!app.hasBotUser()) continue;
const botUserId = app.getBotUserId();
if (botUserId) {
botUserIds.push(botUserId);
}
}
const uniqueBotUserIds = Array.from(new Set(botUserIds));
let allSucceeded = true;
let rerolledCount = 0;
for (const botUserId of uniqueBotUserIds) {
const botUser = await this.userRepository.findUnique(botUserId);
if (!botUser) {
Logger.warn(
{ownerUserId: owner.id.toString(), botUserId: botUserId.toString()},
'Owned bot user missing during discriminator sanitization',
);
allSucceeded = false;
continue;
}
if (!botUser.isBot) {
Logger.warn(
{ownerUserId: owner.id.toString(), botUserId: botUserId.toString()},
'Owned bot user record is not marked as bot during discriminator sanitization',
);
allSucceeded = false;
continue;
}
const isDeleted = (botUser.flags & UserFlags.DELETED) === UserFlags.DELETED;
if (isDeleted) {
continue;
}
if (botUser.discriminator !== 0) {
continue;
}
try {
const discriminatorResult = await this.discriminatorService.generateDiscriminator({
username: botUser.username,
});
if (
!discriminatorResult.available ||
discriminatorResult.discriminator === -1 ||
discriminatorResult.discriminator === 0
) {
Logger.warn(
{
ownerUserId: owner.id.toString(),
botUserId: botUserId.toString(),
username: botUser.username,
discriminatorResult,
},
'Failed to reroll invalid bot discriminator during sanitization',
);
allSucceeded = false;
continue;
}
const updatedBotUser = await this.userRepository.patchUpsert(
botUserId,
{
discriminator: discriminatorResult.discriminator,
},
botUser.toRow(),
);
await this.userCacheService.setUserPartialResponseFromUser(updatedBotUser);
rerolledCount += 1;
} catch (error) {
Logger.warn(
{ownerUserId: owner.id.toString(), botUserId: botUserId.toString(), error},
'Failed to sanitize owned bot discriminator',
);
allSucceeded = false;
}
}
Logger.info(
{
ownerUserId: owner.id.toString(),
botCount: uniqueBotUserIds.length,
rerolledCount,
allSucceeded,
},
'Completed owned bot discriminator sanitization pass',
);
return allSucceeded;
}
private async ensurePersonalNotesChannel(user: User): Promise<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 this.channelRepository.upsert({
channel_id: personalNotesChannelId,
guild_id: null,
type: ChannelTypes.DM_PERSONAL_NOTES,
name: '',
topic: null,
icon_hash: null,
url: null,
parent_id: null,
position: 0,
owner_id: null,
recipient_ids: new Set(),
nsfw: false,
rate_limit_per_user: 0,
bitrate: null,
user_limit: null,
rtc_region: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
nicks: null,
soft_deleted: false,
indexed_at: null,
version: 1,
});
}
private async updateGuildMemberCount(guild: Guild, actualMemberCount: number): Promise<Guild> {
if (guild.memberCount === actualMemberCount) {
return guild;
}
const guildRow = guild.toRow();
return await this.guildRepository.upsert({
...guildRow,
member_count: actualMemberCount,
});
}
private async migrateStickerAnimated(sticker: GuildSticker): Promise<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': {
await this.alertService.logGuildCrash({
guildId: request.guild_id.toString(),
stacktrace: request.stacktrace,
});
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 'get_user_guild_settings': {
const result = await this.getUserGuildSettings({
userIds: request.user_ids.map(createUserID),
guildId: createGuildID(request.guild_id),
});
return {
type: 'get_user_guild_settings',
data: {
user_guild_settings: result.user_guild_settings.map((settings) =>
settings ? mapUserGuildSettingsToResponse(settings) : null,
),
},
};
}
case 'get_push_subscriptions':
return {
type: 'get_push_subscriptions',
data: await this.getPushSubscriptions({
userIds: request.user_ids.map(createUserID),
}),
};
case 'get_badge_counts': {
const badgeCounts = await this.getBadgeCounts({
userIds: request.user_ids.map(createUserID),
});
return {
type: 'get_badge_counts',
data: {
badge_counts: badgeCounts,
},
};
}
case 'geoip_lookup': {
const geoip = await lookupGeoip(request.ip);
return {
type: 'geoip_lookup',
data: {
country_code: geoip.countryCode ?? 'US',
},
};
}
case 'delete_push_subscriptions':
return {
type: 'delete_push_subscriptions',
data: await this.deletePushSubscriptions({
subscriptions: request.subscriptions.map((sub) => ({
userId: createUserID(sub.user_id),
subscriptionId: sub.subscription_id,
})),
}),
};
case 'get_user_blocked_ids':
return {
type: 'get_user_blocked_ids',
data: await this.getUserBlockedIds({
userIds: request.user_ids.map(createUserID),
}),
};
case 'voice_get_token': {
Logger.debug(
{type: 'voice_get_token', guildId: request.guild_id, channelId: request.channel_id, userId: request.user_id},
'RPC voice_get_token received',
);
if (!this.voiceService) {
throw new Error('Voice is not enabled on this server');
}
const result = await this.voiceService.getVoiceToken({
guildId: request.guild_id !== undefined ? createGuildID(request.guild_id) : undefined,
channelId: createChannelID(request.channel_id),
userId: createUserID(request.user_id),
connectionId: request.connection_id,
region: request.rtc_region,
latitude: request.latitude,
longitude: request.longitude,
canSpeak: request.can_speak,
canStream: request.can_stream,
canVideo: request.can_video,
tokenNonce: request.token_nonce,
});
return {
type: 'voice_get_token',
data: result,
};
}
case 'voice_force_disconnect_participant': {
if (!this.voiceService) {
throw new Error('Voice is not enabled on this server');
}
await this.voiceService.disconnectParticipant({
guildId: request.guild_id !== undefined ? createGuildID(request.guild_id) : undefined,
channelId: createChannelID(request.channel_id),
userId: createUserID(request.user_id),
connectionId: request.connection_id,
});
return {
type: 'voice_force_disconnect_participant',
data: {success: true},
};
}
case 'voice_update_participant': {
if (!this.voiceService) {
throw new Error('Voice is not enabled on this server');
}
await this.voiceService.updateParticipant({
guildId: request.guild_id !== undefined ? createGuildID(request.guild_id) : undefined,
channelId: createChannelID(request.channel_id),
userId: createUserID(request.user_id),
mute: request.mute,
deaf: request.deaf,
});
return {
type: 'voice_update_participant',
data: {success: true},
};
}
case 'voice_force_disconnect_channel': {
if (!this.voiceService) {
throw new Error('Voice is not enabled on this server');
}
const result = await this.voiceService.disconnectChannel({
guildId: request.guild_id !== undefined ? createGuildID(request.guild_id) : undefined,
channelId: createChannelID(request.channel_id),
});
return {
type: 'voice_force_disconnect_channel',
data: {
success: result.success,
disconnected_count: result.disconnectedCount,
message: result.message,
},
};
}
case 'voice_update_participant_permissions': {
if (!this.voiceService) {
throw new Error('Voice is not enabled on this server');
}
await this.voiceService.updateParticipantPermissions({
guildId: request.guild_id !== undefined ? createGuildID(request.guild_id) : undefined,
channelId: createChannelID(request.channel_id),
userId: createUserID(request.user_id),
connectionId: request.connection_id,
canSpeak: request.can_speak,
canStream: request.can_stream,
canVideo: request.can_video,
});
return {
type: 'voice_update_participant_permissions',
data: {success: true},
};
}
case 'kick_temporary_member': {
const success = await this.kickTemporaryMember({
userId: createUserID(request.user_id),
guildIds: request.guild_ids.map(createGuildID),
});
return {
type: 'kick_temporary_member',
data: {success},
};
}
case 'call_ended': {
await this.handleCallEnded({
channelId: createChannelID(request.channel_id),
messageId: createMessageID(request.message_id),
participants: request.participants.map(createUserID),
endedTimestamp: new Date(request.ended_timestamp),
requestCache,
});
return {
type: 'call_ended',
data: {success: true},
};
}
case 'validate_custom_status': {
const userId = createUserID(request.user_id);
const validatedCustomStatus =
request.custom_status === null || request.custom_status === undefined
? null
: await this.customStatusValidator.validate(userId, request.custom_status);
return {
type: 'validate_custom_status',
data: {
custom_status: validatedCustomStatus
? {
text: validatedCustomStatus.text,
expires_at: validatedCustomStatus.expiresAt?.toISOString() ?? null,
emoji_id: validatedCustomStatus.emojiId?.toString() ?? null,
emoji_name: validatedCustomStatus.emojiName,
emoji_animated: validatedCustomStatus.emojiAnimated,
}
: null,
},
};
}
case 'get_dm_channel': {
const channel = await this.getDmChannel({
channelId: createChannelID(request.channel_id),
userId: createUserID(request.user_id),
requestCache,
});
return {
type: 'get_dm_channel',
data: {channel},
};
}
default: {
const exhaustiveCheck: never = request;
throw new Error(`Unknown RPC request type: ${String((exhaustiveCheck as {type?: string}).type ?? 'unknown')}`);
}
}
}
private parseTokenType(token: string): 'user' | 'bot' | 'unknown' {
if (token.startsWith('flx_')) {
return 'user';
}
const dotIndex = token.indexOf('.');
if (dotIndex > 0 && dotIndex < token.length - 1) {
const beforeDot = token.slice(0, dotIndex);
if (/^\d+$/.test(beforeDot)) {
return 'bot';
}
}
return 'unknown';
}
private normalizeSessionToken(token: string): string {
if (token.startsWith('Bot ')) {
return token.slice('Bot '.length);
}
return token;
}
private isUnknownUserError(error: unknown): error is UnknownUserError {
return error instanceof UnknownUserError;
}
private async mapRpcSessionPrivateChannels(params: {
channels: Array<Channel>;
userId: UserID;
requestCache: RequestCache;
}): Promise<Array<ChannelResponse>> {
const {channels, userId, requestCache} = params;
const mappedChannels = await Promise.allSettled(
channels.map((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 Promise.allSettled(
relationships.map((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 mapRpcGuildMembers(params: {
guildId: GuildID;
members: Array<GuildMember>;
requestCache: RequestCache;
}): Promise<Array<GuildMemberResponse>> {
const {guildId, members, requestCache} = params;
const mappedMembers = await Promise.allSettled(
members.map((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 async handleSessionRequest({
token,
version,
requestCache,
ip,
latitude,
longitude,
}: HandleSessionRequestParams): Promise<RpcResponseSessionData> {
const normalizedToken = this.normalizeSessionToken(token);
const tokenHash = createHash('sha256').update(normalizedToken).digest('hex');
const tokenType = this.parseTokenType(normalizedToken);
const tokenHashPrefix = tokenHash.slice(0, 12);
Logger.debug(
{
tokenType,
tokenHashPrefix,
version,
hasIp: ip !== undefined,
hasLatitude: latitude !== undefined,
hasLongitude: longitude !== undefined,
},
'RPC session handling started',
);
const bucketKey = `gateway:rpc:session:${tokenType}:${tokenHash}`;
const rateLimitResult = await this.rateLimitService.checkLimit({
identifier: bucketKey,
maxAttempts: 20,
windowMs: ms('1 minute'),
});
if (!rateLimitResult.allowed) {
Logger.warn(
{
tokenType,
tokenHashPrefix,
retryAfter: rateLimitResult.retryAfter,
limit: rateLimitResult.limit,
resetTime: rateLimitResult.resetTime,
},
'RPC session request rate limited',
);
throw new RateLimitError({
retryAfter: rateLimitResult.retryAfter!,
limit: rateLimitResult.limit,
resetTime: rateLimitResult.resetTime,
});
}
let userId: UserID | null = null;
let authSession: AuthSession | null = null;
if (tokenType === 'user') {
authSession = await this.authService.getAuthSessionByToken(normalizedToken);
if (authSession) {
userId = authSession.userId;
}
} else if (tokenType === 'bot') {
userId = await this.botAuthService.validateBotToken(normalizedToken);
}
if (!userId) {
Logger.warn(
{
tokenType,
tokenHashPrefix,
},
'RPC session token validation failed',
);
throw new UnauthorizedError();
}
const userData = await this.getUserData({userId, includePrivateChannels: false});
if (!userData || !userData.user) {
Logger.warn(
{
tokenType,
tokenHashPrefix,
userId: userId.toString(),
},
'RPC session user lookup failed',
);
throw new UnauthorizedError();
}
let user = userData.user;
if (user.avatarHash && user.avatarColor == null) {
try {
const avatarKey = `avatars/${user.id}/${this.stripAnimationPrefix(user.avatarHash)}`;
const object = await this.storageService.readObject(Config.s3.buckets.cdn, avatarKey);
const avatarColor = await deriveDominantAvatarColor(object);
const updatedUser = await this.userRepository.patchUpsert(user.id, {avatar_color: avatarColor}, user.toRow());
if (updatedUser) {
user = updatedUser;
this.userCacheService.setUserPartialResponseFromUserInBackground(updatedUser, requestCache);
}
} catch (error) {
Logger.warn({userId: user.id, error}, 'Failed to repair user avatar color');
}
}
if (user.bannerHash && user.bannerColor == null) {
try {
const bannerKey = `banners/${user.id}/${this.stripAnimationPrefix(user.bannerHash)}`;
const object = await this.storageService.readObject(Config.s3.buckets.cdn, bannerKey);
const bannerColor = await deriveDominantAvatarColor(object);
const updatedUser = await this.userRepository.patchUpsert(user.id, {banner_color: bannerColor}, user.toRow());
if (updatedUser) {
user = updatedUser;
}
} catch (error) {
Logger.warn({userId: user.id, error}, 'Failed to repair user banner color');
}
}
userData.user = user;
let userSettings = userData.settings;
if (userSettings?.customStatus?.isExpired()) {
userSettings = Object.assign(Object.create(Object.getPrototypeOf(userSettings)), userSettings, {
customStatus: null,
});
userData.settings = userSettings;
}
let flagsToUpdate: bigint | null = null;
if (!(user.flags & UserFlags.HAS_SESSION_STARTED)) {
flagsToUpdate = (flagsToUpdate ?? user.flags) | UserFlags.HAS_SESSION_STARTED;
}
const hadPremium = user.premiumType != null && user.premiumType > 0;
const isPremium = user.isPremium();
const needsPremiumStrip = shouldStripExpiredPremium(user);
const hasBeenSanitized = !!(user.flags & UserFlags.PREMIUM_PERKS_SANITIZED);
const hasInvalidNonLifetimeDiscriminator =
user.discriminator === 0 && user.premiumType !== UserPremiumTypes.LIFETIME;
if (needsPremiumStrip) {
try {
const strippedUser = await this.userRepository.patchUpsert(user.id, createPremiumClearPatch(), user.toRow());
if (strippedUser) {
user = strippedUser;
userData.user = strippedUser;
this.userCacheService.setUserPartialResponseFromUserInBackground(strippedUser, requestCache);
}
} catch (error) {
Logger.warn({userId: user.id.toString(), error}, 'Failed to strip expired premium on RPC session start');
}
}
if (hasInvalidNonLifetimeDiscriminator) {
try {
const discriminatorResult = await this.discriminatorService.generateDiscriminator({
username: user.username,
user,
});
if (discriminatorResult.available && discriminatorResult.discriminator !== -1) {
const updatedUser = await this.userRepository.patchUpsert(
user.id,
{
discriminator: discriminatorResult.discriminator,
},
user.toRow(),
);
if (updatedUser) {
Object.assign(user, updatedUser);
userData.user = user;
this.userCacheService.setUserPartialResponseFromUserInBackground(user, requestCache);
}
}
} catch (error) {
Logger.error(
{userId: user.id.toString(), error},
'Failed to reset invalid non-lifetime discriminator on RPC session start',
);
}
flagsToUpdate = (flagsToUpdate ?? user.flags) & ~UserFlags.PREMIUM_DISCRIMINATOR;
}
if (hadPremium && !isPremium && !hasBeenSanitized) {
if (user.flags & UserFlags.PREMIUM_DISCRIMINATOR) {
try {
const discriminatorResult = await this.discriminatorService.generateDiscriminator({
username: user.username,
user,
});
if (discriminatorResult.available && discriminatorResult.discriminator !== -1) {
const updatedUser = await this.userRepository.patchUpsert(
user.id,
{
discriminator: discriminatorResult.discriminator,
},
user.toRow(),
);
if (updatedUser) {
Object.assign(user, updatedUser);
this.userCacheService.setUserPartialResponseFromUserInBackground(user, requestCache);
}
}
} catch (error) {
Logger.error({userId: user.id.toString(), error}, 'Failed to reset discriminator after premium expired');
}
flagsToUpdate = (flagsToUpdate ?? user.flags) & ~UserFlags.PREMIUM_DISCRIMINATOR;
}
const guildIdsToProcess = userData.guildIds;
try {
const members = await Promise.all(
guildIdsToProcess.map(async (guildId) => {
try {
const member = await this.guildRepository.getMember(guildId, user.id);
return {guildId, member, error: null};
} catch (error) {
Logger.error(
{userId: user.id.toString(), guildId: guildId.toString(), error},
'Failed to fetch guild member for premium sanitization',
);
return {guildId, member: null, error};
}
}),
);
const membersToSanitize = members.filter(
({member, error}) =>
!error &&
member &&
!member.isPremiumSanitized &&
(member.avatarHash || member.bannerHash || member.bio || member.accentColor !== null),
);
if (membersToSanitize.length > 0) {
const updatePromises = membersToSanitize.map(({guildId, member}) =>
this.guildRepository
.upsertMember({
...member!.toRow(),
is_premium_sanitized: true,
})
.then((updatedMember) => ({guildId, updatedMember, error: null})),
);
const updatedResults = await Promise.all(updatePromises);
const dispatchPromises = updatedResults.map(async ({guildId, updatedMember, error}) => {
if (error) return;
try {
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_MEMBER_UPDATE',
data: await mapGuildMemberToResponse(updatedMember!, this.userCacheService, requestCache),
});
} catch (error) {
Logger.error(
{userId: user.id.toString(), guildId: guildId.toString(), error},
'Failed to dispatch guild member update after premium sanitization',
);
}
});
await Promise.all(dispatchPromises);
}
} catch (error) {
Logger.error(
{userId: user.id.toString(), guildIds: guildIdsToProcess.map(String), error},
'Failed to sanitize guild member premium perks for multiple guilds',
);
}
flagsToUpdate = (flagsToUpdate ?? user.flags) | UserFlags.PREMIUM_PERKS_SANITIZED;
await this.gatewayService.dispatchPresence({
userId: user.id,
event: 'USER_UPDATE',
data: {user: mapUserToPrivateResponse(user)},
});
}
if (!user.isBot && (user.flags & UserFlags.BOT_SANITIZED) !== UserFlags.BOT_SANITIZED) {
try {
const sanitized = await this.sanitizeOwnedBotDiscriminators(user);
if (sanitized) {
flagsToUpdate = (flagsToUpdate ?? user.flags) | UserFlags.BOT_SANITIZED;
}
} catch (error) {
Logger.warn(
{userId: user.id.toString(), error},
'Failed to run owned bot discriminator sanitization on RPC session start',
);
}
}
if (!(user.flags & UserFlags.HAS_RELATIONSHIPS_INDEXED)) {
try {
const relationships = userData.relationships;
await this.userRepository.backfillRelationshipsIndex(user.id, relationships);
flagsToUpdate = (flagsToUpdate ?? user.flags) | UserFlags.HAS_RELATIONSHIPS_INDEXED;
} catch (error) {
Logger.warn({userId: user.id, error}, 'Failed to backfill relationships index');
}
}
if (!(user.flags & UserFlags.MESSAGES_BY_AUTHOR_BACKFILLED)) {
try {
await this.channelRepository.backfillMessagesByAuthorIndex(user.id);
flagsToUpdate = (flagsToUpdate ?? user.flags) | UserFlags.MESSAGES_BY_AUTHOR_BACKFILLED;
} catch (error) {
Logger.warn({userId: user.id, error}, 'Failed to backfill messages by author index');
}
}
if (flagsToUpdate !== null && flagsToUpdate !== user.flags) {
await this.userRepository.patchUpsert(
user.id,
{
flags: flagsToUpdate,
},
user.toRow(),
);
}
await this.ensurePersonalNotesChannel(user);
const cachedChannels = await this.ensurePrivateChannelsWithinLimit(user);
userData.privateChannels = cachedChannels ?? (await this.userRepository.listPrivateChannels(user.id));
const mapReadState = (readState: ReadState) => ({
id: readState.channelId.toString(),
mention_count: readState.mentionCount,
last_message_id: readState.lastMessageId?.toString() ?? null,
last_pin_timestamp: readState.lastPinTimestamp?.toISOString() ?? null,
});
let countryCode = 'US';
if (ip) {
const geoip = await lookupGeoip(ip);
countryCode = geoip.countryCode ?? countryCode;
} else {
Logger.warn({context: 'rpc_geoip', reason: 'ip_missing'}, 'RPC session request missing IP for GeoIP');
}
const [privateChannels, relationships] = await Promise.all([
this.mapRpcSessionPrivateChannels({
channels: userData.privateChannels,
userId: user.id,
requestCache,
}),
this.mapRpcSessionRelationships({
relationships: userData.relationships,
userId: user.id,
requestCache,
}),
]);
const voiceAccessContext: VoiceAccessContext = {requestingUserId: user.id};
const availableRegions = this.voiceAvailabilityService?.getAvailableRegions(voiceAccessContext) ?? [];
const accessibleRegions = availableRegions.filter((region) => region.isAccessible);
const sortedRegions = accessibleRegions.slice();
const userLatitude = parseCoordinate(latitude);
const userLongitude = parseCoordinate(longitude);
const hasLocation = userLatitude !== null && userLongitude !== null;
if (hasLocation) {
sortedRegions.sort((a, b) => {
const distanceA = calculateDistance(userLatitude, userLongitude, a.latitude, a.longitude);
const distanceB = calculateDistance(userLatitude, userLongitude, b.latitude, b.longitude);
if (distanceA !== distanceB) {
return distanceA - distanceB;
}
return a.id.localeCompare(b.id);
});
} else {
sortedRegions.sort((a, b) => a.id.localeCompare(b.id));
}
const rtcRegions = sortedRegions.map((region) => ({
id: region.id,
name: region.name,
emoji: region.emoji,
}));
Logger.debug(
{
tokenType,
tokenHashPrefix,
userId: user.id.toString(),
guildCount: userData.guildIds.length,
privateChannelCount: privateChannels.length,
relationshipCount: relationships.length,
countryCode,
rtcRegionCount: rtcRegions.length,
},
'RPC session handling completed',
);
return {
auth_session_id_hash: authSession ? uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true}) : undefined,
user: mapUserToPrivateResponse(user),
user_settings: userData.settings
? mapUserSettingsToResponse({
settings: userData.settings,
memberGuildIds: userData.guildIds,
})
: null,
user_guild_settings: userData.guildSettings.map(mapUserGuildSettingsToResponse),
notes: Object.fromEntries(
Array.from(userData.notes.entries()).map(([userId, note]) => [userId.toString(), note]),
),
read_states: userData.readStates.map(mapReadState),
guild_ids: userData.guildIds.map(String),
private_channels: privateChannels,
relationships,
favorite_memes: userData.favoriteMemes.map(mapFavoriteMemeToResponse),
pinned_dms: userData.pinnedDMs?.map(String) ?? [],
country_code: countryCode,
rtc_regions: rtcRegions,
version,
};
}
private async 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});
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,
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);
return {
...this.createGuildCollectionResponse('guild'),
guild: mapGuildToGuildResponse(repairedEmbedSplashGuild),
};
}
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> {
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 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}: GetUserDataParams): Promise<UserData | null> {
const user = await this.userRepository.findUnique(userId);
if (!user) return null;
if (user.isBot) {
const guildIds = await this.userRepository.getUserGuildIds(userId);
return {
user,
settings: null,
guildSettings: [],
notes: new Map(),
readStates: [],
guildIds,
privateChannels: [],
relationships: [],
favoriteMemes: [],
pinnedDMs: [],
};
}
const [settingsResult, notes, readStates, guildIds, relationships, favoriteMemes, pinnedDMs] = await Promise.all([
this.userRepository.findSettings(userId),
this.userRepository.getUserNotes(userId),
this.readStateService.getReadStates(userId),
this.userRepository.getUserGuildIds(userId),
this.userRepository.listRelationships(userId),
this.favoriteMemeRepository.findByUserId(userId),
this.userRepository.getPinnedDms(userId),
]);
const privateChannels = includePrivateChannels ? await this.userRepository.listPrivateChannels(userId) : [];
let settings = settingsResult;
if (settings) {
const needsIncomingCallRepair = settings.incomingCallFlags === 0;
const needsGroupDmRepair = settings.groupDmAddPermissionFlags === 0;
if (needsIncomingCallRepair || needsGroupDmRepair) {
const isAdult = isUserAdult(user.dateOfBirth);
const updatedRow = {
...settings.toRow(),
...(needsIncomingCallRepair && {
incoming_call_flags: isAdult ? IncomingCallFlags.EVERYONE : IncomingCallFlags.FRIENDS_ONLY,
}),
...(needsGroupDmRepair && {group_dm_add_permission_flags: GroupDmAddPermissionFlags.FRIENDS_ONLY}),
};
await this.userRepository.upsertSettings(updatedRow);
settings = new UserSettings(updatedRow);
}
}
const guildSettings = await this.userRepository.findAllGuildSettings(userId);
return {
user,
settings,
guildSettings,
notes,
readStates,
guildIds,
privateChannels,
relationships,
favoriteMemes,
pinnedDMs,
};
}
private async ensurePrivateChannelsWithinLimit(user: User): Promise<Array<Channel> | null> {
if (user.isBot) {
return [];
}
const channels = await this.userRepository.listPrivateChannels(user.id);
const totalPrivateChannels = channels.length;
const limit = this.resolveLimitForUser(
user ?? null,
'max_private_channels_per_user',
MAX_PRIVATE_CHANNELS_PER_USER,
);
if (totalPrivateChannels <= limit) {
return channels;
}
const closableDms = channels
.filter((channel) => channel.type === ChannelTypes.DM)
.sort((a, b) => {
const aValue = a.lastMessageId ?? 0n;
const bValue = b.lastMessageId ?? 0n;
if (aValue < bValue) return -1;
if (aValue > bValue) return 1;
return 0;
});
const toClose = totalPrivateChannels - limit;
let closed = 0;
for (const channel of closableDms) {
if (closed >= toClose) {
break;
}
await this.userRepository.closeDmForUser(user.id, channel.id);
closed += 1;
}
if (closed < toClose) {
Logger.warn(
{
user_id: user.id.toString(),
total_private_channels: totalPrivateChannels,
required_closures: toClose,
actual_closures: closed,
},
'Unable to close enough DMs to satisfy private channel limit',
);
}
return null;
}
private resolveLimitForUser(user: User | null, key: LimitKey, fallback: number): number {
const ctx = createLimitMatchContext({user});
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback);
}
private async getUserGuildSettings(params: {
userIds: Array<UserID>;
guildId: GuildID;
}): Promise<{user_guild_settings: Array<UserGuildSettings | null>}> {
const {userIds, guildId} = params;
const actualGuildId = guildId === createGuildID(0n) ? null : guildId;
const userGuildSettings = await Promise.all(
userIds.map((userId) => this.userRepository.findGuildSettings(userId, actualGuildId)),
);
return {user_guild_settings: userGuildSettings};
}
private async getPushSubscriptions(params: {
userIds: Array<UserID>;
}): Promise<
Record<string, Array<{subscription_id: string; endpoint: string; p256dh_key: string; auth_key: string}>>
> {
const {userIds} = params;
const subscriptionsMap = await this.userRepository.getBulkPushSubscriptions(userIds);
const result: Record<
string,
Array<{subscription_id: string; endpoint: string; p256dh_key: string; auth_key: string}>
> = {};
for (const [userId, subscriptions] of subscriptionsMap.entries()) {
result[userId.toString()] = subscriptions.map((sub) => ({
subscription_id: sub.subscriptionId,
endpoint: sub.endpoint,
p256dh_key: sub.p256dhKey,
auth_key: sub.authKey,
}));
}
return result;
}
private async getBadgeCounts(params: {userIds: Array<UserID>}): Promise<Record<string, number>> {
const {userIds} = params;
const uniqueUserIds = Array.from(new Set(userIds)) as Array<UserID>;
const allReadStates = await Promise.all(uniqueUserIds.map((userId) => this.readStateService.getReadStates(userId)));
const badgeCounts: Record<string, number> = {};
uniqueUserIds.forEach((userId, index) => {
const readStates = allReadStates[index];
const totalMentions = readStates.reduce((sum, state) => sum + state.mentionCount, 0);
badgeCounts[userId.toString()] = totalMentions;
});
return badgeCounts;
}
private async deletePushSubscriptions(params: {
subscriptions: Array<{userId: UserID; subscriptionId: string}>;
}): Promise<{success: boolean}> {
const {subscriptions} = params;
await Promise.all(
subscriptions.map((sub) => this.userRepository.deletePushSubscription(sub.userId, sub.subscriptionId)),
);
return {success: true};
}
private stripAnimationPrefix(hash: string): string {
return hash.startsWith('a_') ? hash.slice(2) : hash;
}
private async repairDanglingChannelReferences(params: {guild: Guild; channels: Array<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',
);
return this.guildRepository.upsert({
...guild.toRow(),
system_channel_id: danglingSystemChannel ? null : guild.systemChannelId,
rules_channel_id: danglingRulesChannel ? null : guild.rulesChannelId,
afk_channel_id: danglingAfkChannel ? null : guild.afkChannelId,
});
}
private async repairGuildBannerHeight(guild: Guild): Promise<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.upsert({
...guild.toRow(),
banner_height: bannerHeight,
banner_width: bannerWidth,
});
return repairedGuild;
} catch (error) {
Logger.warn({guildId: guild.id, error}, 'Failed to repair guild banner height');
return guild;
}
}
private async repairGuildSplashDimensions(guild: Guild): Promise<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.upsert({
...guild.toRow(),
splash_height: splashHeight,
splash_width: splashWidth,
});
return repairedGuild;
} catch (error) {
Logger.warn({guildId: guild.id, error}, 'Failed to repair guild splash dimensions');
return guild;
}
}
private async repairGuildEmbedSplashDimensions(guild: Guild): Promise<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.upsert({
...guild.toRow(),
embed_splash_height: embedSplashHeight,
embed_splash_width: embedSplashWidth,
});
return repairedGuild;
} catch (error) {
Logger.warn({guildId: guild.id, error}, 'Failed to repair guild embed splash dimensions');
return guild;
}
}
private async repairOrphanedInvitesAndWebhooks(params: {guild: Guild; channels: Array<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 result: Record<string, Array<string>> = {};
const relationshipsPromises = userIds.map(async (userId) => {
const relationships = await this.userRepository.listRelationships(userId);
const blockedIds = relationships.filter((rel) => rel.type === 2).map((rel) => rel.targetUserId.toString());
return {userId, blockedIds};
});
const results = await Promise.all(relationshipsPromises);
for (const {userId, blockedIds} of results) {
result[userId.toString()] = blockedIds;
}
return result;
}
private async kickTemporaryMember(params: {userId: UserID; guildIds: Array<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, requestCache} = params;
const [message, channel] = await Promise.all([
this.channelRepository.getMessage(channelId, createMessageID(messageId)),
this.channelRepository.findUnique(channelId),
]);
if (!message || !channel) {
return;
}
if (message.type !== MessageTypes.CALL) {
return;
}
const messageRow = message.toRow();
const updatedMessage = await this.channelRepository.upsertMessage({
...messageRow,
call: {
participant_ids: new Set(participants),
ended_timestamp: endedTimestamp,
},
});
if (!updatedMessage) {
return;
}
const messageResponse = await mapMessageToResponse({
message: updatedMessage,
userCacheService: this.userCacheService,
requestCache,
mediaService: this.mediaService,
getReferencedMessage: (channelId, messageId) => this.channelRepository.getMessage(channelId, messageId),
});
for (const recipientId of channel.recipientIds) {
await this.gatewayService.dispatchPresence({
userId: recipientId,
event: 'MESSAGE_UPDATE',
data: messageResponse,
});
}
}
private async getDmChannel(params: {
channelId: ChannelID;
userId: UserID;
requestCache: RequestCache;
}): Promise<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;
}
}
}