// SPDX-License-Identifier: AGPL-3.0-or-later import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {ALL_PERMISSIONS, ChannelTypes, DEFAULT_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {DiscoveryApplicationStatus} from '@fluxer/constants/src/DiscoveryConstants'; import { ContentWarningLevel, GuildFeatures, GuildNSFWLevel, GuildSplashCardAlignment, GuildVerificationLevel, JoinSourceTypes, SystemChannelFlags, } from '@fluxer/constants/src/GuildConstants'; import { MAX_GUILD_CHANNELS, MAX_GUILD_ROLES, VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT, } from '@fluxer/constants/src/LimitConstants'; import {DEFAULT_GUILD_FOLDER_ICON} from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError'; import {BotsCannotCreateGuildsError} from '@fluxer/errors/src/domains/guild/BotsCannotCreateGuildsError'; import {GuildTemplateInvalidError} from '@fluxer/errors/src/domains/guild/GuildTemplateInvalidError'; import {MaxGuildsError} from '@fluxer/errors/src/domains/guild/MaxGuildsError'; import {UnclaimedAccountCannotCreateGuildsError} from '@fluxer/errors/src/domains/guild/UnclaimedAccountCannotCreateGuildsError'; import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; import {DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults'; import type {GuildCreateRequest, GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas'; import type {GuildPartialResponse, GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import type { TemplateChannel, TemplateRole, TemplateSerializedGuild, } from '@fluxer/schema/src/domains/guild/GuildTemplateSchemas'; import {extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils'; import type {ChannelID, GuildID, RoleID, UserID} from '../../../BrandedTypes'; import {createChannelID, createGuildID, createRoleID, guildIdToRoleId} from '../../../BrandedTypes'; import type {IChannelRepository} from '../../../channel/IChannelRepository'; import type {ChannelService} from '../../../channel/services/ChannelService'; import {BatchBuilder} from '../../../database/CassandraQueryExecution'; import type {PermissionOverwrite} from '../../../database/types/ChannelTypes'; import type {GuildRow} from '../../../database/types/GuildTypes'; import {contentModerationService} from '../../../infrastructure/ContentModerationService'; import type {EntityAssetService, PreparedAssetUpload} from '../../../infrastructure/EntityAssetService'; import type {IGatewayService} from '../../../infrastructure/IGatewayService'; import type {ISnowflakeService} from '../../../infrastructure/ISnowflakeService'; import type {InviteRepository} from '../../../invite/InviteRepository'; import {Logger} from '../../../Logger'; import type {LimitConfigService} from '../../../limits/LimitConfigService'; import {resolveLimitSafe} from '../../../limits/LimitConfigUtils'; import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder'; import type {RequestCache} from '../../../middleware/RequestCacheMiddleware'; import {Guild} from '../../../models/Guild'; import type {User} from '../../../models/User'; import {getGuildSearchService} from '../../../SearchFactory'; import type {GuildDiscoveryContext} from '../../../search/guild/GuildSearchSerializer'; import {deleteChannelMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup'; import {Channels, ChannelsByGuild, GuildMembers, GuildMembersByUserId, GuildRoles, Guilds} from '../../../Tables'; import type {IUserRepository} from '../../../user/IUserRepository'; import {mapUserSettingsToResponse} from '../../../user/UserMappers'; import {addGuildToUncategorizedFolder, removeGuildFromUserFolders} from '../../../user/utils/GuildFolderUtils'; import type {IWebhookRepository} from '../../../webhook/IWebhookRepository'; import {mapGuildToGuildResponse, mapGuildToPartialResponse} from '../../GuildModel'; import type {IGuildDiscoveryRepository} from '../../repositories/GuildDiscoveryRepository'; import type {IGuildRepositoryAggregate} from '../../repositories/IGuildRepositoryAggregate'; import type {GuildDataHelpers} from './GuildDataHelpers'; const DEFAULT_TEXT_CATEGORY_NAME = 'Text Channels'; const DEFAULT_VOICE_CATEGORY_NAME = 'Voice Channels'; const DEFAULT_TEXT_CHANNEL_NAME = 'general'; const DEFAULT_VOICE_CHANNEL_NAME = 'General'; interface PreparedGuildAssets { icon: PreparedAssetUpload | null; banner: PreparedAssetUpload | null; splash: PreparedAssetUpload | null; embed_splash: PreparedAssetUpload | null; } interface TemplateGuildSettings { verificationLevel: number; explicitContentFilter: number; defaultMessageNotifications: number; systemChannelFlags: number; afkTimeout: number; } interface MappedTemplateChannel { channel: TemplateChannel; channelId: ChannelID; fluxerType: number; } const BASE_GUILD_FEATURES: ReadonlyArray = [ GuildFeatures.ANIMATED_ICON, GuildFeatures.ANIMATED_BANNER, GuildFeatures.BANNER, GuildFeatures.INVITE_SPLASH, ]; const USER_TOGGLEABLE_GUILD_FEATURES: ReadonlySet = new Set([ GuildFeatures.INVITES_DISABLED, GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES, GuildFeatures.DETACHED_BANNER, GuildFeatures.CLONE_EMOJI_DISABLED, GuildFeatures.CLONE_STICKER_DISABLED, GuildFeatures.HIDE_OWNER_CROWN, ]); const SUPPORTED_SYSTEM_CHANNEL_FLAGS = SystemChannelFlags.SUPPRESS_JOIN_NOTIFICATIONS; function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { if (a === b) return true; if (a.size !== b.size) return false; for (const value of a) { if (!b.has(value)) return false; } return true; } const TEMPLATE_AFK_TIMEOUT_MIN_SECONDS = 60; const TEMPLATE_AFK_TIMEOUT_MAX_SECONDS = 3600; const THE_OTHER_PLATFORM_GUILD_ANNOUNCEMENT_CHANNEL_TYPE = 5; const THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE = 13; export class GuildOperationsService { constructor( private readonly guildRepository: IGuildRepositoryAggregate, private readonly channelRepository: IChannelRepository, private readonly inviteRepository: InviteRepository, private readonly channelService: ChannelService, private readonly gatewayService: IGatewayService, private readonly entityAssetService: EntityAssetService, private readonly userRepository: IUserRepository, private readonly snowflakeService: ISnowflakeService, private readonly webhookRepository: IWebhookRepository, private readonly helpers: GuildDataHelpers, private readonly limitConfigService: LimitConfigService, private readonly discoveryRepository: IGuildDiscoveryRepository, ) {} async getGuild({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise { try { const guild = await this.gatewayService.getGuildData({guildId, userId}); if (!guild) throw new UnknownGuildError(); return guild; } catch (error) { if (this.isGuildAccessError(error)) { if (await this.guildExists(guildId)) { throw new AccessDeniedError(); } throw new UnknownGuildError(); } throw error; } } async getUserGuilds( userId: UserID, options?: { before?: GuildID; after?: GuildID; limit?: number; withCounts?: boolean; }, ): Promise> { let guilds = await this.guildRepository.listUserGuilds(userId); guilds.sort((a, b) => (a.id < b.id ? -1 : 1)); if (options?.after) { const index = guilds.findIndex((g) => g.id === options.after); if (index !== -1) { guilds = guilds.slice(index + 1); } } else if (options?.before) { const index = guilds.findIndex((g) => g.id === options.before); if (index !== -1) { guilds = guilds.slice(0, index); } } const limit = options?.limit ?? 200; guilds = guilds.slice(0, limit); const guildIds = guilds.map((g) => g.id); let permissionsMap = new Map(); try { permissionsMap = await this.gatewayService.getUserPermissionsBatch({guildIds, userId}); } catch (error) { Logger.warn( {userId: userId.toString(), guildCount: guildIds.length, error}, '[GuildOperationsService] Failed to fetch guild permissions batch for list_guilds; returning without permissions', ); } const responses = guilds.map((guild) => { const permissions = permissionsMap.get(guild.id); if (permissions == null) { return mapGuildToGuildResponse(guild); } return mapGuildToGuildResponse(guild, {permissions}); }); if (!options?.withCounts) { return responses; } const guildsWithCounts: Array = []; const countBatchSize = 25; for (let index = 0; index < guilds.length; index += countBatchSize) { const guildChunk = guilds.slice(index, index + countBatchSize); const responseChunk = responses.slice(index, index + countBatchSize); const batchResults = await Promise.all( guildChunk.map(async (guild, chunkIndex) => { const baseResponse = responseChunk[chunkIndex] ?? mapGuildToGuildResponse(guild); try { const counts = await this.gatewayService.getGuildCounts(guild.id); return { ...baseResponse, approximate_member_count: counts.memberCount, approximate_presence_count: counts.presenceCount, }; } catch (error) { Logger.warn( {guildId: guild.id.toString(), userId: userId.toString(), error}, '[GuildOperationsService] Failed to fetch guild counts for list_guilds; returning without counts for guild', ); return baseResponse; } }), ); guildsWithCounts.push(...batchResults); } return guildsWithCounts; } async getPublicGuildData(guildId: GuildID): Promise { const guild = await this.guildRepository.findUnique(guildId); if (!guild) throw new UnknownGuildError(); return mapGuildToPartialResponse(guild); } async getGuildSystem(guildId: GuildID): Promise { const guild = await this.guildRepository.findUnique(guildId); if (!guild) throw new UnknownGuildError(); return guild; } async createGuild( params: { user: User; data: GuildCreateRequest; locale?: string | null; }, _auditLogReason?: string | null, ): Promise { const {user, data} = params; if (user.isBot) { throw new BotsCannotCreateGuildsError(); } if (user.isUnclaimedAccount()) { throw new UnclaimedAccountCannotCreateGuildsError(); } const currentGuildCount = await this.guildRepository.countUserGuilds(user.id); const ctx = createLimitMatchContext({user}); const maxGuilds = resolveLimitSafe( this.limitConfigService.getConfigSnapshot(), ctx, 'max_guilds', DEFAULT_STOCK_LIMITS.max_guilds, ); if (currentGuildCount >= maxGuilds) throw new MaxGuildsError(maxGuilds); const guildId = createGuildID(await this.snowflakeService.generate()); contentModerationService.scanText(data.name, { userId: user.id, guildId, channelId: null, messageId: null, surface: 'profile_field', }); let preparedIcon: PreparedAssetUpload | null = null; if (data.icon) { preparedIcon = await this.entityAssetService.prepareAssetUpload({ assetType: 'icon', entityType: 'guild', entityId: guildId, previousHash: null, base64Image: data.icon, errorPath: 'icon', }); } const iconKey = preparedIcon?.newHash ?? null; const shouldUseEmptyFeatures = data.empty_features ?? false; const featuresSet = shouldUseEmptyFeatures ? new Set() : new Set(BASE_GUILD_FEATURES); const templateSettings = this.sanitiseTemplateGuildSettings(data.template); const batch = new BatchBuilder(); let systemChannelId: ChannelID; if (data.template) { const templateBatch = new BatchBuilder(); const templateResult = await this.buildTemplateEntities(guildId, data.template, templateBatch); systemChannelId = templateResult.systemChannelId; await templateBatch.executeChunked(50); } else { const defaultResult = await this.buildDefaultEntities(guildId, batch); systemChannelId = defaultResult.systemChannelId; } const guildData: GuildRow = { guild_id: guildId, owner_id: user.id, name: data.name, vanity_url_code: null, icon_hash: iconKey, banner_hash: null, banner_width: null, banner_height: null, splash_hash: null, splash_width: null, splash_height: null, splash_card_alignment: GuildSplashCardAlignment.CENTER, embed_splash_hash: null, embed_splash_width: null, embed_splash_height: null, features: featuresSet, verification_level: templateSettings.verificationLevel, mfa_level: 0, nsfw_level: 0, nsfw: false, content_warning_level: null, content_warning_text: null, explicit_content_filter: templateSettings.explicitContentFilter, default_message_notifications: templateSettings.defaultMessageNotifications, system_channel_id: systemChannelId, system_channel_flags: templateSettings.systemChannelFlags, rules_channel_id: null, afk_channel_id: null, afk_timeout: templateSettings.afkTimeout, disabled_operations: 0, member_count: 1, audit_logs_indexed_at: null, members_indexed_at: null, message_history_cutoff: null, version: 1, }; batch.addPrepared(Guilds.insert(guildData)); batch.addPrepared( GuildMembers.insert({ guild_id: guildId, user_id: user.id, joined_at: new Date(), nick: null, avatar_hash: null, banner_hash: null, bio: null, pronouns: null, accent_color: null, join_source_type: JoinSourceTypes.CREATOR, source_invite_code: null, inviter_id: null, deaf: false, mute: false, communication_disabled_until: null, role_ids: null, is_premium_sanitized: null, temporary: false, profile_flags: null, mention_flags: null, version: 1, }), ); batch.addPrepared(GuildMembersByUserId.insert({user_id: user.id, guild_id: guildId})); await batch.execute(); const guild = new Guild(guildData); await this.gatewayService.startGuild(guildId); await this.gatewayService.joinGuild({userId: user.id, guildId}); if (!user.isBot) { const userSettings = await this.userRepository.findSettings(user.id); if (userSettings) { const settingsRow = userSettings.toRow(); const {folders: nextFolders, modified} = addGuildToUncategorizedFolder({ folders: settingsRow.guild_folders ?? [], guildId, defaultIcon: DEFAULT_GUILD_FOLDER_ICON, }); if (modified) { settingsRow.guild_folders = nextFolders; const updatedSettings = await this.userRepository.upsertSettings(settingsRow); await this.gatewayService.dispatchPresence({ userId: user.id, event: 'USER_SETTINGS_UPDATE', data: mapUserSettingsToResponse({settings: updatedSettings}), }); } } } const guildSearchService = getGuildSearchService(); if (guildSearchService) { await guildSearchService.indexGuild(guild).catch((error) => { Logger.error({guildId: guild.id, error}, 'Failed to index guild in search'); }); } return mapGuildToGuildResponse(guild); } async updateGuild( params: { userId: UserID; guildId: GuildID; data: GuildUpdateRequest; requestCache: RequestCache; }, auditLogReason?: string | null, ): Promise<{ guild: GuildResponse; previousFeatures: Set; updatedFeatures: Set; }> { const {userId, guildId, data} = params; const {checkPermission, guildData} = await this.helpers.getGuildAuthenticated({userId, guildId}); await checkPermission(Permissions.MANAGE_GUILD); const currentGuild = await this.guildRepository.findUnique(guildId); if (!currentGuild) throw new UnknownGuildError(); const previousSnapshot = this.helpers.serializeGuildForAudit(currentGuild); const previousFeatures = new Set(currentGuild.features); contentModerationService.scanText(data.name ?? null, { userId, guildId, channelId: null, messageId: null, surface: 'profile_field', }); if ( data.verification_level !== undefined && data.verification_level < GuildVerificationLevel.LOW && currentGuild.features.has(GuildFeatures.DISCOVERABLE) ) { throw InputValidationError.fromCode( 'verification_level', ValidationErrorCodes.DISCOVERABLE_GUILD_VERIFICATION_LEVEL_TOO_LOW, ); } const isMfaLevelChange = data.mfa_level !== undefined && data.mfa_level !== currentGuild.mfaLevel; if (isMfaLevelChange) { const isOwner = guildData.owner_id === userId.toString(); if (!isOwner) { throw new MissingPermissionsError(); } const owner = await this.userRepository.findUniqueAssert(userId); if (owner.authenticatorTypes.size === 0) { throw InputValidationError.fromCode( 'mfa_level', ValidationErrorCodes.MUST_ENABLE_2FA_BEFORE_REQUIRING_FOR_MODS, ); } } const updatedFeatures = this.computeUpdatedFeatures(previousFeatures, data.features); const featuresChanged = !setsEqual(previousFeatures, updatedFeatures); const preparedAssets: PreparedGuildAssets = {icon: null, banner: null, splash: null, embed_splash: null}; const patch: Partial = {}; if (data.name !== undefined) { patch.name = data.name; } if (data.icon !== undefined) { preparedAssets.icon = await this.entityAssetService.prepareAssetUpload({ assetType: 'icon', entityType: 'guild', entityId: guildId, previousHash: currentGuild.iconHash, base64Image: data.icon, errorPath: 'icon', }); patch.icon_hash = preparedAssets.icon.newHash; } if (data.banner !== undefined) { if (data.banner && !currentGuild.features.has(GuildFeatures.BANNER)) { await this.rollbackPreparedAssets(preparedAssets); throw InputValidationError.fromCode('banner', ValidationErrorCodes.GUILD_BANNER_REQUIRES_FEATURE); } if (data.banner === null) { patch.banner_hash = null; patch.banner_width = null; patch.banner_height = null; } else { try { preparedAssets.banner = await this.entityAssetService.prepareAssetUpload({ assetType: 'banner', entityType: 'guild', entityId: guildId, previousHash: currentGuild.bannerHash, base64Image: data.banner, errorPath: 'banner', }); if (preparedAssets.banner.isAnimated && !currentGuild.features.has(GuildFeatures.ANIMATED_BANNER)) { await this.rollbackPreparedAssets(preparedAssets); throw InputValidationError.fromCode('banner', ValidationErrorCodes.ANIMATED_GUILD_BANNER_REQUIRES_FEATURE); } patch.banner_hash = preparedAssets.banner.newHash; patch.banner_height = preparedAssets.banner.newHash === currentGuild.bannerHash && currentGuild.bannerHeight != null ? currentGuild.bannerHeight : (preparedAssets.banner.height ?? null); patch.banner_width = preparedAssets.banner.newHash === currentGuild.bannerHash && currentGuild.bannerWidth != null ? currentGuild.bannerWidth : (preparedAssets.banner.width ?? null); } catch (error) { await this.rollbackPreparedAssets(preparedAssets); throw error; } } } if (data.splash !== undefined) { if (data.splash && !currentGuild.features.has(GuildFeatures.INVITE_SPLASH)) { await this.rollbackPreparedAssets(preparedAssets); throw InputValidationError.fromCode('splash', ValidationErrorCodes.INVITE_SPLASH_REQUIRES_FEATURE); } if (data.splash === null) { patch.splash_hash = null; patch.splash_width = null; patch.splash_height = null; } else { try { preparedAssets.splash = await this.entityAssetService.prepareAssetUpload({ assetType: 'splash', entityType: 'guild', entityId: guildId, previousHash: currentGuild.splashHash, base64Image: data.splash, errorPath: 'splash', }); patch.splash_hash = preparedAssets.splash.newHash; patch.splash_height = preparedAssets.splash.newHash === currentGuild.splashHash && currentGuild.splashHeight != null ? currentGuild.splashHeight : (preparedAssets.splash.height ?? null); patch.splash_width = preparedAssets.splash.newHash === currentGuild.splashHash && currentGuild.splashWidth != null ? currentGuild.splashWidth : (preparedAssets.splash.width ?? null); } catch (error) { await this.rollbackPreparedAssets(preparedAssets); throw error; } } } if (data.embed_splash !== undefined) { if (data.embed_splash && !currentGuild.features.has(GuildFeatures.INVITE_SPLASH)) { await this.rollbackPreparedAssets(preparedAssets); throw InputValidationError.fromCode('embed_splash', ValidationErrorCodes.EMBED_SPLASH_REQUIRES_FEATURE); } if (data.embed_splash === null) { patch.embed_splash_hash = null; patch.embed_splash_width = null; patch.embed_splash_height = null; } else { try { preparedAssets.embed_splash = await this.entityAssetService.prepareAssetUpload({ assetType: 'embed_splash', entityType: 'guild', entityId: guildId, previousHash: currentGuild.embedSplashHash, base64Image: data.embed_splash, errorPath: 'embed_splash', }); patch.embed_splash_hash = preparedAssets.embed_splash.newHash; patch.embed_splash_height = preparedAssets.embed_splash.newHash === currentGuild.embedSplashHash && currentGuild.embedSplashHeight != null ? currentGuild.embedSplashHeight : (preparedAssets.embed_splash.height ?? null); patch.embed_splash_width = preparedAssets.embed_splash.newHash === currentGuild.embedSplashHash && currentGuild.embedSplashWidth != null ? currentGuild.embedSplashWidth : (preparedAssets.embed_splash.width ?? null); } catch (error) { await this.rollbackPreparedAssets(preparedAssets); throw error; } } } if (data.splash_card_alignment !== undefined) { patch.splash_card_alignment = data.splash_card_alignment; } if (data.afk_channel_id !== undefined) { if (data.afk_channel_id) { const afkChannelId = createChannelID(data.afk_channel_id); const afkChannel = await this.channelRepository.findUnique(afkChannelId); if (!afkChannel || afkChannel.guildId !== guildId) { throw InputValidationError.fromCode('afk_channel_id', ValidationErrorCodes.AFK_CHANNEL_MUST_BE_IN_GUILD); } if (afkChannel.type !== ChannelTypes.GUILD_VOICE) { throw InputValidationError.fromCode('afk_channel_id', ValidationErrorCodes.AFK_CHANNEL_MUST_BE_VOICE); } patch.afk_channel_id = afkChannelId; } else { patch.afk_channel_id = null; } } if (data.afk_timeout !== undefined) { patch.afk_timeout = data.afk_timeout; } if (data.system_channel_id !== undefined) { if (data.system_channel_id) { const systemChannelId = createChannelID(data.system_channel_id); const systemChannel = await this.channelRepository.findUnique(systemChannelId); if (!systemChannel || systemChannel.guildId !== guildId) { throw InputValidationError.fromCode( 'system_channel_id', ValidationErrorCodes.SYSTEM_CHANNEL_MUST_BE_IN_GUILD, ); } if (systemChannel.type !== ChannelTypes.GUILD_TEXT) { throw InputValidationError.fromCode('system_channel_id', ValidationErrorCodes.SYSTEM_CHANNEL_MUST_BE_TEXT); } patch.system_channel_id = systemChannelId; } else { patch.system_channel_id = null; } } if (data.system_channel_flags !== undefined) { patch.system_channel_flags = data.system_channel_flags & SUPPORTED_SYSTEM_CHANNEL_FLAGS; } if (data.default_message_notifications !== undefined) { patch.default_message_notifications = data.default_message_notifications; } if (data.verification_level !== undefined) { patch.verification_level = data.verification_level; } if (data.mfa_level !== undefined) { patch.mfa_level = data.mfa_level; } let nextNsfw: boolean | undefined; if (data.nsfw !== undefined) { nextNsfw = data.nsfw; } else if (data.nsfw_level !== undefined) { nextNsfw = data.nsfw_level === GuildNSFWLevel.AGE_RESTRICTED; if ( nextNsfw === true && data.content_warning_level === undefined && (currentGuild.contentWarningLevel ?? 0) === ContentWarningLevel.INHERIT ) { patch.content_warning_level = ContentWarningLevel.CONTENT_WARNING; } } if (nextNsfw !== undefined) { patch.nsfw = nextNsfw; patch.nsfw_level = nextNsfw ? GuildNSFWLevel.AGE_RESTRICTED : GuildNSFWLevel.SAFE; } if (data.content_warning_level !== undefined) { patch.content_warning_level = data.content_warning_level === ContentWarningLevel.CONTENT_WARNING ? ContentWarningLevel.CONTENT_WARNING : ContentWarningLevel.INHERIT; } if (data.content_warning_text !== undefined) { const trimmed = data.content_warning_text == null ? null : data.content_warning_text.trim(); patch.content_warning_text = trimmed && trimmed.length > 0 ? trimmed : null; } if (data.explicit_content_filter !== undefined) { patch.explicit_content_filter = data.explicit_content_filter; } if (data.message_history_cutoff !== undefined) { if (data.message_history_cutoff === null) { patch.message_history_cutoff = null; } else { const cutoffDate = new Date(data.message_history_cutoff); const guildCreationTimestamp = extractTimestamp(guildId.toString()); if (cutoffDate.getTime() < guildCreationTimestamp) { await this.rollbackPreparedAssets(preparedAssets); throw InputValidationError.fromCode( 'message_history_cutoff', ValidationErrorCodes.MESSAGE_HISTORY_CUTOFF_BEFORE_GUILD_CREATION, ); } if (cutoffDate.getTime() > Date.now()) { await this.rollbackPreparedAssets(preparedAssets); throw InputValidationError.fromCode( 'message_history_cutoff', ValidationErrorCodes.MESSAGE_HISTORY_CUTOFF_IN_FUTURE, ); } patch.message_history_cutoff = cutoffDate; } } if (featuresChanged) { patch.features = updatedFeatures; } let updatedGuild: Guild; if (Object.keys(patch).length === 0) { updatedGuild = currentGuild; } else { try { updatedGuild = await this.guildRepository.upsertPartial(guildId, patch, currentGuild.toRow()); } catch (error) { await this.rollbackPreparedAssets(preparedAssets); Logger.error({error, guildId}, 'Guild update failed, rolled back asset uploads'); throw error; } } try { await this.commitPreparedAssets(preparedAssets); } catch (error) { Logger.error({error, guildId}, 'Failed to commit asset changes after successful guild update'); } await this.helpers.dispatchGuildUpdate(updatedGuild); const guildSearchService = getGuildSearchService(); if (guildSearchService) { let discoveryContext: GuildDiscoveryContext | undefined; if (updatedGuild.features.has(GuildFeatures.DISCOVERABLE)) { const discoveryRow = await this.discoveryRepository.findByGuildId(updatedGuild.id).catch(() => null); if (discoveryRow?.status === DiscoveryApplicationStatus.APPROVED) { discoveryContext = { description: discoveryRow.description, categoryId: discoveryRow.category_type, primaryLanguage: discoveryRow.primary_language ?? null, tags: discoveryRow.custom_tags ?? [], }; } } await guildSearchService.updateGuild(updatedGuild, discoveryContext).catch((error) => { Logger.error({guildId: updatedGuild.id, error}, 'Failed to update guild in search'); }); } const auditLogChanges = this.helpers.computeGuildChanges(previousSnapshot, updatedGuild); if (auditLogChanges.length > 0) { await this.helpers.recordAuditLog({ guildId, userId, action: AuditLogActionType.GUILD_UPDATE, targetId: guildId, auditLogReason: auditLogReason ?? null, metadata: {name: updatedGuild.name}, changes: auditLogChanges, }); } if (data.name !== undefined && currentGuild.name !== updatedGuild.name) { } if (data.icon !== undefined && currentGuild.iconHash !== updatedGuild.iconHash) { } if (data.banner !== undefined && currentGuild.bannerHash !== updatedGuild.bannerHash) { } return { guild: mapGuildToGuildResponse(updatedGuild), previousFeatures, updatedFeatures: new Set(updatedGuild.features), }; } private computeUpdatedFeatures( previousFeatures: ReadonlySet, desiredFeatures: ReadonlyArray | undefined, ): Set { if (!desiredFeatures) { return new Set(previousFeatures); } const desired = new Set(desiredFeatures); const next = new Set(previousFeatures); for (const feature of desired) { if (previousFeatures.has(feature)) continue; if (!USER_TOGGLEABLE_GUILD_FEATURES.has(feature)) { throw InputValidationError.fromCode('features', ValidationErrorCodes.GUILD_FEATURE_NOT_TOGGLEABLE, { feature, }); } next.add(feature); } for (const feature of previousFeatures) { if (desired.has(feature)) continue; if (!USER_TOGGLEABLE_GUILD_FEATURES.has(feature)) continue; next.delete(feature); } return next; } private async rollbackPreparedAssets(assets: PreparedGuildAssets): Promise { const rollbackPromises: Array> = []; if (assets.icon) { rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.icon)); } if (assets.banner) { rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.banner)); } if (assets.splash) { rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.splash)); } if (assets.embed_splash) { rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.embed_splash)); } await Promise.all(rollbackPromises); } private async commitPreparedAssets(assets: PreparedGuildAssets): Promise { const commitPromises: Array> = []; if (assets.icon) { commitPromises.push(this.entityAssetService.commitAssetChange({prepared: assets.icon, deferDeletion: true})); } if (assets.banner) { commitPromises.push(this.entityAssetService.commitAssetChange({prepared: assets.banner, deferDeletion: true})); } if (assets.splash) { commitPromises.push(this.entityAssetService.commitAssetChange({prepared: assets.splash, deferDeletion: true})); } if (assets.embed_splash) { commitPromises.push( this.entityAssetService.commitAssetChange({prepared: assets.embed_splash, deferDeletion: true}), ); } await Promise.all(commitPromises); } async deleteGuild( params: { user: User; guildId: GuildID; }, _auditLogReason?: string | null, ): Promise { const {user, guildId} = params; const {guildData} = await this.helpers.getGuildAuthenticated({userId: user.id, guildId}); if (!guildData || guildData.owner_id !== user.id.toString()) { throw new MissingPermissionsError(); } await this.performGuildDeletion(guildId); } async deleteGuildById(guildId: GuildID): Promise { await this.performGuildDeletion(guildId); } private async performGuildDeletion(guildId: GuildID): Promise { const guild = await this.guildRepository.findUnique(guildId); if (!guild) { throw new UnknownGuildError(); } const members = await this.guildRepository.listMembers(guildId); await this.gatewayService.dispatchGuild({ guildId, event: 'GUILD_DELETE', data: {id: guildId.toString()}, }); await Promise.all( members.map(async (member) => { await this.gatewayService.leaveGuild({userId: member.userId, guildId}); }), ); await Promise.all(members.map((member) => this.userRepository.deleteGuildSettings(member.userId, guildId))); await Promise.all( members.map(async (member) => { const user = await this.userRepository.findUnique(member.userId); if (user && !user.isBot) { await removeGuildFromUserFolders({ userId: member.userId, guildId, userRepository: this.userRepository, gatewayService: this.gatewayService, }); } }), ); const invites = await this.inviteRepository.listGuildInvites(guildId); await Promise.all(invites.map((invite) => this.inviteRepository.delete(invite.code))); const webhooks = await this.webhookRepository.listByGuild(guildId); await Promise.all(webhooks.map((webhook) => this.webhookRepository.delete(webhook.id))); const channels = await this.channelRepository.listGuildChannels(guildId); await Promise.all(channels.map((channel) => this.channelRepository.deleteAllChannelMessages(channel.id))); await Promise.all( channels.map((channel) => deleteChannelMessageSearchDocuments(channel.id, {context: {source: 'guild_delete'}})), ); await Promise.all(channels.map((channel) => this.channelService.attachments.purgeChannelAttachments(channel))); const discoveryRow = await this.discoveryRepository.findByGuildId(guildId); if (discoveryRow) { await this.discoveryRepository.deleteByGuildId(guildId, discoveryRow.status, discoveryRow.applied_at); } await this.guildRepository.delete(guildId, guild.ownerId); await this.gatewayService.stopGuild(guildId); const guildSearchService = getGuildSearchService(); if (guildSearchService) { await guildSearchService.deleteGuild(guildId).catch((error) => { Logger.error({guildId, error}, 'Failed to delete guild from search'); }); } } private async buildDefaultEntities( guildId: GuildID, batch: BatchBuilder, ): Promise<{ systemChannelId: ChannelID; }> { const textCategoryId = createChannelID(await this.snowflakeService.generate()); const voiceCategoryId = createChannelID(await this.snowflakeService.generate()); const generalChannelId = createChannelID(await this.snowflakeService.generate()); const generalVoiceId = createChannelID(await this.snowflakeService.generate()); const addChannel = ( channelId: ChannelID, type: number, name: string, parentId: ChannelID | null, position: number, bitrate: number | null = null, ) => { batch.addPrepared( Channels.insert({ channel_id: channelId, guild_id: guildId, type, name, topic: null, icon_hash: null, url: null, parent_id: parentId, position, owner_id: null, recipient_ids: null, nsfw: false, content_warning_level: null, content_warning_text: null, rate_limit_per_user: 0, bitrate, user_limit: bitrate !== null ? 0 : null, voice_connection_limit: bitrate !== null ? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT : 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, }), ); batch.addPrepared( ChannelsByGuild.upsertAll({ guild_id: guildId, channel_id: channelId, }), ); }; addChannel(textCategoryId, ChannelTypes.GUILD_CATEGORY, DEFAULT_TEXT_CATEGORY_NAME, null, 0); addChannel(voiceCategoryId, ChannelTypes.GUILD_CATEGORY, DEFAULT_VOICE_CATEGORY_NAME, null, 1); addChannel(generalChannelId, ChannelTypes.GUILD_TEXT, DEFAULT_TEXT_CHANNEL_NAME, textCategoryId, 0); addChannel(generalVoiceId, ChannelTypes.GUILD_VOICE, DEFAULT_VOICE_CHANNEL_NAME, voiceCategoryId, 0, 64000); batch.addPrepared( GuildRoles.insert({ guild_id: guildId, role_id: guildIdToRoleId(guildId), name: '@everyone', permissions: DEFAULT_PERMISSIONS, position: 0, hoist_position: null, color: 0, icon_hash: null, unicode_emoji: null, hoist: false, mentionable: false, version: 1, }), ); return {systemChannelId: generalChannelId}; } private async buildTemplateEntities( guildId: GuildID, template: TemplateSerializedGuild, batch: BatchBuilder, ): Promise<{ systemChannelId: ChannelID; }> { if (template.channels.length > MAX_GUILD_CHANNELS) { throw new GuildTemplateInvalidError(); } if (template.roles.length > MAX_GUILD_ROLES) { throw new GuildTemplateInvalidError(); } const roleIdMap = new Map(); const channelIdMap = new Map(); const channelTypeMap = new Map(); const everyoneRole = this.findTemplateEveryoneRole(template.roles); const everyoneRoleKey = everyoneRole ? this.getTemplateEntityKey(everyoneRole.id) : null; const everyoneRoleId = guildIdToRoleId(guildId); roleIdMap.set('0', everyoneRoleId); if (everyoneRoleKey) { roleIdMap.set(everyoneRoleKey, everyoneRoleId); } const seenRoleKeys = new Set(); for (const role of template.roles) { const roleKey = this.getTemplateEntityKey(role.id); if (seenRoleKeys.has(roleKey)) { throw new GuildTemplateInvalidError(); } seenRoleKeys.add(roleKey); if (roleKey === '0' || roleKey === everyoneRoleKey) continue; roleIdMap.set(roleKey, createRoleID(await this.snowflakeService.generate())); } const seenChannelKeys = new Set(); const mappedChannels: Array = []; const skippedOtherPlatformChannelTypes = new Set(); for (const channel of template.channels) { const channelKey = this.getTemplateEntityKey(channel.id); if (seenChannelKeys.has(channelKey)) { throw new GuildTemplateInvalidError(); } seenChannelKeys.add(channelKey); const fluxerType = this.mapOtherPlatformTemplateChannelTypeToFluxer(channel.type); if (fluxerType == null) { skippedOtherPlatformChannelTypes.add(channel.type); continue; } const channelId = createChannelID(await this.snowflakeService.generate()); channelIdMap.set(channelKey, channelId); channelTypeMap.set(channelKey, fluxerType); mappedChannels.push({channel, channelId, fluxerType}); } if (skippedOtherPlatformChannelTypes.size > 0) { Logger.warn( { guildId: guildId.toString(), unsupportedOtherPlatformChannelTypes: Array.from(skippedOtherPlatformChannelTypes), }, '[GuildOperationsService] Template import skipped unsupported channel types', ); } const everyonePermissions = this.parseTemplatePermissionBitfield(everyoneRole?.permissions_new ?? everyoneRole?.permissions) || DEFAULT_PERMISSIONS; batch.addPrepared( GuildRoles.insert({ guild_id: guildId, role_id: everyoneRoleId, name: '@everyone', permissions: everyonePermissions, position: 0, hoist_position: null, color: 0, icon_hash: null, unicode_emoji: null, hoist: false, mentionable: false, version: 1, }), ); let nextRolePosition = 1; for (const role of template.roles) { const roleKey = this.getTemplateEntityKey(role.id); if (roleKey === '0' || roleKey === everyoneRoleKey) continue; const roleId = roleIdMap.get(roleKey); if (!roleId) continue; batch.addPrepared( GuildRoles.insert({ guild_id: guildId, role_id: roleId, name: role.name, permissions: this.parseTemplatePermissionBitfield(role.permissions_new ?? role.permissions), position: nextRolePosition, hoist_position: null, color: role.color ?? 0, icon_hash: null, unicode_emoji: role.unicode_emoji ?? null, hoist: role.hoist ?? false, mentionable: role.mentionable ?? false, version: 1, }), ); nextRolePosition += 1; } let firstTextChannelId: ChannelID | null = null; for (const mappedChannel of mappedChannels) { const {channel, channelId, fluxerType} = mappedChannel; const parentKey = channel.parent_id != null ? this.getTemplateEntityKey(channel.parent_id) : null; const parentType = parentKey != null ? channelTypeMap.get(parentKey) : null; const parentId = parentKey != null && parentType === ChannelTypes.GUILD_CATEGORY ? (channelIdMap.get(parentKey) ?? null) : null; const isVoice = fluxerType === ChannelTypes.GUILD_VOICE; let permissionOverwrites: Map | null = null; if (channel.permission_overwrites && channel.permission_overwrites.length > 0) { permissionOverwrites = new Map(); for (const overwrite of channel.permission_overwrites) { if (overwrite.type !== 0) continue; const mappedRoleId = roleIdMap.get(this.getTemplateEntityKey(overwrite.id)); if (!mappedRoleId) continue; const allow = this.parseTemplatePermissionBitfield(overwrite.allow); const deny = this.parseTemplatePermissionBitfield(overwrite.deny); permissionOverwrites.set(mappedRoleId, { type: overwrite.type, allow_: allow, deny_: deny, }); } if (permissionOverwrites.size === 0) { permissionOverwrites = null; } } if (fluxerType === ChannelTypes.GUILD_TEXT && !firstTextChannelId) { firstTextChannelId = channelId; } batch.addPrepared( Channels.insert({ channel_id: channelId, guild_id: guildId, type: fluxerType, name: channel.name, topic: channel.topic ?? null, icon_hash: null, url: null, parent_id: parentId, position: channel.position, owner_id: null, recipient_ids: null, nsfw: channel.nsfw ?? false, content_warning_level: null, content_warning_text: null, rate_limit_per_user: channel.rate_limit_per_user ?? 0, bitrate: isVoice ? (channel.bitrate ?? 64000) : null, user_limit: isVoice ? (channel.user_limit ?? 0) : null, voice_connection_limit: isVoice ? (channel.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT) : null, rtc_region: null, last_message_id: null, last_pin_timestamp: null, permission_overwrites: permissionOverwrites, nicks: null, soft_deleted: false, indexed_at: null, version: 1, }), ); batch.addPrepared( ChannelsByGuild.upsertAll({ guild_id: guildId, channel_id: channelId, }), ); } let systemChannelId: ChannelID | null = null; if (template.system_channel_id != null) { const templateSystemChannelKey = this.getTemplateEntityKey(template.system_channel_id); const templateSystemChannelType = channelTypeMap.get(templateSystemChannelKey); if (templateSystemChannelType === ChannelTypes.GUILD_TEXT) { systemChannelId = channelIdMap.get(templateSystemChannelKey) ?? null; } } if (!systemChannelId && firstTextChannelId) { systemChannelId = firstTextChannelId; } if (!systemChannelId) { systemChannelId = createChannelID(await this.snowflakeService.generate()); batch.addPrepared( Channels.insert({ channel_id: systemChannelId, guild_id: guildId, type: ChannelTypes.GUILD_TEXT, name: DEFAULT_TEXT_CHANNEL_NAME, topic: null, icon_hash: null, url: null, parent_id: null, position: 0, owner_id: null, recipient_ids: null, nsfw: false, content_warning_level: null, content_warning_text: null, rate_limit_per_user: 0, bitrate: null, user_limit: null, voice_connection_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, }), ); batch.addPrepared( ChannelsByGuild.upsertAll({ guild_id: guildId, channel_id: systemChannelId, }), ); } return {systemChannelId}; } private parseTemplatePermissionBitfield(value: string | number | undefined): bigint { const valueToParse = value ?? '0'; try { return BigInt(valueToParse) & ALL_PERMISSIONS; } catch { throw new GuildTemplateInvalidError(); } } private getTemplateEntityKey(id: number | string): string { return String(id); } private findTemplateEveryoneRole(roles: Array): TemplateRole | null { const namedEveryoneRole = roles.find((role) => role.name === '@everyone'); if (namedEveryoneRole) { return namedEveryoneRole; } return roles.find((role) => this.getTemplateEntityKey(role.id) === '0') ?? null; } private mapOtherPlatformTemplateChannelTypeToFluxer(channelType: number): number | null { if ( channelType === ChannelTypes.GUILD_TEXT || channelType === ChannelTypes.GUILD_VOICE || channelType === ChannelTypes.GUILD_CATEGORY ) { return channelType; } if (channelType === THE_OTHER_PLATFORM_GUILD_ANNOUNCEMENT_CHANNEL_TYPE) { return ChannelTypes.GUILD_TEXT; } if (channelType === THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE) { return ChannelTypes.GUILD_VOICE; } return null; } private sanitiseTemplateGuildSettings(template?: TemplateSerializedGuild): TemplateGuildSettings { return { verificationLevel: this.clampTemplateSetting(template?.verification_level, 0, 4, 0), explicitContentFilter: this.clampTemplateSetting(template?.explicit_content_filter, 0, 2, 0), defaultMessageNotifications: this.clampTemplateSetting(template?.default_message_notifications, 0, 1, 0), systemChannelFlags: (template?.system_channel_flags ?? 0) & SUPPORTED_SYSTEM_CHANNEL_FLAGS, afkTimeout: this.clampTemplateSetting( template?.afk_timeout, TEMPLATE_AFK_TIMEOUT_MIN_SECONDS, TEMPLATE_AFK_TIMEOUT_MAX_SECONDS, 300, ), }; } private clampTemplateSetting(value: number | undefined, min: number, max: number, fallback: number): number { if (value == null || Number.isNaN(value)) { return fallback; } const integerValue = Math.trunc(value); if (integerValue < min) return min; if (integerValue > max) return max; return integerValue; } private isGuildAccessError(error: unknown): boolean { return error instanceof UnknownGuildError; } private async guildExists(guildId: GuildID): Promise { const guild = await this.guildRepository.findUnique(guildId); return guild !== null; } }