17 Commits

Author SHA1 Message Date
45c997380f release: версия 0.0.21
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 19:57:51 +03:00
173d384f06 feat(admin): рычаги нагрузки медиа — потолок качества публикации и лимит плиток
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
instance_settings.media_limits (publish_quality_cap: off/720p/360p/180p,
stage_max_tiles: 4/9/16/25) — новая вкладка «Нагрузка» в админке, дефолты
(off/25) сохраняют текущее поведение существующих инсталляций.

Настройка отдаётся не только GET /admin/settings, но и в join-ответе
(JoinOut) — участнику нужно иметь её на руках ДО публикации трека, а
/admin/settings доступен только администратору.

Потолок качества применяется через RoomOptions.publishDefaults
(videoEncoding + videoSimulcastLayers на пресетах VideoPresets LiveKit) —
режет битрейт верхнего слоя симулкаста, реальное разрешение WebRTC
подстраивает сам. Лимит плиток — фильтрация STAGE_GRID_LAYOUTS по
columns*rows в StageGrid, лишние участники уходят на страницу пагинации
вместо подписки.

Значение приезжает в joinState вместе с токеном ДО первого рендера
LiveKitRoom (RoomPage не рендерит его, пока joinState не заполнен целиком),
поэтому смена настройки не переподключает уже вошедшего участника —
roomOptions пересчитывается по стабильной ссылке на joinState, которая
после подключения не меняется.

Проверено вживую на локальном стенде (docker compose --profile media):
сохранение/персист настроек, join отдаёт актуальные значения, уже
подключённый участник не разрывается при смене настройки в другом окне.
2026-08-02 19:53:23 +03:00
7b2427535a release: версия 0.0.20
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 12:34:04 +03:00
8e45038251 fix(livekit): один UDP-порт вместо диапазона на 101 порт
Диапазон 54000-54100/udp заставлял Docker поднимать по отдельному
docker-proxy на каждый порт — весь медиатрафик шёл лишним userland-хопом.
rtc.udp_port переключает LiveKit на мультиплексирование ICE-сессий через
один порт; TURN и остальные связи (redis/nginx/webhook/prometheus) не
задеты. Проверено локально lk load-test — 0% потерь пакетов, ICE во всех
сессиях выбирает новый порт.
2026-08-02 12:33:53 +03:00
286c01d93b release: версия 0.0.19
Some checks failed
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
2026-08-02 05:03:05 +03:00
e8af2fce10 fix(room): адаптивный тулбар — плавное сжатие кнопок вместо оверфлоу
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Задачи B1/B2 добавили в тулбар «Рука» и «Очередь» (до 11 кнопок вместо
исходных 8) — на промежуточных ширинах (~600–1100px) кнопки вылезали за
края тулбара: жёсткого мобильного брейкпоинта (≤600px, скрывает три
кнопки) не хватало, а до него сжатия не было вовсе.

Иконка/отступы/зазоры/шрифт кнопок теперь плавно уменьшаются на диапазоне
1200px → 600px через `clamp()` с явной линейной интерполяцией (обычный
`clamp(min, Nvw, max)` не подошёл — подобранный N упирался в потолок уже на
довольно широких экранах, сжатие получалось резким скачком заметно раньше
нужной ширины, а не плавным). Нижние границы совпадают с тем, что жёстко
выставляет мобильный медиа-запрос на 600px — переход в него визуально
бесшовный.

Побочный эффект сжатия: подпись «Мини-окно» на промежуточных ширинах
переносилась на 2 строки и делала эту кнопку выше соседних. Ниже 1200px
показываем короткое «Мини» вместо полной подписи — кнопка остаётся
однострочной; aria-label по-прежнему несёт полный смысл для скринридеров.
2026-08-02 05:00:22 +03:00
d7ae462ed5 fix(room): очередь поднятых рук — компактный поповер вместо панели во весь экран
HandQueuePanel рендерился как боковая панель на весь экран на мобильном
(.chat-panel, унаследовано от чата) — для короткого списка поднятых рук это
избыточно, история переписки тут ни при чём. Переделано в HandQueueMenu —
тот же самодостаточный поповер над кнопкой, что и «Вид» (StageViewMenu):
своё состояние открытия, закрытие по клику вне/Escape, .tb-menu вместо
боковой панели. Кнопка «Очередь» больше не получает open-состояние
пропсами сверху — сама решает, открыта ли, и сама проверяет
useIsOrganizer().

Список внутри растёт вместе с очередью (компактно при 1–2 поднятых руках),
но не бесконечно — после ~10 строк упирается в max-height и скроллится
дальше.
2026-08-02 04:58:56 +03:00
826b7639b1 fix(chat): специфичность CSS эмодзи-поповера + сетка 6×5
Кнопка эмодзи и все кнопки внутри поповера лежат в DOM внутри
.chat-input-row (форма отправки) — той же формы, что и круглая зелёная
кнопка «Отправить» (.chat-input-row button, специфичность 0,1,1). Голого
класса .chat-emoji-trigger/.chat-emoji-option (0,1,0) для победы над этим
правилом не хватало: все кнопки поповера красились в зелёный независимо от
порядка объявления в файле — оттуда и «поплывший» вид после добавления
коня, дело было не в самом коне. Каждый селектор уточнён родительским
классом (.chat-emoji-wrap/.chat-emoji-popover), чтобы обойти гонку
специфичности.

Заодно сетка приведена к ровным 5 колонкам × 6 строкам — добавлены
🦾 🚀 🦞 💯 🤷‍♂️, теперь 30 эмодзи без неполной последней строки.
2026-08-02 04:57:39 +03:00
daaa480f03 release: версия 0.0.18
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-01 23:42:53 +03:00
c60047c594 fix(conferences): rate limit блокировал вход всей конференции сразу
Два бага в одном месте, оба вскрылись на нагрузочном тесте 31.07.2026.

1. Ключ лимита строился по `request.client.host`. Backend стоит за nginx,
   поэтому это адрес КОНТЕЙНЕРА NGINX, одинаковый для всех пользователей.
   Проверено на проде: в Redis лежал единственный ключ
   `rate_limit:resolve:172.18.0.13`. То есть лимит «10 запросов в минуту»
   действовал на весь инстанс разом, а не на клиента.

2. Считались все запросы подряд, включая успешные. Одиннадцатый человек,
   открывший ссылку на конференцию в течение минуты, получал 429 — и видел
   «Не удалось найти конференцию» для существующей и активной конференции.
   Люди попадали внутрь с пятой-десятой попытки, попадая в новое окно.

Что изменилось:
- адрес клиента берётся из `X-Real-IP` (nginx его уже передаёт). Именно
  `X-Real-IP`, а не первый элемент `X-Forwarded-For`: последний заполняется
  через `$proxy_add_x_forwarded_for`, то есть дописывается к присланному
  клиентом, и лимит обходился бы одним заголовком;
- жёсткий счётчик (10/мин, как было) теперь считает только ПРОМАХИ:
  конференция не найдена или пароль неверен. Именно так выглядит перебор
  номера, от которого лимит и защищает по ADR-001, п.4;
- на общий поток с адреса оставлен мягкий потолок 300/мин — против тупого
  флуда. Офис за общим NAT это один адрес, поэтому потолок заведомо выше
  правдоподобного числа участников одной конференции.

Тесты: успешные резолвы и гостевые входы не упираются в лимит (50 и 30
подряд); перебор номера, несуществующий идентификатор и подбор пароля
по-прежнему упираются; лимит одного клиента не задевает другого.
2026-08-01 23:42:36 +03:00
f39c21e7e1 release: версия 0.0.17
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-01 23:20:31 +03:00
84b7f807f7 fix(auth): проверка пароля больше не блокирует весь backend
На нагрузочном тесте 31.07.2026 около 70 человек заходили одновременно.
Вход развалился: p95 `/api/v1/auth/token` — 7.28 с, p95 `guest-join` —
7.06 с, в БД 33 соединения `idle in transaction` при ОДНОМ активном
запросе. Люди попадали внутрь с пятой-десятой попытки, часть не попала
вовсе. Медиа при этом работало штатно: 30 участников с 27 камерами в
следующем окне прошли без единого лага.

Причина — argon2 считался синхронно внутри async-обработчика. Замер на
боевом сервере: 95–155 мс на одну проверку, и всё это время event loop
процесса стоит целиком. Транзакция БД к тому моменту уже открыта
(`get_by_email` сделал SELECT), поэтому соединение висело без работы, пул
из 40 выбирался, и отказы получали совершенно посторонние ручки — включая
вход в конференцию, где никакого пароля не проверялось.

Что изменилось:
- `hash_password`/`verify_password` стали асинхронными и считаются в пуле
  потоков (`asyncio.to_thread`). argon2-cffi освобождает GIL, поэтому
  проверки идут по-настоящему параллельно;
- параметры argon2id заменены с дефолтов библиотеки (t=3, m=64 МБ, p=4) на
  рекомендацию OWASP (t=2, m=19 МБ, p=1): 95 мс → 42 мс. Отдельно важен
  `parallelism`: при p=4 одна проверка пароля занимала все четыре ядра
  сервера — те же, на которых работает LiveKit;
- добавлен `needs_rehash`: существующие хэши проверяются как прежде
  (параметры зашиты в саму строку) и лениво перевыпускаются при первом
  успешном входе.

Расчёт по замерам: пачка из 70 логинов — 6.7–10.9 с блокировки против
~0.36 с без неё.

Тесты: event loop продолжает тикать во время проверки; 8 параллельных
проверок укладываются заметно быстрее восьми последовательных; хэш со
старыми параметрами принимается и перевыпускается при входе.
2026-08-01 23:19:52 +03:00
e5c596f2bf release: версия 0.0.16
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-01 22:08:34 +03:00
4c60e092e5 feat(room): принудительный мьют участника организатором
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Новый эндпоинт POST /conferences/{id}/mute-participant: права проверяются
ЗАНОВО по владельцу конференции в БД (ConferenceService.mute_participant),
не по метаданным LiveKit-токена вызывающего — те лишь подсказка для UI и
потенциально подделываемы клиентом. Обычный участник получает 403, чужая/
несуществующая конференция — 404, участник не в комнате LiveKit — отдельный
404 (participant_not_in_room).

Само выключение — серверный вызов api.LiveKitAPI (services/room_control.py,
тот же паттерн, что services/egress.py): backend аутентифицируется
СОБСТВЕННЫМИ api_key/api_secret, а не токеном организатора, поэтому
дополнительный LiveKit-грант в токене организатора не нужен — мьютит сервер
от своего имени. Если трек данного source не опубликован (с 0.0.15 участники
заходят с выключенными микрофоном/камерой) — не ошибка, а no-op: искомое
состояние уже достигнуто, ответ muted:false.

Уведомление участника — тот же общий канал комнаты, что и очередь рук
(hand_queue_channel): рассылается всем, получатель сам сверяет identity
(ForcedMuteWatcher, рендерится внутри LiveKitRoom). Само выключение трека
участник видит сразу через штатный useTrackToggle (LiveKit сам присылает
TrackMuted), тост только поясняет причину — иначе не отличить от глюка.
Включить себя обратно можно сразу тем же тулбаром, сервер это не блокирует.

Кнопки — на чужой плитке камеры, видны только организатору по наведению
(на тач-устройствах — всегда, как и булавка закрепления).

Тесты: владелец мьютит успешно и публикует broadcast, уже-выключенный трек —
muted:false без broadcast, администратор мьютит чужую конференцию, обычный
участник получает 403 без обращения к LiveKit, конференция не найдена и
участник не в комнате — соответствующие 404.
2026-08-01 22:07:06 +03:00
8e5eda88a2 feat(room): поднятие руки и очередь для организатора
Транспорт — существующий аутентифицированный WS чата (api/chat.py), а не
отдельный эндпоинт: сервер уже держит это соединение на каждого участника
(обоснование — докстринг chat_websocket и useChat.ts). Состояние очереди —
Redis (services/hand_queue.py), не Postgres: это эфемерное состояние звонка,
а не история, и два процесса uvicorn делают наивную память одного процесса
недостаточной. HSETNX даёт идемпотентное «поднять» (повторный клик не
переставляет в конец очереди), снапшот шлётся всем участникам при любом
изменении — организатор, зашедший позже, сразу видит актуальную картину.

Опустить чужую руку может организатор (решение оператора) — проверка через
conference.owner_id, не через identity клиента. Участник, вышедший из
комнаты LiveKit (webhook participant_left), теряет место в очереди
автоматически; переподключение WS чата место не сбрасывает (Redis не привязан
к жизни соединения). room_finished чистит очередь целиком — она не должна
пережить завершение звонка.

Побочный эффект транспортного решения: поднять руку нельзя, если чат выключен
настройкой инстанса (WS вообще не открывается) — принятый компромисс ради
переиспользования уже готового канала.

UI: кнопка «Рука» в тулбаре (у всех, бейдж — общий счётчик), бейдж на плитке
говорящего (видно всем), панель «Очередь» организатору (HandQueuePanel).
Кнопка «Рука» и панель «Очередь» намеренно НЕ прячутся в мобильную шторку
настроек, в отличие от «Вида», — поднятие руки посреди разговора требует
кнопки под рукой, а не в два клика вглубь настроек.

Этим же коммитом (файлы разделяемые с задачей B2, RoomParticipantTile.tsx/
useChat.ts/RoomStage.tsx/RoomPage.tsx/room.css) — проброс conferenceId и
каркас forced_mute-обработки, без которых кнопки принудительного мьюта не
скомпилировались бы; сама реализация мьюта — следующим коммитом.
2026-08-01 22:06:43 +03:00
42bfb88a22 feat(room): роль организатора в метаданных LiveKit-токена
Предварительная работа для очереди рук и принудительного мьюта (B1/B2):
build_join кладёт is_organizer:true в метаданные токена организатора
(создатель мгновенной конференции и владелец при обычном входе). Метаданные
токена — только подсказка для UI (см. предупреждение в докстринге
build_join), любое серверное действие организатора обязано перепроверяться
по conference.owner_id в БД — так и сделано в mute_participant (B2).

На фронте — общий парсер метаданных участника (lib/participantMetadata.ts,
переиспользован в RoomParticipantTile вместо локальной копии) и хук
useIsOrganizer (читает подсказку для локального участника через
useLocalParticipant — вызывается только внутри LiveKitRoom).
2026-08-01 22:06:10 +03:00
07dc1aaeef feat(chat): выбор эмодзи скачущего коня
Добавлен 🐎 в набор EMOJI_OPTIONS по просьбе оператора.
2026-08-01 22:04:44 +03:00
58 changed files with 2794 additions and 178 deletions

View File

@@ -112,7 +112,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой # install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно. # установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.15 VIDCONF_VERSION=0.0.21
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного # --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг, # `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -3,6 +3,124 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/). проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.21] — 2026-08-02
Рычаги нагрузки медиа в админке: потолок качества публикации и лимит плиток.
### Добавлено
- Настройка инстанса «Потолок качества публикации видео» (без ограничения /
720p / 360p / 180p) — режет битрейт исходящего видео публикующего через
`publishDefaults` LiveKit, снижает нагрузку на его канал и устройство.
Дефолт — без ограничения, поведение существующих инсталляций не меняется.
- Настройка инстанса «Максимум плиток на экране» (25 / 16 / 9 / 4) — участники
сверх лимита уходят на следующую страницу сетки вместо подписки на их
видеотреки, меньше одновременных видеопотоков на канал и экран участника.
Дефолт — 25 (текущий максимум сетки 5×5), без изменений.
- Обе настройки доступны в новой карточке «Нагрузка» вкладки «Настройки»
админки и отдаются участнику вместе с токеном входа в конференцию — ещё до
подключения к комнате, чтобы применяться до публикации трека и не вызывать
переподключение уже вошедших участников при смене настройки.
## [0.0.20] — 2026-08-02
Сеть LiveKit: один UDP-порт вместо диапазона на 101 порт.
### Исправлено
- Весь медиа-трафик конференций шёл через userland-прокси Docker: диапазон
`54000-54100/udp` заставлял поднимать по отдельному процессу `docker-proxy`
на каждый порт. LiveKit переведён на `rtc.udp_port` (один порт,
мультиплексирование ICE-сессий по ufrag внутри самого сервера) — проброс
портов схлопнут до одного, TURN не затронут. Проверено локально
синтетической нагрузкой (`lk load-test`, 2 видео + 2 аудио publisher'а,
2 subscriber'а) — 0% потерь пакетов, ICE во всех сессиях выбирает новый
единственный порт.
## [0.0.19] — 2026-08-02
Правки по замечаниям к части B (комната конференции).
### Исправлено
- Очередь поднятых рук открывалась боковой панелью во весь экран на
мобильном — теперь компактный поповер над кнопкой (как «Вид»), размер
подстраивается под число записей, после ~10 строк список скроллится.
- Кнопки нижнего тулбара при сужении окна вылезали за края блока (задачи
B1/B2 добавили «Рука»/«Очередь», в тулбаре стало до 11 кнопок вместо
восьми) — теперь плавно уменьшаются на диапазоне 1200600px вместо
жёсткого скачка на мобильный вид.
- Подпись «Мини-окно» на промежуточных ширинах переносилась на 2 строки и
делала эту кнопку выше соседних — ниже 1200px показывается короткое
«Мини».
- Эмодзи-поповер в чате красил все свои кнопки в зелёный цвет кнопки
«Отправить» (гонка специфичности CSS-селекторов) — исправлено; заодно
сетка приведена к ровным 5×6 без неполной строки, добавлены
🦾 🚀 🦞 💯 🤷‍♂️.
## [0.0.18] — 2026-08-01
Ограничение частоты запросов больше не блокирует вход целой конференции.
### Исправлено
- Лимит на резолв конференции и гостевой вход считался по адресу контейнера
nginx, а не клиента, — то есть «10 запросов в минуту» действовали на весь
сервер разом. Одиннадцатый человек, открывший ссылку в течение минуты,
получал отказ и видел «Не удалось найти конференцию» для существующей и
активной конференции. Теперь адрес берётся из заголовка, который nginx уже
передаёт.
- Счётчик считает только неудачные попытки — конференция не найдена или
пароль неверен. Именно так выглядит перебор номера, от которого защищает
ограничение; массовый вход по рабочей ссылке к нему отношения не имеет.
На общий поток с адреса оставлен потолок в 300 запросов в минуту.
## [0.0.17] — 2026-08-01
Массовый вход в систему и в конференцию перестаёт упираться в проверку пароля.
### Исправлено
- Проверка пароля больше не останавливает весь backend. Хэширование argon2 —
это десятки миллисекунд счёта, и выполнялось оно синхронно внутри
асинхронного обработчика: пока считался один пароль, процесс не обслуживал
ничего другого. На нагрузочном тесте 31.07 с примерно семью десятками
одновременных входов это дало p95 логина 7.28 секунды, p95 входа в
конференцию 7.06 секунды и 33 соединения к базе, висящих в открытой
транзакции при одном активном запросе. Страдали и посторонние запросы —
вход в конференцию отказывал, хотя пароль там не проверялся вовсе.
Теперь хэширование считается в пуле потоков.
- Параметры argon2id приведены к рекомендации OWASP (t=2, m=19 МБ, p=1)
вместо дефолтов библиотеки (t=3, m=64 МБ, p=4): 95 мс против 42 мс на
проверку. Прежнее значение `parallelism=4` вдобавок занимало все четыре
ядра сервера — те же, на которых работает медиа-сервер.
- Пароли, сохранённые со старыми параметрами, продолжают работать и
перевыпускаются автоматически при первом успешном входе.
## [0.0.16] — 2026-08-01
Роль организатора в комнате: поднятие руки с очередью и принудительный мьют.
### Добавлено
- Роль организатора прокинута в комнату — метаданные LiveKit-токена несут
подсказку `is_organizer` для UI (владелец конференции/администратор);
любое серверное действие организатора перепроверяется по владельцу
конференции в БД, метаданным токена для авторизации не доверяем.
- Поднятие руки: кнопка «Рука» в тулбаре (у всех участников, с общим
счётчиком), бейдж на плитке говорящего, видимый всем, и панель «Очередь»
для организатора — участники в порядке поднятия руки, с возможностью
опустить любую руку. Состояние — в Redis (не в БД): очередь существует
ровно во время звонка. Участник, вышедший из конференции, автоматически
исчезает из очереди; переподключение место не теряет.
- Принудительный мьют: организатор может выключить микрофон или камеру
любого участника — кнопки на его плитке. Участник получает уведомление,
что его выключил организатор, и может включить себя обратно сразу тем же
тулбаром.
- Эмодзи скачущего коня 🐎 в наборе смайликов чата.
### Изменено
- Транспорт чата (`WS /conferences/{id}/chat`) расширен: очередь поднятых
рук и уведомления о мьюте едут по тому же аутентифицированному
соединению, что и сообщения чата — без нового эндпоинта.
Миграций БД нет — новое состояние (очередь поднятых рук) хранится в Redis,
не в Postgres.
## [0.0.15] — 2026-07-30 ## [0.0.15] — 2026-07-30
Шесть доработок UI комнаты конференции. Шесть доработок UI комнаты конференции.

View File

@@ -1 +1 @@
0.0.15 0.0.21

View File

@@ -217,7 +217,7 @@ async def create_user(
user = await repo.create( user = await repo.create(
email=data.email, email=data.email,
name_user=data.name_user, name_user=data.name_user,
password_hash=hash_password(data.password), password_hash=await hash_password(data.password),
team_id=data.team_id, team_id=data.team_id,
) )
user.email_verified = True user.email_verified = True
@@ -467,6 +467,8 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
registration_email_domains=cfg.registration_email_domains, registration_email_domains=cfg.registration_email_domains,
contact_email_enabled=cfg.contact_email_enabled, contact_email_enabled=cfg.contact_email_enabled,
contact_email=cfg.contact_email, contact_email=cfg.contact_email,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
) )

View File

