Транспорт — существующий аутентифицированный 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-обработки, без которых кнопки принудительного мьюта не скомпилировались бы; сама реализация мьюта — следующим коммитом.
62 lines
2.4 KiB
Python
62 lines
2.4 KiB
Python
"""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
|