2026-09-20 18:13:04 +03:00
|
|
|
|
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>` (`<a:…>` у анимированных),
|
|
|
|
|
|
* поэтому разбор payload'а общий для всех источников.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
export interface GuildEmoji {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
file_id: string;
|
|
|
|
|
|
animated: boolean;
|
|
|
|
|
|
/** Токен для сообщения и реакции: `<:имя:file_id>` или `<a:имя: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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 18:19:19 +03:00
|
|
|
|
export const guildEmojisQueryKey = (guildId: string) => ['guilds', guildId, 'emojis'] as const;
|
2026-09-20 18:13:04 +03:00
|
|
|
|
|
|
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
|
|
|
|
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : 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<GuildEmoji[]> {
|
|
|
|
|
|
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<GuildEmoji | null> {
|
|
|
|
|
|
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<GuildEmoji | null> {
|
|
|
|
|
|
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<void> {
|
|
|
|
|
|
await request<{ ok: true }>(
|
|
|
|
|
|
`/guilds/${encodeURIComponent(guildId)}/emojis/${encodeURIComponent(emojiId)}`,
|
|
|
|
|
|
{ method: 'DELETE' },
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|