@@ -1,10 +1,17 @@
"""WS-роутер текстового чата конференции: `WS /api/v1/conferences/{id}/chat`. """WS-роутер комнаты конференции: `WS /api/v1/conferences/{id}/chat`.
Протокол: `connect` -> `accept()` -> клиент шлёт `{"type":"auth","token":...}` Протокол: `connect` -> `accept()` -> клиент шлёт `{"type":"auth","token":...}`
первым сообщением (таймаут 10 с; токен не query-параметр — не палим его в первым сообщением (таймаут 10 с; токен не query-параметр — не палим его в
логах nginx) -> сервер проверяет тоггл `chat.enabled` и LiveKit-токен -> логах nginx) -> сервер проверяет тоггл `chat.enabled` и LiveKit-токен ->
история последних 50 сообщений открытой сессии -> двунаправленный обмен история последних 50 сообщений чата + текущая очередь поднятых рук ->
`{"type":"message","text":...}` через Redis pub/sub (echo отправителю тоже). двунаправленный обмен: `{"type":"message","text":...}` (чат, Redis pub/sub,
echo отправителю тоже), `{"type":"raise_hand"}`/`{"type":"lower_hand"}`
(очередь рук, задача B1 — состояние в Redis, см. `services/hand_queue.py`,
НЕ в БД: это эфемерное состояние звонка, а не история). Название файла и
эндпоинта («чат») оставлено как есть — эндпоинт исторически первый и
единственный аутентифицированный WS комнаты, поэтому очередь рук едет по
нему же, а не заводит отдельное соединение (дешевле: сервер уже держит
это соединение на каждого участника).
""" """
import asyncio import asyncio
@@ -13,7 +20,7 @@ import uuid
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from pydantic import ValidationError from pydantic import Field, TypeAdapter, ValidationError
from redis.asyncio.client import PubSub from redis.asyncio.client import PubSub
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -28,6 +35,8 @@ from schemas.chat import (
ChatMessageIn, ChatMessageIn,
ChatMessageOut, ChatMessageOut,
) )
from schemas.room_events import LowerHandIn, RaiseHandIn
from services import hand_queue
from services.chat import ChatAuthError, ChatIdentity, ChatService, InvalidTokenError, chat_channel from services.chat import ChatAuthError, ChatIdentity, ChatService, InvalidTokenError, chat_channel
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -37,6 +46,14 @@ router = APIRouter(prefix="/api/v1/conferences", tags=["chat"])
# Таймаут ожидания первого (auth) сообщения клиента. # Таймаут ожидания первого (auth) сообщения клиента.
AUTH_TIMEOUT_SECONDS = 10.0 AUTH_TIMEOUT_SECONDS = 10.0
# Дискриминированное объединение сообщений клиента ПОСЛЕ auth — по полю `type`.
_ClientEnvelope = Annotated[
ChatMessageIn | RaiseHandIn | LowerHandIn, Field(discriminator="type")
]
_client_envelope_adapter: TypeAdapter[ChatMessageIn | RaiseHandIn | LowerHandIn] = TypeAdapter(
_ClientEnvelope
)
@router.websocket("/{conference_id}/chat") @router.websocket("/{conference_id}/chat")
async def chat_websocket( async def chat_websocket(
@@ -44,7 +61,7 @@ async def chat_websocket(
conference_id: uuid.UUID, conference_id: uuid.UUID,
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
) -> None: ) -> None:
"""WS-эндпоинт текстового чата конференции — единая аутентификация LiveKit-токеном.""" """WS-эндпоинт комнаты конференции — единая аутентификация LiveKit-токеном."""
await websocket.accept() await websocket.accept()
service = ChatService(session) service = ChatService(session)
@@ -57,21 +74,26 @@ async def chat_websocket(
pubsub = redis_client.pubsub() pubsub = redis_client.pubsub()
channel = chat_channel(conference.id) channel = chat_channel(conference.id)
# Подписка ДО чтения истории: сообщение, room_channel = hand_queue.hand_queue_channel(conference.id)
# опубликованное другим клиентом в окне между SELECT истории и # Подписка ДО чтения истории/снапшота очереди: событие,
# subscribe, иначе теряется для подключающегося клиента — Redis начинает # опубликованное другим клиентом в окне между SELECT/HGETALL и subscribe,
# иначе теряется для подключающегося клиента — Redis начинает
# буферизовать входящие publish для этого соединения сразу после # буферизовать входящие publish для этого соединения сразу после
# subscribe, до первого вызова `get_message`. На стыке возможен дубликат # subscribe, до первого вызова `get_message`. На стыке возможен дубликат
# (то же сообщение и в history, и в первом pub/sub-сообщении) — безопаснее # (то же сообщение чата и в history, и в первом pub/sub-сообщении) —
# дедуплицировать по `id`, чем потерять сообщение. # безопаснее дедуплицировать по `id`, чем потерять сообщение; снапшот
await pubsub.subscribe(channel) # очереди дублировать безвредно (полная замена состояния на клиенте).
await pubsub.subscribe(channel, room_channel)
try: try:
history = await service.history(conference) history = await service.history(conference)
await websocket.send_json(ChatHistoryOut(messages=history).model_dump(mode="json")) await websocket.send_json(ChatHistoryOut(messages=history).model_dump(mode="json"))
seen_ids = {item.id for item in history} seen_ids = {item.id for item in history}
queue_out = await hand_queue.get_snapshot_out(conference.id)
await websocket.send_json(queue_out.model_dump(mode="json"))
async with asyncio.TaskGroup() as tg: async with asyncio.TaskGroup() as tg:
tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, seen_ids)) tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, channel, seen_ids))
tg.create_task(_pump_websocket_to_service(websocket, service, conference, identity)) tg.create_task(_pump_websocket_to_service(websocket, service, conference, identity))
except* WebSocketDisconnect: except* WebSocketDisconnect:
# Штатное закрытие соединения клиентом — не ошибка. # Штатное закрытие соединения клиентом — не ошибка.
@@ -91,7 +113,7 @@ async def chat_websocket(
finally: finally:
# Всегда отписываемся и закрываем pubsub-соединение, иначе при частых # Всегда отписываемся и закрываем pubsub-соединение, иначе при частых
# обрывах соединений копятся забытые подписки на стороне Redis. # обрывах соединений копятся забытые подписки на стороне Redis.
await pubsub.unsubscribe(channel) await pubsub.unsubscribe(channel, room_channel)
# `PubSub.aclose` в redis-py не аннотирован (untyped def) несмотря на # `PubSub.aclose` в redis-py не аннотирован (untyped def) несмотря на
# `py.typed` пакета — узкий игнор именно этого вызова. # `py.typed` пакета — узкий игнор именно этого вызова.
await pubsub.aclose() # type: ignore[no-untyped-call] await pubsub.aclose() # type: ignore[no-untyped-call]
@@ -111,37 +133,71 @@ async def _authenticate(websocket: WebSocket, service: ChatService) -> ChatIdent
async def _pump_pubsub_to_websocket( async def _pump_pubsub_to_websocket(
websocket: WebSocket, pubsub: PubSub, seen_ids: set[int] websocket: WebSocket, pubsub: PubSub, chat_channel_name: str, seen_ids: set[int]
) -> None: ) -> None:
"""Читать сообщения Redis pub/sub канала чата и пересылать их подключённому клиенту. """Читать оба Redis pub/sub канала комнаты (чат + очередь рук) и пересылать клиенту.
`seen_ids` — id сообщений, уже отправленных клиенту в `history` (на `seen_ids` — id сообщений чата, уже отправленных клиенту в `history` (на
стыке подписки и SELECT истории возможен дубликат, см. докстринг стыке подписки и SELECT истории возможен дубликат, см. докстринг
`chat_websocket`) — такие сообщения не пересылаются повторно. `chat_websocket`) — такие сообщения не пересылаются повторно. Снапшоты
очереди рук такой дедупликации не требуют (полная замена состояния).
""" """
while True: while True:
raw = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None) raw = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None)
if raw is None: if raw is None:
continue continue
if raw["channel"] == chat_channel_name:
message = ChatMessageOut.model_validate_json(raw["data"]) message = ChatMessageOut.model_validate_json(raw["data"])
if message.id in seen_ids: if message.id in seen_ids:
continue continue
seen_ids.add(message.id) seen_ids.add(message.id)
await websocket.send_json(ChatMessageEventOut(message=message).model_dump(mode="json")) await websocket.send_json(
ChatMessageEventOut(message=message).model_dump(mode="json")
)
else:
# Канал комнаты (`hand_queue.hand_queue_channel`) — уже готовый
# JSON исходящего конверта (`HandQueueOut`/`ForcedMuteOut`,
# см. `services/hand_queue.py::publish_snapshot` и эндпоинт мьюта
# в `api/conferences.py`), пересылаем как есть без пересборки.
await websocket.send_text(raw["data"])
async def _pump_websocket_to_service( async def _pump_websocket_to_service(
websocket: WebSocket, service: ChatService, conference: Conference, identity: ChatIdentity websocket: WebSocket, service: ChatService, conference: Conference, identity: ChatIdentity
) -> None: ) -> None:
"""Читать текстовые сообщения клиента, валидировать и сохранять+публиковать их.""" """Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать."""
is_organizer = conference.owner_id is not None and conference.owner_id == identity.user_id
while True: while True:
raw = await websocket.receive_text() raw = await websocket.receive_text()
try: try:
envelope = ChatMessageIn.model_validate_json(raw) envelope = _client_envelope_adapter.validate_json(raw)
except ValidationError: except ValidationError:
await websocket.send_json(ChatErrorOut(code="invalid_message").model_dump(mode="json")) await websocket.send_json(ChatErrorOut(code="invalid_message").model_dump(mode="json"))
continue continue
if isinstance(envelope, ChatMessageIn):
await service.persist_and_publish(conference, identity=identity, text=envelope.text) await service.persist_and_publish(conference, identity=identity, text=envelope.text)
elif isinstance(envelope, RaiseHandIn):
await hand_queue.raise_hand(
conference.id, identity=_identity_key(identity), name=identity.author_name
)
await hand_queue.publish_snapshot(conference.id)
else:
target = envelope.identity or _identity_key(identity)
if target != _identity_key(identity) and not is_organizer:
await websocket.send_json(
ChatErrorOut(code="forbidden").model_dump(mode="json")
)
continue
await hand_queue.lower_hand(conference.id, identity=target)
await hand_queue.publish_snapshot(conference.id)
def _identity_key(identity: ChatIdentity) -> str:
"""Identity участника в формате LiveKit/очереди рук — `str(user_id)` либо `guest:{id}`."""
if identity.user_id is not None:
return str(identity.user_id)
return f"guest:{identity.guest_access_id}"
async def _close_quietly(websocket: WebSocket, code: int) -> None: async def _close_quietly(websocket: WebSocket, code: int) -> None:

View File

