Files
glchat/web/src/api/emojis.ts
T
grendervill 2749a0e831 fix(install): LIVEKIT_URL попадает в compose приложения
Шаблон compose подставляет переменные из явного списка: LIVEKIT_PUBLIC_URL не
был в нём, поэтому приложение получало пустой адрес LiveKit и голос оставался
выключенным. Добавлены LIVEKIT_API_KEY/SECRET/PUBLIC_URL в рендер compose.
2026-09-20 18:19:19 +03:00

140 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
export const guildEmojisQueryKey = (guildId: string) => ['guilds', guildId, 'emojis'] as const;
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' },
);
}