"""Очередь поднятых рук конференции — состояние в 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": }`. `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())