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
+2
View File
@@ -37,6 +37,8 @@ export interface AutocompleteItem {
/** Имя для аватара-заглушки (есть только у участников). */
avatarName?: string | undefined;
avatarFileId?: string | undefined;
/** Файл картинки кастомного эмодзи: подсказка рисует его вместо символа. */
emojiFileId?: string | undefined;
}
/** Команда чата: сервер разбирает её сам (AGENT.md §7.6). */
+60
View File
@@ -0,0 +1,60 @@
/**
* Токен кастомного эмодзи сервера (AGENT.md 7.12).
*
* В тексте сообщения и в реакции эмодзи едет токеном `<:имя:file_id>`
* (у анимированных — `<a:имя:file_id>`): имя разрешено только из латиницы,
* цифр и подчёркиваний, `file_id` — снежинка. Единое описание формата живёт
* здесь, чтобы разбор в разметке (`lib/markdown.ts`), подсказки композера и
* настройки сервера не расходились.
*/
/** Имя эмодзи: ровно то, что принимает сервер при загрузке и переименовании. */
export const CUSTOM_EMOJI_NAME_SOURCE = '[a-zA-Z0-9_]{2,32}';
/** Токен целиком без якорей — вставляется в общий шаблон разметки. */
export const CUSTOM_EMOJI_TOKEN_SOURCE = `<a?:${CUSTOM_EMOJI_NAME_SOURCE}:\\d+>`;
/** Проверка имени эмодзи на клиенте: до запроса, чтобы не гонять сервер. */
export const CUSTOM_EMOJI_NAME_PATTERN = new RegExp(`^${CUSTOM_EMOJI_NAME_SOURCE}$`, 'u');
const CUSTOM_EMOJI_TOKEN_PATTERN = new RegExp(
`^<(?<animated>a?):(?<name>${CUSTOM_EMOJI_NAME_SOURCE}):(?<fileId>\\d+)>$`,
'u',
);
/** Разобранный токен: имя, файл и признак анимации. */
export interface CustomEmojiToken {
name: string;
fileId: string;
animated: boolean;
}
/**
* Разбор токена. `null` — строка не является токеном эмодзи (битый или
* неизвестный формат): вызывающий код показывает её как обычный текст.
*/
export function parseCustomEmoji(raw: string): CustomEmojiToken | null {
const match = CUSTOM_EMOJI_TOKEN_PATTERN.exec(raw);
const groups = match?.groups;
const name = groups?.['name'];
const fileId = groups?.['fileId'];
if (name === undefined || fileId === undefined) {
return null;
}
return { name, fileId, animated: groups?.['animated'] === 'a' };
}
/** Короткий код эмодзи — то, что видит пользователь: `:имя:`. */
export function customEmojiShortcode(name: string): string {
return `:${name}:`;
}
/** Токен для вставки в текст сообщения или реакцию. */
export function customEmojiToken(name: string, fileId: string, animated = false): string {
return `<${animated ? 'a' : ''}:${name}:${fileId}>`;
}
/** Является ли строка токеном кастомного эмодзи. */
export function isCustomEmojiToken(value: string): boolean {
return parseCustomEmoji(value) !== null;
}
+22 -2
View File
@@ -40,6 +40,7 @@ export const PERMISSION_ADMINISTRATOR = 'ADMINISTRATOR';
export const PERMISSION_MANAGE_GUILD = 'MANAGE_GUILD';
export const PERMISSION_MANAGE_CHANNELS = 'MANAGE_CHANNELS';
export const PERMISSION_MANAGE_MESSAGES = 'MANAGE_MESSAGES';
export const PERMISSION_MANAGE_EMOJIS = 'MANAGE_EMOJIS';
export const PERMISSION_SEND_MESSAGES = 'SEND_MESSAGES';
export const PERMISSION_ATTACH_FILES = 'ATTACH_FILES';
export const PERMISSION_ADD_REACTIONS = 'ADD_REACTIONS';
@@ -102,8 +103,7 @@ export function canAdministerGuild(
* Может ли пользователь модерировать сообщения (удалять и закреплять чужие).
* Права приходят на уровне сервера, поэтому это приближение: сервер всё равно
* проверит права в конкретной комнате.
*/
export function canManageMessages(
*/export function canManageMessages(
permissions: readonly string[],
isInstanceAdmin = false,
): boolean {
@@ -116,6 +116,26 @@ export function canManageMessages(
);
}
/**
* Может ли пользователь управлять эмодзи сервера (AGENT.md 7.12): владелец,
* ADMINISTRATOR, MANAGE_EMOJIS или администратор инстанса. `MANAGE_GUILD`
* сюда не входит: сервер проверяет именно `MANAGE_EMOJIS`.
*/
export function canManageEmojis(
permissions: readonly string[],
ownerId: string,
userId: string | undefined,
isInstanceAdmin = false,
): boolean {
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
return true;
}
return (
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
hasPermission(permissions, PERMISSION_MANAGE_EMOJIS)
);
}
/** Может ли пользователь отправлять сообщения в комнату. */
export function canSendMessages(
permissions: readonly string[],
+35 -5
View File
@@ -17,12 +17,14 @@
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>`, ссылки на комнаты
* `<#id>` и `#канал`.
* `<#id>`, `#канал` и кастомные эмодзи сервера `<:имя:file_id>`.
*
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
*/
import { CUSTOM_EMOJI_TOKEN_SOURCE } from '@/lib/customEmoji';
/** Вид строчного токена. */
export type InlineKind =
| 'code'
@@ -34,6 +36,7 @@ export type InlineKind =
| 'mark'
| 'mention'
| 'channel-link'
| 'custom-emoji'
| 'link'
| 'channel';
@@ -61,9 +64,30 @@ export interface InlineToken {
* «текст со скобками». Одиночная `*` не считается маркером внутри ряда
* звёздочек (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а
* `_` — внутри слова (`foo_bar_baz`).
*
* Токен кастомного эмодзи `<:имя:file_id>` (`<a:…>` — анимированный) стоит
* отдельной альтернативой: с `<@id>` и `<#id>` он не пересекается, а строгий
* формат (латиница/цифры/подчёркивания в имени, снежинка в id) оставляет
* битые токены обычным текстом.
*/
const INLINE_PATTERN =
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|((?<!\*)\*(?!\*)[^*\n]+(?<!\*)\*(?!\*))|((?<![\p{L}\p{N}_])_(?!_)[^_\n]+(?<!_)_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@[^\s<>]+>)|(<#[^\s<>]+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
const INLINE_ALTERNATIVES = [
'(`[^`\\n]+`)',
'(\\*\\*\\*[^*\\n]+\\*\\*\\*)',
'(\\*\\*[^*\\n]+\\*\\*)',
'(__[^_\\n]+__)',
'(~~[^~\\n]+~~)',
'(\\|\\|[^|\\n]+\\|\\|)',
'((?<!\\*)\\*(?!\\*)[^*\\n]+(?<!\\*)\\*(?!\\*))',
'((?<![\\p{L}\\p{N}_])_(?!_)[^_\\n]+(?<!_)_(?![\\p{L}\\p{N}_]))',
'(==[^=\\n]+==)',
'(<@[^\\s<>]+>)',
'(<#[^\\s<>]+>)',
`(${CUSTOM_EMOJI_TOKEN_SOURCE})`,
'(https?:\\/\\/[^\\s<>()]+)',
'(#[\\p{L}\\p{N}_-]+)',
];
const INLINE_PATTERN = new RegExp(INLINE_ALTERNATIVES.join('|'), 'gu');
/** Вид токена по номеру группы шаблона (порядок альтернатив выше). */
const GROUP_KINDS: readonly InlineKind[] = [
@@ -78,6 +102,7 @@ const GROUP_KINDS: readonly InlineKind[] = [
'mark',
'mention',
'channel-link',
'custom-emoji',
'link',
'channel',
];
@@ -95,6 +120,9 @@ const MARKER_LENGTH: Readonly<Record<InlineKind, number>> = {
// содержимое — весь токен.
mention: 0,
'channel-link': 0,
// Токен эмодзи `<:имя:id>` целиком считается содержимым: маркеры `<:имя:`
// и `>` рисует зеркало композера, когда каретка внутри токена.
'custom-emoji': 0,
link: 0,
channel: 0,
};
@@ -149,8 +177,10 @@ export function parseInline(text: string, offset = 0): InlineToken[] {
}
/**
* Есть ли у токена парные маркеры. У упоминаний, ссылок на комнаты, ссылок и
* каналов их нет: содержимое — весь токен, прятать нечего.
* Есть ли у токена маркеры, которые зеркало прячет вне каретки. У упоминаний,
* ссылок на комнаты, ссылок и каналов их нет: содержимое — весь токен, прятать
* нечего. У кастомного эмодзи маркеры есть (`<:имя:` и `>`), но «содержимым»
* остаётся весь токен — зеркало показывает его текстом, пока каретка внутри.
*/
export function hasMarkers(kind: InlineKind): boolean {
return kind !== 'mention' && kind !== 'channel-link' && kind !== 'link' && kind !== 'channel';
+57
View File
@@ -0,0 +1,57 @@
/**
* Системные сообщения (AGENT.md §7.6).
*
* Сервер пишет их с `type: "system"`, а `content` — JSON-строка о событии:
* `{"event":"member_join","user_id":"123"}`, `{"event":"member_kick",…}` или
* `{"event":"channel_created","name":"общий","type":"text"}`. Разбор живёт
* отдельно от компонентов: лента рисует человекочитаемую строку, а
* справочник авторов по нему же добирает идентификаторы участников.
*
* Неизвестное событие и невалидный JSON возвращают `null` — вызывающий код
* показывает исходный текст сообщения как есть, ничего не теряя.
*/
/** Событие системного сообщения. */
export type SystemMessageEvent =
| { kind: 'member_join'; userId: string }
| { kind: 'member_kick'; userId: string }
| { kind: 'channel_created'; name: string; channelType: string };
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
/** Разбор `content` системного сообщения. */
export function parseSystemMessage(content: string): SystemMessageEvent | null {
let payload: unknown;
try {
payload = JSON.parse(content) as unknown;
} catch {
// Не JSON — показываем текст как есть.
return null;
}
const record = asRecord(payload);
const event = record?.['event'];
const userId = record?.['user_id'];
if (event === 'member_join' || event === 'member_kick') {
return typeof userId === 'string' && userId !== '' ? { kind: event, userId } : null;
}
if (event === 'channel_created') {
const name = record?.['name'];
if (typeof name !== 'string' || name === '') {
return null;
}
const type = record?.['type'];
return { kind: 'channel_created', name, channelType: typeof type === 'string' ? type : 'text' };
}
return null;
}
/**
* Идентификатор пользователя из системного сообщения. Нужен ленте: имя
* автора события резолвится тем же справочником, что и авторы сообщений.
*/
export function systemMessageUserId(content: string): string | null {
const event = parseSystemMessage(content);
return event !== null && event.kind !== 'channel_created' ? event.userId : null;
}