import { CUSTOM_EMOJI_NAME_PATTERN, customEmojiToken } from '@/lib/customEmoji'; import { request } from './client'; /** * Кастомные эмодзи сервера (AGENT.md 7.12). * * Контракт один и тот же в трёх местах: `GET /guilds/{id}/emojis`, снапшот * READY (`guilds[].emojis`) и событие шлюза `GUILD_EMOJIS_UPDATE`. Эмодзи * в тексте и в реакции — токен `<:имя:file_id>` (`` у анимированных), * поэтому разбор payload'а общий для всех источников. */ export interface GuildEmoji { id: string; name: string; file_id: string; animated: boolean; /** Токен для сообщения и реакции: `<:имя:file_id>` или ``. */ token: string; /** Ссылка на картинку (REST); в снапшоте READY её нет. */ url?: string; /** Размер файла: сервер присылает его не во всех ответах. */ size_bytes?: number; } /** Лимиты из контракта: 100 эмодзи на сервер, 512 КБ на файл. */ export const EMOJI_LIMIT_PER_GUILD = 100; export const EMOJI_MAX_BYTES = 512 * 1024; /** Форматы, которые принимает сервер. */ export const EMOJI_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; /** Разрешён ли файл по формату (размер проверяется отдельно). */ export function isAllowedEmojiType(file: File): boolean { return (EMOJI_CONTENT_TYPES as readonly string[]).includes(file.type); } /** Проходит ли имя клиентскую проверку (та же, что на сервере). */ export function isValidEmojiName(name: string): boolean { return CUSTOM_EMOJI_NAME_PATTERN.test(name); } export const guildEmojisQueryKey = (guildId: string) => ['guilds', guildId, 'emojis'] as const; function asRecord(value: unknown): Record | null { return typeof value === 'object' && value !== null ? (value as Record) : null; } function asString(value: unknown): string | undefined { return typeof value === 'string' && value !== '' ? value : undefined; } /** * Разбор одного эмодзи. Токен берём из ответа, а если сервер его не прислал — * собираем сами: формат один и тот же, а без токена эмодзи бесполезен. */ export function parseGuildEmoji(value: unknown): GuildEmoji | null { const record = asRecord(value); if (record === null) { return null; } const id = asString(record['id']); const name = asString(record['name']); const fileId = asString(record['file_id']); if (id === undefined || name === undefined || fileId === undefined) { return null; } const animated = record['animated'] === true; const emoji: GuildEmoji = { id, name, file_id: fileId, animated, token: asString(record['token']) ?? customEmojiToken(name, fileId, animated), }; const url = asString(record['url']); const sizeBytes = record['size_bytes']; if (url !== undefined) { emoji.url = url; } if (typeof sizeBytes === 'number' && Number.isFinite(sizeBytes) && sizeBytes >= 0) { emoji.size_bytes = sizeBytes; } return emoji; } /** Разбор списка эмодзи: лишние и битые записи отбрасываются. */ export function parseGuildEmojiList(value: unknown): GuildEmoji[] { if (!Array.isArray(value)) { return []; } return value.map(parseGuildEmoji).filter((item): item is GuildEmoji => item !== null); } export async function fetchGuildEmojis( guildId: string, signal?: AbortSignal, ): Promise { const payload = await request<{ emojis?: unknown }>( `/guilds/${encodeURIComponent(guildId)}/emojis`, signal === undefined ? {} : { signal }, ); return parseGuildEmojiList(payload.emojis); } /** Загрузка: multipart с полями `name` и `file` (право MANAGE_EMOJIS). */ export async function uploadGuildEmoji( guildId: string, name: string, file: File, ): Promise { const form = new FormData(); form.append('name', name); form.append('file', file); const payload = await request<{ emoji?: unknown }>( `/guilds/${encodeURIComponent(guildId)}/emojis`, { method: 'POST', form }, ); return parseGuildEmoji(payload.emoji); } export async function renameGuildEmoji( guildId: string, emojiId: string, name: string, ): Promise { const payload = await request<{ emoji?: unknown }>( `/guilds/${encodeURIComponent(guildId)}/emojis/${encodeURIComponent(emojiId)}`, { method: 'PATCH', body: { name } }, ); return parseGuildEmoji(payload.emoji); } export async function deleteGuildEmoji(guildId: string, emojiId: string): Promise { await request<{ ok: true }>( `/guilds/${encodeURIComponent(guildId)}/emojis/${encodeURIComponent(emojiId)}`, { method: 'DELETE' }, ); }