@@ -9,7 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_current_user from api.deps import get_current_user
from core.db import get_session from core.db import get_session
from core.rate_limit import enforce_rate_limit from core.rate_limit import (
RATE_LIMIT_MISS_MAX_REQUESTS,
RATE_LIMIT_SOFT_MAX_REQUESTS,
client_ip,
enforce_rate_limit,
)
from models.user import User from models.user import User
from schemas.conferences import ( from schemas.conferences import (
ConferenceCreateIn, ConferenceCreateIn,
@@ -18,6 +23,8 @@ from schemas.conferences import (
GuestJoinIn, GuestJoinIn,
JoinIn, JoinIn,
JoinOut, JoinOut,
MuteParticipantIn,
MuteParticipantOut,
OccurrenceOut, OccurrenceOut,
ResolveOut, ResolveOut,
) )
@@ -34,6 +41,7 @@ from services.conferences import (
InviteeUserNotFoundError, InviteeUserNotFoundError,
NotConferenceOwnerError, NotConferenceOwnerError,
) )
from services.room_control import ParticipantNotInRoomError
router = APIRouter(prefix="/api/v1/conferences", tags=["conferences"]) router = APIRouter(prefix="/api/v1/conferences", tags=["conferences"])
@@ -100,10 +108,18 @@ async def resolve_conference(
п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у
join/guest-join), а признак закрытости неактуален для мёртвой конференции. join/guest-join), а признак закрытости неактуален для мёртвой конференции.
""" """
await enforce_rate_limit(f"resolve:{_client_ip(request)}") ip = client_ip(request)
# Мягкий потолок против флуда: успешные резолвы легитимны и массовы —
# вся конференция открывает ссылку в одну минуту.
await enforce_rate_limit(f"resolve:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
service = ConferenceService(session) service = ConferenceService(session)
conference = await service.resolve(q) conference = await service.resolve(q)
if conference is None: if conference is None:
# Жёсткий счётчик — только на промахи: перебор номера конференции
# выглядит именно так (см. core/rate_limit.py и ADR-001, п.4).
await enforce_rate_limit(
f"resolve_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found")
if conference.status == "ended": if conference.status == "ended":
return ResolveOut(id=conference.id, title=conference.title, status=conference.status) return ResolveOut(id=conference.id, title=conference.title, status=conference.status)
@@ -151,11 +167,15 @@ async def guest_join_conference(
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
) -> JoinOut: ) -> JoinOut:
"""Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit.""" """Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit."""
await enforce_rate_limit(f"guest_join:{_client_ip(request)}") ip = client_ip(request)
# Мягкий потолок: успешный гостевой вход — обычное дело для всей
# конференции сразу, ограничивать его числом «10 в минуту» нельзя.
await enforce_rate_limit(f"guest_join:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
service = ConferenceService(session) service = ConferenceService(session)
try: try:
return await service.join_as_guest(conference_id, data=data) return await service.join_as_guest(conference_id, data=data)
except ConferenceNotFoundError as exc: except ConferenceNotFoundError as exc:
await _count_guest_join_miss(ip)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc ) from exc
@@ -166,11 +186,45 @@ async def guest_join_conference(
status_code=status.HTTP_403_FORBIDDEN, detail="password_required" status_code=status.HTTP_403_FORBIDDEN, detail="password_required"
) from exc ) from exc
except InvalidPasswordError as exc: except InvalidPasswordError as exc:
# Подбор пароля закрытой конференции — тот же класс атаки, что и
# перебор номера, поэтому считается жёстким счётчиком.
await _count_guest_join_miss(ip)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password" status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password"
) from exc ) from exc
@router.post("/{conference_id}/mute-participant", response_model=MuteParticipantOut)
async def mute_participant(
conference_id: uuid.UUID,
data: MuteParticipantIn,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> MuteParticipantOut:
"""Принудительно выключить микрофон/камеру участника (задача B2) — владелец/администратор.
Права проверяются ЗАНОВО по владельцу конференции в БД
(`ConferenceService.mute_participant`), а не по метаданным LiveKit-токена
вызывающего — те лишь подсказка для UI и потенциально подделываемы клиентом.
"""
service = ConferenceService(session)
try:
muted = await service.mute_participant(
conference_id, actor=user, target_identity=data.identity, source=data.source
)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except NotConferenceOwnerError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc
except ParticipantNotInRoomError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="participant_not_in_room"
) from exc
return MuteParticipantOut(muted=muted)
@router.get("/{conference_id}", response_model=ConferenceOut) @router.get("/{conference_id}", response_model=ConferenceOut)
async def get_conference( async def get_conference(
conference_id: uuid.UUID, conference_id: uuid.UUID,
@@ -250,6 +304,11 @@ def _require_utc(value: datetime) -> datetime:
return value.astimezone(UTC) return value.astimezone(UTC)
def _client_ip(request: Request) -> str: async def _count_guest_join_miss(ip: str) -> None:
"""IP-адрес клиента для rate limit (без auth — ключ по IP, а не по пользователю).""" """Учесть неудачную попытку гостевого входа в жёстком счётчике.
return request.client.host if request.client else "unknown"
Вынесено отдельно, потому что вызывается из двух веток обработки ошибок
(несуществующая конференция и неверный пароль) и обязано бросать 429
ровно так же, как обычный `enforce_rate_limit`.
"""
await enforce_rate_limit(f"guest_join_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS)

View File

@@ -101,11 +101,11 @@ async def change_current_user_password(
вместе со сбросом пароля по email (v0.1.0, см. ADR-005 вместе со сбросом пароля по email (v0.1.0, см. ADR-005
`docs/architecture/adr/005-password-reset-deferred.md`). `docs/architecture/adr/005-password-reset-deferred.md`).
""" """
if not verify_password(data.current_password, user.password_hash): if not await verify_password(data.current_password, user.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password" status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password"
) )
user.password_hash = hash_password(data.new_password) user.password_hash = await hash_password(data.new_password)
await session.commit() await session.commit()

View File

@@ -62,6 +62,28 @@ SummaryRecipientsMode = Literal["all", "owner"]
"""Режим рассылки саммари по умолчанию: всем участникам либо только """Режим рассылки саммари по умолчанию: всем участникам либо только
владельцу конференции (переопределяется на уровне `conferences.summary_recipients`).""" владельцу конференции (переопределяется на уровне `conferences.summary_recipients`)."""
PublishQualityCap = Literal["off", "180p", "360p", "720p"]
"""Потолок качества исходящего видео участника (задача «рычаги качества
медиа»). `off` — без ограничения (дефолт, поведение как до появления
настройки). Остальные значения режут `publishDefaults.videoEncoding` и
`videoSimulcastLayers` на клиенте (`frontend/src/lib/publishQualityCap.ts`) —
именно битрейт верхнего слоя симулкаста, а не жёсткое разрешение захвата
камеры; фактическое разрешение WebRTC подстраивает под битрейт сам."""
StageMaxTiles = Literal[4, 9, 16, 25]
"""Потолок числа одновременно видимых плиток на сцене (`StageGrid`) —
режет набор доступных раскладок сетки, что бросает лишних участников на
следующую страницу пагинации вместо подписки на их треки. `25` — дефолт,
совпадает с текущим максимумом сетки (5×5), то есть без ограничения."""
class MediaLimitsConfig(BaseModel):
"""Рычаги нагрузки медиа для администратора инстанса (не логика состояния
конференции — статичные потолки, применяются на клиенте при входе)."""
publish_quality_cap: PublishQualityCap = "off"
stage_max_tiles: StageMaxTiles = 25
class InstanceConfig(BaseModel): class InstanceConfig(BaseModel):
"""Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов """Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов
@@ -87,3 +109,9 @@ class InstanceConfig(BaseModel):
# адреса) — см. `services/instance_settings.py`, `services/email.py`. # адреса) — см. `services/instance_settings.py`, `services/email.py`.
contact_email_enabled: bool = False contact_email_enabled: bool = False
contact_email: str | None = None contact_email: str | None = None
# Потолок качества публикации + максимум плиток сцены — см.
# `services/instance_settings.py`. Отдаётся участнику ДО входа в
# LiveKit-комнату (в ответе join, `schemas/conferences.py::JoinOut`), а
# не только в админке — настройка должна быть на руках у клиента до
# публикации трека.
media_limits: MediaLimitsConfig = Field(default_factory=MediaLimitsConfig)

View File

@@ -3,15 +3,59 @@
Используется резолвом конференций и гостевым входом (`api/conferences.py`) — Используется резолвом конференций и гостевым входом (`api/conferences.py`) —
эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции
(см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP). (см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP).
## Два счётчика вместо одного (0.0.18)
Прежняя схема считала ВСЕ запросы подряд с лимитом 10/мин. На нагрузочном
тесте 31.07.2026 это остановило вход целой конференции: люди открывали ссылку
одновременно, одиннадцатый получал 429, а фронтенд показывал «Не удалось найти
конференцию» — при том что конференция существовала и была активна.
Смысл лимита по ADR-001 — защита от ПЕРЕБОРА номера конференции. Перебор — это
поток промахов; легитимный участник открывает существующую ссылку и получает
успех. Поэтому:
- `RATE_LIMIT_MISS_MAX_REQUESTS` — жёсткий счётчик промахов (конференция не
найдена, неверный пароль). Именно он защищает от перебора, и он остался
прежним — 10/мин;
- `RATE_LIMIT_SOFT_MAX_REQUESTS` — мягкий потолок на общее число обращений с
одного адреса. Нужен только против тупого флуда; рассчитан так, чтобы сотня
человек из офиса за общим NAT спокойно зашла в одну конференцию.
""" """
from fastapi import HTTPException, status from fastapi import HTTPException, Request, status
from core.redis import redis_client from core.redis import redis_client
RATE_LIMIT_MAX_REQUESTS = 10 RATE_LIMIT_MAX_REQUESTS = 10
RATE_LIMIT_WINDOW_SECONDS = 60 RATE_LIMIT_WINDOW_SECONDS = 60
# Промахи: перебор номера/ссылки или подбор пароля конференции.
RATE_LIMIT_MISS_MAX_REQUESTS = 10
# Общий поток с одного IP. Офис за общим NAT — это ОДИН адрес, поэтому потолок
# заведомо выше правдоподобного числа участников одной конференции.
RATE_LIMIT_SOFT_MAX_REQUESTS = 300
def client_ip(request: Request) -> str:
"""IP клиента для rate limit — с учётом того, что backend стоит за nginx.
`request.client.host` — это TCP-peer, то есть контейнер nginx, один и тот же
для всех пользователей. С ним лимит превращался в общий на весь инстанс:
на проде в Redis лежал единственный ключ `rate_limit:resolve:172.18.0.13`,
и десяти запросов в минуту хватало, чтобы заблокировать вход всем сразу.
Берём `X-Real-IP`, а НЕ первый элемент `X-Forwarded-For`: nginx заполняет
его через `$proxy_add_x_forwarded_for`, то есть ДОПИСЫВАЕТ к присланному
клиентом. Первый элемент там подделывается одним заголовком, и лимит
обходился бы тривиально. `X-Real-IP` nginx всегда перезаписывает своим
`$remote_addr` (см. deploy/nginx/nginx.conf.template).
"""
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
return request.client.host if request.client else "unknown"
async def enforce_rate_limit( async def enforce_rate_limit(
key: str, key: str,

View File

@@ -1,33 +1,85 @@
"""Хэширование паролей (argon2) и выпуск/проверка JWT (access + refresh).""" """Хэширование паролей (argon2) и выпуск/проверка JWT (access + refresh)."""
import asyncio
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
import jwt import jwt
from argon2 import PasswordHasher from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError from argon2.exceptions import InvalidHashError, VerifyMismatchError
from core.config import get_settings from core.config import get_settings
JWT_ALGORITHM = "HS256" JWT_ALGORITHM = "HS256"
_hasher = PasswordHasher() # Параметры argon2id по рекомендации OWASP (Password Storage Cheat Sheet):
# t=2, m=19 МБ, p=1. Раньше использовались дефолты argon2-cffi
# (t=3, m=64 МБ, p=4) — это был не выбор, а «что было в коробке».
#
# Замер на боевом сервере (4 ядра): 95 мс против 42 мс на одну проверку.
# Отдельно важен `parallelism`: при p=4 ОДНА проверка пароля занимала все
# четыре ядра, конкурируя с LiveKit за то же железо ровно в момент, когда
# люди массово заходят в конференцию.
#
# Существующие хэши не ломаются: параметры хранятся внутри самой строки хэша
# и читаются при verify. Старые хэши перевыпускаются постепенно — см.
# `needs_rehash` и его использование при успешном входе.
_hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
def hash_password(password: str) -> str: def _hash_password_sync(password: str) -> str:
"""Захэшировать пароль алгоритмом argon2 для хранения в БД."""
return _hasher.hash(password) return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool: def _verify_password_sync(password: str, password_hash: str) -> bool:
"""Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются."""
try: try:
return _hasher.verify(password_hash, password) return _hasher.verify(password_hash, password)
except VerifyMismatchError: except VerifyMismatchError:
return False return False
async def hash_password(password: str) -> str:
"""Захэшировать пароль алгоритмом argon2 для хранения в БД.
Считается в отдельном потоке — argon2 это CPU-bound работа на десятки
миллисекунд, и в event loop ей не место (см. `verify_password`).
"""
return await asyncio.to_thread(_hash_password_sync, password)
async def verify_password(password: str, password_hash: str) -> bool:
"""Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются.
Выполняется в пуле потоков, а не в event loop. Причина — нагрузочный тест
31.07.2026: синхронный вызов останавливал весь процесс на 95155 мс, и при
массовом входе (около 70 человек разом) это давало p95 логина 7.28 секунды,
33 соединения к БД в состоянии `idle in transaction` при одном активном
запросе и отказы на совершенно посторонних ручках — включая вход в
конференцию, где никакого пароля не проверялось.
Потоки здесь работают по-настоящему параллельно: argon2-cffi — это
C-расширение, освобождающее GIL на время вычисления.
"""
return await asyncio.to_thread(_verify_password_sync, password, password_hash)
def needs_rehash(password_hash: str) -> bool:
"""Проверить, что хэш выпущен устаревшими параметрами argon2.
Дешёвая операция: разбор строки хэша, без вычислений. Вызывается после
успешной проверки пароля — только тогда у нас на руках открытый пароль,
которым можно перевыпустить хэш.
Невалидную строку считаем требующей перевыпуска: если в базе оказался
мусор, лучше заменить его корректным хэшем, чем падать при каждом входе.
"""
try:
return _hasher.check_needs_rehash(password_hash)
except InvalidHashError:
return True
def create_access_token(user_id: uuid.UUID, role: str) -> str: def create_access_token(user_id: uuid.UUID, role: str) -> str:
"""Выпустить access-токен: `sub`=user_id, `role`=роль, TTL из настроек.""" """Выпустить access-токен: `sub`=user_id, `role`=роль, TTL из настроек."""
settings = get_settings() settings = get_settings()

View File

@@ -6,7 +6,7 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field from pydantic import BaseModel, ConfigDict, EmailStr, Field
from core.plugins.config import AiLevel, SummaryRecipientsMode from core.plugins.config import AiLevel, PublishQualityCap, StageMaxTiles, SummaryRecipientsMode
from schemas.conferences import ConferenceOut from schemas.conferences import ConferenceOut
from services.ai_levels import AiLevelStatus from services.ai_levels import AiLevelStatus
@@ -158,6 +158,10 @@ class SettingsOut(BaseModel):
registration_email_domains: list[str] = Field(default_factory=list) registration_email_domains: list[str] = Field(default_factory=list)
contact_email_enabled: bool contact_email_enabled: bool
contact_email: str | None = None contact_email: str | None = None
# Рычаги нагрузки медиа (`InstanceConfig.media_limits`) — потолок
# качества публикации и максимум плиток сцены, см. `core/plugins/config.py`.
publish_quality_cap: PublishQualityCap
stage_max_tiles: StageMaxTiles
class TestEmailIn(BaseModel): class TestEmailIn(BaseModel):

View File

@@ -5,7 +5,8 @@ from datetime import UTC, datetime, timedelta
from pydantic import BaseModel, EmailStr, Field, field_serializer, field_validator, model_validator from pydantic import BaseModel, EmailStr, Field, field_serializer, field_validator, model_validator
from core.plugins.config import SummaryRecipientsMode from core.plugins.config import PublishQualityCap, StageMaxTiles, SummaryRecipientsMode
from schemas.room_events import ForcedMuteSource
from services.recurrence import RecurrenceRule from services.recurrence import RecurrenceRule
# Допуск в прошлое при плановом создании/правке — небольшой запас на задержку # Допуск в прошлое при плановом создании/правке — небольшой запас на задержку
@@ -129,6 +130,11 @@ class JoinOut(BaseModel):
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает, # Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
# показывать ли UI чата, не дожидаясь ошибки WS-подключения. # показывать ли UI чата, не дожидаясь ошибки WS-подключения.
chat_enabled: bool chat_enabled: bool
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
# прямо в join-ответе, а не только в админке: участнику нужно иметь их
# на руках ДО публикации своего трека (см. `services/conference_access.py`).
publish_quality_cap: PublishQualityCap
stage_max_tiles: StageMaxTiles
class ConferenceOut(BaseModel): class ConferenceOut(BaseModel):
@@ -210,3 +216,21 @@ class GuestJoinIn(BaseModel):
display_name: str = Field(min_length=1, max_length=255) display_name: str = Field(min_length=1, max_length=255)
email: EmailStr | None = None email: EmailStr | None = None
password: str | None = None password: str | None = None
class MuteParticipantIn(BaseModel):
"""Тело запроса принудительного мьюта участника организатором (задача B2).
`identity` — тот же формат, что и `Participant.identity` в LiveKit
(`str(user_id)` либо `guest:{id}`); клиент берёт его из `useParticipants()`
LiveKit, не подбирает вручную.
"""
identity: str = Field(min_length=1)
source: ForcedMuteSource
class MuteParticipantOut(BaseModel):
"""Ответ на принудительный мьют — `muted=False`, если трек и так не был опубликован."""
muted: bool

View File

@@ -0,0 +1,61 @@
"""Pydantic-схемы событий комнаты, мультиплексируемых поверх WS-чата (`api/chat.py`).
Отдельный протокол от собственно чата (`schemas/chat.py`): очередь поднятых
рук и уведомления о принудительном мьюте — эфемерное состояние звонка
(Redis, не БД, см. `services/hand_queue.py`) и не должны попадать в
персистентную историю сообщений чата, хотя и едут по тому же соединению.
"""
from datetime import UTC, datetime
from typing import Literal
from pydantic import BaseModel, Field, field_serializer
class RaiseHandIn(BaseModel):
"""Клиент поднимает свою руку."""
type: Literal["raise_hand"]
class LowerHandIn(BaseModel):
"""Клиент опускает руку — свою (без `identity`) либо, только для организатора, чужую."""
type: Literal["lower_hand"]
identity: str | None = None
class HandQueueEntryOut(BaseModel):
"""Один участник в очереди поднятых рук."""
identity: str
name: str
raised_at: datetime
@field_serializer("raised_at")
def _serialize_raised_at(self, value: datetime) -> str:
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
class HandQueueOut(BaseModel):
"""Снапшот очереди поднятых рук — рассылается всем участникам при любом изменении."""
type: Literal["hand_queue"] = "hand_queue"
queue: list[HandQueueEntryOut] = Field(default_factory=list)
ForcedMuteSource = Literal["microphone", "camera"]
class ForcedMuteOut(BaseModel):
"""Организатор принудительно выключил трек участника (задача B2) — уведомление всем.
Рассылается всем (не только затронутому), как и `HandQueueOut`: канал —
общий broadcast, а не адресная доставка одному соединению; получатели,
для которых `identity` не совпадает с их собственной, событие
игнорируют.
"""
type: Literal["forced_mute"] = "forced_mute"
identity: str
source: ForcedMuteSource

View File

@@ -24,6 +24,7 @@ from core.security import (
create_refresh_token, create_refresh_token,
decode_token, decode_token,
hash_password, hash_password,
needs_rehash,
verify_password, verify_password,
) )
from models.email_verification import EmailVerificationToken from models.email_verification import EmailVerificationToken
@@ -131,7 +132,7 @@ class AuthService:
user = await self._users.create( user = await self._users.create(
email=email, email=email,
name_user=name_user, name_user=name_user,
password_hash=hash_password(password), password_hash=await hash_password(password),
team_id=team_id, team_id=team_id,
) )
reply_to = cfg.contact_email if cfg.contact_email_enabled else None reply_to = cfg.contact_email if cfg.contact_email_enabled else None
@@ -161,10 +162,21 @@ class AuthService:
async def login(self, *, email: str, password: str) -> TokenPair: async def login(self, *, email: str, password: str) -> TokenPair:
"""Проверить учётные данные и выдать пару access/refresh токенов.""" """Проверить учётные данные и выдать пару access/refresh токенов."""
user = await self._users.get_by_email(email) user = await self._users.get_by_email(email)
if user is None or not verify_password(password, user.password_hash): if user is None or not await verify_password(password, user.password_hash):
raise InvalidCredentialsError raise InvalidCredentialsError
if not user.email_verified: if not user.email_verified:
raise EmailNotVerifiedError raise EmailNotVerifiedError
# Постепенная миграция на актуальные параметры argon2 (см. core/security.py):
# параметры зашиты в саму строку хэша, поэтому старые записи так и
# проверялись бы вдвое дольше. Открытый пароль есть только здесь и
# только сейчас — другого места для перевыпуска не будет.
if needs_rehash(user.password_hash):
user.password_hash = await hash_password(password)
# Явный commit: выдача токенов идёт через Redis и БД не трогает,
# поэтому без него перевыпущенный хэш откатился бы вместе с сессией.
await self._session.commit()
return await self._issue_token_pair(user.id, user.role) return await self._issue_token_pair(user.id, user.role)
async def refresh(self, refresh_token: str) -> TokenPair: async def refresh(self, refresh_token: str) -> TokenPair:

View File

@@ -9,6 +9,7 @@
import json import json
from core.config import get_settings from core.config import get_settings
from core.plugins.config import PublishQualityCap, StageMaxTiles
from core.security import verify_password from core.security import verify_password
from models.conference import Conference from models.conference import Conference
from schemas.conferences import JoinOut from schemas.conferences import JoinOut
@@ -27,7 +28,7 @@ class InvalidPasswordError(Exception):
"""Указанный пароль не совпадает с паролем закрытой конференции.""" """Указанный пароль не совпадает с паролем закрытой конференции."""
def ensure_joinable(conference: Conference, *, password: str | None) -> None: async def ensure_joinable(conference: Conference, *, password: str | None) -> None:
"""Проверить, что в конференцию можно войти прямо сейчас. """Проверить, что в конференцию можно войти прямо сейчас.
Бросает `ConferenceEndedError` для терминального статуса `ended` Бросает `ConferenceEndedError` для терминального статуса `ended`
@@ -42,7 +43,7 @@ def ensure_joinable(conference: Conference, *, password: str | None) -> None:
return return
if conference.password_hash is None or password is None: if conference.password_hash is None or password is None:
raise PasswordRequiredError raise PasswordRequiredError
if not verify_password(password, conference.password_hash): if not await verify_password(password, conference.password_hash):
raise InvalidPasswordError raise InvalidPasswordError
@@ -52,20 +53,35 @@ def build_join(
identity: str, identity: str,
name: str, name: str,
chat_enabled: bool, chat_enabled: bool,
publish_quality_cap: PublishQualityCap,
stage_max_tiles: StageMaxTiles,
avatar_url: str | None = None, avatar_url: str | None = None,
is_organizer: bool = False,
) -> JoinOut: ) -> JoinOut:
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции. """Построить ответ join: LiveKit access-токен для входа в комнату конференции.
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4). Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
`chat_enabled` — снятый вызывающей стороной тоггл `instance_settings`: `chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей
читается здесь параметром, а не заново из БД, чтобы не плодить стороной значения `instance_settings`: читаются здесь параметрами, а не
отдельный запрос настроек на каждый join. `avatar_url` прокидывается заново из БД, чтобы не плодить отдельный запрос настроек на каждый join.
в метаданные токена как JSON `avatar_url`/`is_organizer`
`{"avatar_url": ...}`; `None` (гость либо пользователь без аватара) — прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
метаданные не выставляются вовсе. — поля добавляются, только если заданы (гость без аватара и не-организатор
получают токен вовсе без метаданных, как и раньше).
⚠️ `is_organizer` в метаданных — только подсказка для UI клиента (показать/
скрыть кнопки организатора). Метаданным токена доверять для АВТОРИЗАЦИИ
нельзя — участник технически может их подделать на своей стороне. Любое
серверное действие организатора (например, принудительный мьют) обязано
заново проверяться по `conference.owner_id` в БД, а не по этому полю.
""" """
settings = get_settings() settings = get_settings()
metadata = json.dumps({"avatar_url": avatar_url}) if avatar_url else None metadata_payload: dict[str, object] = {}
if avatar_url:
metadata_payload["avatar_url"] = avatar_url
if is_organizer:
metadata_payload["is_organizer"] = True
metadata = json.dumps(metadata_payload) if metadata_payload else None
token = create_room_access_token( token = create_room_access_token(
room_name=conference.slug, identity=identity, name=name, metadata=metadata room_name=conference.slug, identity=identity, name=name, metadata=metadata
) )
@@ -75,4 +91,6 @@ def build_join(
room_name=conference.slug, room_name=conference.slug,
conference_id=conference.id, conference_id=conference.id,
chat_enabled=chat_enabled, chat_enabled=chat_enabled,
publish_quality_cap=publish_quality_cap,
stage_max_tiles=stage_max_tiles,
) )

View File

@@ -33,12 +33,14 @@ from schemas.conferences import (
JoinOut, JoinOut,
OccurrenceOut, OccurrenceOut,
) )
from services import hand_queue
from services.avatars import avatar_url as resolve_avatar_url from services.avatars import avatar_url as resolve_avatar_url
from services.conference_access import build_join, ensure_joinable from services.conference_access import build_join, ensure_joinable
from services.conference_ids import generate_number, generate_slug from services.conference_ids import generate_number, generate_slug
from services.instance_settings import InstanceSettingsService from services.instance_settings import InstanceSettingsService
from services.invitations_producer import enqueue_invitations from services.invitations_producer import enqueue_invitations
from services.recurrence import RecurrenceRule, expand_occurrences from services.recurrence import RecurrenceRule, expand_occurrences
from services.room_control import MuteSource, mute_participant_track
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -99,7 +101,7 @@ class ConferenceService:
повторением без явного `scheduled_at` — плановая конференция, повторением без явного `scheduled_at` — плановая конференция,
ожидающая своего первого вхождения, а не мгновенный вход. ожидающая своего первого вхождения, а не мгновенный вход.
""" """
password_hash = hash_password(data.password) if data.password else None password_hash = await hash_password(data.password) if data.password else None
is_instant = data.scheduled_at is None and data.recurrence is None is_instant = data.scheduled_at is None and data.recurrence is None
conference_status = "active" if is_instant else "scheduled" conference_status = "active" if is_instant else "scheduled"
recurrence_json = data.recurrence.model_dump(mode="json") if data.recurrence else None recurrence_json = data.recurrence.model_dump(mode="json") if data.recurrence else None
@@ -147,13 +149,16 @@ class ConferenceService:
join = None join = None
if is_instant: if is_instant:
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled cfg = await InstanceSettingsService(self._session).get()
join = build_join( join = build_join(
conference, conference,
identity=str(owner_id), identity=str(owner_id),
name=owner_name, name=owner_name,
chat_enabled=chat_enabled, chat_enabled=cfg.chat.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path), avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
is_organizer=True,
) )
else: else:
# Плановая (разовая) либо закреплённая с повторением/датой — есть # Плановая (разовая) либо закреплённая с повторением/датой — есть
@@ -239,20 +244,23 @@ class ConferenceService:
) -> JoinOut: ) -> JoinOut:
"""Войти в конференцию зарегистрированным пользователем.""" """Войти в конференцию зарегистрированным пользователем."""
conference = await self._get_or_raise(conference_id) conference = await self._get_or_raise(conference_id)
ensure_joinable(conference, password=password) await ensure_joinable(conference, password=password)
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled cfg = await InstanceSettingsService(self._session).get()
return build_join( return build_join(
conference, conference,
identity=str(user.id), identity=str(user.id),
name=user.name_user, name=user.name_user,
chat_enabled=chat_enabled, chat_enabled=cfg.chat.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path), avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
is_organizer=conference.owner_id is not None and conference.owner_id == user.id,
) )
async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut: async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut:
"""Войти в конференцию гостем: создать `GuestAccess` и выдать токен.""" """Войти в конференцию гостем: создать `GuestAccess` и выдать токен."""
conference = await self._get_or_raise(conference_id) conference = await self._get_or_raise(conference_id)
ensure_joinable(conference, password=data.password) await ensure_joinable(conference, password=data.password)
guest = GuestAccess( guest = GuestAccess(
conference_id=conference.id, display_name=data.display_name, email=data.email conference_id=conference.id, display_name=data.display_name, email=data.email
@@ -261,14 +269,46 @@ class ConferenceService:
await self._session.flush() await self._session.flush()
await self._session.commit() await self._session.commit()
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled cfg = await InstanceSettingsService(self._session).get()
return build_join( return build_join(
conference, conference,
identity=f"guest:{guest.id}", identity=f"guest:{guest.id}",
name=data.display_name, name=data.display_name,
chat_enabled=chat_enabled, chat_enabled=cfg.chat.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
) )
async def mute_participant(
self,
conference_id: uuid.UUID,
*,
actor: User,
target_identity: str,
source: MuteSource,
) -> bool:
"""Принудительно замьютить трек участника (задача B2); владелец/администратор.
Права — ТОЛЬКО отсюда (`_ensure_owner_or_admin` по `conference.owner_id`
в БД), не по метаданным LiveKit-токена вызывающего: те лишь подсказка
для UI (см. `services/conference_access.py::build_join`) и потенциально
подделываемы клиентом. `target_identity` НИКАК не валидируется против
состава участников заранее — если его сейчас нет в комнате LiveKit,
`mute_participant_track` бросит `ParticipantNotInRoomError` (ловит
API-роутер).
"""
conference = await self._get_or_raise(conference_id)
self._ensure_owner_or_admin(conference, actor)
muted = await mute_participant_track(
conference.slug, identity=target_identity, source=source
)
if muted:
await hand_queue.publish_forced_mute(
conference.id, identity=target_identity, source=source
)
return muted
async def update( async def update(
self, conference_id: uuid.UUID, *, actor: User, data: ConferenceUpdateIn self, conference_id: uuid.UUID, *, actor: User, data: ConferenceUpdateIn
) -> Conference: ) -> Conference:
@@ -296,7 +336,7 @@ class ConferenceService:
if data.is_closed is not None: if data.is_closed is not None:
conference.is_closed = data.is_closed conference.is_closed = data.is_closed
if data.password is not None: if data.password is not None:
conference.password_hash = hash_password(data.password) conference.password_hash = await hash_password(data.password)
if "summary_recipients" in data.model_fields_set: if "summary_recipients" in data.model_fields_set:
# Явная передача (в т.ч. `null`) — сбросить/установить # Явная передача (в т.ч. `null`) — сбросить/установить
# переопределение; отсутствие поля в запросе значение не трогает. # переопределение; отсутствие поля в запросе значение не трогает.

View File

@@ -0,0 +1,144 @@
"""Очередь поднятых рук конференции — состояние в Redis, не в Postgres (задача B1).
Транспорт для клиентов — тот же аутентифицированный WS чата (`api/chat.py`):
переиспользуем уже открытые и держащиеся сервером соединения вместо отдельного
эндпоинта. Хранение — Redis, а не БД: очередь существует ровно во время звонка
и не должна переживать его завершение (в отличие от истории чата), а два
процесса uvicorn (`UVICORN_WORKERS`) делают наивное состояние в памяти одного
процесса недостаточным — организатор и участник могут оказаться на разных
воркерах.
Один Redis-ключ (HASH) на конференцию: поле — identity участника (тот же
формат, что в LiveKit-токене и вебхуках — `str(user_id)` или
`guest:{guest_access.id}`), значение — JSON `{"name": ..., "raised_at": <unix
epoch>}`. `HSETNX` даёт атомарное «добавить, только если ещё нет» — повторное
поднятие уже поднятой руки НЕ сбрасывает её место в очереди (идемпотентно).
Порядок — сортировкой по `raised_at` при чтении снапшота (участников в одной
конференции — единицы-десятки, сортировка в Python здесь дешевле, чем держать
вторую структуру (ZSET) синхронно с первой).
"""
import json
import time
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from core.redis import redis_client
from schemas.room_events import ForcedMuteOut, ForcedMuteSource, HandQueueEntryOut, HandQueueOut
# TTL ключа очереди — подстраховка на случай пропущенного webhook
# `room_finished` (см. `services/webhook_handlers.py::_on_room_finished`,
# который чистит очередь явно при штатном завершении). Сама конференция
# столько не длится ни при каких сценариях.
HAND_QUEUE_TTL_SECONDS = 24 * 60 * 60
def hand_queue_key(conference_id: uuid.UUID) -> str:
"""Redis-ключ HASH очереди поднятых рук конкретной конференции."""
return f"hand_queue:{conference_id}"
def hand_queue_channel(conference_id: uuid.UUID) -> str:
"""Redis pub/sub канал событий комнаты (очередь рук + принудительный мьют, задача B2)."""
return f"room_events:{conference_id}"
@dataclass(frozen=True, slots=True)
class HandQueueEntry:
"""Один участник в очереди поднятых рук."""
identity: str
name: str
raised_at: float
async def raise_hand(conference_id: uuid.UUID, *, identity: str, name: str) -> bool:
"""Поднять руку участника; `True` — рука реально поднялась (не была поднята раньше).
`HSETNX` — атомарная проверка-и-запись: если участник уже в очереди,
ничего не меняет (в т.ч. НЕ обновляет `raised_at`) — переподключение и
повторный клик не переставляют его в конец очереди.
"""
key = hand_queue_key(conference_id)
payload = json.dumps({"name": name, "raised_at": time.time()})
added = await redis_client.hsetnx(key, identity, payload)
await redis_client.expire(key, HAND_QUEUE_TTL_SECONDS)
return bool(added)
async def lower_hand(conference_id: uuid.UUID, *, identity: str) -> bool:
"""Опустить руку участника; `True` — рука была поднята и теперь снята."""
removed = await redis_client.hdel(hand_queue_key(conference_id), identity)
return bool(removed)
async def snapshot(conference_id: uuid.UUID) -> list[HandQueueEntry]:
"""Текущая очередь, упорядоченная по времени поднятия (раньше — раньше в списке)."""
raw = await redis_client.hgetall(hand_queue_key(conference_id))
entries = []
for identity, payload in raw.items():
try:
data = json.loads(payload)
entries.append(
HandQueueEntry(
identity=str(identity), name=data["name"], raised_at=data["raised_at"]
)
)
except (ValueError, KeyError, TypeError):
# Побитый/устаревшего формата элемент — пропускаем, а не роняем всю очередь.
continue
entries.sort(key=lambda entry: entry.raised_at)
return entries
async def clear(conference_id: uuid.UUID) -> None:
"""Полностью снести очередь конференции (штатное завершение — `room_finished`)."""
await redis_client.delete(hand_queue_key(conference_id))
def _to_out(entries: list[HandQueueEntry]) -> HandQueueOut:
"""Собрать исходящий снапшот из внутренних записей очереди."""
return HandQueueOut(
queue=[
HandQueueEntryOut(
identity=entry.identity,
name=entry.name,
raised_at=datetime.fromtimestamp(entry.raised_at, tz=UTC),
)
for entry in entries
]
)
async def get_snapshot_out(conference_id: uuid.UUID) -> HandQueueOut:
"""Текущая очередь в исходящем формате — для отправки сразу после подключения к WS."""
return _to_out(await snapshot(conference_id))
async def publish_snapshot(conference_id: uuid.UUID) -> None:
"""Опубликовать текущий снапшот очереди всем подписчикам канала комнаты.
Вызывается после любого изменения очереди (`raise_hand`/`lower_hand` —
из `api/chat.py`, а также `participant_left`/`room_finished` — из
`services/webhook_handlers.py`), чтобы у всех участников (и особенно у
организатора, зашедшего позже) была всегда актуальная картина.
"""
payload = _to_out(await snapshot(conference_id))
await redis_client.publish(hand_queue_channel(conference_id), payload.model_dump_json())
async def publish_forced_mute(
conference_id: uuid.UUID, *, identity: str, source: ForcedMuteSource
) -> None:
"""Оповестить всех участников комнаты о принудительном мьюте (задача B2).
Тот же канал, что и у очереди рук (`hand_queue_channel`) — `api/chat.py`
пересылает с него ЛЮБОЙ JSON как есть, различая события по полю `type`
(см. `_pump_pubsub_to_websocket`). Рассылается ВСЕМ, а не адресно
затронутому участнику: канал общий на конференцию, адресной доставки
одному соединению тут нет, поэтому клиент сам сверяет `identity` со
своей (см. `ForcedMuteOut` в `schemas/room_events.py`).
"""
payload = ForcedMuteOut(identity=identity, source=source)
await redis_client.publish(hand_queue_channel(conference_id), payload.model_dump_json())

View File

@@ -2,7 +2,8 @@
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`, Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`, `ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`, `contact_email`) — `registration_team_choice`, `registration_email_domain`, `contact_email`,
`media_limits`) —
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`) новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
@@ -28,7 +29,10 @@ from core.plugins.config import (
AiLevel, AiLevel,
ChatConfig, ChatConfig,
InstanceConfig, InstanceConfig,
MediaLimitsConfig,
PluginsConfig, PluginsConfig,
PublishQualityCap,
StageMaxTiles,
SummarizerConfig, SummarizerConfig,
SummaryRecipientsMode, SummaryRecipientsMode,
TranscriberConfig, TranscriberConfig,
@@ -47,6 +51,7 @@ _KEY_DISPLAY_TIMEZONE = "display_timezone"
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice" _KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain" _KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
_KEY_CONTACT_EMAIL = "contact_email" _KEY_CONTACT_EMAIL = "contact_email"
_KEY_MEDIA_LIMITS = "media_limits"
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = ( BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
_KEY_CHAT, _KEY_CHAT,
@@ -70,6 +75,7 @@ _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "d
для обратной совместимости с уже развёрнутыми инстансами; при первом же для обратной совместимости с уже развёрнутыми инстансами; при первом же
`update()` значение переписывается в новую форму (см. `update`).""" `update()` значение переписывается в новую форму (см. `update`)."""
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None} _DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
_DEFAULT_MEDIA_LIMITS_VALUE: dict[str, Any] = {"publish_quality_cap": "off", "stage_max_tiles": 25}
# Простой паттерн доменного имени: минимум один символ, минимум одна точка, # Простой паттерн доменного имени: минимум один символ, минимум одна точка,
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса), # метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
@@ -103,6 +109,8 @@ class SettingsUpdateIn(BaseModel):
registration_email_domains: list[str] | None = None registration_email_domains: list[str] | None = None
contact_email_enabled: bool | None = None contact_email_enabled: bool | None = None
contact_email: str | None = None contact_email: str | None = None
publish_quality_cap: PublishQualityCap | None = None
stage_max_tiles: StageMaxTiles | None = None
class BootstrapOverrides(BaseModel): class BootstrapOverrides(BaseModel):
@@ -152,6 +160,7 @@ def build_bootstrap_defaults(
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE), _KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE), _KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE), _KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
_KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE),
} }
if overrides is None: if overrides is None:
return defaults return defaults
@@ -340,6 +349,20 @@ class InstanceSettingsService:
await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json")) await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json"))
await self._set(_KEY_SUMMARIZER, cfg.summarizer.model_dump(mode="json")) await self._set(_KEY_SUMMARIZER, cfg.summarizer.model_dump(mode="json"))
if patch.publish_quality_cap is not None or patch.stage_max_tiles is not None:
cap = (
patch.publish_quality_cap
if patch.publish_quality_cap is not None
else cfg.media_limits.publish_quality_cap
)
max_tiles = (
patch.stage_max_tiles
if patch.stage_max_tiles is not None
else cfg.media_limits.stage_max_tiles
)
cfg.media_limits = MediaLimitsConfig(publish_quality_cap=cap, stage_max_tiles=max_tiles)
await self._set(_KEY_MEDIA_LIMITS, cfg.media_limits.model_dump(mode="json"))
await self._session.commit() await self._session.commit()
return cfg return cfg
@@ -489,4 +512,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
"enabled", False "enabled", False
), ),
contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"), contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"),
media_limits=MediaLimitsConfig.model_validate(
rows.get(_KEY_MEDIA_LIMITS, _DEFAULT_MEDIA_LIMITS_VALUE)
),
) )

View File

@@ -0,0 +1,81 @@
"""Управление комнатой LiveKit от имени организатора (задача B2): принудительный мьют.
Тонкая обёртка над `RoomServiceClient` — тот же паттерн, что и
`services/egress.py` (единственная точка мокирования в тестах, свой
`api.LiveKitAPI` на вызов, аутентификация СЕРВЕРНЫМИ `api_key`/`api_secret`,
а не токеном организатора). Именно поэтому организатору не нужен отдельный
LiveKit-грант в собственном access-токене под это действие — мьютит backend
от своего имени, клиент лишь инициирует вызов, а право на это проверяется
по владельцу конференции в БД (`services/conferences.py::mute_participant`),
ДО обращения сюда.
"""
import logging
from livekit import api
from livekit.protocol.models import TrackSource
from core.config import get_settings
from schemas.room_events import ForcedMuteSource
logger = logging.getLogger(__name__)
# Переэкспорт под более общим именем — этот модуль не завязан на протокол WS
# (`schemas/room_events.py`), которому концептуально принадлежит `ForcedMuteSource`.
MuteSource = ForcedMuteSource
_TRACK_SOURCE_BY_NAME: dict[MuteSource, int] = {
"microphone": TrackSource.MICROPHONE,
"camera": TrackSource.CAMERA,
}
class ParticipantNotInRoomError(Exception):
"""Участника с таким identity сейчас нет в комнате LiveKit (уже вышел/не заходил)."""
async def mute_participant_track(room_name: str, *, identity: str, source: MuteSource) -> bool:
"""Принудительно замьютить опубликованный трек участника; `True` — трек реально замьючен.
Если трек данного `source` сейчас не опубликован — не ошибка, а no-op:
искомое состояние («трек не идёт») уже достигнуто. Обычный случай с
0.0.15 — участники заходят с выключенными микрофоном/камерой (задача
A1), трек попросту не существует, пока человек не включит его сам;
мьютить в этот момент нечего, и это НЕ повод отвечать клиенту ошибкой.
"""
settings = get_settings()
lkapi = api.LiveKitAPI(
settings.livekit_url,
api_key=settings.livekit_api_key,
api_secret=settings.livekit_api_secret,
)
try:
try:
participant = await lkapi.room.get_participant(
api.RoomParticipantIdentity(room=room_name, identity=identity)
)
except api.TwirpError as exc:
if exc.status == 404:
raise ParticipantNotInRoomError from exc
raise
target_source = _TRACK_SOURCE_BY_NAME[source]
track = next((t for t in participant.tracks if t.source == target_source), None)
if track is None or track.muted:
return False
await lkapi.room.mute_published_track(
api.MuteRoomTrackRequest(
room=room_name, identity=identity, track_sid=track.sid, muted=True
)
)
logger.info(
"room_control: принудительный мьют — комната=%s identity=%s source=%s трек=%s",
room_name,
identity,
source,
track.sid,
)
return True
finally:
await lkapi.aclose()

View File

@@ -27,6 +27,7 @@ from repositories.conferences import (
ConferenceRepository, ConferenceRepository,
ConferenceSessionRepository, ConferenceSessionRepository,
) )
from services import hand_queue
from services.egress import run_track_egress from services.egress import run_track_egress
from services.instance_settings import InstanceSettingsService from services.instance_settings import InstanceSettingsService
from services.pipeline_producer import enqueue_pipeline from services.pipeline_producer import enqueue_pipeline
@@ -154,6 +155,17 @@ class WebhookDispatcher:
return return
user_id, guest_id = identity user_id, guest_id = identity
# Очередь поднятых рук живёт в Redis по `conference.id`, независимо
# от `ConferenceSession` (задача B1) — снимаем руку СРАЗУ, до guard'а
# на отсутствующий открытый сеанс ниже: пропущенный/задержанный
# `room_started` не должен оставлять фантомную запись в очереди у
# реально вышедшего участника. Не путать с обрывом WS-соединения
# самой очереди рук — то живёт своей жизнью и переживается без
# потери места (см. `services/hand_queue.py`).
removed = await hand_queue.lower_hand(conference.id, identity=event.participant.identity)
if removed:
await hand_queue.publish_snapshot(conference.id)
session_record = await self._sessions.get_open_by_conference(conference.id) session_record = await self._sessions.get_open_by_conference(conference.id)
if session_record is None: if session_record is None:
logger.warning( logger.warning(
@@ -308,6 +320,10 @@ class WebhookDispatcher:
now = datetime.now(UTC) now = datetime.now(UTC)
await self._sessions.close(session_record, t_end=now) await self._sessions.close(session_record, t_end=now)
await self._sessions.close_all_open_participants(session_id=session_record.id, left_at=now) await self._sessions.close_all_open_participants(session_id=session_record.id, left_at=now)
# Очередь поднятых рук — состояние звонка, не история; следующий
# заход (в т.ч. у закреплённой конференции) должен начинать с чистой
# очереди, а не наследовать поднятые руки из прошлого раза.
await hand_queue.clear(conference.id)
# Незакреплённая умирает по завершении (история/саммари остаются); # Незакреплённая умирает по завершении (история/саммари остаются);
# закреплённая возвращается в ожидание следующего вхождения (ADR-001, п.2). # закреплённая возвращается в ожидание следующего вхождения (ADR-001, п.2).

View File

@@ -33,7 +33,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user="Admin API Tester", name_user="Admin API Tester",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
role=role, role=role,
) )
@@ -706,6 +706,68 @@ async def test_put_settings_contact_email_invalid_returns_400(
assert response.status_code == 400 assert response.status_code == 400
async def test_get_settings_media_limits_defaults(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Дефолты рычагов нагрузки — без ограничения качества и с текущим
максимумом сетки (5×5) — существующие инсталляции после обновления не
получают внезапно ухудшенное качество."""
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert response.status_code == 200, response.text
body = response.json()
assert body["publish_quality_cap"] == "off"
assert body["stage_max_tiles"] == 25
async def test_put_settings_media_limits_partial_update(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"publish_quality_cap": "360p", "stage_max_tiles": 9},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["publish_quality_cap"] == "360p"
assert body["stage_max_tiles"] == 9
reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert reloaded.json()["publish_quality_cap"] == "360p"
assert reloaded.json()["stage_max_tiles"] == 9
async def test_put_settings_media_limits_invalid_values_return_422(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Значения вне разрешённого набора (`Literal`) — ошибка валидации тела запроса
ДО сервисного слоя, ещё на уровне FastAPI/pydantic."""
admin = await _make_user(db_session, role="admin")
await db_session.commit()
bad_cap = await client.put(
"/api/v1/admin/settings",
json={"publish_quality_cap": "4k"},
headers=_auth_headers(admin),
)
assert bad_cap.status_code == 422
bad_tiles = await client.put(
"/api/v1/admin/settings",
json={"stage_max_tiles": 100},
headers=_auth_headers(admin),
)
assert bad_tiles.status_code == 422
# --- Тестовое письмо ---------------------------------------------------------------- # --- Тестовое письмо ----------------------------------------------------------------

View File

@@ -20,7 +20,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user="Team API Tester", name_user="Team API Tester",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
role=role, role=role,
) )

View File

@@ -9,6 +9,7 @@ from typing import Annotated
import httpx import httpx
import pytest_asyncio import pytest_asyncio
from argon2 import PasswordHasher
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.auth import get_auth_service from api.auth import get_auth_service
from core.db import get_session from core.db import get_session
from core.redis import redis_client from core.redis import redis_client
from core.security import needs_rehash
from models.team import Team from models.team import Team
from models.user import User from models.user import User
from services.auth import AuthService from services.auth import AuthService
@@ -491,3 +493,35 @@ async def test_register_no_reply_to_when_contact_email_disabled(
assert response.status_code == 201, response.text assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] is None assert email_backend.reply_to[-1] is None
async def test_login_rehashes_legacy_password(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Вход с паролем, захэшированным старыми параметрами, перевыпускает хэш.
Параметры argon2 зашиты в саму строку хэша, поэтому смена настроек
(0.0.17: дефолты библиотеки → рекомендации OWASP) сама по себе не ускоряет
проверку уже существующих паролей. Миграция идёт лениво — при первом
успешном входе, когда открытый пароль есть на руках.
"""
email = "legacy-hash@example.com"
password = "supersecret1"
await _register_and_verify(client, email_backend, email=email, password=password)
# Подменяем хэш на выданный прежними параметрами (t=3, m=64 МБ, p=4).
legacy_hash = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4).hash(password)
user = await db_session.scalar(select(User).where(User.email == email))
assert user is not None
user.password_hash = legacy_hash
await db_session.commit()
response = await client.post(
"/api/v1/auth/token", data={"username": email, "password": password}
)
assert response.status_code == 200, response.text
await db_session.refresh(user)
assert user.password_hash != legacy_hash, "старый хэш не был перевыпущен"
assert "m=19456" in user.password_hash
assert needs_rehash(user.password_hash) is False

View File

@@ -44,7 +44,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Chat Tester") -> Use
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user=name, name_user=name,
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
) )
session.add(user) session.add(user)
@@ -85,11 +85,19 @@ def _guest_token(conference: Conference, guest: GuestAccess) -> str:
async def _connect_and_auth(session: ASGIWebSocketSession, token: str) -> dict[str, Any]: async def _connect_and_auth(session: ASGIWebSocketSession, token: str) -> dict[str, Any]:
"""Подключиться, аутентифицироваться и вернуть первое сообщение (`history`).""" """Подключиться, аутентифицироваться и вернуть первое сообщение (`history`).
После `history` сервер сразу шлёт снапшот очереди поднятых рук
(`{"type":"hand_queue",...}`, задача B1) — здесь он молча вычитывается
и отбрасывается, чтобы не путать существующие тесты чата, которым он
не интересен (см. `tests/test_hand_queue_ws.py` для тестов самой очереди).
"""
accept = await session.connect() accept = await session.connect()
assert accept["type"] == "websocket.accept" assert accept["type"] == "websocket.accept"
await session.send_json({"type": "auth", "token": token}) await session.send_json({"type": "auth", "token": token})
return await session.receive_json() history = await session.receive_json()
await session.receive_json()
return history
# --- Основной сценарий: обмен сообщениями + история ------------------------- # --- Основной сценарий: обмен сообщениями + история -------------------------

View File

@@ -19,7 +19,7 @@ async def _make_user(session: AsyncSession) -> User:
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user="Invitee Tester", name_user="Invitee Tester",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
) )
session.add(user) session.add(user)

