/* * 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 . */ import type {EmojiID, GuildID, StickerID, UserID} from '@fluxer/api/src/BrandedTypes'; import {createEmojiID, createGuildID, createStickerID} from '@fluxer/api/src/BrandedTypes'; import { mapGuildEmojisWithUsersToResponse, mapGuildEmojiToResponse, mapGuildStickersWithUsersToResponse, mapGuildStickerToResponse, } from '@fluxer/api/src/guild/GuildModel'; import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate'; import type {ExpressionAssetPurger} from '@fluxer/api/src/guild/services/content/ExpressionAssetPurger'; import type {AvatarService} from '@fluxer/api/src/infrastructure/AvatarService'; import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService'; import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService'; import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService'; 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 {ExpressionPack} from '@fluxer/api/src/models/ExpressionPack'; import type {User} from '@fluxer/api/src/models/User'; import type { PackExpressionAccessResolution, PackExpressionAccessResolver, PackExpressionAccessResolverParams, } from '@fluxer/api/src/pack/PackExpressionAccessResolver'; import {mapPackToSummary} from '@fluxer/api/src/pack/PackModel'; import type {PackRepository, PackType} from '@fluxer/api/src/pack/PackRepository'; import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository'; import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata'; import { MAX_CREATED_PACKS_NON_PREMIUM, MAX_INSTALLED_PACKS_NON_PREMIUM, MAX_PACK_EXPRESSIONS, } from '@fluxer/constants/src/LimitConstants'; import {ManagedTraits} from '@fluxer/constants/src/ManagedTraits'; import {FeatureAccessError} from '@fluxer/errors/src/domains/core/FeatureAccessError'; import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError'; import {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGuildEmojiError'; import {UnknownGuildStickerError} from '@fluxer/errors/src/domains/guild/UnknownGuildStickerError'; import {InvalidPackTypeError} from '@fluxer/errors/src/domains/pack/InvalidPackTypeError'; import {MaxPackExpressionsError} from '@fluxer/errors/src/domains/pack/MaxPackExpressionsError'; import {MaxPackLimitError} from '@fluxer/errors/src/domains/pack/MaxPackLimitError'; import {PackAccessDeniedError} from '@fluxer/errors/src/domains/pack/PackAccessDeniedError'; import {UnknownPackError} from '@fluxer/errors/src/domains/pack/UnknownPackError'; import type { GuildEmojiResponse, GuildEmojiWithUserResponse, GuildStickerResponse, GuildStickerWithUserResponse, } from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas'; import type {PackDashboardResponse, PackDashboardSectionResponse} from '@fluxer/schema/src/domains/pack/PackSchemas'; export class PackService { constructor( private readonly packRepository: PackRepository, private readonly guildRepository: IGuildRepositoryAggregate, private readonly avatarService: AvatarService, private readonly snowflakeService: SnowflakeService, private readonly assetPurger: ExpressionAssetPurger, private readonly userRepository: IUserRepository, private readonly userCacheService: UserCacheService, private readonly limitConfigService: LimitConfigService, ) {} private async requireExpressionPackAccess(userId: UserID): Promise { const user = await this.userRepository.findUnique(userId); if (!user || !user.traits.has(ManagedTraits.EXPRESSION_PACKS)) { throw new FeatureTemporarilyDisabledError(); } } private async hasFeatureAccess(userId: UserID, limitKey: LimitKey): Promise { const user = await this.userRepository.findUnique(userId); if (!user) { return false; } const ctx = createLimitMatchContext({user}); const value = resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, limitKey, 0); return value > 0; } private async ensurePackOwner(userId: UserID, packId: GuildID): Promise { const pack = await this.packRepository.getPack(packId); if (!pack) { throw new UnknownPackError(); } if (pack.creatorId !== userId) { throw new PackAccessDeniedError(); } return pack; } private async collectInstalledPacks(userId: UserID): Promise> { const installations = await this.packRepository.listInstallations(userId); const results: Array<{pack: ExpressionPack; installedAt: Date}> = []; for (const row of installations) { const pack = await this.packRepository.getPack(row.pack_id); if (!pack) { await this.packRepository.removeInstallation(userId, row.pack_id); continue; } results.push({pack, installedAt: row.installed_at}); } return results; } private async requireFeature(userId: UserID, limitKey: LimitKey): Promise { if (!(await this.hasFeatureAccess(userId, limitKey))) { throw new FeatureAccessError(); } } private async getInstalledPackIdsByType(userId: UserID, packType: PackType): Promise> { const installations = await this.packRepository.listInstallations(userId); return new Set(installations.filter((row) => row.pack_type === packType).map((row) => row.pack_id)); } private buildPackExpressionAccessResolution( userId: UserID, packType: PackType, pack: ExpressionPack | null, ): PackExpressionAccessResolution { if (!pack) { return 'not-pack'; } if (pack.type !== packType) { return 'not-pack'; } return pack.creatorId === userId ? 'accessible' : 'not-accessible'; } async createPackExpressionAccessResolver( params: PackExpressionAccessResolverParams, ): Promise { const {userId, type} = params; if (!userId) { return { resolve: async () => 'not-pack', }; } const installedPackIds = await this.getInstalledPackIdsByType(userId, type); const resolutionCache = new Map(); return { resolve: async (packId: GuildID) => { if (installedPackIds.has(packId)) { resolutionCache.set(packId, 'accessible'); return 'accessible'; } const cached = resolutionCache.get(packId); if (cached) { return cached; } const pack = await this.packRepository.getPack(packId); const resolution = this.buildPackExpressionAccessResolution(userId, type, pack); resolutionCache.set(packId, resolution); return resolution; }, }; } async listUserPacks(userId: UserID): Promise { await this.requireExpressionPackAccess(userId); const user = await this.userRepository.findUnique(userId); const ctx = createLimitMatchContext({user}); resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, 'feature_global_expressions', 0); const createdEmoji = await this.packRepository.listPacksByCreator(userId, 'emoji'); const createdSticker = await this.packRepository.listPacksByCreator(userId, 'sticker'); const installations = await this.collectInstalledPacks(userId); const installedEmoji = installations .filter((entry) => entry.pack.type === 'emoji') .map((entry) => mapPackToSummary(entry.pack, entry.installedAt)); const installedSticker = installations .filter((entry) => entry.pack.type === 'sticker') .map((entry) => mapPackToSummary(entry.pack, entry.installedAt)); const fallbackCreatedLimit = MAX_CREATED_PACKS_NON_PREMIUM; const fallbackInstalledLimit = MAX_INSTALLED_PACKS_NON_PREMIUM; const createdLimit = this.resolveLimitForUser(user ?? null, 'max_created_packs', fallbackCreatedLimit); const installedLimit = this.resolveLimitForUser(user ?? null, 'max_installed_packs', fallbackInstalledLimit); const emojiSection: PackDashboardSectionResponse = { installed_limit: installedLimit, created_limit: createdLimit, installed: installedEmoji, created: createdEmoji.map((pack) => mapPackToSummary(pack)), }; const stickerSection: PackDashboardSectionResponse = { installed_limit: installedLimit, created_limit: createdLimit, installed: installedSticker, created: createdSticker.map((pack) => mapPackToSummary(pack)), }; return { emoji: emojiSection, sticker: stickerSection, }; } async createPack(params: { user: User; type: PackType; name: string; description?: string | null; }): Promise { await this.requireExpressionPackAccess(params.user.id); await this.requireFeature(params.user.id, 'feature_global_expressions'); const createdCount = await this.packRepository.countPacksByCreator(params.user.id, params.type); const fallbackLimit = MAX_CREATED_PACKS_NON_PREMIUM; const limit = this.resolveLimitForUser(params.user, 'max_created_packs', fallbackLimit); if (createdCount >= limit) { throw new MaxPackLimitError(params.type, limit, 'create'); } const now = new Date(); const packId = createGuildID(await this.snowflakeService.generate()); return await this.packRepository.upsertPack({ pack_id: packId, pack_type: params.type, creator_id: params.user.id, name: params.name, description: params.description ?? null, created_at: now, updated_at: now, version: 1, }); } async updatePack(params: { userId: UserID; packId: GuildID; name?: string; description?: string | null; }): Promise { await this.requireExpressionPackAccess(params.userId); const pack = await this.ensurePackOwner(params.userId, params.packId); const now = new Date(); const updatedPack = new ExpressionPack({ ...pack.toRow(), name: params.name ?? pack.name, description: params.description === undefined ? pack.description : params.description, updated_at: now, }); return await this.packRepository.upsertPack(updatedPack.toRow()); } async deletePack(userId: UserID, packId: GuildID): Promise { await this.requireExpressionPackAccess(userId); await this.ensurePackOwner(userId, packId); await this.packRepository.deletePack(packId); } async installPack(userId: UserID, packId: GuildID): Promise { await this.requireExpressionPackAccess(userId); const pack = await this.packRepository.getPack(packId); if (!pack) { throw new UnknownPackError(); } const alreadyInstalled = await this.packRepository.hasInstallation(userId, packId); if (alreadyInstalled) { return; } await this.requireFeature(userId, 'feature_global_expressions'); const user = await this.userRepository.findUnique(userId); const fallbackLimit = MAX_INSTALLED_PACKS_NON_PREMIUM; const limit = this.resolveLimitForUser(user ?? null, 'max_installed_packs', fallbackLimit); const installations = await this.collectInstalledPacks(userId); const typeCount = installations.filter((entry) => entry.pack.type === pack.type).length; if (typeCount >= limit) { throw new MaxPackLimitError(pack.type, limit, 'install'); } await this.packRepository.addInstallation({ user_id: userId, pack_id: packId, pack_type: pack.type, installed_at: new Date(), }); } async uninstallPack(userId: UserID, packId: GuildID): Promise { await this.requireExpressionPackAccess(userId); await this.packRepository.removeInstallation(userId, packId); } async getInstalledPackIds(userId: UserID): Promise> { await this.requireExpressionPackAccess(userId); const installations = await this.collectInstalledPacks(userId); return new Set(installations.map((entry) => entry.pack.id)); } async getPackEmojis(params: { userId: UserID; packId: GuildID; requestCache: RequestCache; }): Promise> { await this.requireExpressionPackAccess(params.userId); const pack = await this.ensurePackOwner(params.userId, params.packId); if (pack.type !== 'emoji') { throw new InvalidPackTypeError('emoji'); } const emojis = await this.guildRepository.listEmojis(pack.id); return await mapGuildEmojisWithUsersToResponse(emojis, this.userCacheService, params.requestCache); } async getPackStickers(params: { userId: UserID; packId: GuildID; requestCache: RequestCache; }): Promise> { await this.requireExpressionPackAccess(params.userId); const pack = await this.ensurePackOwner(params.userId, params.packId); if (pack.type !== 'sticker') { throw new InvalidPackTypeError('sticker'); } const stickers = await this.guildRepository.listStickers(pack.id); return await mapGuildStickersWithUsersToResponse(stickers, this.userCacheService, params.requestCache); } async createPackEmoji(params: { user: User; packId: GuildID; name: string; image: string; }): Promise { await this.requireExpressionPackAccess(params.user.id); await this.requireFeature(params.user.id, 'feature_global_expressions'); const pack = await this.ensurePackOwner(params.user.id, params.packId); if (pack.type !== 'emoji') { throw new InvalidPackTypeError('emoji'); } const emojiCount = await this.guildRepository.countEmojis(pack.id); const expressionLimit = this.resolveLimitForUser(params.user, 'max_pack_expressions', MAX_PACK_EXPRESSIONS); if (emojiCount >= expressionLimit) { throw new MaxPackExpressionsError(expressionLimit); } const {animated, imageBuffer, contentType} = await this.avatarService.processEmoji({ errorPath: 'image', base64Image: params.image, }); const emojiId = createEmojiID(await this.snowflakeService.generate()); await this.avatarService.uploadEmoji({ prefix: 'emojis', emojiId, imageBuffer, contentType, }); const emoji = await this.guildRepository.upsertEmoji({ guild_id: pack.id, emoji_id: emojiId, name: params.name, creator_id: params.user.id, animated, version: 1, }); return mapGuildEmojiToResponse(emoji); } async bulkCreatePackEmojis(params: { user: User; packId: GuildID; emojis: Array<{name: string; image: string}>; }): Promise<{success: Array; failed: Array<{name: string; error: string}>}> { await this.requireExpressionPackAccess(params.user.id); await this.requireFeature(params.user.id, 'feature_global_expressions'); const pack = await this.ensurePackOwner(params.user.id, params.packId); if (pack.type !== 'emoji') { throw new InvalidPackTypeError('emoji'); } let emojiCount = await this.guildRepository.countEmojis(pack.id); const expressionLimit = this.resolveLimitForUser(params.user, 'max_pack_expressions', MAX_PACK_EXPRESSIONS); const success: Array = []; const failed: Array<{name: string; error: string}> = []; for (const emojiData of params.emojis) { if (emojiCount >= expressionLimit) { failed.push({name: emojiData.name, error: 'Pack expression limit reached'}); continue; } try { const {animated, imageBuffer, contentType} = await this.avatarService.processEmoji({ errorPath: `emojis[${success.length + failed.length}].image`, base64Image: emojiData.image, }); const emojiId = createEmojiID(await this.snowflakeService.generate()); await this.avatarService.uploadEmoji({ prefix: 'emojis', emojiId, imageBuffer, contentType, }); const emoji = await this.guildRepository.upsertEmoji({ guild_id: pack.id, emoji_id: emojiId, name: emojiData.name, creator_id: params.user.id, animated, version: 1, }); success.push(mapGuildEmojiToResponse(emoji)); emojiCount += 1; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; failed.push({name: emojiData.name, error: message}); } } return {success, failed}; } async updatePackEmoji(params: { userId: UserID; packId: GuildID; emojiId: EmojiID; name: string; }): Promise { await this.requireExpressionPackAccess(params.userId); const pack = await this.ensurePackOwner(params.userId, params.packId); if (pack.type !== 'emoji') { throw new InvalidPackTypeError('emoji'); } const emoji = await this.guildRepository.getEmoji(params.emojiId, pack.id); if (!emoji) { throw new UnknownGuildEmojiError(); } const updatedEmoji = await this.guildRepository.upsertEmoji({ ...emoji.toRow(), name: params.name, }); return mapGuildEmojiToResponse(updatedEmoji); } async deletePackEmoji(params: {userId: UserID; packId: GuildID; emojiId: EmojiID; purge?: boolean}): Promise { await this.requireExpressionPackAccess(params.userId); const pack = await this.ensurePackOwner(params.userId, params.packId); if (pack.type !== 'emoji') { throw new InvalidPackTypeError('emoji'); } const emoji = await this.guildRepository.getEmoji(params.emojiId, pack.id); if (!emoji) { throw new UnknownGuildEmojiError(); } await this.guildRepository.deleteEmoji(pack.id, params.emojiId); if (params.purge) { await this.assetPurger.purgeEmoji(emoji.id.toString()); } } async createPackSticker(params: { user: User; packId: GuildID; name: string; description?: string | null; tags: Array; image: string; }): Promise { await this.requireExpressionPackAccess(params.user.id); await this.requireFeature(params.user.id, 'feature_global_expressions'); const pack = await this.ensurePackOwner(params.user.id, params.packId); if (pack.type !== 'sticker') { throw new InvalidPackTypeError('sticker'); } const stickerCount = await this.guildRepository.countStickers(pack.id); const expressionLimit = this.resolveLimitForUser(params.user, 'max_pack_expressions', MAX_PACK_EXPRESSIONS); if (stickerCount >= expressionLimit) { throw new MaxPackExpressionsError(expressionLimit); } const {animated, imageBuffer} = await this.avatarService.processSticker({ errorPath: 'image', base64Image: params.image, }); const stickerId = createStickerID(await this.snowflakeService.generate()); await this.avatarService.uploadSticker({prefix: 'stickers', stickerId, imageBuffer}); const sticker = await this.guildRepository.upsertSticker({ guild_id: pack.id, sticker_id: stickerId, name: params.name, description: params.description ?? null, tags: params.tags, animated, creator_id: params.user.id, version: 1, }); const response = mapGuildStickerToResponse(sticker); getMetricsService().counter({name: 'fluxer.stickers.created', value: 1}); return response; } async bulkCreatePackStickers(params: { user: User; packId: GuildID; stickers: Array<{name: string; description?: string | null; tags: Array; image: string}>; }): Promise<{success: Array; failed: Array<{name: string; error: string}>}> { await this.requireExpressionPackAccess(params.user.id); await this.requireFeature(params.user.id, 'feature_global_expressions'); const pack = await this.ensurePackOwner(params.user.id, params.packId); if (pack.type !== 'sticker') { throw new InvalidPackTypeError('sticker'); } let stickerCount = await this.guildRepository.countStickers(pack.id); const expressionLimit = this.resolveLimitForUser(params.user, 'max_pack_expressions', MAX_PACK_EXPRESSIONS); const success: Array = []; const failed: Array<{name: string; error: string}> = []; for (const stickerData of params.stickers) { if (stickerCount >= expressionLimit) { failed.push({name: stickerData.name, error: 'Pack expression limit reached'}); continue; } try { const {animated, imageBuffer} = 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: pack.id, sticker_id: stickerId, name: stickerData.name, description: stickerData.description ?? null, tags: stickerData.tags, animated, creator_id: params.user.id, version: 1, }); success.push(mapGuildStickerToResponse(sticker)); stickerCount += 1; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; failed.push({name: stickerData.name, error: message}); } } for (const _ of success) { getMetricsService().counter({name: 'fluxer.stickers.created', value: 1}); } return {success, failed}; } async updatePackSticker(params: { userId: UserID; packId: GuildID; stickerId: StickerID; name: string; description?: string | null; tags: Array; }): Promise { await this.requireExpressionPackAccess(params.userId); const pack = await this.ensurePackOwner(params.userId, params.packId); if (pack.type !== 'sticker') { throw new InvalidPackTypeError('sticker'); } const sticker = await this.guildRepository.getSticker(params.stickerId, pack.id); if (!sticker) { throw new UnknownGuildStickerError(); } const updatedSticker = await this.guildRepository.upsertSticker({ ...sticker.toRow(), name: params.name, description: params.description ?? null, tags: params.tags, }); return mapGuildStickerToResponse(updatedSticker); } async deletePackSticker(params: { userId: UserID; packId: GuildID; stickerId: StickerID; purge?: boolean; }): Promise { await this.requireExpressionPackAccess(params.userId); const pack = await this.ensurePackOwner(params.userId, params.packId); if (pack.type !== 'sticker') { throw new InvalidPackTypeError('sticker'); } const sticker = await this.guildRepository.getSticker(params.stickerId, pack.id); if (!sticker) { throw new UnknownGuildStickerError(); } await this.guildRepository.deleteSticker(pack.id, params.stickerId); if (params.purge) { await this.assetPurger.purgeSticker(sticker.id.toString()); } getMetricsService().counter({name: 'fluxer.stickers.deleted', value: 1}); } private resolveLimitForUser(user: User | null, key: LimitKey, fallback: number): number { const ctx = createLimitMatchContext({user}); return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback); } }