fix(install): публичный адрес LiveKit вычисляется после TLS_ENABLED

Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL
стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS,
dry-run проходит.
This commit is contained in:
2026-09-20 18:13:04 +03:00
parent ecd52c11d3
commit ca34d05377
33 changed files with 2505 additions and 71 deletions
+140
View File
@@ -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' },
);
}
+6 -1
View File
@@ -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']),
+21
View File
@@ -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);