// SPDX-License-Identifier: AGPL-3.0-or-later import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {GuildFeatures} from '@fluxer/constants/src/GuildConstants'; import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata'; import {MAX_GUILD_EXPRESSION_SLOTS_UNLIMITED, MAX_GUILD_STICKERS} from '@fluxer/constants/src/LimitConstants'; import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError'; import {MaxGuildStickersStaticError} from '@fluxer/errors/src/domains/guild/MaxGuildStickersStaticError'; import {UnknownGuildStickerError} from '@fluxer/errors/src/domains/guild/UnknownGuildStickerError'; import {FluxerError} from '@fluxer/errors/src/FluxerError'; import {getErrorMessageUnsafe} from '@fluxer/errors/src/i18n/ErrorI18n'; import {resolveLimit} from '@fluxer/limits/src/LimitResolver'; import type { GuildStickerResponse, GuildStickerWithUserResponse, } from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas'; import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import {createStickerID, type GuildID, type StickerID, type UserID} from '../../../BrandedTypes'; import {getContentMessage} from '../../../content_i18n/ContentI18n'; import type {AvatarService} from '../../../infrastructure/AvatarService'; import {contentModerationService} from '../../../infrastructure/ContentModerationService'; import type {IGatewayService} from '../../../infrastructure/IGatewayService'; import type {ISnowflakeService} from '../../../infrastructure/ISnowflakeService'; import type {UserCacheService} from '../../../infrastructure/UserCacheService'; import type {LimitConfigService} from '../../../limits/LimitConfigService'; import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder'; import type {RequestCache} from '../../../middleware/RequestCacheMiddleware'; import type {GuildSticker} from '../../../models/GuildSticker'; import type {User} from '../../../models/User'; import {getCachedUserPartialResponse} from '../../../user/UserCacheHelpers'; import {mapGuildStickersWithUsersToResponse, mapGuildStickerToResponse} from '../../GuildModel'; import type {IGuildRepositoryAggregate} from '../../repositories/IGuildRepositoryAggregate'; import type {ContentHelpers} from './ContentHelpers'; import type {ExpressionAssetPurger} from './ExpressionAssetPurger'; export class StickerService { constructor( private readonly guildRepository: IGuildRepositoryAggregate, private readonly userCacheService: UserCacheService, private readonly gatewayService: IGatewayService, private readonly avatarService: AvatarService, private readonly snowflakeService: ISnowflakeService, private readonly contentHelpers: ContentHelpers, private readonly assetPurger: ExpressionAssetPurger, private readonly limitConfigService: LimitConfigService, ) {} private resolveGuildLimit(key: LimitKey, fallback: number, guildFeatures: Iterable | null): number { const ctx = createLimitMatchContext({user: null, guildFeatures}); const resolved = resolveLimit(this.limitConfigService.getConfigSnapshot(), ctx, key, { evaluationContext: 'guild', }); if (!Number.isFinite(resolved) || resolved < 0) { return fallback; } return Math.floor(resolved); } private getLocalizedBulkCreateError(error: unknown, locale: string | null | undefined): string { if (error instanceof FluxerError) { return getErrorMessageUnsafe(error.code, locale, error.messageVariables, error.message); } return getContentMessage('guild.bulk_create.unknown_error', locale); } async getStickers(params: { userId: UserID; guildId: GuildID; requestCache: RequestCache; }): Promise> { const {userId, guildId, requestCache} = params; await this.contentHelpers.getGuildData({userId, guildId}); const stickers = await this.guildRepository.listStickers(guildId); return await mapGuildStickersWithUsersToResponse(stickers, this.userCacheService, requestCache); } async getStickerUser(params: { userId: UserID; guildId: GuildID; stickerId: StickerID; requestCache: RequestCache; }): Promise { const {userId, guildId, stickerId, requestCache} = params; await this.contentHelpers.getGuildData({userId, guildId}); const sticker = await this.guildRepository.getSticker(stickerId, guildId); if (!sticker) throw new UnknownGuildStickerError(); const userPartial = await getCachedUserPartialResponse({ userId: sticker.creatorId, userCacheService: this.userCacheService, requestCache, }); return userPartial; } async createSticker( params: { user: User; guildId: GuildID; name: string; description?: string | null; tags: Array; image: string; }, auditLogReason?: string | null, ): Promise { const {user, guildId, name, description, tags, image} = params; const stickerModCtx = { userId: user.id, guildId, channelId: null, messageId: null, surface: 'sticker' as const, }; contentModerationService.scanText(name, stickerModCtx); contentModerationService.scanText(description, stickerModCtx); contentModerationService.scanText(tags.join(' '), stickerModCtx); const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId}); await this.contentHelpers.checkCreateExpressionsPermission({userId: user.id, guildId}); const allStickers = await this.guildRepository.listStickers(guildId); const stickerCount = allStickers.length; const guildFeatures = guildData.features; const hasUnlimitedStickers = guildFeatures.includes(GuildFeatures.UNLIMITED_STICKERS); const maxStickers = hasUnlimitedStickers ? MAX_GUILD_EXPRESSION_SLOTS_UNLIMITED : this.resolveGuildLimit('max_guild_stickers', MAX_GUILD_STICKERS, guildFeatures); if (stickerCount >= maxStickers) { throw new MaxGuildStickersStaticError(maxStickers); } const { animated, imageBuffer, nsfw: isNsfw, } = await this.avatarService.processSticker({ errorPath: 'image', base64Image: image, }); const stickerId = createStickerID(await this.snowflakeService.generate()); await this.avatarService.uploadSticker({prefix: 'stickers', stickerId, imageBuffer}); const sticker = await this.guildRepository.upsertSticker({ guild_id: guildId, sticker_id: stickerId, name, description: description ?? null, animated, nsfw: isNsfw, tags, creator_id: user.id, version: 1, }); const updatedStickers = [...allStickers, sticker]; await this.dispatchGuildStickersUpdate({guildId, stickers: updatedStickers}); await this.contentHelpers.recordAuditLog({ guildId, userId: user.id, action: AuditLogActionType.STICKER_CREATE, targetId: sticker.id, auditLogReason: auditLogReason ?? null, changes: this.contentHelpers.guildAuditLogService.computeChanges( null, this.contentHelpers.serializeStickerForAudit(sticker), ), }); return mapGuildStickerToResponse(sticker); } async cloneSticker( params: { user: User; guildId: GuildID; sourceStickerId: StickerID; }, auditLogReason?: string | null, ): Promise { const {user, guildId, sourceStickerId} = params; const sourceSticker = await this.guildRepository.getStickerById(sourceStickerId); if (!sourceSticker) throw new UnknownGuildStickerError(); const sourceGuild = await this.guildRepository.findUnique(sourceSticker.guildId); if (!sourceGuild || sourceGuild.features.has(GuildFeatures.CLONE_STICKER_DISABLED)) { throw new MissingAccessError(); } const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId}); await this.contentHelpers.checkCreateExpressionsPermission({userId: user.id, guildId}); const allStickers = await this.guildRepository.listStickers(guildId); const stickerCount = allStickers.length; const guildFeatures = guildData.features; const hasUnlimitedStickers = guildFeatures.includes(GuildFeatures.UNLIMITED_STICKERS); const maxStickers = hasUnlimitedStickers ? MAX_GUILD_EXPRESSION_SLOTS_UNLIMITED : this.resolveGuildLimit('max_guild_stickers', MAX_GUILD_STICKERS, guildFeatures); if (stickerCount >= maxStickers) { throw new MaxGuildStickersStaticError(maxStickers); } const stickerId = createStickerID(await this.snowflakeService.generate()); await this.avatarService.cloneStickerImage({sourceStickerId, stickerId}); const sticker = await this.guildRepository.upsertSticker({ guild_id: guildId, sticker_id: stickerId, name: sourceSticker.name, description: sourceSticker.description, animated: sourceSticker.animated, nsfw: sourceSticker.hasNsfwClassification ? sourceSticker.isNsfw : null, tags: sourceSticker.tags, creator_id: user.id, version: 1, }); const updatedStickers = [...allStickers, sticker]; await this.dispatchGuildStickersUpdate({guildId, stickers: updatedStickers}); await this.contentHelpers.recordAuditLog({ guildId, userId: user.id, action: AuditLogActionType.STICKER_CREATE, targetId: sticker.id, auditLogReason: auditLogReason ?? null, changes: this.contentHelpers.guildAuditLogService.computeChanges( null, this.contentHelpers.serializeStickerForAudit(sticker), ), }); return mapGuildStickerToResponse(sticker); } async bulkCreateStickers( params: { user: User; guildId: GuildID; stickers: Array<{ name: string; description?: string | null; tags: Array; image: string; }>; }, auditLogReason?: string | null, ): Promise<{ success: Array; failed: Array<{ name: string; error: string; }>; }> { const {user, guildId, stickers} = params; const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId}); await this.contentHelpers.checkCreateExpressionsPermission({userId: user.id, guildId}); const allStickers = await this.guildRepository.listStickers(guildId); const guildFeatures = guildData.features; const hasUnlimitedStickers = guildFeatures.includes(GuildFeatures.UNLIMITED_STICKERS); const maxStickers = hasUnlimitedStickers ? MAX_GUILD_EXPRESSION_SLOTS_UNLIMITED : this.resolveGuildLimit('max_guild_stickers', MAX_GUILD_STICKERS, guildFeatures); let stickerCount = allStickers.length; const success: Array = []; const failed: Array<{ name: string; error: string; }> = []; const newStickers: Array = []; for (const stickerData of stickers) { try { if (stickerCount >= maxStickers) { failed.push({ name: stickerData.name, error: getContentMessage('guild.bulk_create.sticker_limit', user.locale, { limit: Math.floor(maxStickers), }), }); continue; } const bulkStickerModCtx = { userId: user.id, guildId, channelId: null, messageId: null, surface: 'sticker' as const, }; contentModerationService.scanText(stickerData.name, bulkStickerModCtx); contentModerationService.scanText(stickerData.description, bulkStickerModCtx); contentModerationService.scanText(stickerData.tags.join(' '), bulkStickerModCtx); const { animated, imageBuffer, nsfw: isNsfw, } = await this.avatarService.processSticker({ errorPath: `stickers[${success.length + failed.length}].image`, base64Image: stickerData.image, }); const stickerId = createStickerID(await this.snowflakeService.generate()); await this.avatarService.uploadSticker({prefix: 'stickers', stickerId, imageBuffer}); const sticker = await this.guildRepository.upsertSticker({ guild_id: guildId, sticker_id: stickerId, name: stickerData.name, description: stickerData.description ?? null, tags: stickerData.tags, animated, nsfw: isNsfw, creator_id: user.id, version: 1, }); stickerCount++; newStickers.push(sticker); success.push(mapGuildStickerToResponse(sticker)); } catch (error) { failed.push({name: stickerData.name, error: this.getLocalizedBulkCreateError(error, user.locale)}); } } if (newStickers.length > 0) { const updatedStickers = [...allStickers, ...newStickers]; await this.dispatchGuildStickersUpdate({guildId, stickers: updatedStickers}); await Promise.all( newStickers.map((sticker) => this.contentHelpers.recordAuditLog({ guildId, userId: user.id, action: AuditLogActionType.STICKER_CREATE, targetId: sticker.id, auditLogReason: auditLogReason ?? null, changes: this.contentHelpers.guildAuditLogService.computeChanges( null, this.contentHelpers.serializeStickerForAudit(sticker), ), }), ), ); } return {success, failed}; } async updateSticker( params: { userId: UserID; guildId: GuildID; stickerId: StickerID; name: string; description?: string | null; tags: Array; }, auditLogReason?: string | null, ): Promise { const {userId, guildId, stickerId, name, description, tags} = params; const stickerModCtx = { userId, guildId, channelId: null, messageId: null, surface: 'sticker' as const, }; contentModerationService.scanText(name, stickerModCtx); contentModerationService.scanText(description, stickerModCtx); contentModerationService.scanText(tags.join(' '), stickerModCtx); const allStickers = await this.guildRepository.listStickers(guildId); const sticker = allStickers.find((e) => e.id === stickerId); if (!sticker) throw new UnknownGuildStickerError(); await this.contentHelpers.checkModifyExpressionPermission({userId, guildId, creatorId: sticker.creatorId}); const previousSnapshot = this.contentHelpers.serializeStickerForAudit(sticker); const updatedSticker = await this.guildRepository.upsertSticker({ ...sticker.toRow(), name, description: description ?? null, tags, }); const updatedStickers = allStickers.map((e) => (e.id === stickerId ? updatedSticker : e)); await this.dispatchGuildStickersUpdate({guildId, stickers: updatedStickers}); await this.contentHelpers.recordAuditLog({ guildId, userId, action: AuditLogActionType.STICKER_UPDATE, targetId: stickerId, auditLogReason: auditLogReason ?? null, changes: this.contentHelpers.guildAuditLogService.computeChanges( previousSnapshot, this.contentHelpers.serializeStickerForAudit(updatedSticker), ), }); return mapGuildStickerToResponse(updatedSticker); } async deleteSticker( params: { userId: UserID; guildId: GuildID; stickerId: StickerID; purge?: boolean; }, auditLogReason?: string | null, ): Promise { const {userId, guildId, stickerId, purge = false} = params; const guildData = await this.contentHelpers.getGuildData({userId, guildId}); if (purge && !guildData.features.includes(GuildFeatures.EXPRESSION_PURGE_ALLOWED)) { throw new MissingAccessError(); } const allStickers = await this.guildRepository.listStickers(guildId); const sticker = allStickers.find((e) => e.id === stickerId); if (!sticker) throw new UnknownGuildStickerError(); await this.contentHelpers.checkModifyExpressionPermission({userId, guildId, creatorId: sticker.creatorId}); const previousSnapshot = this.contentHelpers.serializeStickerForAudit(sticker); await this.guildRepository.deleteSticker(guildId, stickerId); const updatedStickers = allStickers.filter((e) => e.id !== stickerId); await this.dispatchGuildStickersUpdate({guildId, stickers: updatedStickers}); if (purge) { await this.assetPurger.purgeSticker(sticker.id.toString()); } await this.contentHelpers.recordAuditLog({ guildId, userId, action: AuditLogActionType.STICKER_DELETE, targetId: stickerId, auditLogReason: auditLogReason ?? null, changes: this.contentHelpers.guildAuditLogService.computeChanges(previousSnapshot, null), }); } private async dispatchGuildStickersUpdate(params: {guildId: GuildID; stickers: Array}): Promise { const {guildId, stickers} = params; await this.gatewayService.dispatchGuild({ guildId, event: 'GUILD_STICKERS_UPDATE', data: {stickers: stickers.map(mapGuildStickerToResponse)}, }); } }