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' },
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { parseGuildEmojiList } from './emojis';
|
||||
import { parseChannelPayload } from './gatewayEvents';
|
||||
import { describeUnknown } from '@/lib/http';
|
||||
|
||||
import type { GuildEmoji } from './emojis';
|
||||
import type { Channel, Role, UserStatus } from './types';
|
||||
|
||||
/**
|
||||
@@ -34,7 +36,7 @@ export interface GatewayHello {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
/** Сервер в снапшоте READY: каналы/роли/права приходят сразу. */
|
||||
/** Сервер в снапшоте READY: каналы/роли/права/эмодзи приходят сразу. */
|
||||
export interface GatewayGuild {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -43,6 +45,8 @@ export interface GatewayGuild {
|
||||
icon_file_id?: string;
|
||||
channels: Channel[];
|
||||
roles: Role[];
|
||||
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
|
||||
emojis: GuildEmoji[];
|
||||
member_ids: string[];
|
||||
my_role_ids: string[];
|
||||
my_nickname?: string;
|
||||
@@ -231,6 +235,7 @@ function parseGuild(value: unknown): GatewayGuild | null {
|
||||
is_main: record['is_main'] === true,
|
||||
channels,
|
||||
roles,
|
||||
emojis: parseGuildEmojiList(record['emojis']),
|
||||
member_ids: asStringArray(record['member_ids']),
|
||||
my_role_ids: asStringArray(record['my_role_ids']),
|
||||
my_permissions: asStringArray(record['my_permissions']),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { parseGuildEmojiList } from './emojis';
|
||||
import { userStatuses, type Channel, type UserStatus } from './types';
|
||||
import type { GatewayUser } from './gateway';
|
||||
import type { GuildEmoji } from './emojis';
|
||||
import type { Message, MessageAttachment, MessageReaction } from './messages';
|
||||
|
||||
/**
|
||||
@@ -51,6 +53,11 @@ export interface RoleEvent {
|
||||
role_id: string;
|
||||
}
|
||||
|
||||
export interface GuildEmojisUpdateEvent {
|
||||
guild_id: string;
|
||||
emojis: GuildEmoji[];
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
@@ -182,6 +189,20 @@ export function parseRoleEvent(value: unknown): RoleEvent | null {
|
||||
return { guild_id: guildId, role_id: roleId };
|
||||
}
|
||||
|
||||
/**
|
||||
* `GUILD_EMOJIS_UPDATE`: набор эмодзи сервера изменился (загрузка,
|
||||
* переименование, удаление). Список приходит целиком — тем же форматом, что
|
||||
* и в снапшоте READY, поэтому заменяем его в сторе, а не мержим.
|
||||
*/
|
||||
export function parseGuildEmojisUpdate(value: unknown): GuildEmojisUpdateEvent | null {
|
||||
const record = asRecord(value);
|
||||
const guildId = asString(record?.['guild_id']);
|
||||
if (record === null || guildId === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { guild_id: guildId, emojis: parseGuildEmojiList(record['emojis']) };
|
||||
}
|
||||
|
||||
/** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */
|
||||
export function parseChannelPayload(value: unknown): Channel | null {
|
||||
const record = asRecord(value);
|
||||
|
||||
Reference in New Issue
Block a user