fix(install): публичный адрес LiveKit вычисляется после TLS_ENABLED
Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS, dry-run проходит.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
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' },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user