"""Проверка права входа в конференцию (статус/пароль) и генерация ответа join. Заменяет `services/room_access.py`: комнаты больше не конкурируют за время («правило часа» удалено вместе с бронированием, ADR-001, п.5), но пароль закрытой конференции по-прежнему проверяется здесь же — для обычного пользователя и гостя одинаково. """ import json from core.config import get_settings from core.plugins.config import PublishQualityCap, StageMaxTiles from core.security import verify_password from models.conference import Conference from schemas.conferences import JoinOut from services.livekit_tokens import create_room_access_token class ConferenceEndedError(Exception): """Конференция завершена (терминальный статус) — повторный вход невозможен.""" class PasswordRequiredError(Exception): """Конференция закрыта паролем; пароль не передан.""" class InvalidPasswordError(Exception): """Указанный пароль не совпадает с паролем закрытой конференции.""" async def ensure_joinable(conference: Conference, *, password: str | None) -> None: """Проверить, что в конференцию можно войти прямо сейчас. Бросает `ConferenceEndedError` для терминального статуса `ended` (история и саммари остаются, но повторный вход невозможен — ADR-001, п.2), либо `PasswordRequiredError`/`InvalidPasswordError` для закрытой паролем конференции. Ничего не бросает для открытой конференции в статусе `scheduled`/`active`. """ if conference.status == "ended": raise ConferenceEndedError if not conference.is_closed: return if conference.password_hash is None or password is None: raise PasswordRequiredError if not await verify_password(password, conference.password_hash): raise InvalidPasswordError def build_join( conference: Conference, *, identity: str, name: str, chat_enabled: bool, hand_queue_enabled: bool, publish_quality_cap: PublishQualityCap, stage_max_tiles: StageMaxTiles, avatar_url: str | None = None, is_organizer: bool = False, ) -> JoinOut: """Построить ответ join: LiveKit access-токен для входа в комнату конференции. Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4). `chat_enabled`/`hand_queue_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей стороной значения `instance_settings`: читаются здесь параметрами, а не заново из БД, чтобы не плодить отдельный запрос настроек на каждый join. `avatar_url`/`is_organizer` прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}` — поля добавляются, только если заданы (гость без аватара и не-организатор получают токен вовсе без метаданных, как и раньше). ⚠️ `is_organizer` в метаданных — только подсказка для UI клиента (показать/ скрыть кнопки организатора). Метаданным токена доверять для АВТОРИЗАЦИИ нельзя — участник технически может их подделать на своей стороне. Любое серверное действие организатора (например, принудительный мьют) обязано заново проверяться по `conference.owner_id` в БД, а не по этому полю. """ settings = get_settings() 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( room_name=conference.slug, identity=identity, name=name, metadata=metadata ) return JoinOut( livekit_url=settings.livekit_public_url, token=token, room_name=conference.slug, conference_id=conference.id, chat_enabled=chat_enabled, hand_queue_enabled=hand_queue_enabled, publish_quality_cap=publish_quality_cap, stage_max_tiles=stage_max_tiles, )