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

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

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

@@ -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

@@ -27,6 +27,7 @@ from repositories.conferences import (
ConferenceRepository,
ConferenceSessionRepository,
)
from services import hand_queue
from services.egress import run_track_egress
from services.instance_settings import InstanceSettingsService
from services.pipeline_producer import enqueue_pipeline
@@ -154,6 +155,17 @@ class WebhookDispatcher:
return
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)
if session_record is None:
logger.warning(
@@ -308,6 +320,10 @@ class WebhookDispatcher:
now = datetime.now(UTC)
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 hand_queue.clear(conference.id)
# Незакреплённая умирает по завершении (история/саммари остаются);
# закреплённая возвращается в ожидание следующего вхождения (ADR-001, п.2).

View File

@@ -85,11 +85,19 @@ def _guest_token(conference: Conference, guest: GuestAccess) -> str:
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()
assert accept["type"] == "websocket.accept"
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

@@ -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=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

@@ -34,6 +34,7 @@ from models.instance_setting import InstanceSetting
from models.participant import ConferenceParticipant
from models.session import ConferenceSession
from models.user import User
from services import hand_queue
from services.conference_ids import generate_number, generate_slug
from services.egress import EgressStartResult
@@ -217,6 +218,53 @@ async def test_full_cycle_joined_left_finished(
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(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None: