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-обработки, без которых кнопки принудительного мьюта не
скомпилировались бы; сама реализация мьюта — следующим коммитом.
This commit is contained in:
2026-08-01 22:06:43 +03:00
parent 42bfb88a22
commit 8e5eda88a2
14 changed files with 1147 additions and 62 deletions

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