- internal/retention: истёкшие сессии, аудит старше RETENTION_AUDIT_DAYS и
файлы без ссылок старше RETENTION_ORPHAN_HOURS; сиротой файл считается
только если на него не ссылается ничего (вложения, аватары, оформление
сервера, комнат, ролей, эмодзи, звуки, вебхуки, приглашения)
- обслуживание запускается в приложении по RETENTION_INTERVAL_SECONDS;
команды glchat cleanup и glchat reindex делают то же вручную
- PWA: манифест с иконками и shortcuts, офлайн-страница, сервис-воркер
(кэш только /assets/*, приватные запросы не кэшируются)
- тесты: Go на чистку (сирота удаляется, используемые файлы и свежие сироты
остаются, истёкшая сессия уходит), Vitest на манифест и регистрацию воркера
- GET /files/{id} без сессии отдаёт только оформление сервера и картинку
приглашения (guild_icon, guild_banner, guild_splash, invite_background):
их показывают до входа (AGENT.md 7.9), остальное по-прежнему требует сессии
- optionalUser резолвит сессию без ответа об ошибке; путь файла проверяется на
принадлежность каталогу данных (AGENT.md 9.2)
- аватар пригласившего показывается только вошедшим
- тесты: Go (splash анонимно, вложение закрыто) и правки живого скрипта
- миграция 00015: переопределение splash у приглашения (AGENT.md 7.9)
- GET /invites/{code} доступен без сессии: гость по ссылке видит карточку
сервера, описание, число участников и пригласившего
- POST/DELETE /invites/{code}/background: своя картинка страницы под правом
создателя или MANAGE_GUILD, файл удаляется при замене и снятии
- клиент: маршрут /invite/:code с фоном (переопределение → splash → баннер),
карточкой сервера, кнопками «Присоединиться»/«Открыть сервер» и входом с
возвратом на страницу (?next=)
- тесты: Go (карточка, загрузка, права, снятие) и Vitest (splash, гость,
присоединение, недействительная ссылка)
- PUT/DELETE /users/@me/blocks/{id}: блокировка снимает дружбу и заявки в обе
стороны, разблокировка возвращает возможность писать (AGENT.md 7.2)
- блокировка запрещает личные сообщения (ошибка dm.blocked), открытие беседы и
заявки в друзья с любой стороны
- клиент: раздел «Заблокированные» в друзьях со снятием блокировки, действие
«Заблокировать» в меню строки друга и в меню модерации участника
- тесты: Go (блокировка, заявки, беседа, сообщение) и Vitest (список, снятие,
блокировка из меню)
- ReadyChannel отдаёт background_file_id: после перезагрузки фон комнаты больше
не теряется до REST-запроса (AGENT.md 7.5)
- шаблон больше не создаёт категории дважды: они перечислены в шаблоне и
раньше создавались ещё и по ссылке из комнаты
- тест: структура «Сообщества» ровно 7 комнат без повторов
- участники сервера отдают список бейджей из badges_json (AGENT.md 7.2):
список расширяемый, назначается только системой
- клиент: каталог бейджей (щит администратора инстанса, корона владельца,
ранний сторонник, проверенный), неизвестные показываются нейтральной меткой
- бейджи видны в ленте, списке участников и друзьях вместо текстовой плашки
- тесты: Go (бейджи в списке участников) и Vitest (каталог и отрисовка)
- миграция 00014: background_file_id у комнаты (AGENT.md 7.5, 7.7)
- API: POST/DELETE /channels/{id}/background под MANAGE_CHANNEL_BACKGROUND,
фон отдаётся в списке комнат и рассылается событием CHANNEL_UPDATE
- клиент: фон рисуется за лентой с затемнением, блок «Фон комнаты» в настройках
комнаты (загрузка, замена, удаление)
- AnimatedImage: при «уменьшить движение» показывает статичный первый кадр,
нарисованный на canvas, вместо анимации
- тесты: Go (загрузка, права, снятие) и Vitest (лента, настройки, reduced-motion)
- каталог шаблонов «Пустой», «Сообщество», «Игровой» (AGENT.md 7.3): роли,
категории, текстовые и голосовые комнаты без сообщений
- POST /guilds принимает template_id, неизвестный шаблон отклоняется; аудит
фиксирует выбранный шаблон
- GET /guild-templates отдаёт каталог с предпросмотром структуры
- клиент: выбор шаблона в окне создания сервера
- тесты: Go (структура каждого шаблона) и Vitest (выбор шаблона)
- API: список друзей отдаёт вычисленное оформление «для друзей» одним запросом
на весь список (AGENT.md 7.2)
- клиент: рамка, иконка, цвет и эффект ника в списке друзей, в ленте личных
бесед — из профиля собеседника
- тесты: Go на список друзей, Vitest на отрисовку в друзьях
Владелец сервера или участник с MANAGE_ROLES не мог выдать роль себе: общий
запрет самомодерации (кик, бан, тайм-аут) распространялся и на оформление.
Проверка иерархии для ролей вынесена отдельно: себя менять можно, владельца и
администратора инстанса — по-прежнему нет.
- GET /users/@me/styles отдаёт запись для каждой области, даже если стиль ещё
не задан: иначе клиент не мог выбрать пер-серверную область
- живая проверка ждёт загрузки галереи и вариантов рамок
- уточнён приоритет: эффект роли важнее личного стиля (AGENT.md 7.2)
- миграция 00013: guild_cosmetics (рамки и иконки) и user_styles («для друзей»
и пер-серверные стили) (AGENT.md 7.2, 7.4)
- store: CRUD галереи, личные стили и вычисление оформления поэлементно
(роль → пер-серверный стиль → «для друзей» → дефолт) с откатом, когда роль
больше не выдаёт элемент
- API: галерея сервера под MANAGE_ROLES, оформление роли в create/update,
GET/PUT/DELETE /users/@me/styles с доступными элементами по областям
- клиент: редактор личного стиля с предпросмотром, галерея оформления в
настройках сервера, оформление роли в разделе «Роли»
- рендер: рамка под аватаром, иконка рядом с ником, цвет и встроенные эффекты
ника (CSS, при reduced-motion статично) в ленте и списке участников
- миграция 00012: таблица webhooks, снимок имени и аватара в messages (AGENT.md 7.11)
- API: список/создание/правка/удаление и пересоздание токена (MANAGE_WEBHOOKS,
step-up при создании, аудит), загрузка аватара отдельной multipart-ручкой
- исполнение POST /webhooks/{id}/{token} без сессии: content, username,
avatar_url, файлы создателя вебхука, лимит 30/мин на вебхук
- клиент: пункт настроек «Комната» со списком вебхуков, копированием ссылки,
пересозданием токена и удалением
- сообщения вебхуков: имя, аватар и значок в ленте вместо «неизвестного автора»
- fix: неполный ответ REST больше не затирает данные READY-снапшота
- READY-снапшот больше не теряет banner/splash/accent: шапка сервера
показывает баннер сразу после подключения (AGENT.md 7.4)
- GUILD_UPDATE несёт всё оформление, клиент применяет его без перезагрузки
- кнопка приглашения в шапке сервера со ссылкой и копированием
- встроенные звуки интерфейса на Web Audio с разблокировкой по жесту
- object URL превью через безопасные обёртки (jsdom и урезанные webview)
Фаза 4 (AGENT.md 7.4): интерфейс для иконки, баннера, splash и акцентного цвета.
- секция «Оформление» в настройках сервера: загрузка и удаление иконки,
баннера и splash с превью, проверка типа и размера на клиенте, пресеты
акцентного цвета;
- иконка сервера показывается в рейке (заглушка-буквы остаётся, если файла
нет), акцентный цвет переопределяет `--color-accent` внутри приложения и
снимается при значении 0;
- предпросмотр приглашения показывает splash фоном и иконку сервера;
- ответ сервера обновляет карточку и список серверов — оформление видно без
перезагрузки;
- тесты: превью всех трёх видов, multipart-загрузка, удаление, применение и
снятие акцента, отказ для не-изображения, скрытие секции без MANAGE_GUILD.
Фаза 4 (AGENT.md 7.4): сервер можно оформить, поля в схеме были с Фазы 1, но
ручек и отдачи в API не было.
- `POST/DELETE /guilds/{id}/appearance/{icon|banner|splash}` (multipart,
MANAGE_GUILD, только изображения до 8 МБ): файл сохраняется назначением
`guild_icon|guild_banner|guild_splash`, предыдущий удаляется, пишется аудит
и уходит `GUILD_UPDATE`; ответ — обновлённый сервер;
- `PATCH /guilds/{id}` принимает `accent_color` (0 — цвет темы);
- оформление отдаётся в READY, в списке серверов, в детальной карточке и в
предпросмотре приглашения — клиенту не нужен отдельный запрос;
- анимированные GIF/APNG/WebP сохраняются как есть: изображения не
перекодируются, поэтому анимация не теряется;
- тесты: загрузка и очистка всех трёх видов, замена файла, запрет без
MANAGE_GUILD и без сессии, отказ для не-изображения, акцентный цвет.
Банить и исключать нужно того, кто прямо сейчас в сервере, а не только
разбирать готовый список банов (AGENT.md 7.17).
- `MemberModerationMenu`: тайм-аут (1/10/60/1440 минут и снятие), бан с причиной
и исключение; пункты скрыты без прав KICK_MEMBERS/BAN_MEMBERS/TIMEOUT_MEMBERS,
себя модерировать нельзя;
- после действия обновляются списки участников и банов (без F5);
- тесты: бан с причиной, тайм-аут, исключение и скрытие меню без прав.
Проверка иерархии пропускала все запреты для администратора инстанса, включая
запрет на модерацию себя: он мог забанить или исключить самого себя и потерять
доступ к серверу (поймано при проверке интерфейса на стенде — самобан снял
участие в «Главном сервере»).
Теперь проверка `targetID == actor.ID` выполняется до обхода иерархии, для
администратора инстанса остаются доступны любые другие цели. Регрессионный
тест: самобан и самоисключение администратора инстанса дают 403.
Фаза 4 (AGENT.md 7.17, 7.18): модерация из интерфейса.
- `api/moderation.ts`: список банов, бан, разбан, тайм-аут, кик и журнал
аудита с фильтрами;
- «Баны»: причина и автор каждого бана, снятие бана и пресеты тайм-аута
(1/5/10/60/1440 минут) прямо из списка; ответ сервера обновляет кэш, поэтому
строка исчезает без перезагрузки;
- «Журнал аудита»: фильтры по действию, автору и цели уходят в запрос,
кнопки «Показать» и «Сбросить»;
- секции видны только с правами BAN_MEMBERS и VIEW_AUDIT_LOG
(`canBanMembers`, `canViewAuditLog`);
- тесты: список с причиной и автором, снятие бана с обновлением списка,
тайм-аут с проверкой времени, скрытие секций без прав, фильтры в запросе.
Фаза 4 (AGENT.md 7.17, 7.18): модерация участников.
- миграция 00011: таблица `guild_bans` (бан переживает исключение участника);
- `PUT /guilds/{id}/bans/{user_id}` — бан с причиной и права `BAN_MEMBERS`:
исключает участника, отключает его от голосовой комнаты, пишет системное
сообщение, аудит и события `MEMBER_REMOVE`/`GUILD_DELETE`;
- `DELETE /guilds/{id}/bans/{user_id}` — снятие бана, `GET .../bans` — список
с автором и причиной (только модераторам);
- вход по приглашению для забаненного отклоняется с `guild.banned`;
- тайм-аут уже был в схеме, но не работал: кэш прав комнат не сбрасывался при
изменениях сервера, поэтому участник с тайм-аутом продолжал писать. Ключ
кэша комнат теперь включает сервер, `InvalidateGuild` чистит и его;
- тайм-аут уходит в `MEMBER_UPDATE` (и снимается событием), истёкшие
тайм-ауты не показываются в профиле участника;
- журнал аудита получил фильтры `action`, `actor_id`, `target_id`, `before_id`;
- тесты: полный цикл бана (включая запрет входа и разбан), права и иерархия,
блокировка сообщений тайм-аутом, запрет тайм-аута на себя, фильтры аудита.
Постоянная подпись в плитке занимала место и была не нужна: задержка теперь
живёт в подсказке индикатора связи («Отличное соединение, 46 мс — задержка до
голосового сервера»), а плитка осталась компактной. Тесты и e2e проверяют
`title`/`aria-label` индикатора вместо видимого текста.
Два реальных браузера в одной комнате: у каждого в плитке появляется своя
задержка («N мс», измеряется по WebRTC-статистике), а второй участник видит
пинг первого, пришедший по data-каналу LiveKit.
- дашборд инстанса: два ряда полукругов с подписями сверху — «Контейнер
инстанса» (лимиты профиля) и «Сервер целиком» (вся машина);
- голосовая комната: рядом с качеством связи видно пинг участника
(«Отличное соединение, 42 мс») и он обновляется раз в секунду. Свой RTT
измеряется по статистике WebRTC, чужой приходит по data-каналу LiveKit
(`glchat.latency`, lossy), поэтому пинг виден для каждого участника;
- статистика берётся с любой своей дорожки: в аудио-сессии RTT раньше был
недоступен (отчёт читался только с видео);
- в «Аудио-видео» появился тумблер «Показывать пинг в голосовой комнате»:
выключенный не опрашивает SDK и не публикует своё значение;
- тесты: ряд хоста и подписи, пинг в плитке своего и чужого участника,
публикация по служебной теме, выключенный пинг не дёргает SDK.
Дашборд показывал только контейнер (лимиты профиля), поэтому не было видно,
сколько ресурсов у хоста всего.
- `sysinfo.NewHostSampler` и `ReadHostMemory` всегда читают `/proc/stat` и
`/proc/meminfo`, игнорируя cgroup;
- в ответе метрик появились `host_cpu` и `host_memory` (`source: host`);
- недоступные источники попадают в проверку `metrics`, а не ломают ручку.
Ноль — такое же значение нагрузки, поэтому до прихода первой порции данных
индикаторы и блоки диска показывают «—», а не 0 %: иначе интерфейс мигал
нулевой нагрузкой при открытии раздела.
`statfs.Bsize` в Linux — int64, в Darwin — uint32: сборка образа для linux/amd64
падала на приведении к uint32. Приведение к int64 безопасно на обеих
платформах; проверено `GOOS=linux go vet` по всем пакетам.
Вкладка «Инстанс» сверху показывает нагрузку сервера, ниже — диск и базу, внизу
healthcheck с пингом (AGENT.md 7.19).
- `Gauge` — полукруглый индикатор: заполняется по значению, меняет цвет на
порогах 70 % и 90 %, доступен как `role="meter"`;
- процессор и память обновляются раз в секунду (`useInstanceMetrics`), для
простоя вкладки опрос останавливается;
- два блока диска: «занято / всего» и вес базы (файл + WAL); видимых подписей
нет — они раскрываются в тултипе при наведении на значение;
- healthcheck: состояние каждой проверки, общий статус, техническая деталь и
живой пинг — время ответа инстанса на запрос дашборда в миллисекундах;
- переводы ru/en, тесты на индикаторы, формат «занято / всего», тултипы,
healthcheck и отсутствие раздела у обычного пользователя.
Раздел «Инстанс» получает живые показатели сервера (AGENT.md 7.19).
- `internal/sysinfo`: загрузка процессора по cgroup v2 (`cpu.stat`/`cpu.max`)
с откатом на `/proc/stat`, память по лимиту контейнера либо `/proc/meminfo`,
место на разделе данных и размер базы вместе с журналом WAL;
- `GET /api/v1/instance/metrics` (только администратор инстанса): метрики,
состояние (`ok|warn|failed`), проверки app/database/disk/storage/voice/
gateway, версия и время работы; недоступные источники не ломают ответ, а
попадают в проверку `metrics`;
- тяжёлые проверки (БД, SFU, запись) кэшируются на 5 секунд: ручку можно
опрашивать раз в секунду;
- `voice.IssueRoomList` и `AdminClient.Ping` проверяют доступность RoomService
и валидность ключей LiveKit (та же связка, что ломала модерацию);
- у метрик свой лимит частоты, они исключены из общего лимита API
(`RateLimiter.MiddlewareExcept`), иначе открытый раздел съедал бы половину
бюджета запросов;
- тесты: `internal/sysinfo` (cgroup, /proc, память, диск, размеры файлов) и
`internal/server/api_metrics_test.go` (доступ, состав метрик, кэш, 429).
Регистрация тестового пользователя выполнялась на любой неудачный вход, из-за
чего при `rate_limited` прогон падал на попытке регистрации. Теперь:
- 401 → пользователя нет, регистрируем; 429 → ждём `retry_after_ms` и повторяем;
- 409 при регистрации (аккаунт уже есть) → просто входим;
- число повторов увеличено до шести.
Прогон зелёный, аккаунт `voiceprobe` переиспользуется.
Если встроенный TURN выключен (`--no-turn`), сертификат LiveKit больше не
нужен: coturn на внешнем узле обновляет свой сертификат сам (D-036).
Повторная установка теперь снимает старый `glchat-cert-refresh.timer`,
юниты и скрипт, чтобы не оставлять в системе неработающую автоматику.
Заодно в .gitignore добавлены артефакты Playwright (`web/test-results/`,
`web/playwright-report/`) — они локальные и не должны попадать в коммиты.
Браузеры отклоняют TLS-соединение к TURN по IP, если сертификат выписан на
домен: релей с публичным адресом отдавал `turns:<ip>:5349`, и этот эндпоинт был
нерабочим.
- флаг `--turn-relay-tls-host` (по умолчанию `turn.<домен>`, если он резолвится,
иначе адрес релея) попадает в `rtc.turn_servers` как TLS-эндпоинт;
- значение сохраняется в `.env` и переиспользуется при `--reconfigure`;
- `scripts/test-install.sh` проверяет, что в `livekit.yaml` попали и UDP-адрес,
и TLS-имя релея.
- постоянный аккаунт `voiceprobe` вместо регистрации нового на каждый прогон
(API удаления пользователей пока нет — это Фаза 5);
- `postWithRetry` и повтор входа в браузере учитывают `rate_limited` и
`retry_after_ms`: защита от флуда входа обязана работать, а тест не должен
падать из-за неё;
- проверено два прогона подряд: оба зелёные, в базе стенда остаются только
аккаунт администратора, тестовый пользователь и серверы владельца.
RoomService отвечает объектом участника на верхнем уровне, а не в обёртке
`{"participant": …}`: дорожка микрофона не находилась и серверный мьют молча
пропускался (AGENT.md 7.14). Обёртку тоже принимаем — на случай смены формы
ответа в новых версиях.
- `participantEnvelope` разбирает оба варианта;
- тесты `internal/voice/admin_test.go`: выбор дорожки микрофона (не камеры),
тело MutePublishedTrack, отсутствие микрофона, обёрнутый ответ,
RemoveParticipant и выключенный клиент; в каждом вызове проверяется
Bearer-токен, привязанный к комнате.
LiveKit принимает право `roomAdmin` только вместе с именем комнаты: без
`video.room` RoomService отвечает 401 `permissions denied` — серверный мьют и
отключение участника не доходили до SFU, хотя API отвечал 200 (AGENT.md 7.14).
- `voice.IssueAdmin(room)` кладёт в грахт `room` и `roomAdmin` и отклоняет
пустое имя комнаты;
- `AdminClient.call` выпускает токен под конкретную комнату каждого вызова
(GetParticipant, MutePublishedTrack, RemoveParticipant, ListParticipants);
- тест на состав грахта и отказ при пустой комнате;
- e2e: администратор и гость работают в отдельных api-контекстах (код 2FA
одноразовый), нажатие «Войти» в комнату не ждёт стабильности исчезающей
кнопки.
Проверка голоса двумя реальными браузерами (AGENT.md 11.2): два контекста с
фейковыми медиа-устройствами входят в одну комнату, инструментируется
`RTCPeerConnection` и по `inbound-rtp` подтверждается, что аудио реально
доходит; затем серверный мьют блокирует микрофон у гостя, а отключение
участника убирает его плитку у обоих клиентов.
- `playwright.config.ts`: прогон против развёрнутого инстанса (`GLCHAT_URL`),
трейс при падении, флаги фейковых медиа-устройств;
- `e2e/voice.spec.ts`: подготовка через REST (сервер, комната, инвайт, гость),
завершение онбординга, уборка тестовых серверов и включение только по
`GLCHAT_E2E_VOICE=1`;
- `tsconfig.e2e.json` и запись в eslint для новых файлов.
Клиентская часть Фазы 3 (AGENT.md 7.13, 7.14): звуки сервера, локальные
звуки интерфейса и телеметрия медиа.
- саундборд: `api/sounds.ts` (лимиты 512 КБ и 30 на вид, multipart-загрузка,
play, `SOUND_EVENTS`), `lib/soundboard.ts` (blob-кэш `/files/{id}`,
наложение до 8 звуков, громкость = `outputVolume`, тишина при дефене),
попап в панели комнаты (`USE_SOUNDBOARD`), таймер на `rate_limited`,
подпись «включил звук» и защита от двойного воспроизведения;
- звуковая палитра: `SOUNDBOARD_PLAY` и события сервера (вход/выход
участника, упоминание, сообщение, вход/выход из голосовой, свои
микрофон/камера/экран, серверный мьют) → `playEventSound`;
- секция «Звуки» в настройках сервера (`MANAGE_SOUNDS`): два вида со
счётчиками, drag&drop, имя, эмодзи, событие палитры, переименование,
удаление и клиентские проверки файла;
- push-to-talk: режим и клавиша (`event.code`), хук в `AppLayout`, вход в
комнату с выключенным микрофоном, удержание включает публикацию, отпускание
и потеря фокуса выключают, серверный мьют приоритетнее;
- статистика: поповер с разрешением, fps, кодеком, битрейтом, RTT, потерями
и jitter (опрос раз в секунду, остановка при закрытии), выбор качества
публикации low/medium/high, пресеты шаринга экрана 1080p60…480p30;
- данные: `guilds[].sounds` из READY, `GUILD_SOUNDS_UPDATE`, селектор
`useGuildSounds`, тумблеры звуков в «Аудио-видео» (localStorage);
- тесты: `soundboard`, `soundSettings`, `pushToTalk`, `voiceStats` и
расширенный фейковый LiveKit SDK.
LiveKit SDK сам дописывает сигнальный путь (`/rtc`, `/rtc/v1`) к базовому
адресу, поэтому `wss://host/rtc` из конфига превращался в `wss://host/rtc/rtc`
и комната навсегда оставалась в состоянии «Подключение…» (AGENT.md 7.14).
- `voice.ResolveLiveKitURL` снимает завершающий сегмент `/rtc`, сохраняя
префикс прокси (`wss://host/livekit/rtc` → `wss://host/livekit`);
- `POST /channels/{id}/voice/join` отдаёт адрес через `VoiceURLForClient` и
отвечает `voice.disabled`, если адрес не разрешился;
- тесты: таблица адресов, ожидание `wss://gl.test` в голосовом сценарии.
AGENT.md 7.13, 7.14:
- `internal/voice/admin.go` — клиент RoomService (Twirp/JSON, без SDK):
GetParticipant, ListParticipants, MutePublishedTrack, RemoveParticipant с
административным токеном; внутренний адрес SFU задаётся LIVEKIT_API_URL;
- серверный мьют теперь применяется и на стороне SFU (клиент не обойдёт),
кик из голосовой (DELETE /guilds/{id}/voice-states/{user_id}) удаляет
участника из комнаты через RemoveParticipant, перемещение — тоже;
- саундборд и звуковая палитра: таблица `guild_sounds` (миграция 00010),
загрузка MP3/OGG/WAV/WebM/M4A до 512 КБ с MANAGE_SOUNDS, отдельные лимиты
30 + 30, переименование, удаление, список с фильтром по виду;
- проигрывание POST /guilds/{id}/sounds/{sound_id}/play: нужно право
USE_SOUNDBOARD и присутствие в голосовой комнате, событие SOUNDBOARD_PLAY
уходит только участникам этой комнаты (звук играют клиенты), лимит 3 звука
за 10 секунд; набор звуков приходит в READY и обновляется событием
GUILD_SOUNDS_UPDATE;
- тесты: права на загрузку, требование события для звука интерфейса, запрет
проигрывания вне комнаты, лимит частоты, переименование и удаление.
- `livekit-client` подключён как ленивый чанк: SDK грузится только при входе в
комнату, стартовый бандл не вырос;
- `src/lib/livekit.ts` — единственное место знания о SDK: подключение, дорожки
микрофона/камеры/экрана, снимки участников (речь, флаги, качество связи),
выбор устройств на лету, разбор exp токена;
- `src/stores/voiceConnection.ts` — сессия голоса: статусы
idle/connecting/connected/reconnecting/error, продление токена за минуту до
истечения с переподключением и повторным применением флагов, серверный мьют и
глушение, громкость участников, перемещение, подсказка «говорите с выключенным
микрофоном» не чаще раза в 5 с;
- UI: состав голосовой комнаты в сайдбаре (речь, флаги, качество), панель
комнаты с плитками и модерацией, меню участника (личная громкость, мьют,
перемещение), drag&drop между голосовыми комнатами, кнопки панели пользователя
управляют реальной сессией, при выключенном голосе — понятная подсказка;
- 368 тестов Vitest (было 330), check/lint/test/build зелёные.
LiveKit читает livekit.yaml только при старте, а compose не замечает правок в
примонтированном файле — из-за этого вебхуки не включались после обновления.
Установщик сравнивает sha256 конфига с сохранённым и перезапускает сервис.
В конфиг LiveKit добавлен блок `webhook`: адрес
`http://app:8080/api/v1/livekit/webhook` внутри compose-сети и ключ подписи.
Установщик передаёт ключ в рендер livekit.yaml. Так голосовые состояния
синхронизируются с SFU без опроса (AGENT.md 7.14).
AGENT.md 7.14:
- POST /api/v1/livekit/webhook: подпись проверяется JWT-токеном, подписанным
API-секретом, с проверкой sha256 тела (иначе 401), при выключенном голосе —
503; обработка идемпотентна по event.id (повторная доставка → duplicate);
- события: participant_joined восстанавливает состояние после рестарта
приложения, participant_left и participant_connection_aborted убирают
участника, room_finished очищает комнату целиком, track_published и
track_unpublished обновляют флаги камеры и демонстрации экрана; каждое
изменение рассылает VOICE_STATE_UPDATE участникам сервера;
- сторож `StartVoiceWatchdog` раз в 15 минут удаляет состояния без обновлений
дольше шести часов (сбои без вебхука) и уведомляет клиентов;
- тесты: подпись (чужой секрет, другое тело, отсутствие заголовка), полный
цикл событий SFU и идемпотентность повторной доставки.
Шаблон compose подставляет переменные из явного списка: LIVEKIT_PUBLIC_URL не
был в нём, поэтому приложение получало пустой адрес LiveKit и голос оставался
выключенным. Добавлены LIVEKIT_API_KEY/SECRET/PUBLIC_URL в рендер compose.
Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL
стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS,
dry-run проходит.
AGENT.md 7.14, начало Фазы 3:
- `internal/voice`: собственный подписыватель токенов LiveKit (HS256, grants
roomJoin/canPublish/canSubscribe/canPublishData) и адрес комнаты
`guild_{g}_channel_{c}` — без внешних зависимостей;
- таблица `voice_states` (миграция 00009) и store-методы: вход, флаги
(микрофон, звук, камера, экран), серверный мьют и глушение, перемещение,
выход, подсчёт участников комнаты;
- ручки: POST /channels/{id}/voice/join (проверяет CONNECT_VOICE, тип комнаты,
лимит участников и выдаёт токен), POST /channels/{id}/voice/leave,
PATCH /guilds/{id}/voice-states/@me, GET /guilds/{id}/voice-states,
PATCH /guilds/{id}/voice-states/{user_id} (MUTE_MEMBERS/DEAFEN_MEMBERS,
аудит), POST /guilds/{id}/voice-states/{user_id}/move (MOVE_MEMBERS);
- события VOICE_STATE_UPDATE всем участникам сервера, системные записи о входе
в голосовую комнату, READY отдаёт голосовые состояния серверов;
- конфиг: LIVEKIT_API_KEY/SECRET/URL приложению, публичный адрес
`LIVEKIT_PUBLIC_URL` (по умолчанию ws(s)://<домен>/rtc) в установщике и
compose; `/api/v1/meta` сообщает voice_enabled и voice_url;
- тесты: claims токена LiveKit и подпись, выключенный голос, имя комнаты, вход
и флаги, запрет мьюта без прав, перемещение и выход.
AGENT.md 7.6, 7.12:
- таблица `guild_emojis` (миграция 00008), загрузка PNG/JPEG/WebP/GIF до 512 КБ
с правом MANAGE_EMOJIS, имя `[a-zA-Z0-9_]{2,32}`, лимит 100 на сервер;
- ручки: список эмодзи сервера, переименование, удаление (с удалением файла),
загрузка multipart; аудит emoji.create/update/delete и событие
GUILD_EMOJIS_UPDATE всем участникам сервера;
- кастомные эмодзи в READY (`guilds[].emojis` с токеном `<:name:id>`), реакции
кастомными эмодзи проверяются по файлу, неизвестный эмодзи — 422;
- файлы эмодзи доступны авторизованным (используются в чужих сообщениях);
- системные сообщения: создание комнаты, вход участника (по приглашению и в
публичный сервер) и исключение участника пишутся в ленту типом `system`;
- тесты: права на загрузку, список, реакция кастомным эмодзи, переименование и
удаление, фильтрация системных записей в проверках истории.
Позиционирование переведено на прямые стили и класс `gl-anchored-panel`:
React не управляет left/top/max-height, поэтому повторный рендер не сбрасывает
раскладку, а `data-placement` задаёт направление анимации (панель может
развернуться вниз, если сверху мало места). Линтер по хукам чистый, 289 тестов
зелёные.