View File

@@ -25,7 +25,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Service Tester") ->
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user=name, name_user=name,
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
) )
session.add(user) session.add(user)

View File

@@ -33,7 +33,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user="Conference Tester", name_user="Conference Tester",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
role=role, role=role,
) )
@@ -67,7 +67,7 @@ async def _make_conference(
status=status, status=status,
is_pinned=is_pinned, is_pinned=is_pinned,
is_closed=is_closed, is_closed=is_closed,
password_hash=hash_password(password) if password else None, password_hash=await hash_password(password) if password else None,
ended_at=ended_at, ended_at=ended_at,
scheduled_at=scheduled_at, scheduled_at=scheduled_at,
duration_minutes=duration_minutes, duration_minutes=duration_minutes,
@@ -113,6 +113,10 @@ async def test_create_instant_conference_returns_active_with_join(
assert body["join"]["conference_id"] == body["id"] assert body["join"]["conference_id"] == body["id"]
assert body["join"]["room_name"] == body["slug"] assert body["join"]["room_name"] == body["slug"]
assert body["join"]["token"] assert body["join"]["token"]
# Рычаги нагрузки медиа — дефолты без ограничения (существующие
# инсталляции не должны получить внезапно ухудшенное качество).
assert body["join"]["publish_quality_cap"] == "off"
assert body["join"]["stage_max_tiles"] == 25
async def test_create_instant_conference_join_metadata_contains_owner_avatar_url( async def test_create_instant_conference_join_metadata_contains_owner_avatar_url(
@@ -573,17 +577,75 @@ async def test_resolve_unknown_returns_uniform_404(client: httpx.AsyncClient) ->
assert response.json()["detail"] == "not_found" assert response.json()["detail"] == "not_found"
async def test_resolve_is_rate_limited_after_10_requests_per_minute( def _ip_headers() -> dict[str, str]:
client: httpx.AsyncClient, """Уникальный `X-Real-IP` на каждый тест.
) -> None:
Счётчики rate limit живут в Redis 60 секунд и общие для всего инстанса,
поэтому без изоляции тесты влияли бы друг на друга через остаточные ключи.
Заодно это проверяет, что заголовок вообще читается: раньше ключ строился
по `request.client.host`, то есть по адресу nginx, одинаковому для всех.
"""
return {"X-Real-IP": f"198.51.100.{uuid.uuid4().int % 250 + 1}-{uuid.uuid4().hex[:8]}"}
async def test_resolve_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
"""Перебор номера конференции упирается в жёсткий лимит промахов (ADR-001, п.4)."""
headers = _ip_headers()
for _ in range(10): for _ in range(10):
response = await client.get("/api/v1/conferences/resolve", params={"q": "irrelevant-query"}) response = await client.get(
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
)
assert response.status_code == 404 assert response.status_code == 404
limited = await client.get("/api/v1/conferences/resolve", params={"q": "irrelevant-query"}) limited = await client.get(
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
)
assert limited.status_code == 429 assert limited.status_code == 429
async def test_successful_resolves_are_not_limited_by_miss_counter(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Вся конференция может открыть ссылку одновременно (регресс теста 31.07.2026).
Прежняя схема считала любые запросы с лимитом 10/мин, и одиннадцатый
участник получал 429 — фронтенд показывал «Не удалось найти конференцию»
для существующей и активной конференции.
"""
conference = await _make_conference(db_session)
await db_session.commit()
headers = _ip_headers()
for _ in range(50):
response = await client.get(
"/api/v1/conferences/resolve", params={"q": conference.slug}, headers=headers
)
assert response.status_code == 200, response.text
async def test_rate_limit_is_per_client_ip(client: httpx.AsyncClient) -> None:
"""Счётчик привязан к адресу клиента, а не к адресу nginx.
Исчерпав лимит промахов с одного адреса, с другого по-прежнему можно
работать. До исправления ключ был общим на весь инстанс.
"""
first, second = _ip_headers(), _ip_headers()
for _ in range(11):
await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
)
exhausted = await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
)
assert exhausted.status_code == 429
other = await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=second
)
assert other.status_code == 404, "лимит одного клиента не должен задевать другого"
# --- Вход зарегистрированным пользователем --------------------------------------- # --- Вход зарегистрированным пользователем ---------------------------------------
@@ -708,6 +770,38 @@ async def test_join_metadata_absent_for_user_without_avatar(
assert "metadata" not in payload assert "metadata" not in payload
async def test_join_metadata_contains_is_organizer_for_owner(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(owner)
)
assert response.status_code == 200, response.text
payload = _decode_livekit_token(response.json()["token"])
metadata = json.loads(str(payload["metadata"]))
assert metadata["is_organizer"] is True
async def test_join_metadata_absent_is_organizer_for_non_owner(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
other = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(other)
)
assert response.status_code == 200, response.text
payload = _decode_livekit_token(response.json()["token"])
assert "metadata" not in payload
async def test_guest_join_metadata_is_absent( async def test_guest_join_metadata_is_absent(
client: httpx.AsyncClient, db_session: AsyncSession client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:
@@ -751,6 +845,33 @@ async def test_guest_join_creates_guest_access_and_returns_join(
assert guests[0].email == "alice-guest@example.com" assert guests[0].email == "alice-guest@example.com"
async def test_guest_join_reflects_admin_configured_media_limits(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Настройки, сохранённые администратором в `PUT /admin/settings`, доезжают
до гостя в join-ответе ДО входа в комнату — публичный путь доставки
(см. `services/conference_access.py::build_join`), гость админку не видит."""
admin = await _make_user(db_session, role="admin")
conference = await _make_conference(db_session)
await db_session.commit()
settings_response = await client.put(
"/api/v1/admin/settings",
json={"publish_quality_cap": "180p", "stage_max_tiles": 4},
headers=_auth_headers(admin),
)
assert settings_response.status_code == 200, settings_response.text
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guest Bob"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["publish_quality_cap"] == "180p"
assert body["stage_max_tiles"] == 4
async def test_guest_join_without_email_is_allowed( async def test_guest_join_without_email_is_allowed(
client: httpx.AsyncClient, db_session: AsyncSession client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:
@@ -803,21 +924,69 @@ async def test_guest_join_ended_conference_returns_410(
assert response.json()["detail"] == "conference_ended" assert response.json()["detail"] == "conference_ended"
async def test_guest_join_is_rate_limited_after_10_requests_per_minute( async def test_guest_join_allows_a_whole_conference_to_enter(
client: httpx.AsyncClient, db_session: AsyncSession client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:
"""Успешные гостевые входы не упираются в лимит промахов.
На нагрузочном тесте 31.07.2026 конференцию из семи десятков человек не
пускало внутрь именно это ограничение — счётчик не различал легитимный
массовый вход и перебор.
"""
conference = await _make_conference(db_session) conference = await _make_conference(db_session)
await db_session.commit() await db_session.commit()
headers = _ip_headers()
for i in range(30):
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": f"Guest {i}"},
headers=headers,
)
assert response.status_code == 200, response.text
async def test_guest_join_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
"""Перебор идентификатора конференции по-прежнему упирается в лимит."""
headers = _ip_headers()
missing_id = uuid.uuid4()
for _ in range(10):
response = await client.post(
f"/api/v1/conferences/{missing_id}/guest-join",
json={"display_name": "Bruteforce"},
headers=headers,
)
assert response.status_code == 404
limited = await client.post(
f"/api/v1/conferences/{missing_id}/guest-join",
json={"display_name": "Bruteforce"},
headers=headers,
)
assert limited.status_code == 429
async def test_guest_join_wrong_password_is_rate_limited(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Подбор пароля закрытой конференции считается тем же жёстким счётчиком."""
conference = await _make_conference(db_session, is_closed=True, password="right-password")
await db_session.commit()
headers = _ip_headers()
for _ in range(10): for _ in range(10):
response = await client.post( response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Repeat Guest"}, json={"display_name": "Guesser", "password": "wrong"},
headers=headers,
) )
assert response.status_code == 200 assert response.status_code == 403
limited = await client.post( limited = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Repeat Guest"} f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guesser", "password": "wrong"},
headers=headers,
) )
assert limited.status_code == 429 assert limited.status_code == 429

View File

@@ -0,0 +1,272 @@
"""Тесты очереди поднятых рук поверх WS комнаты (`WS /api/v1/conferences/{id}/chat`, задача B1).
Протокол и аутентификация — общие с чатом (`api/chat.py`), поэтому структура
тестов и хелперы намеренно зеркалят `tests/test_chat_ws.py`.
"""
import uuid
from collections.abc import Callable
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import hash_password
from models.conference import Conference
from models.guest import GuestAccess
from models.user import User
from services.conference_ids import generate_number, generate_slug
from services.livekit_tokens import create_room_access_token
from tests.conftest import ASGIWebSocketSession
WSFactory = Callable[[str], ASGIWebSocketSession]
# --- Хелперы (см. tests/test_chat_ws.py) ------------------------------------
async def _make_user(session: AsyncSession, *, name: str = "Hand Tester") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user=name,
password_hash=await hash_password("password123"),
email_verified=True,
)
session.add(user)
await session.flush()
return user
async def _make_conference(
session: AsyncSession, *, owner_id: uuid.UUID | None = None, status: str = "active"
) -> Conference:
conference = Conference(
number=generate_number(),
slug=generate_slug(),
title="Hand Queue Test",
status=status,
owner_id=owner_id,
)
session.add(conference)
await session.flush()
return conference
async def _make_guest(session: AsyncSession, conference: Conference, *, name: str) -> GuestAccess:
guest = GuestAccess(conference_id=conference.id, display_name=name)
session.add(guest)
await session.flush()
return guest
def _chat_path(conference_id: uuid.UUID) -> str:
return f"/api/v1/conferences/{conference_id}/chat"
def _user_token(conference: Conference, user: User) -> str:
return create_room_access_token(
room_name=conference.slug, identity=str(user.id), name=user.name_user
)
def _guest_token(conference: Conference, guest: GuestAccess) -> str:
return create_room_access_token(
room_name=conference.slug, identity=f"guest:{guest.id}", name=guest.display_name
)
async def _connect_auth_and_queue(
session: ASGIWebSocketSession, token: str
) -> dict[str, Any]:
"""Подключиться, аутентифицироваться, вычитать `history` и вернуть снапшот очереди."""
accept = await session.connect()
assert accept["type"] == "websocket.accept"
await session.send_json({"type": "auth", "token": token})
await session.receive_json() # history — не интересен этим тестам
return await session.receive_json()
def _identities(queue_frame: dict[str, Any]) -> list[str]:
return [entry["identity"] for entry in queue_frame["queue"]]
# --- Поднять/опустить свою руку -----------------------------------------------
async def test_raise_and_lower_own_hand_broadcasts_to_everyone(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
conference = await _make_conference(db_session)
alice = await _make_user(db_session, name="Alice")
bob = await _make_user(db_session, name="Bob")
await db_session.commit()
path = _chat_path(conference.id)
ws1 = ws_client(path)
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
ws2 = ws_client(path)
initial2 = await _connect_auth_and_queue(ws2, _user_token(conference, bob))
assert initial2 == {"type": "hand_queue", "queue": []}
await ws1.send_json({"type": "raise_hand"})
queue1 = await ws1.receive_json()
assert _identities(queue1) == [str(alice.id)]
assert queue1["queue"][0]["name"] == "Alice"
assert queue1["queue"][0]["raised_at"].endswith("Z")
queue2 = await ws2.receive_json()
assert queue2 == queue1
await ws1.send_json({"type": "lower_hand"})
queue1_after = await ws1.receive_json()
assert queue1_after == {"type": "hand_queue", "queue": []}
queue2_after = await ws2.receive_json()
assert queue2_after == queue1_after
async def test_raise_hand_order_is_preserved(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Порядок в очереди — по времени поднятия, не по алфавиту/подключению."""
conference = await _make_conference(db_session)
alice = await _make_user(db_session, name="Alice")
bob = await _make_user(db_session, name="Bob")
await db_session.commit()
path = _chat_path(conference.id)
ws1 = ws_client(path)
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
ws2 = ws_client(path)
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
# Боб поднимает руку ПЕРВЫМ, хотя подключился вторым — он и должен
# оказаться первым в очереди.
await ws2.send_json({"type": "raise_hand"})
await ws2.receive_json()
await ws1.receive_json()
await ws1.send_json({"type": "raise_hand"})
queue = await ws1.receive_json()
assert _identities(queue) == [str(bob.id), str(alice.id)]
async def test_re_raising_hand_does_not_move_position(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Повторное поднятие уже поднятой руки — идемпотентно, место в очереди не меняется."""
conference = await _make_conference(db_session)
alice = await _make_user(db_session, name="Alice")
bob = await _make_user(db_session, name="Bob")
await db_session.commit()
path = _chat_path(conference.id)
ws1 = ws_client(path)
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
ws2 = ws_client(path)
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
await ws1.send_json({"type": "raise_hand"})
first = await ws1.receive_json()
await ws2.receive_json()
await ws2.send_json({"type": "raise_hand"})
await ws2.receive_json()
await ws1.receive_json()
# Алиса (уже в очереди первой) поднимает руку ещё раз.
await ws1.send_json({"type": "raise_hand"})
repeated = await ws1.receive_json()
await ws2.receive_json()
assert _identities(repeated) == [str(alice.id), str(bob.id)]
assert repeated["queue"][0]["raised_at"] == first["queue"][0]["raised_at"]
async def test_guest_can_raise_hand(db_session: AsyncSession, ws_client: WSFactory) -> None:
conference = await _make_conference(db_session)
guest = await _make_guest(db_session, conference, name="Guest Carl")
await db_session.commit()
ws = ws_client(_chat_path(conference.id))
await _connect_auth_and_queue(ws, _guest_token(conference, guest))
await ws.send_json({"type": "raise_hand"})
queue = await ws.receive_json()
assert _identities(queue) == [f"guest:{guest.id}"]
assert queue["queue"][0]["name"] == "Guest Carl"
# --- Права организатора -------------------------------------------------------
async def test_non_organizer_cannot_lower_someone_elses_hand(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
owner = await _make_user(db_session, name="Owner")
conference = await _make_conference(db_session, owner_id=owner.id)
alice = await _make_user(db_session, name="Alice")
bob = await _make_user(db_session, name="Bob")
await db_session.commit()
path = _chat_path(conference.id)
ws1 = ws_client(path)
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
ws2 = ws_client(path)
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
await ws1.send_json({"type": "raise_hand"})
await ws1.receive_json()
await ws2.receive_json()
# Боб (обычный участник, не организатор) пытается опустить руку Алисы.
await ws2.send_json({"type": "lower_hand", "identity": str(alice.id)})
error = await ws2.receive_json()
assert error == {"type": "error", "code": "forbidden"}
async def test_organizer_can_lower_someone_elses_hand(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
owner = await _make_user(db_session, name="Owner")
conference = await _make_conference(db_session, owner_id=owner.id)
alice = await _make_user(db_session, name="Alice")
await db_session.commit()
path = _chat_path(conference.id)
ws_alice = ws_client(path)
await _connect_auth_and_queue(ws_alice, _user_token(conference, alice))
ws_owner = ws_client(path)
await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
await ws_alice.send_json({"type": "raise_hand"})
await ws_alice.receive_json()
await ws_owner.receive_json()
await ws_owner.send_json({"type": "lower_hand", "identity": str(alice.id)})
queue_owner = await ws_owner.receive_json()
queue_alice = await ws_alice.receive_json()
assert queue_owner == {"type": "hand_queue", "queue": []}
assert queue_alice == queue_owner
async def test_organizer_joining_late_sees_already_raised_hands(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Организатор зашёл позже, когда руки уже подняты, — видит актуальную очередь сразу."""
owner = await _make_user(db_session, name="Owner")
conference = await _make_conference(db_session, owner_id=owner.id)
alice = await _make_user(db_session, name="Alice")
await db_session.commit()
path = _chat_path(conference.id)
ws_alice = ws_client(path)
await _connect_auth_and_queue(ws_alice, _user_token(conference, alice))
await ws_alice.send_json({"type": "raise_hand"})
await ws_alice.receive_json()
ws_owner = ws_client(path)
initial_queue = await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
assert _identities(initial_queue) == [str(alice.id)]

View File

@@ -71,6 +71,7 @@ _MANAGED_KEYS = (
"registration_team_choice", "registration_team_choice",
"registration_email_domain", "registration_email_domain",
"contact_email", "contact_email",
"media_limits",
) )
@@ -126,6 +127,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"registration_team_choice", "registration_team_choice",
"registration_email_domain", "registration_email_domain",
"contact_email", "contact_email",
"media_limits",
} }
cfg = await service.get() cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu" assert cfg.transcriber.provider == "faster_whisper_cpu"
@@ -137,6 +139,11 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
assert cfg.registration_email_domains == [] assert cfg.registration_email_domains == []
assert cfg.contact_email_enabled is False assert cfg.contact_email_enabled is False
assert cfg.contact_email is None assert cfg.contact_email is None
# Дефолты сохраняют текущее (до появления настройки) поведение —
# без ограничения качества и с максимумом сетки, равным фактическому
# потолку `StageGrid` (5×5).
assert cfg.media_limits.publish_quality_cap == "off"
assert cfg.media_limits.stage_max_tiles == 25
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits( async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
@@ -538,6 +545,28 @@ async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarize
assert cfg.summarizer.enabled is False assert cfg.summarizer.enabled is False
async def test_media_limits_partial_update_keeps_untouched_field(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Патч только одного поля `media_limits` не сбрасывает соседнее — оба поля
живут в одной строке JSON, `update()` обязан подставлять текущее значение
несменённого поля, а не дефолт модели."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(SettingsUpdateIn(publish_quality_cap="360p"))
assert cfg.media_limits.publish_quality_cap == "360p"
assert cfg.media_limits.stage_max_tiles == 25 # дефолт не тронут
cfg = await service.update(SettingsUpdateIn(stage_max_tiles=9))
assert cfg.media_limits.stage_max_tiles == 9
assert cfg.media_limits.publish_quality_cap == "360p" # предыдущая правка сохранилась
reloaded = await service.get()
assert reloaded.media_limits.publish_quality_cap == "360p"
assert reloaded.media_limits.stage_max_tiles == 9
async def test_update_rejects_unavailable_ai_level( async def test_update_rejects_unavailable_ai_level(
db_session: AsyncSession, clean_instance_settings: None db_session: AsyncSession, clean_instance_settings: None
) -> None: ) -> None:

View File

@@ -34,6 +34,7 @@ from models.instance_setting import InstanceSetting
from models.participant import ConferenceParticipant from models.participant import ConferenceParticipant
from models.session import ConferenceSession from models.session import ConferenceSession
from models.user import User from models.user import User
from services import hand_queue
from services.conference_ids import generate_number, generate_slug from services.conference_ids import generate_number, generate_slug
from services.egress import EgressStartResult from services.egress import EgressStartResult
@@ -130,7 +131,7 @@ async def _make_user(session: AsyncSession, email: str) -> User:
user = User( user = User(
email=email, email=email,
name_user="Participant", name_user="Participant",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
) )
session.add(user) session.add(user)
@@ -217,6 +218,53 @@ async def test_full_cycle_joined_left_finished(
assert conference.ended_at is not None assert conference.ended_at is not None
async def test_participant_left_removes_raised_hand_from_queue(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Задача B1: участник с поднятой рукой вышел из конференции — рука исчезает из очереди."""
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-hand-1@example.com")
await db_session.commit()
identity = str(user.id)
await hand_queue.raise_hand(conference.id, identity=identity, name=user.name_user)
assert [e.identity for e in await hand_queue.snapshot(conference.id)] == [identity]
left = _load_fixture(
"participant_left.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=identity,
)
resp = await _post_webhook(client, left)
assert resp.status_code == 200
assert await hand_queue.snapshot(conference.id) == []
async def test_room_finished_clears_hand_queue(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Задача B1: очередь поднятых рук — состояние звонка, не переживает его завершение."""
conference = await _make_conference(db_session, generate_slug())
await db_session.commit()
started = _load_fixture(
"room_started.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
)
assert (await _post_webhook(client, started)).status_code == 200
await hand_queue.raise_hand(conference.id, identity="guest:leftover", name="Leftover Guest")
assert len(await hand_queue.snapshot(conference.id)) == 1
finished = _load_fixture(
"room_finished.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
)
assert (await _post_webhook(client, finished)).status_code == 200
assert await hand_queue.snapshot(conference.id) == []
async def test_pinned_conference_returns_to_scheduled_on_finish( async def test_pinned_conference_returns_to_scheduled_on_finish(
client: httpx.AsyncClient, db_session: AsyncSession client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:

View File

@@ -0,0 +1,245 @@
"""Тесты `POST /api/v1/conferences/{id}/mute-participant` (задача B2).
`mute_participant_track` (реальный вызов LiveKit `RoomServiceClient`) мокается
на уровне `services.conferences` — тот же паттерн, что и `start_track_egress`
в `tests/test_livekit_webhook.py`: сетевой вызов к LiveKit в тестах не нужен,
важна только бизнес-логика (права, маршрутизация ошибок, broadcast).
"""
import asyncio
import uuid
from typing import Any
from unittest.mock import AsyncMock
import httpx
import pytest
from redis.asyncio.client import PubSub
from sqlalchemy.ext.asyncio import AsyncSession
import services.conferences as conferences_module
from core.redis import redis_client
from core.security import create_access_token, hash_password
from models.conference import Conference
from models.user import User
from services.conference_ids import generate_number, generate_slug
from services.hand_queue import hand_queue_channel
from services.room_control import ParticipantNotInRoomError
MUTE_URL = "{base}/mute-participant"
async def _receive_within(pubsub: PubSub, *, max_wait: float) -> dict[str, Any] | None:
"""Дождаться СОДЕРЖАТЕЛЬНОГО сообщения канала в пределах `max_wait` секунд.
`ignore_subscribe_messages=True` у `get_message` фильтрует служебное
подтверждение подписки, но при этом всё равно может вернуть `None` для
ЭТОГО конкретного вызова (см. `api/chat.py::_pump_pubsub_to_websocket`,
ровно поэтому там `while True: ... if raw is None: continue`) — здесь тот
же цикл, но с общим дедлайном вместо бесконечного ожидания.
"""
deadline = asyncio.get_event_loop().time() + max_wait
while True:
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
return None
raw: dict[str, Any] | None = await pubsub.get_message(
ignore_subscribe_messages=True, timeout=remaining
)
if raw is not None:
return raw
async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Mute Tester",
password_hash=await hash_password("password123"),
email_verified=True,
role=role,
)
session.add(user)
await session.flush()
return user
async def _make_conference(
session: AsyncSession, *, owner_id: uuid.UUID | None = None
) -> Conference:
conference = Conference(
number=generate_number(),
slug=generate_slug(),
title="Mute Test",
status="active",
owner_id=owner_id,
)
session.add(conference)
await session.flush()
return conference
def _auth_headers(user: User) -> dict[str, str]:
return {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
def _url(conference_id: uuid.UUID) -> str:
return MUTE_URL.format(base=f"/api/v1/conferences/{conference_id}")
async def test_owner_can_mute_participant_and_broadcast_is_published(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
mock_mute = AsyncMock(return_value=True)
monkeypatch.setattr(conferences_module, "mute_participant_track", mock_mute)
pubsub = redis_client.pubsub()
channel = hand_queue_channel(conference.id)
await pubsub.subscribe(channel)
try:
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "microphone"},
headers=_auth_headers(owner),
)
assert response.status_code == 200, response.text
assert response.json() == {"muted": True}
mock_mute.assert_awaited_once_with(
conference.slug, identity="some-identity", source="microphone"
)
raw = await _receive_within(pubsub, max_wait=2)
assert raw is not None
assert raw["data"] == (
'{"type":"forced_mute","identity":"some-identity","source":"microphone"}'
)
finally:
await pubsub.unsubscribe(channel)
await pubsub.aclose() # type: ignore[no-untyped-call]
async def test_mute_already_off_does_not_broadcast(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Трек не был опубликован (камера/мьют и так выключены, задача A1) — не ошибка.
Ответ `muted=false` (искомое состояние уже достигнуто), без broadcast'а.
"""
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
monkeypatch.setattr(
conferences_module, "mute_participant_track", AsyncMock(return_value=False)
)
pubsub = redis_client.pubsub()
channel = hand_queue_channel(conference.id)
await pubsub.subscribe(channel)
try:
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "camera"},
headers=_auth_headers(owner),
)
assert response.status_code == 200, response.text
assert response.json() == {"muted": False}
raw = await _receive_within(pubsub, max_wait=0.5)
assert raw is None
finally:
await pubsub.unsubscribe(channel)
await pubsub.aclose() # type: ignore[no-untyped-call]
async def test_admin_can_mute_participant_of_someone_elses_conference(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
admin = await _make_user(db_session, role="admin")
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
monkeypatch.setattr(conferences_module, "mute_participant_track", AsyncMock(return_value=True))
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "microphone"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
async def test_regular_participant_cannot_mute_someone_else(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
other = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
mock_mute = AsyncMock()
monkeypatch.setattr(conferences_module, "mute_participant_track", mock_mute)
response = await client.post(
_url(conference.id),
json={"identity": str(owner.id), "source": "microphone"},
headers=_auth_headers(other),
)
assert response.status_code == 403
assert response.json()["detail"] == "not_owner"
mock_mute.assert_not_awaited()
async def test_mute_conference_not_found(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
_url(uuid.uuid4()),
json={"identity": "some-identity", "source": "microphone"},
headers=_auth_headers(user),
)
assert response.status_code == 404
assert response.json()["detail"] == "conference_not_found"
async def test_mute_participant_not_in_room_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
monkeypatch.setattr(
conferences_module,
"mute_participant_track",
AsyncMock(side_effect=ParticipantNotInRoomError()),
)
response = await client.post(
_url(conference.id),
json={"identity": "ghost", "source": "microphone"},
headers=_auth_headers(owner),
)
assert response.status_code == 404
assert response.json()["detail"] == "participant_not_in_room"
async def test_mute_rejects_invalid_source(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "screen_share"},
headers=_auth_headers(owner),
)
assert response.status_code == 422

View File

@@ -0,0 +1,128 @@
"""Проверка пароля не должна блокировать event loop (регресс после теста 31.07.2026).
Синхронный `verify_password` останавливал весь процесс backend на 95155 мс.
При массовом входе (около 70 человек разом) это давало p95 логина 7.28 секунды,
33 соединения к БД в состоянии `idle in transaction` при одном активном запросе
и отказы на посторонних ручках — включая вход в конференцию, где пароль вообще
не проверялся. Разбор — `.forcc/LOGIN-BOTTLENECK.md`.
Тесты ниже проверяют не скорость (она зависит от железа), а **свойства**:
event loop остаётся живым, проверки идут параллельно, старые хэши мигрируют.
"""
import asyncio
import time
from argon2 import PasswordHasher
from core.security import hash_password, needs_rehash, verify_password
PASSWORD = "correct-horse-battery-staple"
async def test_verify_password_does_not_block_event_loop() -> None:
"""Пока считается argon2, event loop продолжает обслуживать другие задачи.
Это главное свойство правки. Фоновая корутина тикает каждую миллисекунду;
если проверка пароля выполняется синхронно в loop, тиков за её время будет
ноль или единицы — именно так и вело себя приложение до исправления.
"""
password_hash = await hash_password(PASSWORD)
ticks = 0
stop = False
async def ticker() -> None:
nonlocal ticks
while not stop:
ticks += 1
await asyncio.sleep(0.001)
ticker_task = asyncio.create_task(ticker())
await asyncio.sleep(0.005) # даём тикеру стартовать
ticks_before = ticks
assert await verify_password(PASSWORD, password_hash) is True
ticks_during = ticks - ticks_before
stop = True
await ticker_task
# Даже на быстром железе argon2 занимает десятки миллисекунд — за это время
# loop обязан прокрутить заметное число тиков. Порог намеренно щадящий:
# при блокировке тиков будет 01, а не «мало».
assert ticks_during >= 5, (
f"event loop простоял во время проверки пароля: {ticks_during} тиков — "
"похоже, argon2 снова считается синхронно"
)
async def test_parallel_verifications_are_concurrent() -> None:
"""Параллельные проверки идут одновременно, а не выстраиваются в очередь.
argon2-cffi — C-расширение и освобождает GIL, поэтому пул потоков даёт
настоящий параллелизм. Проверяем, что 8 проверок занимают заметно меньше,
чем 8 последовательных: иначе массовый вход снова упрётся в сериализацию.
"""
password_hash = await hash_password(PASSWORD)
start = time.perf_counter()
await verify_password(PASSWORD, password_hash)
single = time.perf_counter() - start
start = time.perf_counter()
results = await asyncio.gather(*(verify_password(PASSWORD, password_hash) for _ in range(8)))
parallel = time.perf_counter() - start
assert all(results)
# На 4-ядерном сервере 8 проверок идеально легли бы в 2×single; берём 5×
# с большим запасом на шум CI и разное железо — важно лишь то, что это
# НЕ 8× (последовательное выполнение).
assert parallel < single * 5, (
f"8 параллельных проверок заняли {parallel:.3f} с при {single:.3f} с на одну — "
"похоже, они выполняются последовательно"
)
async def test_wrong_password_is_rejected() -> None:
"""Асинхронная обёртка не сломала саму проверку."""
password_hash = await hash_password(PASSWORD)
assert await verify_password(PASSWORD, password_hash) is True
assert await verify_password("wrong-password", password_hash) is False
async def test_hasher_uses_owasp_parameters() -> None:
"""Параметры argon2id — по рекомендации OWASP, а не дефолт библиотеки.
Дефолт argon2-cffi (t=3, m=64 МБ, p=4) стоил 95 мс на проверку, причём
`parallelism=4` занимал все четыре ядра сервера — те же, на которых
работает LiveKit.
"""
password_hash = await hash_password(PASSWORD)
# Параметры зашиты в саму строку хэша: $argon2id$v=19$m=19456,t=2,p=1$...
assert "m=19456" in password_hash
assert "t=2" in password_hash
assert "p=1" in password_hash
async def test_legacy_hash_is_verified_and_marked_for_rehash() -> None:
"""Хэш со старыми параметрами проверяется, но помечается на перевыпуск.
Гарантия обратной совместимости: пароли, выданные до смены параметров,
продолжают работать. `AuthService.login` перевыпускает такой хэш при
первом же успешном входе — другого момента, когда открытый пароль есть
на руках, не будет.
"""
legacy_hasher = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
legacy_hash = legacy_hasher.hash(PASSWORD)
assert await verify_password(PASSWORD, legacy_hash) is True
assert needs_rehash(legacy_hash) is True
fresh_hash = await hash_password(PASSWORD)
assert needs_rehash(fresh_hash) is False
async def test_broken_hash_is_marked_for_rehash() -> None:
"""Мусор вместо хэша не роняет вход, а помечается на замену."""
assert needs_rehash("not-a-valid-argon2-hash") is True

View File

@@ -20,7 +20,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user="Test User", name_user="Test User",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
role=role, role=role,
email_verified=True, email_verified=True,
) )

View File

@@ -18,7 +18,7 @@ async def _make_user(session: AsyncSession) -> User:
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user="Teams API Tester", name_user="Teams API Tester",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
) )
session.add(user) session.add(user)

View File

@@ -24,7 +24,7 @@ async def _make_user(session: AsyncSession) -> User:
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user="List Tester", name_user="List Tester",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
) )
session.add(user) session.add(user)
@@ -98,7 +98,7 @@ async def test_get_me_with_reserved_tld_email_does_not_500(
user = User( user = User(
email=legacy_email, email=legacy_email,
name_user="Legacy Admin", name_user="Legacy Admin",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
role="admin", role="admin",
) )
@@ -380,7 +380,7 @@ async def test_list_users_search_by_q_filters_by_name_or_email(
match = User( match = User(
email=f"{unique_marker}@example.com", email=f"{unique_marker}@example.com",
name_user=f"Findable {unique_marker}", name_user=f"Findable {unique_marker}",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
) )
db_session.add(match) db_session.add(match)

View File

@@ -89,7 +89,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media} MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение # Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health. # из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.15} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.21}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан # Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение, # на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул # проверьте бюджет соединений с БД: каждый воркер держит свой пул
@@ -407,16 +407,19 @@ services:
# 7880 (signaling) — ТОЛЬКО loopback: nginx проксирует /livekit/ по имени # 7880 (signaling) — ТОЛЬКО loopback: nginx проксирует /livekit/ по имени
# `livekit:7880` внутри docker-сети (см. nginx.conf.template), браузеры # `livekit:7880` внутри docker-сети (см. nginx.conf.template), браузеры
# снаружи ходят через nginx/443 (wss://), прямой доступ к 7880 им не # снаружи ходят через nginx/443 (wss://), прямой доступ к 7880 им не
# нужен. 7881/tcp и UDP-диапазон ниже — реальные медиа-порты, остаются # нужен. 7881/tcp и UDP-порт ниже — реальные медиа-порты, остаются
# публичными. # публичными.
ports: ports:
- "127.0.0.1:7880:7880" # HTTP/WebSocket signaling - "127.0.0.1:7880:7880" # HTTP/WebSocket signaling
- "7881:7881" # RTC TCP fallback - "7881:7881" # RTC TCP fallback
# Узкий диапазон для dev на macOS: широкий (50000-60000) почти всегда # Один порт вместо диапазона (был 54000-54100/udp) — LiveKit
# конфликтует с занятыми UDP-портами хоста и тормозит Docker Desktop. # мультиплексирует все ICE-сессии через него (rtc.udp_port в
# 54000+ выбран после конфликтов: нижние диапазоны (50000+, 52000+) # livekit.yaml.template), а не открывает по порту на участника.
# занимают Steam/системные процессы macOS и эфемерные QUIC-соединения. # На диапазон Docker поднимал по docker-proxy на КАЖДЫЙ порт —
- "54000-54100:54000-54100/udp" # WebRTC media (ICE) # 101 порт держали 101 лишний userland-процесс на медиапути.
# 54000 выбран, как раньше: нижние диапазоны (50000+, 52000+) на
# macOS заняты Steam/системными процессами и эфемерными QUIC.
- "54000:54000/udp" # WebRTC media (ICE, мультиплекс)
depends_on: depends_on:
redis: redis:
condition: service_healthy condition: service_healthy

View File

@@ -15,16 +15,20 @@ port: 7880
rtc: rtc:
tcp_port: 7881 tcp_port: 7881
# Диапазон сужен для dev (см. комментарий в docker-compose.yml); в проде # Один UDP-порт с мультиплексированием ICE вместо диапазона портов.
# расширить и синхронизировать с пробросом портов. # Раньше здесь был port_range_start/port_range_end (54000-54100) — под
port_range_start: 54000 # каждый порт диапазона Docker поднимал отдельный процесс docker-proxy
port_range_end: 54100 # (userland-прокси на весь медиатрафик), на 101 порт — 101 процесс.
# udp_port переключает LiveKit на единственный сокет с демультиплексацией
# по ICE ufrag; port_range_start/end при заданном udp_port игнорируются
# (проверено по исходникам сервера) — оставлять их рядом бессмысленно.
udp_port: 54000
# use_external_ip: false + node_ip=127.0.0.1 — режим для локальной # use_external_ip: false + node_ip=127.0.0.1 — режим для локальной
# разработки (Docker Desktop): use_external_ip=true определяет публичный # разработки (Docker Desktop): use_external_ip=true определяет публичный
# IP через STUN, что в контейнере на macOS даёт недостижимый изнутри хоста # IP через STUN, что в контейнере на macOS даёт недостижимый изнутри хоста
# внутренний IP (172.18.x.x) — DTLS-хендшейк по data-каналам не проходит # внутренний IP (172.18.x.x) — DTLS-хендшейк по data-каналам не проходит
# ("dtls timeout" в логах). node_ip=127.0.0.1 работает, потому что порты # ("dtls timeout" в логах). node_ip=127.0.0.1 работает, потому что порты
# 7881/tcp и 54000-54100/udp проброшены на loopback хоста, а браузер-клиент # 7881/tcp и 54000/udp проброшены на loopback хоста, а браузер-клиент
# запускается на том же хосте. # запускается на том же хосте.
# В проде (LIVEKIT_USE_EXTERNAL_IP=true, LIVEKIT_NODE_IP=<внешний IP/домен # В проде (LIVEKIT_USE_EXTERNAL_IP=true, LIVEKIT_NODE_IP=<внешний IP/домен
# сервера> в .env) клиенты снаружи хоста подключаются по этому адресу — # сервера> в .env) клиенты снаружи хоста подключаются по этому адресу —

View File

@@ -393,7 +393,9 @@ Email сразу считается подтверждённым (письмо
"registration_team_choice": false, "registration_team_choice": false,
"registration_email_domain_enabled": false, "registration_email_domain_enabled": false,
"registration_email_domain": null, "registration_email_domain": null,
"transcription_queue_served": true "transcription_queue_served": true,
"publish_quality_cap": "off",
"stage_max_tiles": 25
} }
``` ```
@@ -408,6 +410,8 @@ Email сразу считается подтверждённым (письмо
- `registration_email_domain_enabled` — включена ли верификация регистрирующихся по домену email (дефолт `false`) - `registration_email_domain_enabled` — включена ли верификация регистрирующихся по домену email (дефолт `false`)
- `registration_email_domain` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена - `registration_email_domain` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена
- `transcription_queue_served``true`, если хотя бы один Celery-воркер `transcriber` активно обслуживает очередь транскрибации; `false` = предупреждение в админке (см. ниже) - `transcription_queue_served``true`, если хотя бы один Celery-воркер `transcriber` активно обслуживает очередь транскрибации; `false` = предупреждение в админке (см. ниже)
- `publish_quality_cap` — потолок качества исходящего видео публикующего: `off` (без ограничения, дефолт), `720p`, `360p` или `180p`; отдаётся участнику ещё и в join-ответе (`JoinOut`, `POST /api/v1/conferences/{id}/join`/`guest-join`) — до входа в LiveKit-комнату
- `stage_max_tiles` — максимум одновременно видимых плиток сетки конференции: `4`, `9`, `16` или `25` (дефолт, совпадает с текущим потолком сетки 5×5, то есть без ограничения); участники сверх лимита уходят на следующую страницу пагинации
--- ---

View File

@@ -81,7 +81,7 @@ ufw allow 22/tcp # SSH — сузьте до вашей сети, если
ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge) ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge)
ufw allow 443/tcp # HTTPS ufw allow 443/tcp # HTTPS
ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media) ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media)
ufw allow 54000:54100/udp # LiveKit WebRTC media (ICE), см. docker-compose.yml ufw allow 54000/udp # LiveKit WebRTC media (ICE, мультиплекс), см. docker-compose.yml
# TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта: # TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта:
# сигнальные порты И диапазон relay-аллокаций (min-port/max-port из # сигнальные порты И диапазон relay-аллокаций (min-port/max-port из
# deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но # deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но
@@ -276,7 +276,7 @@ docker compose -f deploy/docker-compose.yml --env-file .env --profile monitoring
# 1. Все сервисы healthy # 1. Все сервисы healthy
docker compose -f deploy/docker-compose.yml --env-file .env ps docker compose -f deploy/docker-compose.yml --env-file .env ps
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000-54100, # 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000,
# плюс 3478 tcp+udp, если включили TURN — раздел 8) # плюс 3478 tcp+udp, если включили TURN — раздел 8)
ss -ltnp ss -ltnp
@@ -318,7 +318,7 @@ Protocols`. Проверьте **гостевой вход** (`/j/<slug>` в п
**Статус: НЕ обязателен.** Реальное кросс-сетевое тестирование (участники в **Статус: НЕ обязателен.** Реальное кросс-сетевое тестирование (участники в
разных сетях/на разных устройствах) прошло успешно **без** раздачи TURN разных сетях/на разных устройствах) прошло успешно **без** раздачи TURN
клиентам — комбинации `LIVEKIT_USE_EXTERNAL_IP=false` + реальный клиентам — комбинации `LIVEKIT_USE_EXTERNAL_IP=false` + реальный
`LIVEKIT_NODE_IP` + проброшенный UDP-диапазон `54000-54100` (шаг 1, `LIVEKIT_NODE_IP` + проброшенный UDP-порт `54000` (шаг 1,
firewall) хватает для подавляющего большинства сетей. Включайте этот firewall) хватает для подавляющего большинства сетей. Включайте этот
раздел только если у вас есть конкретные пользователи за CGNAT или раздел только если у вас есть конкретные пользователи за CGNAT или
жёстким корпоративным firewall, которые не могут установить медиа-соединение жёстким корпоративным firewall, которые не могут установить медиа-соединение

View File

@@ -179,12 +179,15 @@ dev-стека.
профилей битрейта заводить не требуется; при необходимости ограничить профилей битрейта заводить не требуется; при необходимости ограничить
верхнюю границу — `videoEncoding`/`simulcastLayers` на фронтенде верхнюю границу — `videoEncoding`/`simulcastLayers` на фронтенде
(клиентский SDK, вне скоупа devops-части). (клиентский SDK, вне скоупа devops-части).
4. **UDP-диапазон 54000-54100 (101 порт)** не был узким местом ни на одной 4. **UDP-диапазон 54000-54100 (101 порт)**, на котором проводился этот тест,
ступени (максимум 60 участников в тесте) — при планировании прод-узла с не был узким местом ни на одной ступени (максимум 60 участников). Тогда
ожидаемым бОльшим числом одновременных участников across все комнаты же с ним была цена: под каждый порт диапазона Docker держал отдельный
узла держать `port_range_end - port_range_start` заметно больше пикового процесс `docker-proxy` — 101 порт-101 процесс на медиапути, весь трафик
числа участников на узле (LiveKit резервирует пару портов на участника шёл лишним userland-хопом. С переходом на `rtc.udp_port` (один порт,
на медиа-транспорт). мультиплексирование по ICE ufrag внутри LiveKit) рекомендация «держать
диапазон шире пикового числа участников» больше не актуальна — портов
для планирования ёмкости не остаётся вовсе, LiveKit разводит участников
поверх одного сокета сам.
5. **STUN/TURN-находка (см. «Методика») —** рекомендуется отдельной задачей 5. **STUN/TURN-находка (см. «Методика») —** рекомендуется отдельной задачей
зарегистрировать `deploy/coturn/` в `rtc.turn_servers` LiveKit и на зарегистрировать `deploy/coturn/` в `rtc.turn_servers` LiveKit и на
проде, а не только для теста — иначе клиенты в вырожденном случае проде, а не только для теста — иначе клиенты в вырожденном случае

View File

@@ -4,7 +4,7 @@
* конверте пагинации `items`/`total`. * конверте пагинации `items`/`total`.
*/ */
import { apiRequest } from '@/api/client' import { apiRequest } from '@/api/client'
import type { ConferenceRecurrence, ConferenceStatus, SummaryRecipientsMode } from '@/api/conferences' import type { ConferenceRecurrence, ConferenceStatus, PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
/** Уровень качества AI-обработки (транскрибация + суммаризация). */ /** Уровень качества AI-обработки (транскрибация + суммаризация). */
export type AiLevel = 'min' | 'medium' | 'max' export type AiLevel = 'min' | 'medium' | 'max'
@@ -39,6 +39,10 @@ export interface SettingsOut {
contact_email_enabled: boolean contact_email_enabled: boolean
/** Контактный адрес — `null`, если не задан/выключен. */ /** Контактный адрес — `null`, если не задан/выключен. */
contact_email: string | null contact_email: string | null
/** Потолок качества исходящего видео публикующего — см. `PublishQualityCap`. */
publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`). */
stage_max_tiles: number
} }
/** Тело частичного обновления настроек инстанса — все поля опциональны. */ /** Тело частичного обновления настроек инстанса — все поля опциональны. */
@@ -56,6 +60,8 @@ export interface SettingsUpdateIn {
/** Включение без email или невалидный email — backend отвечает 400. */ /** Включение без email или невалидный email — backend отвечает 400. */
contact_email_enabled?: boolean contact_email_enabled?: boolean
contact_email?: string | null contact_email?: string | null
publish_quality_cap?: PublishQualityCap
stage_max_tiles?: number
} }
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */ /** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */

View File

@@ -19,6 +19,15 @@ export type RecurrenceType = 'weekly' | 'biweekly' | 'monthly' | 'every_n_days'
*/ */
export type SummaryRecipientsMode = 'all' | 'owner' export type SummaryRecipientsMode = 'all' | 'owner'
/**
* Потолок качества исходящего видео участника (`instance_settings.media_limits`,
* см. `SettingsOut`/`SettingsUpdateIn` в `src/api/admin.ts`). `off` — без
* ограничения. Применяется на клиенте через `publishDefaults`
* (`lib/publishQualityCap.ts`) — режет битрейт верхнего слоя симулкаста, а не
* жёсткое разрешение захвата камеры.
*/
export type PublishQualityCap = 'off' | '180p' | '360p' | '720p'
/** /**
* Правило повторения закреплённой конференции — форма 1:1 с pydantic-моделью * Правило повторения закреплённой конференции — форма 1:1 с pydantic-моделью
* `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там). * `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там).
@@ -52,6 +61,10 @@ export interface ConferenceJoinData {
conference_id: string conference_id: string
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */ /** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
chat_enabled: boolean chat_enabled: boolean
/** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */
publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */
stage_max_tiles: number
} }
/** /**
@@ -145,6 +158,14 @@ export interface ConferenceGuestJoinPayload {
password?: string password?: string
} }
/** Источник трека, который организатор может принудительно выключить (задача B2). */
export type MuteSource = 'microphone' | 'camera'
/** Ответ на принудительный мьют — `false`, если трек и так не был опубликован (нечего было мьютить). */
export interface MuteParticipantResult {
muted: boolean
}
/** Тело частичного обновления конференции — те же поля, что и при создании, все опциональны. */ /** Тело частичного обновления конференции — те же поля, что и при создании, все опциональны. */
export type ConferenceUpdatePayload = Partial<ConferenceCreatePayload> export type ConferenceUpdatePayload = Partial<ConferenceCreatePayload>
@@ -202,6 +223,23 @@ export async function guestJoinConference(
}) })
} }
/**
* Принудительно выключить микрофон/камеру участника (задача B2) — только
* владелец конференции/администратор, иначе 403 (`not_owner`). 404
* (`participant_not_in_room`) — участника с таким `identity` сейчас нет в
* комнате LiveKit.
*/
export async function muteParticipant(
conferenceId: string,
identity: string,
source: MuteSource,
): Promise<MuteParticipantResult> {
return apiRequest<MuteParticipantResult>(`/conferences/${conferenceId}/mute-participant`, {
method: 'POST',
body: { identity, source },
})
}
/** Список «моих» конференций — закреплённые (повторяющиеся) и предстоящие разовые владельца. */ /** Список «моих» конференций — закреплённые (повторяющиеся) и предстоящие разовые владельца. */
export async function getMyConferences(): Promise<ConferenceOut[]> { export async function getMyConferences(): Promise<ConferenceOut[]> {
return apiRequest<ConferenceOut[]>('/conferences/my') return apiRequest<ConferenceOut[]>('/conferences/my')

View File

@@ -10,7 +10,7 @@ import {
type SettingsUpdateIn, type SettingsUpdateIn,
type TestEmailOut, type TestEmailOut,
} from '@/api/admin' } from '@/api/admin'
import type { SummaryRecipientsMode } from '@/api/conferences' import type { PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client' import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth' import { useAuth } from '@/auth/useAuth'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
@@ -21,6 +21,20 @@ const SUMMARY_RECIPIENTS_OPTIONS = [
{ value: 'owner', label: 'Только организатору' }, { value: 'owner', label: 'Только организатору' },
] ]
const PUBLISH_QUALITY_CAP_OPTIONS = [
{ value: 'off', label: 'Без ограничения' },
{ value: '720p', label: 'Не выше 720p' },
{ value: '360p', label: 'Не выше 360p' },
{ value: '180p', label: 'Не выше 180p' },
]
const STAGE_MAX_TILES_OPTIONS = [
{ value: '25', label: '25 (5×5, без ограничения)' },
{ value: '16', label: '16 (4×4)' },
{ value: '9', label: '9 (3×3)' },
{ value: '4', label: '4 (2×2)' },
]
const AI_LEVEL_LABEL: Record<AiLevel, string> = { const AI_LEVEL_LABEL: Record<AiLevel, string> = {
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)', min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
medium: 'Средний', medium: 'Средний',
@@ -64,6 +78,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const [newDomainInput, setNewDomainInput] = useState('') const [newDomainInput, setNewDomainInput] = useState('')
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled) const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '') const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
const [publishQualityCap, setPublishQualityCap] = useState<PublishQualityCap>(data.publish_quality_cap)
const [stageMaxTiles, setStageMaxTiles] = useState(data.stage_max_tiles)
const [testEmailTo, setTestEmailTo] = useState('') const [testEmailTo, setTestEmailTo] = useState('')
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null) const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
@@ -136,6 +152,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
if (trimmedContactEmail !== (data.contact_email ?? null)) { if (trimmedContactEmail !== (data.contact_email ?? null)) {
payload.contact_email = trimmedContactEmail payload.contact_email = trimmedContactEmail
} }
if (publishQualityCap !== data.publish_quality_cap) payload.publish_quality_cap = publishQualityCap
if (stageMaxTiles !== data.stage_max_tiles) payload.stage_max_tiles = stageMaxTiles
mutation.mutate(payload) mutation.mutate(payload)
} }
@@ -361,6 +379,51 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</div> </div>
</section> </section>
<section className="settings-card">
<h2>Нагрузка</h2>
<p className="desc">
Рычаги для инстансов на слабом канале/железе режут исходящий трафик и нагрузку на
устройство участника. Дефолты сохраняют прежнее поведение (без ограничений).
</p>
<div className="settings-card-body settings-card-body--spread">
<div className="field" style={{ marginBottom: 0 }}>
<label id="settings-quality-cap-label" htmlFor="settings-quality-cap">
Потолок качества публикации видео
</label>
<Select
id="settings-quality-cap"
aria-labelledby="settings-quality-cap-label"
value={publishQualityCap}
onChange={(v) => setPublishQualityCap(v as PublishQualityCap)}
options={PUBLISH_QUALITY_CAP_OPTIONS}
/>
<p className="field-hint">
Ограничивает битрейт исходящего видео публикующего снижает нагрузку на его канал и
устройство, независимо от размера плитки у смотрящих (adaptiveStream режет с их
стороны отдельно)
</p>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label id="settings-max-tiles-label" htmlFor="settings-max-tiles">
Максимум плиток на экране
</label>
<Select
id="settings-max-tiles"
aria-labelledby="settings-max-tiles-label"
value={String(stageMaxTiles)}
onChange={(v) => setStageMaxTiles(Number(v))}
options={STAGE_MAX_TILES_OPTIONS}
/>
<p className="field-hint">
Сверх лимита участники уходят на следующую страницу сетки вместо подписки меньше
одновременных видеопотоков на канал и экран участника
</p>
</div>
</div>
</section>
<section className="settings-card"> <section className="settings-card">
<h2>Контактный адрес</h2> <h2>Контактный адрес</h2>
<p className="desc"> <p className="desc">

View File

@@ -6,12 +6,15 @@ import { useModalDismiss } from '@/hooks/useModalDismiss'
/** /**
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на * Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
* сотни килобайт ради десятка кнопок в поповере. * сотни килобайт ради десятка кнопок в поповере. Ровно 30 штук (6×5 —
* `.chat-emoji-popover` в room.css рассчитан на эту сетку без остатка;
* добавляя/убирая эмодзи, держи кратность 5).
*/ */
const EMOJI_OPTIONS = [ const EMOJI_OPTIONS = [
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢', '😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️', '😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝', '🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
'🐎', '🦾', '🚀', '🦞', '💯', '🤷‍♂️',
] ]
interface ChatPanelProps { interface ChatPanelProps {

View File

@@ -0,0 +1,40 @@
import { useEffect } from 'react'
import { useLocalParticipant } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
import type { ForcedMuteEvent } from '@/hooks/useChat'
/**
* Уведомляет ЛОКАЛЬНОГО участника тостом, когда организатор принудительно
* выключил его микрофон/камеру (задача B2). Рендерится безусловно внутри
* `<LiveKitRoom>` — `useLocalParticipant` недоступен снаружи (`RoomPage`
* сам вне контекста LiveKit, см. докстринг `useIsOrganizer`).
*
* Само выключение трека организатор делает СЕРВЕРНЫМ вызовом LiveKit API
* (`services/room_control.py`) — тулбарные кнопки (useTrackToggle) сами
* отразят новое состояние по родному событию LiveKit `TrackMuted`, этот
* компонент только поясняет ПОЧЕМУ: без тоста человек не отличил бы
* действие организатора от случайного глюка. Участник может включить себя
* обратно сразу тем же тулбаром — сервер это не блокирует (см. докстринг B2
* в CHANGELOG/коммите).
*/
export function ForcedMuteWatcher({ event }: { event: ForcedMuteEvent | null }) {
const { localParticipant } = useLocalParticipant()
const toast = useToast()
useEffect(() => {
if (!event || event.identity !== localParticipant.identity) return
toast.show(
event.source === 'microphone'
? 'Организатор выключил ваш микрофон'
: 'Организатор выключил вашу камеру',
'info',
)
// `event` (включая `nonce`) — единственная зависимость, которая должна
// повторно показывать тост; `localParticipant`/`toast` стабильны в
// рамках подключения и намеренно не входят в список, чтобы их
// пересоздание (если когда-нибудь случится) не дублировало уведомление.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [event])
return null
}

View File

@@ -0,0 +1,99 @@
import { useEffect, useRef, useState } from 'react'
import { Hand, ListOrdered } from 'lucide-react'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat'
interface HandQueueMenuProps {
queue: HandQueueEntry[]
onLower: (identity: string) => void
}
/**
* Кнопка «Очередь» в тулбаре с поповером над ней — видна только организатору
* (задача B1). Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»):
* собственное состояние открытия, закрытие по клику вне/Escape, поповер
* `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат):
* очередь рук — короткий список, а не история переписки, разворачивать её
* во весь экран незачем и на мобильном.
*
* Размер поповера подстраивается под число записей — `.hand-queue-list`
* растёт вместе со списком и не даёт пустого места при 12 поднятых руках,
* но не бесконечно: после ~10 строк список упирается в `max-height` и дальше
* скроллится (см. room.css) — иначе организатор на энергичной встрече
* получил бы поповер выше экрана.
*/
export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
const isOrganizer = useIsOrganizer()
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handlePointerDown(event: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('keydown', handleKeydown)
}
}, [open])
if (!isOrganizer) return null
return (
<div className="tb-menu-wrap" ref={wrapRef}>
<button
type="button"
className={`tb-btn${open ? ' is-panel-open' : ''}`}
aria-pressed={open}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={open ? 'Свернуть очередь поднятых рук' : 'Открыть очередь поднятых рук'}
onClick={() => setOpen((v) => !v)}
>
<span className="icon-shell">
<ListOrdered className="lucide" aria-hidden="true" />
{queue.length > 0 && (
<span className="badge-count">{queue.length > 9 ? '9+' : queue.length}</span>
)}
</span>
<span className="label">Очередь</span>
</button>
{open && (
<div className="tb-menu hand-queue-menu" role="dialog" aria-label="Очередь поднятых рук">
{queue.length === 0 ? (
<p className="chat-empty">Пока никто не поднял руку</p>
) : (
<ol className="hand-queue-list">
{queue.map((entry, index) => (
<li className="hand-queue-item" key={entry.identity}>
<span className="hand-queue-position">{index + 1}</span>
<span className="hand-queue-name">
<Hand className="lucide" aria-hidden="true" />
{entry.name}
</span>
<button
type="button"
className="hand-queue-lower"
onClick={() => onLower(entry.identity)}
>
Опустить
</button>
</li>
))}
</ol>
)}
</div>
)}
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { Pin, PinOff, ScreenShare } from 'lucide-react' import { Hand, Mic, Pin, PinOff, ScreenShare, Video } from 'lucide-react'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import { import {
AudioTrack, AudioTrack,
@@ -20,21 +20,79 @@ import {
} from '@livekit/components-react' } from '@livekit/components-react'
import { Avatar } from '@/components/ui/Avatar' import { Avatar } from '@/components/ui/Avatar'
import { stageTrackKey } from '@/components/room/stageFocus' import { stageTrackKey } from '@/components/room/stageFocus'
import { parseParticipantMetadata } from '@/lib/participantMetadata'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import { useToast } from '@/components/ui/ToastProvider'
import { muteParticipant } from '@/api/conferences'
/** Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata` на backend) — JSON `{"avatar_url": "..."}`; у гостей отсутствуют. */ /** Достаёт URL аватара из метаданных участника — `null`, если поля нет (гость/без аватара). */
interface ParticipantMetadata {
avatar_url?: string | null
}
/** Разбирает `participant.metadata` в URL аватара — `null`, если поля нет, метаданные пусты или невалидны (гость). */
function parseAvatarUrl(metadata: string | undefined): string | null { function parseAvatarUrl(metadata: string | undefined): string | null {
if (!metadata) return null const parsed = parseParticipantMetadata(metadata)
try {
const parsed = JSON.parse(metadata) as ParticipantMetadata
return typeof parsed.avatar_url === 'string' && parsed.avatar_url ? parsed.avatar_url : null return typeof parsed.avatar_url === 'string' && parsed.avatar_url ? parsed.avatar_url : null
} catch {
return null
} }
/**
* Кнопки принудительного мьюта организатором (задача B2) — микрофон/камера
* ЧУЖОГО участника. Видны только организатору (`useIsOrganizer`, подсказка
* UI — сервер перепроверяет права по владельцу конференции в БД) и только на
* чужой плитке камеры (на своей — обычный тулбарный toggle, мьютить себя
* через «принудительное» действие не нужно).
*
* Не проверяют текущее состояние мьюта заранее (усложнило бы плитку ради
* малополезной оптимизации): клик по уже выключенному треку — не ошибка, а
* no-op на backend (`muted: false` в ответе, см. `services/room_control.py`).
*/
function OrganizerMuteControls({
conferenceId,
identity,
displayName,
}: {
conferenceId: string
identity: string
displayName: string
}) {
const toast = useToast()
async function handleMute(source: 'microphone' | 'camera') {
try {
const result = await muteParticipant(conferenceId, identity, source)
if (!result.muted) {
toast.show(
source === 'microphone' ? 'Микрофон и так выключен' : 'Камера и так выключена',
'info',
)
}
} catch {
toast.show('Не удалось выключить трек участника', 'error')
}
}
return (
<div className="room-organizer-controls">
<button
type="button"
title={`Выключить микрофон: ${displayName}`}
aria-label={`Выключить микрофон: ${displayName}`}
onClick={(e) => {
e.stopPropagation()
void handleMute('microphone')
}}
>
<Mic aria-hidden="true" />
</button>
<button
type="button"
title={`Выключить камеру: ${displayName}`}
aria-label={`Выключить камеру: ${displayName}`}
onClick={(e) => {
e.stopPropagation()
void handleMute('camera')
}}
>
<Video aria-hidden="true" />
</button>
</div>
)
} }
/** /**
@@ -44,8 +102,15 @@ function parseAvatarUrl(metadata: string | undefined): string | null {
* разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`, * разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`,
* версия 2.9.23 — источник этой копии). * версия 2.9.23 — источник этой копии).
*/ */
function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps) { function TileBody({
onStopSharing,
pinnedKey,
onTogglePin,
raisedHandIdentities,
conferenceId,
}: TileControlsProps) {
const trackReference = useEnsureTrackRef() const trackReference = useEnsureTrackRef()
const isOrganizer = useIsOrganizer()
const isEncrypted = useIsEncrypted(trackReference.participant) const isEncrypted = useIsEncrypted(trackReference.participant)
const autoManageSubscription = useFeatureContext()?.autoSubscription const autoManageSubscription = useFeatureContext()?.autoSubscription
// useParticipantInfo — реактивные name/metadata участника (переподписка на // useParticipantInfo — реактивные name/metadata участника (переподписка на
@@ -67,6 +132,18 @@ function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps)
// рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать. // рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать.
const tileKey = stageTrackKey(trackReference) const tileKey = stageTrackKey(trackReference)
const isPinned = pinnedKey === tileKey const isPinned = pinnedKey === tileKey
// Бейдж поднятой руки (задача B1) — только на плитке КАМЕРЫ участника, не
// на плитке его демонстрации экрана (рука — про человека, не про экран).
const isHandRaised =
trackReference.source === Track.Source.Camera &&
Boolean(raisedHandIdentities?.has(trackReference.participant.identity))
// Кнопки принудительного мьюта (задача B2) — организатору, только на
// чужой плитке камеры (см. докстринг `OrganizerMuteControls`).
const showOrganizerMuteControls =
isOrganizer &&
Boolean(conferenceId) &&
trackReference.source === Track.Source.Camera &&
!trackReference.participant.isLocal
return ( return (
<> <>
@@ -88,6 +165,11 @@ function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps)
<div className="lk-participant-placeholder"> <div className="lk-participant-placeholder">
<Avatar name={displayName} avatarUrl={avatarUrl} className="room-tile-avatar" /> <Avatar name={displayName} avatarUrl={avatarUrl} className="room-tile-avatar" />
</div> </div>
{isHandRaised && (
<div className="room-hand-badge" title={`${displayName}: поднята рука`}>
<Hand className="lucide" aria-hidden="true" />
</div>
)}
<div className="lk-participant-metadata"> <div className="lk-participant-metadata">
<div className="lk-participant-metadata-item"> <div className="lk-participant-metadata-item">
{trackReference.source === Track.Source.Camera ? ( {trackReference.source === Track.Source.Camera ? (
@@ -141,6 +223,13 @@ function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps)
</button> </button>
</div> </div>
)} )}
{showOrganizerMuteControls && conferenceId && (
<OrganizerMuteControls
conferenceId={conferenceId}
identity={trackReference.participant.identity}
displayName={displayName}
/>
)}
</> </>
) )
} }
@@ -166,6 +255,17 @@ interface TileControlsProps {
* Не передан — кнопки-булавки на плитке нет (мини-плеер: плитка одна). * Не передан — кнопки-булавки на плитке нет (мини-плеер: плитка одна).
*/ */
onTogglePin?: (key: string) => void onTogglePin?: (key: string) => void
/**
* Identity участников с поднятой рукой прямо сейчас (задача B1, из
* `useChat().handQueue`) — плитка сама решает, её ли это identity. Не
* передан — бейдж нигде не рендерится (мини-плеер).
*/
raisedHandIdentities?: Set<string>
/**
* Id конференции (не slug/номер) — нужен для вызова эндпоинта мьюта
* (задача B2). Не передан — кнопок мьюта на плитке нет (мини-плеер).
*/
conferenceId?: string
} }
interface RoomParticipantTileProps extends TileControlsProps { interface RoomParticipantTileProps extends TileControlsProps {
@@ -197,6 +297,8 @@ export function RoomParticipantTile({
onStopSharing, onStopSharing,
pinnedKey, pinnedKey,
onTogglePin, onTogglePin,
raisedHandIdentities,
conferenceId,
}: RoomParticipantTileProps) { }: RoomParticipantTileProps) {
return ( return (
<ParticipantTile <ParticipantTile
@@ -204,7 +306,13 @@ export function RoomParticipantTile({
disableSpeakingIndicator={disableSpeakingIndicator} disableSpeakingIndicator={disableSpeakingIndicator}
onParticipantClick={onParticipantClick} onParticipantClick={onParticipantClick}
> >
<TileBody onStopSharing={onStopSharing} pinnedKey={pinnedKey} onTogglePin={onTogglePin} /> <TileBody
onStopSharing={onStopSharing}
pinnedKey={pinnedKey}
onTogglePin={onTogglePin}
raisedHandIdentities={raisedHandIdentities}
conferenceId={conferenceId}
/>
</ParticipantTile> </ParticipantTile>
) )
} }

View File

@@ -204,6 +204,9 @@ export function RoomStage({
initialFocusKey = null, initialFocusKey = null,
onFocusKeyChange, onFocusKeyChange,
onPinFocus, onPinFocus,
raisedHandIdentities,
conferenceId,
stageMaxTiles,
}: { }: {
variant?: 'full' | 'pip' variant?: 'full' | 'pip'
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */ /** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
@@ -226,6 +229,12 @@ export function RoomStage({
* переключаем (см. докстринг `RoomPage`, обоснование решения в коммите). * переключаем (см. докстринг `RoomPage`, обоснование решения в коммите).
*/ */
onPinFocus?: () => void onPinFocus?: () => void
/** Identity участников с поднятой рукой (задача B1) — бейдж на плитке; игнорируется при `variant="pip"`. */
raisedHandIdentities?: Set<string>
/** Id конференции (задача B2) — кнопки принудительного мьюта на чужих плитках; игнорируется при `variant="pip"`. */
conferenceId?: string
/** Потолок админки на число плиток `StageGrid` (`instance_settings.media_limits`); игнорируется при `variant="pip"`. */
stageMaxTiles?: number
}) { }) {
const room = useRoomContext() const room = useRoomContext()
const isCompact = useIsCompactViewport() const isCompact = useIsCompactViewport()
@@ -415,21 +424,23 @@ export function RoomStage({
const showCarousel = !hideOthers && sideTracks.length > 0 const showCarousel = !hideOthers && sideTracks.length > 0
// Булавка закрепления есть на КАЖДОЙ плитке во всех режимах (задача 3.3): // Булавка закрепления есть на КАЖДОЙ плитке во всех режимах (задача 3.3):
// сама кнопка вызывает переключение на `standard`, где закреплённый и // сама кнопка вызывает переключение на `standard`, где закреплённый и
// попадёт в фокус (см. `handleTogglePin`/`onPinFocus`). // попадёт в фокус (см. `handleTogglePin`/`onPinFocus`). Бейдж поднятой
const pinProps = { pinnedKey, onTogglePin: handleTogglePin } // руки (задача B1) и кнопки принудительного мьюта (задача B2) едут тем же
// спредом — тоже нужны на КАЖДОЙ плитке.
const tileProps = { pinnedKey, onTogglePin: handleTogglePin, raisedHandIdentities, conferenceId }
function renderMain(): ReactNode { function renderMain(): ReactNode {
if (effectiveMode === 'tiles') { if (effectiveMode === 'tiles') {
return ( return (
<StageGrid tracks={tracks}> <StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
<RoomParticipantTile {...pinProps} /> <RoomParticipantTile {...tileProps} />
</StageGrid> </StageGrid>
) )
} }
if (effectiveMode === 'live-tiles') { if (effectiveMode === 'live-tiles') {
return ( return (
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}> <StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks} maxTiles={stageMaxTiles}>
<RoomParticipantTile {...pinProps} /> <RoomParticipantTile {...tileProps} />
</StageGrid> </StageGrid>
) )
} }
@@ -437,8 +448,8 @@ export function RoomStage({
// прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка. // прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка.
if (sideTracks.length === 0 && !hideOthers) { if (sideTracks.length === 0 && !hideOthers) {
return ( return (
<StageGrid tracks={tracks}> <StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
<RoomParticipantTile {...pinProps} /> <RoomParticipantTile {...tileProps} />
</StageGrid> </StageGrid>
) )
} }
@@ -446,7 +457,7 @@ export function RoomStage({
// (см. её исходник), поэтому вместо неё используем свою обёртку // (см. её исходник), поэтому вместо неё используем свою обёртку
// напрямую с тем же trackRef (аватар в фокус-плитке). // напрямую с тем же trackRef (аватар в фокус-плитке).
return ( return (
focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} {...pinProps} /> focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} {...tileProps} />
) )
} }
@@ -470,7 +481,7 @@ export function RoomStage({
</button> </button>
)} )}
<CarouselLayout tracks={sideTracks}> <CarouselLayout tracks={sideTracks}>
<RoomParticipantTile {...pinProps} /> <RoomParticipantTile {...tileProps} />
</CarouselLayout> </CarouselLayout>
</div> </div>
{renderMain()} {renderMain()}

View File

@@ -1,4 +1,5 @@
import { import {
Hand,
LogOut, LogOut,
Maximize, Maximize,
MessageSquare, MessageSquare,
@@ -13,10 +14,12 @@ import {
VideoOff, VideoOff,
} from 'lucide-react' } from 'lucide-react'
import { Track, type ScreenShareCaptureOptions } from 'livekit-client' import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
import { DisconnectButton, useTrackToggle } from '@livekit/components-react' import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport' import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import type { HandQueueEntry } from '@/hooks/useChat'
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions' import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
import { HandQueueMenu } from '@/components/room/HandQueueMenu'
/** /**
* Опции захвата демонстрации экрана: `audio: true` — звук * Опции захвата демонстрации экрана: `audio: true` — звук
@@ -55,18 +58,35 @@ interface RoomToolbarProps extends StageViewProps {
pipSupported: boolean pipSupported: boolean
pipActive: boolean pipActive: boolean
onTogglePiP: () => void onTogglePiP: () => void
/**
* Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама
* решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity`
* через `useLocalParticipant`), и показывает бейдж общего счётчика.
*/
handQueue: HandQueueEntry[]
onRaiseHand: () => void
onLowerHand: () => void
/** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */
onLowerHandById: (identity: string) => void
} }
/** /**
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/вид сцены/ * Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/рука/очередь
* настройки устройств/полноэкранный режим/мини-плеер/чат/выход — собственные * рук/вид сцены/настройки устройств/полноэкранный режим/мини-плеер/чат/выход —
* кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и панели чата, * собственные кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и
* стилизованные по design/mockups/room.html. * панели чата, стилизованные по design/mockups/room.html.
* *
* Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на * Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на
* широком экране — условным рендерингом, а не скрытием через CSS: тулбар на * широком экране — условным рендерингом, а не скрытием через CSS: тулбар на
* мобильном и так ужат до пяти кнопок, а те же настройки там доступны секцией * мобильном и так ужат до пяти «безусловных» кнопок (демонстрация/
* «Вид» в шторке настроек (`DeviceSettingsDialog`). * полноэкранный режим/мини-плеер скрыты на узком экране через CSS, см.
* `styles/room.css`), а те же настройки там доступны секцией «Вид» в шторке
* настроек (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* экономии: поднять руку посреди разговора — действие со временем жизни в
* секунды, прятать его в шторку настроек означало бы делать его практически
* недоступным с телефона. «Очередь» показывается только организатору —
* встречается редко, но по той же причине оставлена в тулбаре, а не в
* шторке: организатору с телефона тоже нужно видеть очередь сразу.
*/ */
export function RoomToolbar({ export function RoomToolbar({
chatVisible, chatVisible,
@@ -80,6 +100,10 @@ export function RoomToolbar({
pipSupported, pipSupported,
pipActive, pipActive,
onTogglePiP, onTogglePiP,
handQueue,
onRaiseHand,
onLowerHand,
onLowerHandById,
layoutMode, layoutMode,
onLayoutModeChange, onLayoutModeChange,
hideOthers, hideOthers,
@@ -87,6 +111,8 @@ export function RoomToolbar({
}: RoomToolbarProps) { }: RoomToolbarProps) {
const toast = useToast() const toast = useToast()
const isCompact = useIsCompactViewport() const isCompact = useIsCompactViewport()
const { localParticipant } = useLocalParticipant()
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
const mic = useTrackToggle({ source: Track.Source.Microphone }) const mic = useTrackToggle({ source: Track.Source.Microphone })
const camera = useTrackToggle({ source: Track.Source.Camera }) const camera = useTrackToggle({ source: Track.Source.Camera })
const screenShare = useTrackToggle({ const screenShare = useTrackToggle({
@@ -152,6 +178,24 @@ export function RoomToolbar({
<span className="label">Демонстрация</span> <span className="label">Демонстрация</span>
</button> </button>
<button
type="button"
className={`tb-btn${handRaised ? ' is-hand-raised' : ''}`}
aria-pressed={handRaised}
aria-label={handRaised ? 'Опустить руку' : 'Поднять руку'}
onClick={() => (handRaised ? onLowerHand() : onRaiseHand())}
>
<span className="icon-shell">
<Hand className="lucide" aria-hidden="true" />
{handQueue.length > 0 && (
<span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span>
)}
</span>
<span className="label">Рука</span>
</button>
<HandQueueMenu queue={handQueue} onLower={onLowerHandById} />
{!isCompact && ( {!isCompact && (
<StageViewMenu <StageViewMenu
layoutMode={layoutMode} layoutMode={layoutMode}
@@ -203,7 +247,14 @@ export function RoomToolbar({
<span className="icon-shell"> <span className="icon-shell">
<PictureInPicture2 className="lucide" aria-hidden="true" /> <PictureInPicture2 className="lucide" aria-hidden="true" />
</span> </span>
<span className="label">Мини-окно</span> {/* На узком экране («Мини-окно» иначе переносится на 2 строки и
кнопка становится выше соседних, см. .label-full/.label-short
в room.css) — только «Мини». Текст, не структура: aria-label
выше уже несёт полный смысл независимо от видимой подписи. */}
<span className="label">
<span className="label-full">Мини-окно</span>
<span className="label-short">Мини</span>
</span>
</button> </button>
)} )}

View File

@@ -1,4 +1,4 @@
import { useRef, type ReactNode, type RefObject } from 'react' import { useMemo, useRef, type ReactNode, type RefObject } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react' import { ChevronLeft, ChevronRight } from 'lucide-react'
import { import {
TrackLoop, TrackLoop,
@@ -47,6 +47,17 @@ interface StageGridProps {
tracks: TrackReferenceOrPlaceholder[] tracks: TrackReferenceOrPlaceholder[]
/** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */ /** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */
children: ReactNode children: ReactNode
/**
* Потолок админки на число одновременно видимых плиток (`instance_settings.media_limits.stage_max_tiles`,
* см. `RoomPage`). Не задан — все раскладки `STAGE_GRID_LAYOUTS` доступны как
* раньше (текущий максимум сетки — 25, 5×5). Реализовано отсечением раскладок
* КРУПНЕЕ потолка из набора, который видит `useGridLayout`: она сама выбирает
* бОльшую свободную раскладку, укладывающую всех участников без пагинации,
* поэтому урезанный набор просто не даёт ей раздуть сетку сверх лимита —
* лишние участники уходят на следующую страницу пагинации (`usePagination`),
* то есть перестают быть подписанными треками, а не просто визуально мельче.
*/
maxTiles?: number
} }
/** /**
@@ -64,7 +75,7 @@ interface StageGridProps {
* `.stage-grid-pages` (кнопки со стрелками + счётчик, доступен и мышью, и с * `.stage-grid-pages` (кнопки со стрелками + счётчик, доступен и мышью, и с
* клавиатуры; на тач-экране страницы листаются ещё и свайпом). * клавиатуры; на тач-экране страницы листаются ещё и свайпом).
*/ */
export function StageGrid({ tracks, children }: StageGridProps) { export function StageGrid({ tracks, children, maxTiles }: StageGridProps) {
const gridEl = useRef<HTMLDivElement | null>(null) const gridEl = useRef<HTMLDivElement | null>(null)
// Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где // Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где
// `current` был readonly и тип вёл себя ковариантно). В типах React 19 // `current` был readonly и тип вёл себя ковариантно). В типах React 19
@@ -72,7 +83,11 @@ export function StageGrid({ tracks, children }: StageGridProps) {
// параметр уже не присваивается — приведение безопасно: оба хука только // параметр уже не присваивается — приведение безопасно: оба хука только
// читают `.current` (ResizeObserver и слушатели touch-событий). // читают `.current` (ResizeObserver и слушатели touch-событий).
const gridRef = gridEl as RefObject<HTMLDivElement> const gridRef = gridEl as RefObject<HTMLDivElement>
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts: STAGE_GRID_LAYOUTS }) const gridLayouts = useMemo(
() => (maxTiles == null ? STAGE_GRID_LAYOUTS : STAGE_GRID_LAYOUTS.filter((l) => l.columns * l.rows <= maxTiles)),
[maxTiles],
)
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts })
const pagination = usePagination(layout.maxTiles, tracks) const pagination = usePagination(layout.maxTiles, tracks)
useSwipe(gridRef, { useSwipe(gridRef, {

View File

@@ -24,10 +24,44 @@ export interface ChatMessageOut {
*/ */
export type ChatConnectionStatus = 'connecting' | 'open' | 'closed' | 'error' export type ChatConnectionStatus = 'connecting' | 'open' | 'closed' | 'error'
/**
* Один участник в очереди поднятых рук (задача B1) — 1:1 с pydantic-схемой
* `HandQueueEntryOut` backend. `identity` — тот же формат, что и
* `Participant.identity` в LiveKit (`str(user_id)` либо `guest:{id}`),
* пригоден для прямого сравнения с `localParticipant.identity`/
* `participant.identity` на сцене.
*/
export interface HandQueueEntry {
identity: string
name: string
raised_at: string
}
/** Источник трека, принудительно выключенного организатором (задача B2). */
export type ForcedMuteSource = 'microphone' | 'camera'
/**
* Одно событие принудительного мьюта (задача B2) — рассылается ВСЕМ
* участникам конференции (канал общий, адресной доставки нет), поэтому
* несёт `identity` затронутого: получатель сам решает, про него ли это
* (см. `ForcedMuteWatcher` — сравнивает с `localParticipant.identity`).
* `nonce` — счётчик хука, растёт на каждое полученное событие: тот же
* `source`/`identity` два раза подряд (например, повторный клик
* организатора на уже выключенный трек) должен переоткрыть тост, а не
* молча схлопнуться в один и тот же объект по `useEffect`-сравнению.
*/
export interface ForcedMuteEvent {
identity: string
source: ForcedMuteSource
nonce: number
}
type IncomingFrame = type IncomingFrame =
| { type: 'history'; messages: ChatMessageOut[] } | { type: 'history'; messages: ChatMessageOut[] }
| { type: 'message'; message: ChatMessageOut } | { type: 'message'; message: ChatMessageOut }
| { type: 'error'; code: string } | { type: 'error'; code: string }
| { type: 'hand_queue'; queue: HandQueueEntry[] }
| { type: 'forced_mute'; identity: string; source: ForcedMuteSource }
interface UseChatOptions { interface UseChatOptions {
/** id конференции — пока не известен (страница ещё не подключилась к LiveKit), WS не открываем. */ /** id конференции — пока не известен (страница ещё не подключилась к LiveKit), WS не открываем. */
@@ -47,6 +81,23 @@ interface UseChatResult {
unavailable: boolean unavailable: boolean
/** Отправить сообщение (1..2000 символов после strip, пустое/слишком длинное — игнорируется). */ /** Отправить сообщение (1..2000 символов после strip, пустое/слишком длинное — игнорируется). */
sendMessage: (text: string) => void sendMessage: (text: string) => void
/**
* Очередь поднятых рук, упорядоченная по времени поднятия — сервер
* присылает полный снапшот при любом изменении (см. `schemas/room_events.py`
* backend), поэтому клиенту не нужно вести собственное состояние очереди.
* Пуста, пока WS не открыт/не пришёл первый снапшот.
*/
handQueue: HandQueueEntry[]
/** Поднять СВОЮ руку — повторный вызов на уже поднятой руке безвреден (идемпотентно на сервере). */
raiseHand: () => void
/**
* Опустить руку — свою (без аргумента) либо чужую по `identity` (только
* организатору, иначе сервер отклонит `{type:"error",code:"forbidden"}`,
* см. `statusMessage`).
*/
lowerHand: (identity?: string) => void
/** Последнее событие принудительного мьюта (задача B2) — `null` до первого. */
lastForcedMute: ForcedMuteEvent | null
} }
/** Close-коды сервера — см. зафиксированный протокол WS. */ /** Close-коды сервера — см. зафиксированный протокол WS. */
@@ -61,16 +112,30 @@ function buildChatWsUrl(conferenceId: string): string {
} }
/** /**
* WS-клиент чата комнаты конференции. Реализует зафиксированный протокол: * WS-клиент комнаты конференции (несмотря на имя — не только чат, задача
* connect → `{type:"auth"}` → `{type:"history"}` → далее входящие * B1). Реализует зафиксированный протокол: connect → `{type:"auth"}` →
* `{type:"message"}`/`{type:"error"}`. * `{type:"history"}` → `{type:"hand_queue"}` → далее входящие
* `{type:"message"}`/`{type:"hand_queue"}`/`{type:"error"}`.
* *
* Optimistic-append собственных сообщений НЕ делается: сервер всегда * Очередь поднятых рук (`raiseHand`/`lowerHand`/`handQueue`) едет по тому же
* соединению, что и чат, — переиспользование уже открытого аутентифицированного
* WS дешевле отдельного эндпоинта (см. `backend/api/chat.py`). Следствие:
* поднять руку нельзя, если чат выключен настройкой инстанса (`enabled=false`,
* соединение вообще не открывается) — принятый компромисс, обоснование в
* коммите задачи B1.
*
* Optimistic-append собственных сообщений чата НЕ делается: сервер всегда
* присылает наше же сообщение обратно echo-фреймом `message` — если * присылает наше же сообщение обратно echo-фреймом `message` — если
* добавлять его на клиенте сразу при отправке, оно задублируется в списке. * добавлять его на клиенте сразу при отправке, оно задублируется в списке.
* Очередь рук устроена иначе: сервер шлёт ПОЛНЫЙ снапшот на каждое
* изменение, поэтому `raiseHand`/`lowerHand` ничего не трогают в состоянии
* сами — ждут снапшот.
*/ */
export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseChatResult { export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseChatResult {
const [messages, setMessages] = useState<ChatMessageOut[]>([]) const [messages, setMessages] = useState<ChatMessageOut[]>([])
const [handQueue, setHandQueue] = useState<HandQueueEntry[]>([])
const [lastForcedMute, setLastForcedMute] = useState<ForcedMuteEvent | null>(null)
const forcedMuteNonceRef = useRef(0)
// `wsStatus` меняется ТОЛЬКО из колбэков реального WS-соединения (см. ниже) — // `wsStatus` меняется ТОЛЬКО из колбэков реального WS-соединения (см. ниже) —
// никогда синхронно в теле эффекта, иначе react-hooks/set-state-in-effect // никогда синхронно в теле эффекта, иначе react-hooks/set-state-in-effect
// (эффект без активной подписки, только синхронизирующий производное // (эффект без активной подписки, только синхронизирующий производное
@@ -106,6 +171,7 @@ export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseCh
ws.onopen = () => { ws.onopen = () => {
if (stale) return if (stale) return
setMessages([]) setMessages([])
setHandQueue([])
setStatusMessage(null) setStatusMessage(null)
setUnavailable(false) setUnavailable(false)
setWsStatus('open') setWsStatus('open')
@@ -124,9 +190,19 @@ export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseCh
setMessages(frame.messages) setMessages(frame.messages)
} else if (frame.type === 'message') { } else if (frame.type === 'message') {
setMessages((prev) => [...prev, frame.message]) setMessages((prev) => [...prev, frame.message])
} else if (frame.type === 'hand_queue') {
setHandQueue(frame.queue)
} else if (frame.type === 'forced_mute') {
forcedMuteNonceRef.current += 1
setLastForcedMute({
identity: frame.identity,
source: frame.source,
nonce: forcedMuteNonceRef.current,
})
} else if (frame.type === 'error') { } else if (frame.type === 'error') {
// Ошибка отдельной операции (например, отклонённое сообщение) — соединение // Ошибка отдельной операции (например, отклонённое сообщение или
// не рвётся, просто короткое пояснение пользователю. // запрет опустить чужую руку не-организатору, code:"forbidden") —
// соединение не рвётся, просто короткое пояснение пользователю.
setStatusMessage(`Ошибка чата: ${frame.code}`) setStatusMessage(`Ошибка чата: ${frame.code}`)
} }
} }
@@ -168,10 +244,32 @@ export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseCh
ws.send(JSON.stringify({ type: 'message', text: trimmed })) ws.send(JSON.stringify({ type: 'message', text: trimmed }))
}, []) }, [])
const raiseHand = useCallback(() => {
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'raise_hand' }))
}, [])
const lowerHand = useCallback((identity?: string) => {
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'lower_hand', identity: identity ?? null }))
}, [])
// Наружу — производный статус: пока подключаться нечем (выключено/нет // Наружу — производный статус: пока подключаться нечем (выключено/нет
// conferenceId/token), всегда `closed`, даже если внутренний `wsStatus` // conferenceId/token), всегда `closed`, даже если внутренний `wsStatus`
// ещё хранит значение от предыдущего подключения. // ещё хранит значение от предыдущего подключения.
const status: ChatConnectionStatus = canConnect ? wsStatus : 'closed' const status: ChatConnectionStatus = canConnect ? wsStatus : 'closed'
return { messages, status, statusMessage, unavailable, sendMessage } return {
messages,
status,
statusMessage,
unavailable,
sendMessage,
handQueue,
raiseHand,
lowerHand,
lastForcedMute,
}
} }

View File

@@ -0,0 +1,13 @@
import { useLocalParticipant } from '@livekit/components-react'
import { parseParticipantMetadata } from '@/lib/participantMetadata'
/**
* Организатор ли ТЕКУЩИЙ (локальный) участник комнаты — читает подсказку
* `is_organizer` из метаданных собственного LiveKit-токена (см.
* `lib/participantMetadata.ts`). Только для UI (показать/скрыть кнопки
* организатора) — серверные действия перепроверяют права по БД сами.
*/
export function useIsOrganizer(): boolean {
const { localParticipant } = useLocalParticipant()
return Boolean(parseParticipantMetadata(localParticipant.metadata).is_organizer)
}

View File

@@ -0,0 +1,26 @@
/**
* Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata`
* на backend, `services/conference_access.py::build_join`) — JSON
* `{"avatar_url"?: string, "is_organizer"?: true}`. У гостей и участников без
* аватара/прав организатора соответствующие поля отсутствуют.
*/
export interface ParticipantMetadata {
avatar_url?: string | null
/**
* Подсказка для UI — организатор ли участник. НЕ источник авторизации:
* метаданные читает и потенциально может подделать сам клиент. Любое
* серверное действие организатора (например, принудительный мьют)
* перепроверяется backend'ом по владельцу конференции в БД.
*/
is_organizer?: boolean
}
/** Разобрать `participant.metadata` — пустой объект, если поля нет, метаданные пусты или невалидны. */
export function parseParticipantMetadata(metadata: string | undefined): ParticipantMetadata {
if (!metadata) return {}
try {
return JSON.parse(metadata) as ParticipantMetadata
} catch {
return {}
}
}

View File

@@ -0,0 +1,36 @@
import { VideoPresets, type TrackPublishDefaults } from 'livekit-client'
import type { PublishQualityCap } from '@/api/conferences'
/**
* Потолок качества публикации → `TrackPublishDefaults` для `RoomOptions.publishDefaults`
* (см. `RoomPage.tsx`, `roomOptions`).
*
* Ограничивается только `videoEncoding` (битрейт/framerate верхнего слоя
* симулкаста) и набор дополнительных слоёв `videoSimulcastLayers` — НЕ
* фактическое разрешение захвата камеры (`videoCaptureDefaults`, трогать
* его не входит в задачу). WebRTC сам подстраивает реальное разрешение
* кодирования под урезанный битрейт (`degradationPreference`), поэтому
* проверять эффект нужно по фактическому битрейту исходящего видео, а не по
* заявленному разрешению потока.
*
* Слои каждого потолка — все пресеты LiveKit НИЖЕ и РАВНО потолку (без
* дефолтного «h180, h360», который иначе подставился бы сам при пустом
* `videoSimulcastLayers` и мог бы превысить потолок 180p).
*/
const PUBLISH_DEFAULTS_BY_CAP: Record<Exclude<PublishQualityCap, 'off'>, TrackPublishDefaults> = {
'180p': { videoEncoding: VideoPresets.h180.encoding, videoSimulcastLayers: [] },
'360p': { videoEncoding: VideoPresets.h360.encoding, videoSimulcastLayers: [VideoPresets.h180] },
'720p': {
videoEncoding: VideoPresets.h720.encoding,
videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360],
},
}
/**
* `off` — `undefined`: `publishDefaults` не задаётся вовсе, поведение
* библиотеки не отличается от состояния до появления настройки (см.
* критерий готовности «дефолты сохраняют текущее поведение»).
*/
export function buildPublishDefaults(cap: PublishQualityCap): TrackPublishDefaults | undefined {
return cap === 'off' ? undefined : PUBLISH_DEFAULTS_BY_CAP[cap]
}

View File

@@ -146,6 +146,8 @@ export function JoinPage() {
title: resolved.title, title: resolved.title,
conferenceId: data.conference_id, conferenceId: data.conference_id,
chatEnabled: data.chat_enabled, chatEnabled: data.chat_enabled,
publishQualityCap: data.publish_quality_cap,
stageMaxTiles: data.stage_max_tiles,
}, },
}) })
} catch (err) { } catch (err) {

View File

@@ -43,6 +43,8 @@ export function LobbyPage() {
title: conference.title, title: conference.title,
conferenceId: conference.join.conference_id, conferenceId: conference.join.conference_id,
chatEnabled: conference.join.chat_enabled, chatEnabled: conference.join.chat_enabled,
publishQualityCap: conference.join.publish_quality_cap,
stageMaxTiles: conference.join.stage_max_tiles,
number: conference.number, number: conference.number,
slug: conference.slug, slug: conference.slug,
}, },

View File

@@ -6,7 +6,7 @@ import { LiveKitRoom, usePersistentUserChoices } from '@livekit/components-react
import type { RoomOptions } from 'livekit-client' import type { RoomOptions } from 'livekit-client'
import '@livekit/components-styles' import '@livekit/components-styles'
import '@/styles/room.css' import '@/styles/room.css'
import { joinConference, resolveConference } from '@/api/conferences' import { joinConference, resolveConference, type PublishQualityCap } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client' import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth' import { useAuth } from '@/auth/useAuth'
import { useChat } from '@/hooks/useChat' import { useChat } from '@/hooks/useChat'
@@ -16,8 +16,10 @@ import { RoomTopbar } from '@/components/room/RoomTopbar'
import { RoomStage } from '@/components/room/RoomStage' import { RoomStage } from '@/components/room/RoomStage'
import { RoomToolbar } from '@/components/room/RoomToolbar' import { RoomToolbar } from '@/components/room/RoomToolbar'
import { ChatPanel } from '@/components/room/ChatPanel' import { ChatPanel } from '@/components/room/ChatPanel'
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog' import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice' import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
import { buildPublishDefaults } from '@/lib/publishQualityCap'
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode' import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
interface RoomJoinState { interface RoomJoinState {
@@ -30,6 +32,18 @@ interface RoomJoinState {
number?: string number?: string
/** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */ /** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
chatEnabled?: boolean chatEnabled?: boolean
/**
* Рычаги нагрузки медиа (`JoinOut.publish_quality_cap`/`stage_max_tiles`,
* `instance_settings.media_limits`) — приезжают вместе с токеном, ДО
* первого рендера `LiveKitRoom` (см. `roomOptions` ниже и докстринг про
* стабильность его ссылки): `joinState` целиком появляется одним актом
* (`setJoinState`), а до этого момента `LiveKitRoom` не рендерится вовсе
* (ранний `return` на «Подключаемся…» ниже) — значит, оба значения уже
* на руках к моменту публикации трека, без отдельного асинхронного
* похода за настройками после подключения и без риска переподключения.
*/
publishQualityCap?: PublishQualityCap
stageMaxTiles?: number
} }
/** /**
@@ -96,6 +110,8 @@ export function RoomPage() {
title: info.title, title: info.title,
conferenceId: result.conference_id, conferenceId: result.conference_id,
chatEnabled: result.chat_enabled, chatEnabled: result.chat_enabled,
publishQualityCap: result.publish_quality_cap,
stageMaxTiles: result.stage_max_tiles,
}) })
} }
} catch (err) { } catch (err) {
@@ -145,6 +161,13 @@ export function RoomPage() {
// изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте). // изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
// на каждой плитке сцены (см. `RoomParticipantTile`).
const raisedHandIdentities = useMemo(
() => new Set(chat.handQueue.map((entry) => entry.identity)),
[chat.handQueue],
)
// Корневой контейнер комнаты — цель для fullscreen и источник video-элемента // Корневой контейнер комнаты — цель для fullscreen и источник video-элемента
// для video-PiP-фолбэка. // для video-PiP-фолбэка.
const roomRootRef = useRef<HTMLDivElement>(null) const roomRootRef = useRef<HTMLDivElement>(null)
@@ -194,9 +217,16 @@ export function RoomPage() {
// потому что `userChoices` ЭТОГО вызова хука меняется, только если МЫ САМИ // потому что `userChoices` ЭТОГО вызова хука меняется, только если МЫ САМИ
// вызовем saveAudioInputDeviceId/saveVideoInputDeviceId НА НЁМ — а мы этого // вызовем saveAudioInputDeviceId/saveVideoInputDeviceId НА НЁМ — а мы этого
// не делаем (сохранение — только в DeviceSettingsDialog). // не делаем (сохранение — только в DeviceSettingsDialog).
//
// `joinState?.publishQualityCap` в зависимостях безопасен по той же
// причине: `joinState` выставляется РОВНО ОДИН раз (см. докстринг
// `RoomJoinState.publishQualityCap`) до первого рендера `LiveKitRoom`, а
// не меняется постфактум — значит, `roomOptions` не пересоздастся у уже
// подключённого участника.
const { userChoices } = usePersistentUserChoices() const { userChoices } = usePersistentUserChoices()
const roomOptions = useMemo<RoomOptions>( const roomOptions = useMemo<RoomOptions>(
() => ({ () => ({
publishDefaults: buildPublishDefaults(joinState?.publishQualityCap ?? 'off'),
// Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент // Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент
// подписан на полное качество всех чужих треков независимо от размера // подписан на полное качество всех чужих треков независимо от размера
// плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто // плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто
@@ -222,7 +252,7 @@ export function RoomPage() {
// (setActiveMediaDevice), а не пересозданием roomOptions. // (setActiveMediaDevice), а не пересозданием roomOptions.
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined }, audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
}), }),
[userChoices], [userChoices, joinState?.publishQualityCap],
) )
if (error) { if (error) {
@@ -277,6 +307,9 @@ export function RoomPage() {
initialFocusKey={stageFocusKey} initialFocusKey={stageFocusKey}
onFocusKeyChange={setStageFocusKey} onFocusKeyChange={setStageFocusKey}
onPinFocus={handlePinFocus} onPinFocus={handlePinFocus}
raisedHandIdentities={raisedHandIdentities}
conferenceId={joinState.conferenceId}
stageMaxTiles={joinState.stageMaxTiles}
/> />
)} )}
{chatVisible && chatOpen && ( {chatVisible && chatOpen && (
@@ -301,6 +334,10 @@ export function RoomPage() {
pipSupported={pip.supported} pipSupported={pip.supported}
pipActive={pip.active} pipActive={pip.active}
onTogglePiP={pip.toggle} onTogglePiP={pip.toggle}
handQueue={chat.handQueue}
onRaiseHand={chat.raiseHand}
onLowerHand={() => chat.lowerHand()}
onLowerHandById={(identity) => chat.lowerHand(identity)}
layoutMode={layoutMode} layoutMode={layoutMode}
onLayoutModeChange={handleLayoutModeChange} onLayoutModeChange={handleLayoutModeChange}
hideOthers={hideOthers} hideOthers={hideOthers}
@@ -327,6 +364,7 @@ export function RoomPage() {
<RoomStage variant="pip" initialFocusKey={stageFocusKey} onFocusKeyChange={setStageFocusKey} />, <RoomStage variant="pip" initialFocusKey={stageFocusKey} onFocusKeyChange={setStageFocusKey} />,
pip.pipWindow.document.body, pip.pipWindow.document.body,
)} )}
<ForcedMuteWatcher event={chat.lastForcedMute} />
</LiveKitRoom> </LiveKitRoom>
</div> </div>
) )

View File

@@ -277,32 +277,54 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
.stage-show-others svg { width: 18px; height: 18px; flex-shrink: 0; } .stage-show-others svg { width: 18px; height: 18px; flex-shrink: 0; }
/* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */ /* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */
/*
* Кнопки тулбара плавно уменьшаются (иконка/отступы/шрифт/зазор) на всём
* диапазоне 1200px → 600px — до этого тулбар стал шире, чем при исходном
* проектировании (задачи B1/B2 добавили «Рука»/«Очередь», раньше помещались
* без сжатия 8 кнопок, теперь до 11 — без этого блока получался
* горизонтальный оверфлоу вплоть до самого мобильного брейкпоинта, кнопки
* вылезали за края тулбара).
*
* Обычный `clamp(min, Nvw, max)` тут не подходит: подобранный `N`
* дотягивается до `max` уже на довольно узких экранах (например,
* `3vw` = 24px ровно на 800px viewport) и дальше просто стоит на потолке —
* получается не плавное сжатие в нужном диапазоне, а резкий скачок сильно
* раньше нужной ширины (поймали именно так на первой версии этого блока).
* Вместо этого — явная линейная интерполяция между двумя точками
* (600px→минимум, 1200px→максимум): `calc(MIN + (MAX-MIN) * (100vw - 600px)
* / 600)`, снаружи в `clamp()` только чтобы намертво остановиться на
* границах диапазона. Нижние границы — те же значения, что жёстко
* выставляет мобильный медиа-запрос ниже (`max-width: 600px`), поэтому
* переход в него на 600px визуально бесшовный.
*/
.room-toolbar { .room-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--space-2); gap: clamp(2px, calc(2px + (100vw - 600px) * 6 / 600), var(--space-2));
background: var(--color-room-surface); background: var(--color-room-surface);
border-top: 1px solid var(--color-room-tile-border); border-top: 1px solid var(--color-room-tile-border);
padding: var(--space-3) var(--space-6); padding: var(--space-3) clamp(8px, calc(8px + (100vw - 600px) * 16 / 600), var(--space-6));
flex-shrink: 0; flex-shrink: 0;
} }
.tb-btn { .tb-btn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4px; gap: clamp(2px, calc(2px + (100vw - 600px) * 2 / 600), 4px);
background: transparent; background: transparent;
border: none; border: none;
padding: 8px 18px; padding:
clamp(6px, calc(6px + (100vw - 600px) * 2 / 600), 8px)
clamp(6px, calc(6px + (100vw - 600px) * 12 / 600), 18px);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-room-text-primary); color: var(--color-room-text-primary);
min-width: 76px; min-width: clamp(0px, calc((100vw - 600px) * 76 / 600), 76px);
cursor: pointer; cursor: pointer;
} }
.tb-btn .icon-shell { .tb-btn .icon-shell {
width: 48px; width: clamp(40px, calc(40px + (100vw - 600px) * 8 / 600), 48px);
height: 48px; height: clamp(40px, calc(40px + (100vw - 600px) * 8 / 600), 48px);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -311,9 +333,27 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
background: var(--color-room-tile); background: var(--color-room-tile);
color: var(--color-room-text-primary); color: var(--color-room-text-primary);
} }
.tb-btn span.label { font: var(--text-caption); text-transform: none; letter-spacing: normal; color: var(--color-room-text-secondary); font-weight: 500; } .tb-btn span.label {
font: var(--text-caption);
font-size: clamp(11px, calc(11px + (100vw - 600px) * 1 / 600), 12px);
text-transform: none;
letter-spacing: normal;
color: var(--color-room-text-secondary);
font-weight: 500;
}
.tb-btn:hover .icon-shell { background: var(--color-room-tile-hover); } .tb-btn:hover .icon-shell { background: var(--color-room-tile-hover); }
/* Короткая подпись мини-окна (см. RoomToolbar.tsx) — «Мини-окно» на узком
экране переносится на 2 строки и делает эту кнопку выше соседних; ниже
порога, где начинается перенос, прячем длинный вариант и показываем
короткий «Мини» — кнопка остаётся однострочной и той же высоты, что и
остальные. */
.tb-btn .label-short { display: none; }
@media (max-width: 1200px) {
.tb-btn .label-full { display: none; }
.tb-btn .label-short { display: inline; }
}
.tb-btn.is-off .icon-shell { background: var(--color-room-mic-off); border-color: var(--color-room-mic-off); color: #3a0f16; } .tb-btn.is-off .icon-shell { background: var(--color-room-mic-off); border-color: var(--color-room-mic-off); color: #3a0f16; }
.tb-btn.is-off span.label { color: var(--color-room-mic-off); font-weight: 700; } .tb-btn.is-off span.label { color: var(--color-room-mic-off); font-weight: 700; }
@@ -323,6 +363,12 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
.tb-btn.is-sharing span.label { color: var(--color-room-mic-on); font-weight: 700; } .tb-btn.is-sharing span.label { color: var(--color-room-mic-on); font-weight: 700; }
.tb-btn:disabled { opacity: 0.6; cursor: default; } .tb-btn:disabled { opacity: 0.6; cursor: default; }
/* Кнопка «Рука» — та же зелёная подсветка активного состояния, что у
«Демонстрации» (задача B1): своя поднятая рука — такой же позитивный
индикатор «я сейчас что-то сигнализирую комнате». */
.tb-btn.is-hand-raised .icon-shell { background: var(--color-room-mic-on); border-color: var(--color-room-mic-on); color: #10331f; }
.tb-btn.is-hand-raised span.label { color: var(--color-room-mic-on); font-weight: 700; }
.tb-btn.danger .icon-shell { border-color: var(--color-room-danger); color: var(--color-room-danger); background: transparent; } .tb-btn.danger .icon-shell { border-color: var(--color-room-danger); color: var(--color-room-danger); background: transparent; }
.tb-btn.danger:hover .icon-shell { background: var(--color-room-danger-bg); color: #fff; border-color: var(--color-room-danger-bg); } .tb-btn.danger:hover .icon-shell { background: var(--color-room-danger-bg); color: #fff; border-color: var(--color-room-danger-bg); }
.tb-btn.danger span.label { color: var(--color-room-danger); font-weight: 700; } .tb-btn.danger span.label { color: var(--color-room-danger); font-weight: 700; }
@@ -520,8 +566,16 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
} }
.chat-input-row button:disabled { opacity: 0.5; cursor: default; } .chat-input-row button:disabled { opacity: 0.5; cursor: default; }
/* Триггер и опции эмодзи-поповера лежат внутри `.chat-input-row` (форма
отправки) — тот же контейнер, что у круглой зелёной кнопки «Отправить»
(`.chat-input-row button`, специфичность 0,1,1). Голого класса
(`.chat-emoji-trigger`/`.chat-emoji-option`, 0,1,0) для победы над ней не
хватает — ЛЮБАЯ кнопка внутри формы (включая кнопки в самом поповере,
он тоже в этом поддереве) красилась в зелёный независимо от порядка
правил в файле. Каждый селектор ниже уточнён родительским классом ровно
затем, чтобы обойти именно эту гонку специфичности. */
.chat-emoji-wrap { position: relative; display: flex; flex-shrink: 0; } .chat-emoji-wrap { position: relative; display: flex; flex-shrink: 0; }
.chat-emoji-trigger { .chat-emoji-wrap .chat-emoji-trigger {
width: 42px; width: 42px;
height: 42px; height: 42px;
border-radius: 50%; border-radius: 50%;
@@ -533,35 +587,40 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
} }
.chat-emoji-trigger:hover { color: var(--color-room-text-primary); } .chat-emoji-wrap .chat-emoji-trigger:hover { color: var(--color-room-text-primary); }
.chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); } .chat-emoji-wrap .chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); }
.chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; } .chat-emoji-wrap .chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
/* 5 колонок × 6 строк — ровно 30 эмодзи в EMOJI_OPTIONS (ChatPanel.tsx), без
неполной последней строки. */
.chat-emoji-popover { .chat-emoji-popover {
position: absolute; position: absolute;
bottom: calc(100% + var(--space-2)); bottom: calc(100% + var(--space-2));
left: 0; left: 0;
z-index: 50; z-index: 50;
width: 224px; width: 220px;
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 2px; gap: 4px;
padding: var(--space-3); padding: var(--space-3);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--color-room-tile-border); border: 1px solid var(--color-room-tile-border);
background: var(--color-room-surface-raised); background: var(--color-room-surface-raised);
box-shadow: var(--shadow-room-panel); box-shadow: var(--shadow-room-panel);
} }
.chat-emoji-option { .chat-emoji-popover .chat-emoji-option {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: none; background: none;
border: none; border: none;
font-size: 20px; font-size: 20px;
line-height: 1; line-height: 1;
padding: 6px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
} }
.chat-emoji-option:hover { background: var(--color-room-tile); } .chat-emoji-popover .chat-emoji-option:hover { background: var(--color-room-tile); }
@media (max-width: 900px) { @media (max-width: 900px) {
.chat-panel { .chat-panel {
@@ -574,6 +633,72 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
} }
} }
/* ---------- Поповер очереди поднятых рук (`HandQueueMenu`, задача B1) ----------
* Контейнер — `.tb-menu` (тот же поповер над кнопкой, что у «Вида»), не
* `.chat-panel`: очередь — короткий список, а не история переписки,
* разворачивать её на весь экран/боковой панелью незачем даже на мобильном. */
.hand-queue-menu { width: 300px; padding: var(--space-3); }
.hand-queue-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
/* Высота растёт вместе со списком (при 12 записях поповер компактный), но
не безгранично — после ~10 строк упирается в потолок и скроллится
дальше, иначе на энергичной встрече поповер вылез бы выше экрана. */
max-height: 460px;
overflow-y: auto;
}
.hand-queue-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
background: var(--color-room-tile);
}
.hand-queue-position {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--color-room-mic-on);
color: #10331f;
font: var(--text-caption);
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.hand-queue-name {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
font: var(--text-body);
color: var(--color-room-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hand-queue-name svg { width: 16px; height: 16px; flex-shrink: 0; color: var(--color-room-mic-on); }
.hand-queue-lower {
flex-shrink: 0;
padding: 6px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--color-room-tile-border);
background: transparent;
color: var(--color-room-text-secondary);
font: var(--text-caption);
text-transform: none;
letter-spacing: normal;
cursor: pointer;
}
.hand-queue-lower:hover { background: var(--color-room-tile-hover); color: var(--color-room-text-primary); }
/* /*
* ---------- Диалог «Настройки устройств» ---------- * ---------- Диалог «Настройки устройств» ----------
* Нет отдельного макета для диалога в design/mockups/room.html — собран из * Нет отдельного макета для диалога в design/mockups/room.html — собран из
@@ -762,6 +887,54 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
outline-offset: -2px; outline-offset: -2px;
} }
/* Бейдж поднятой руки (задача B1) — левый верхний угол, зеркально булавке
закрепления (правый верхний). В отличие от булавки ВСЕГДА видим, пока
рука поднята, — это статус для ВСЕХ участников, а не собственный
элемент управления, видимый по наведению. */
.room-hand-badge {
position: absolute;
top: 0.25rem;
left: 0.25rem;
z-index: 5;
display: flex;
padding: 0.25rem;
border-radius: calc(var(--lk-border-radius, 0.5rem) / 2);
background: var(--color-room-mic-on);
color: #10331f;
}
.room-hand-badge svg { width: 18px; height: 18px; }
/* Кнопки принудительного мьюта организатором (задача B2) — нижний правый
угол чужой плитки, видны по наведению (как булавка закрепления) —
элемент управления, а не статус, прятать по умолчанию уместно. */
.room-organizer-controls {
position: absolute;
bottom: 0.25rem;
right: 0.25rem;
z-index: 5;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease-in-out;
transition-delay: 0.2s;
}
.lk-participant-tile:hover .room-organizer-controls,
.lk-participant-tile:focus-within .room-organizer-controls { opacity: 1; transition-delay: 0s; }
@media (hover: none) {
.room-organizer-controls { opacity: 1; transition-delay: 0s; }
}
.room-organizer-controls button {
display: flex;
padding: 0.25rem;
border: none;
border-radius: calc(var(--lk-border-radius, 0.5rem) / 2);
background: rgba(0, 0, 0, 0.5);
color: var(--color-room-text-primary);
cursor: pointer;
}
.room-organizer-controls button:hover { background: var(--color-room-danger-bg); color: #fff; }
.room-organizer-controls svg { width: 18px; height: 18px; }
/* ---------- Заглушка «конференция в мини-окне» (Document PiP) ---------- */ /* ---------- Заглушка «конференция в мини-окне» (Document PiP) ---------- */
.room-pip-placeholder { .room-pip-placeholder {
flex: 1; flex: 1;