Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

View File

@@ -0,0 +1,98 @@
"""Определение доступности уровней AI-модуля (`min`/`medium`/`max`) инстанса.
Матрица уровней (модели, требования RAM/GPU/VRAM, пути моделей на дисковых
томах) — константа `TIERS` (`services/ai_tiers.py`), единственный источник
истины — ADR-004 (`docs/architecture/adr/004-ai-tier-matrix.md`). Детект
читает обнаруженное `install.sh` железо (`HW_*` в `.env`, `core.config.Settings`)
и факт наличия файлов моделей на дисковых томах — без зависимости от
torch/nvidia-smi внутри процесса backend/воркеров (переменные пишет установщик,
а не рантайм-детект GPU).
"""
from pathlib import Path
from pydantic import BaseModel
from core.config import Settings, get_settings
from core.plugins.config import AiLevel, InstanceConfig
from services.ai_tiers import TIERS, WHISPER_MODELS_ROOT, TierSpec
_PRESET_BY_LEVEL: dict[AiLevel, int] = {"min": 3, "medium": 4, "max": 5}
"""Номер пресета инсталлятора, соответствующего уровню (ADR-004, таблица
требований железа) — используется в тексте причины недоступности."""
class AiLevelStatus(BaseModel):
"""Доступность одного уровня AI с человекочитаемой причиной отказа."""
level: AiLevel
available: bool
reason: str | None = None
def detect_ai_levels(cfg: InstanceConfig) -> list[AiLevelStatus]:
"""Вернуть статусы всех уровней AI по обнаруженному железу и скачанным моделям.
`cfg` пока не влияет на результат (доступность уровня зависит только от
железа и файлов моделей на диске, не от текущих настроек инстанса), но
остаётся параметром сигнатуры — используется и `api/admin.py`, и
`services/instance_settings.py::update`, где эффективная конфигурация уже
под рукой.
"""
settings = get_settings()
statuses: list[AiLevelStatus] = []
for level in ("min", "medium", "max"):
reasons = _unavailability_reasons(level, TIERS[level], settings)
statuses.append(
AiLevelStatus(level=level, available=not reasons, reason="; ".join(reasons) or None)
)
return statuses
def _unavailability_reasons(level: AiLevel, spec: TierSpec, settings: Settings) -> list[str]:
"""Собрать причины недоступности уровня `level` (пустой список — уровень доступен)."""
reasons: list[str] = []
if settings.hw_ram_mb is not None and settings.hw_ram_mb < spec.min_ram_mb:
reasons.append(f"недостаточно RAM: нужно {spec.min_ram_mb // 1024} ГБ")
if spec.requires_gpu:
required_vram_gb = (spec.min_vram_mb or 0) // 1024
if not settings.hw_gpu_name:
reasons.append(f"требуется GPU NVIDIA ≥{required_vram_gb} ГБ VRAM, не обнаружен")
elif spec.min_vram_mb is not None and (settings.hw_vram_mb or 0) < spec.min_vram_mb:
found_vram_gb = (settings.hw_vram_mb or 0) // 1024
reasons.append(
f"требуется GPU NVIDIA ≥{required_vram_gb} ГБ VRAM, "
f"обнаружено только {found_vram_gb} ГБ"
)
preset = _PRESET_BY_LEVEL[level]
for path in spec.model_files:
if not _model_downloaded(path):
reasons.append(
f"{_describe_model_file(path)} не скачана — запустите install.sh "
f"с пресетом {preset}"
)
return reasons
def _model_downloaded(path: str) -> bool:
"""Проверить, скачана ли модель по пути на томе.
Файл — непустой; каталог (например, кэш huggingface_hub с хэшированными
поддиректориями снапшотов) — непустой каталог, без проверки конкретных
вложенных файлов.
"""
p = Path(path)
if p.is_dir():
return any(p.iterdir())
return p.is_file() and p.stat().st_size > 0
def _describe_model_file(path: str) -> str:
"""Человекочитаемое имя модели для причины недоступности («модель транскрибации small»)."""
name = Path(path).name
kind = "транскрибации" if path.startswith(f"{WHISPER_MODELS_ROOT}/") else "суммаризации"
return f"модель {kind} {name}"

View File

@@ -0,0 +1,137 @@
"""Константная матрица уровней AI (`min`/`medium`/`max`) — ADR-004.
Единственный источник моделей/квантов/параметров генерации/требований
железа — `docs/architecture/adr/004-ai-tier-matrix.md`; этот модуль переводит
матрицу ADR в структуры, которыми пользуются `services/ai_levels.py` (детект
доступности уровня) и `services/instance_settings.py::load_effective_config`
(подмена `transcriber`/`summarizer` эффективной конфигурации для `medium`/`max` —
`min` продолжает использовать дефолты `plugins.yaml`/правки администратора).
Пути моделей на дисковых томах (`model_files`, `options.download_root`,
`options.tokenizer_path`) — контракт с инсталлятором
(`deploy/llm/download-model.sh` и его аналог для faster-whisper): скрипты
обязаны скачивать модели именно по этим путям, иначе детект доступности
уровня будет ошибочно считать модель нескачанной.
"""
from pydantic import BaseModel
from core.plugins.config import AiLevel, SummarizerConfig, TranscriberConfig
WHISPER_MODELS_ROOT = "/models/whisper"
"""Корень тома с моделями faster-whisper; каждый уровень хранит свою модель в
одноимённом (модели, не уровню) подкаталоге — общий volume `whisper-cache`."""
QWEN_MODELS_ROOT = "/models/qwen"
"""Корень тома с GGUF-квантами и токенизаторами Qwen — общий volume `llm-models`."""
_LLM_BASE_URL_CPU = "http://llm:8080/v1"
"""CPU-сервер llama.cpp (compose-сервис `llm`) — уровни `min`/`medium`."""
_LLM_BASE_URL_GPU = "http://llm-gpu:8080/v1"
"""GPU-сервер llama.cpp (compose-сервис `llm-gpu`, образ `...:server-cuda`)
— уровень `max` (`requires_gpu=True`, GPU обязателен)."""
class TierSpec(BaseModel):
"""Полная конфигурация одного уровня AI: плагины, требования железа, модели."""
transcriber: TranscriberConfig
summarizer: SummarizerConfig
min_ram_mb: int
requires_gpu: bool
min_vram_mb: int | None = None
model_files: list[str]
TIERS: dict[AiLevel, TierSpec] = {
"min": TierSpec(
transcriber=TranscriberConfig(
provider="faster_whisper_cpu",
model="small",
options={"download_root": f"{WHISPER_MODELS_ROOT}/small"},
),
summarizer=SummarizerConfig(
provider="qwen_local",
model="qwen3.5-4b-instruct-q4_k_m",
chunk_minutes=20,
options={
"base_url": _LLM_BASE_URL_CPU,
"tokenizer_path": f"{QWEN_MODELS_ROOT}/qwen3.5-4b-instruct.tokenizer.json",
"temperature": 0.2,
"max_tokens_map": 1024,
"max_tokens_reduce": 1536,
},
),
min_ram_mb=16 * 1024,
requires_gpu=False,
min_vram_mb=None,
model_files=[
f"{WHISPER_MODELS_ROOT}/small",
f"{QWEN_MODELS_ROOT}/qwen3.5-4b-instruct-q4_k_m.gguf",
f"{QWEN_MODELS_ROOT}/qwen3.5-4b-instruct.tokenizer.json",
],
),
"medium": TierSpec(
transcriber=TranscriberConfig(
provider="faster_whisper_cpu",
model="medium",
options={"download_root": f"{WHISPER_MODELS_ROOT}/medium"},
),
summarizer=SummarizerConfig(
provider="qwen_local",
model="qwen3.5-9b-instruct-q4_k_m",
chunk_minutes=20,
options={
"base_url": _LLM_BASE_URL_CPU,
"tokenizer_path": f"{QWEN_MODELS_ROOT}/qwen3.5-9b-instruct.tokenizer.json",
"temperature": 0.2,
"max_tokens_map": 1024,
"max_tokens_reduce": 2048,
},
),
min_ram_mb=32 * 1024,
# GPU опционален на этом уровне (ADR-004: полный offload от ~8 ГБ
# VRAM ускоряет, но не требуется) — константная матрица держит
# безопасный CPU-вариант `faster_whisper_cpu`/CPU llama.cpp;
# GPU-ускорение уровня `medium` — ручная настройка администратора
# поверх этой матрицы (вне детекта доступности).
requires_gpu=False,
min_vram_mb=8 * 1024,
model_files=[
f"{WHISPER_MODELS_ROOT}/medium",
f"{QWEN_MODELS_ROOT}/qwen3.5-9b-instruct-q4_k_m.gguf",
f"{QWEN_MODELS_ROOT}/qwen3.5-9b-instruct.tokenizer.json",
],
),
"max": TierSpec(
transcriber=TranscriberConfig(
provider="faster_whisper_gpu",
model="large-v3",
options={
"download_root": f"{WHISPER_MODELS_ROOT}/large-v3",
"compute_type": "float16",
},
),
summarizer=SummarizerConfig(
provider="qwen_local",
model="qwen3.5-35b-a3b-instruct-q4_k_m",
chunk_minutes=20,
options={
"base_url": _LLM_BASE_URL_GPU,
"tokenizer_path": f"{QWEN_MODELS_ROOT}/qwen3.5-35b-a3b-instruct.tokenizer.json",
"temperature": 0.2,
"max_tokens_map": 1536,
"max_tokens_reduce": 2560,
},
),
min_ram_mb=64 * 1024,
requires_gpu=True,
min_vram_mb=16 * 1024,
model_files=[
f"{WHISPER_MODELS_ROOT}/large-v3",
f"{QWEN_MODELS_ROOT}/qwen3.5-35b-a3b-instruct-q4_k_m.gguf",
f"{QWEN_MODELS_ROOT}/qwen3.5-35b-a3b-instruct.tokenizer.json",
],
),
}

239
backend/services/auth.py Normal file
View File

@@ -0,0 +1,239 @@
"""Бизнес-логика аутентификации: регистрация, подтверждение email, JWT access/refresh.
Refresh-токены хранятся server-side в Redis (`refresh:{jti}` -> user_id) с
TTL, равным сроку жизни refresh-токена. Каждое успешное использование
refresh-токена ротирует его: старый `jti` немедленно удаляется, выдаётся
новый; повторное использование уже потраченного refresh-токена (reuse)
обнаруживается по отсутствию ключа в Redis.
"""
import hashlib
import secrets
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import jwt
from redis.asyncio import Redis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
from models.email_verification import EmailVerificationToken
from models.user import User
from repositories.admin import TeamRepository
from repositories.users import UserRepository
from services.email import EmailBackend
from services.instance_settings import InstanceSettingsService
REFRESH_KEY_PREFIX = "refresh:"
class EmailAlreadyRegisteredError(Exception):
"""Пользователь с таким email уже зарегистрирован."""
class InvalidTeamSelectionError(Exception):
"""Выбор команды при регистрации недоступен или команда не существует.
Публичный эндпоинт `/auth/register` не должен различать эти две причины
в ответе (не раскрываем администраторскую настройку/список команд
перебором id) — единая ошибка для обоих случаев.
"""
class InvalidEmailDomainError(Exception):
"""Домен email регистрирующегося не совпадает с эталонным доменом инстанса.
Поднимается только при включённой настройке инстанса
`registration_email_domain_enabled` (см. `InstanceSettingsService`).
"""
class InvalidVerificationTokenError(Exception):
"""Токен подтверждения email не найден, просрочен или уже использован."""
class InvalidCredentialsError(Exception):
"""Неверный email или пароль."""
class EmailNotVerifiedError(Exception):
"""Email пользователя ещё не подтверждён."""
class InvalidRefreshTokenError(Exception):
"""Refresh-токен невалиден, просрочен, отозван или уже был использован (reuse)."""
@dataclass(frozen=True, slots=True)
class TokenPair:
"""Пара выданных JWT-токенов (access — в теле ответа, refresh — в cookie)."""
access_token: str
refresh_token: str
class AuthService:
"""Инкапсулирует сценарии регистрации, входа, обновления и отзыва токенов."""
def __init__(self, session: AsyncSession, redis: Redis, email_backend: EmailBackend) -> None:
self._session = session
self._redis = redis
self._email_backend = email_backend
self._users = UserRepository(session)
self._settings = get_settings()
async def register(
self,
*,
email: str,
name_user: str,
password: str,
team_id: uuid.UUID | None = None,
) -> User:
"""Зарегистрировать пользователя и отправить письмо для подтверждения email.
`team_id` допустим, только если в настройках инстанса включён выбор
команды при регистрации (`registration_team_choice`) и команда
существует — иначе `InvalidTeamSelectionError` (публичный
эндпоинт, деталей не раскрываем). Если включена верификация домена
email (`registration_email_domain_enabled`), домен `email` (часть
после `@`, без учёта регистра) должен совпадать с эталонным —
иначе `InvalidEmailDomainError`. Обе проверки — до создания
пользователя.
"""
existing = await self._users.get_by_email(email)
if existing is not None:
raise EmailAlreadyRegisteredError(email)
cfg = await InstanceSettingsService(self._session).get()
if cfg.registration_email_domain_enabled:
email_domain = email.rsplit("@", 1)[-1].lower()
if email_domain != cfg.registration_email_domain:
raise InvalidEmailDomainError(email)
if team_id is not None:
if not cfg.registration_team_choice:
raise InvalidTeamSelectionError(team_id)
team = await TeamRepository(self._session).get(team_id)
if team is None:
raise InvalidTeamSelectionError(team_id)
user = await self._users.create(
email=email,
name_user=name_user,
password_hash=hash_password(password),
team_id=team_id,
)
await self._issue_verification_email(user)
await self._session.commit()
return user
async def verify_email(self, token: str) -> None:
"""Подтвердить email пользователя по токену из письма."""
token_hash = _hash_token(token)
result = await self._session.execute(
select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash)
)
record = result.scalar_one_or_none()
now = datetime.now(UTC)
if record is None or record.used_at is not None or record.expires_at < now:
raise InvalidVerificationTokenError
user = await self._users.get_by_id(record.user_id)
if user is None:
raise InvalidVerificationTokenError
record.used_at = now
user.email_verified = True
await self._session.commit()
async def login(self, *, email: str, password: str) -> TokenPair:
"""Проверить учётные данные и выдать пару access/refresh токенов."""
user = await self._users.get_by_email(email)
if user is None or not verify_password(password, user.password_hash):
raise InvalidCredentialsError
if not user.email_verified:
raise EmailNotVerifiedError
return await self._issue_token_pair(user.id, user.role)
async def refresh(self, refresh_token: str) -> TokenPair:
"""Провалидировать refresh-токен, ротировать его и выдать новую пару токенов."""
user_id = await self._validate_and_consume(refresh_token)
user = await self._users.get_by_id(user_id)
if user is None:
raise InvalidRefreshTokenError
return await self._issue_token_pair(user.id, user.role)
async def logout(self, refresh_token: str) -> None:
"""Отозвать refresh-токен (удалить его из Redis), если он вообще декодируется."""
try:
payload = decode_token(refresh_token)
except jwt.PyJWTError:
return
jti = payload.get("jti")
if jti:
await self._redis.delete(f"{REFRESH_KEY_PREFIX}{jti}")
async def _validate_and_consume(self, refresh_token: str) -> uuid.UUID:
"""Проверить refresh JWT и его наличие в Redis, затем сразу удалить (ротация)."""
try:
payload = decode_token(refresh_token)
except jwt.PyJWTError as exc:
raise InvalidRefreshTokenError from exc
if payload.get("type") != "refresh":
raise InvalidRefreshTokenError
jti = payload.get("jti")
sub = payload.get("sub")
if not jti or not sub:
raise InvalidRefreshTokenError
redis_key = f"{REFRESH_KEY_PREFIX}{jti}"
stored_user_id = await self._redis.get(redis_key)
if stored_user_id is None or stored_user_id != sub:
raise InvalidRefreshTokenError
# Немедленное удаление использованного jti: повторное предъявление
# того же refresh-токена (reuse) после этой точки всегда даст 401.
await self._redis.delete(redis_key)
return uuid.UUID(sub)
async def _issue_token_pair(self, user_id: uuid.UUID, role: str) -> TokenPair:
access_token = create_access_token(user_id, role)
refresh_token, jti = create_refresh_token(user_id)
ttl_seconds = self._settings.refresh_token_ttl_days * 24 * 3600
await self._redis.set(f"{REFRESH_KEY_PREFIX}{jti}", str(user_id), ex=ttl_seconds)
return TokenPair(access_token=access_token, refresh_token=refresh_token)
async def _issue_verification_email(self, user: User) -> None:
token = secrets.token_urlsafe(32) # 256 бит случайности
expires_at = datetime.now(UTC) + timedelta(
hours=self._settings.email_verification_ttl_hours
)
self._session.add(
EmailVerificationToken(
user_id=user.id, token_hash=_hash_token(token), expires_at=expires_at
)
)
link = f"{self._settings.frontend_url}/verify-email?token={token}"
await self._email_backend.send(
to=user.email,
subject="Подтверждение регистрации VidConf",
body=f"Для подтверждения email перейдите по ссылке: {link}",
)
def _hash_token(token: str) -> str:
"""Захэшировать токен подтверждения email алгоритмом sha256 (hex-строка)."""
return hashlib.sha256(token.encode()).hexdigest()

123
backend/services/avatars.py Normal file
View File

@@ -0,0 +1,123 @@
"""Хранение аватаров пользователей: валидация загрузки, файлы на диске, URL.
Файл лежит на диске `MEDIA_ROOT/avatars/{user_id}.{ext}`; в БД (`users.avatar_path`)
хранится путь относительно `MEDIA_ROOT` (`avatars/{user_id}.{ext}`) — тот же
приём, что и у записей аудиотреков (`recordings_dir`, `core/config.py`).
"""
import uuid
from collections.abc import Callable
from pathlib import Path
from fastapi import UploadFile
# Лимит размера загружаемого аватара — 2 МБ.
MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024
# Читаем файл чанками, не доверяя заголовку `Content-Length` (клиент может
# солгать о размере) — реальный размер считается по факту прочитанных байт.
_CHUNK_SIZE_BYTES = 64 * 1024
# Допустимые типы изображений -> расширение файла на диске.
_ALLOWED_CONTENT_TYPES: dict[str, str] = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
}
# Магические байты (сигнатуры) форматов — заголовку `Content-Type` от клиента
# доверять нельзя (легко подделать), реальный формат определяется по
# содержимому файла.
_MAGIC_CHECKS: dict[str, Callable[[bytes], bool]] = {
"image/jpeg": lambda head: head[:3] == b"\xff\xd8\xff",
"image/png": lambda head: head[:8] == b"\x89PNG\r\n\x1a\n",
"image/webp": lambda head: head[:4] == b"RIFF" and head[8:12] == b"WEBP",
}
# Достаточно первых 12 байт, чтобы проверить все сигнатуры выше (WebP —
# самая длинная проверка, требует байты 8..11 включительно).
_MAGIC_HEAD_SIZE = 12
class AvatarTooLargeError(Exception):
"""Загружаемый файл превышает `MAX_AVATAR_SIZE_BYTES` (413)."""
class AvatarInvalidTypeError(Exception):
"""`Content-Type` не входит в список допустимых либо не совпадает с содержимым (415)."""
async def read_and_validate_avatar(file: UploadFile) -> tuple[bytes, str]:
"""Прочитать содержимое файла аватара чанками и провалидировать тип/размер.
Возвращает `(содержимое, расширение)`. Порядок проверок: сначала
заявленный `Content-Type` (быстрый отсев), затем фактический размер по
мере чтения, затем магические байты содержимого — заявленный тип должен
совпасть с реальным (иначе подделка `Content-Type` не даст загрузить,
например, исполняемый файл под видом `image/png`).
"""
declared_type = file.content_type
if declared_type not in _ALLOWED_CONTENT_TYPES:
raise AvatarInvalidTypeError(f"unsupported_content_type: {declared_type}")
chunks: list[bytes] = []
total_size = 0
while True:
chunk = await file.read(_CHUNK_SIZE_BYTES)
if not chunk:
break
total_size += len(chunk)
if total_size > MAX_AVATAR_SIZE_BYTES:
raise AvatarTooLargeError(f"file exceeds {MAX_AVATAR_SIZE_BYTES} bytes")
chunks.append(chunk)
content = b"".join(chunks)
magic_check = _MAGIC_CHECKS[declared_type]
if not magic_check(content[:_MAGIC_HEAD_SIZE]):
raise AvatarInvalidTypeError("content_does_not_match_declared_content_type")
return content, _ALLOWED_CONTENT_TYPES[declared_type]
def _avatar_relative_path(user_id: uuid.UUID, ext: str) -> str:
"""Путь аватара относительно `MEDIA_ROOT`."""
return f"avatars/{user_id}.{ext}"
def save_avatar(
media_root: Path, user_id: uuid.UUID, content: bytes, ext: str, *, old_path: str | None
) -> str:
"""Сохранить содержимое аватара на диск, удалить предыдущий файл (если был другого формата).
Возвращает новый относительный путь (`users.avatar_path`).
"""
avatars_dir = media_root / "avatars"
avatars_dir.mkdir(parents=True, exist_ok=True)
delete_avatar(media_root, old_path)
relative_path = _avatar_relative_path(user_id, ext)
(media_root / relative_path).write_bytes(content)
return relative_path
def delete_avatar(media_root: Path, avatar_path: str | None) -> None:
"""Удалить файл аватара с диска, если он существует; `None`/отсутствие файла — no-op."""
if not avatar_path:
return
file_path = media_root / avatar_path
file_path.unlink(missing_ok=True)
def avatar_url(media_root: Path, avatar_path: str | None) -> str | None:
"""URL аватара с cache-busting параметром `v={mtime файла}`; `None`, если аватара нет.
`mtime` — не хранящееся в БД значение (файл может быть перезалит в обход
ORM, например, вручную на проде), поэтому считывается со диска на лету.
"""
if not avatar_path:
return None
file_path = media_root / avatar_path
try:
mtime = int(file_path.stat().st_mtime)
except FileNotFoundError:
return None
return f"/media/{avatar_path}?v={mtime}"

218
backend/services/chat.py Normal file
View File

@@ -0,0 +1,218 @@
"""Бизнес-логика WS-чата конференции: аутентификация LiveKit-токеном, история, publish.
Единая аутентификация для пользователей и гостей — LiveKit access-токен
(`livekit.api.TokenVerifier`), а не backend-JWT: у гостя backend-JWT нет
вовсе (ADR-001, п.6), только LiveKit-токен, выданный при входе
(`services/livekit_tokens.py`). Grant `video.room` доказывает допуск именно
в эту конференцию — совпадение с `conference.slug` (имя LiveKit-комнаты,
ADR-001, п.4).
"""
import logging
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from livekit import api
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.redis import redis_client
from models.chat import ChatMessage
from models.conference import Conference
from repositories.chat import ChatMessageRepository
from repositories.conferences import ConferenceRepository, ConferenceSessionRepository
from schemas.chat import ChatMessageOut
from services.instance_settings import InstanceSettingsService
logger = logging.getLogger(__name__)
# Последние N сообщений открытой сессии, отправляемых новому подключению.
CHAT_HISTORY_LIMIT = 50
# Префикс identity гостя в LiveKit-токене (см. `services/webhook_handlers.py`).
GUEST_IDENTITY_PREFIX = "guest:"
def chat_channel(conference_id: uuid.UUID) -> str:
"""Имя Redis pub/sub канала чата конкретной конференции."""
return f"chat:{conference_id}"
class ChatAuthError(Exception):
"""Базовая ошибка допуска WS-подключения к чату — несёт WS close-код."""
def __init__(self, close_code: int) -> None:
self.close_code = close_code
super().__init__(close_code)
class InvalidTokenError(ChatAuthError):
"""Нет auth-сообщения, таймаут, невалидный/нераспознанный LiveKit-токен (close 4401)."""
def __init__(self) -> None:
super().__init__(4401)
class WrongRoomError(ChatAuthError):
"""Токен валиден, но выдан не для этой конференции (close 4403)."""
def __init__(self) -> None:
super().__init__(4403)
class ChatUnavailableError(ChatAuthError):
"""Чат выключен настройкой инстанса ИЛИ конференция не найдена/завершена.
Единый close-код 4404 для обоих случаев (номер/
ссылку конференции не перебираем, наличие конкретной конференции не
палим отдельным кодом).
"""
def __init__(self) -> None:
super().__init__(4404)
@dataclass(frozen=True)
class ChatIdentity:
"""Идентичность автора сообщения, восстановленная из LiveKit access-токена."""
user_id: uuid.UUID | None
guest_access_id: uuid.UUID | None
author_name: str
room: str
class ChatService:
"""Инкапсулирует сценарии WS-чата: auth, допуск, история, persist+publish."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
self._conferences = ConferenceRepository(session)
self._sessions = ConferenceSessionRepository(session)
self._messages = ChatMessageRepository(session)
async def authenticate(self, token: str) -> ChatIdentity:
"""Проверить LiveKit access-токен и восстановить identity автора сообщений.
Любая ошибка формата/подписи токена — единообразный `InvalidTokenError`
(детали причины не раскрываются клиенту).
"""
settings = get_settings()
verifier = api.TokenVerifier(settings.livekit_api_key, settings.livekit_api_secret)
try:
claims = verifier.verify(token)
except Exception as exc: # noqa: BLE001 — любая ошибка JWT/формата токена = 4401
raise InvalidTokenError from exc
if claims.video is None or not claims.video.room or not claims.identity:
raise InvalidTokenError
identity = claims.identity
room = claims.video.room
if identity.startswith(GUEST_IDENTITY_PREFIX):
try:
guest_id = uuid.UUID(identity.removeprefix(GUEST_IDENTITY_PREFIX))
except ValueError as exc:
raise InvalidTokenError from exc
return ChatIdentity(
user_id=None, guest_access_id=guest_id, author_name=claims.name, room=room
)
try:
user_id = uuid.UUID(identity)
except ValueError as exc:
raise InvalidTokenError from exc
return ChatIdentity(
user_id=user_id, guest_access_id=None, author_name=claims.name, room=room
)
async def ensure_chat_open(
self, conference_id: uuid.UUID, *, identity: ChatIdentity
) -> Conference:
"""Проверить допуск identity к чату конкретной конференции; вернуть конференцию.
Порядок проверок важен: тоггл и существование/статус конференции —
единый `ChatUnavailableError` (4404, инвариант №2), несовпадение
комнаты токена — отдельный `WrongRoomError` (4403), но только после
того, как убедились, что сама конференция легитимна.
"""
cfg = await InstanceSettingsService(self._session).get()
if not cfg.chat.enabled:
raise ChatUnavailableError
conference = await self._conferences.get_by_id(conference_id)
if conference is None or conference.status == "ended":
raise ChatUnavailableError
if identity.room != conference.slug:
raise WrongRoomError
return conference
async def history(self, conference: Conference) -> list[ChatMessageOut]:
"""Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет)."""
session_record = await self._sessions.get_open_by_conference(conference.id)
if session_record is None:
return []
rows = await self._messages.last_for_session(session_record.id, limit=CHAT_HISTORY_LIMIT)
return [_to_out(row) for row in rows]
async def persist_and_publish(
self, conference: Conference, *, identity: ChatIdentity, text: str
) -> None:
"""Сохранить сообщение в открытой сессии конференции и опубликовать его в Redis.
Допуск проверяется только при коннекте (`ensure_chat_open`), а WS
(с LiveKit-токеном TTL 6 часов, `services/livekit_tokens.py`) может
жить намного дольше одной конференции — клиент способен слать
сообщения уже ПОСЛЕ `room_finished`. Если открытой сессии нет, брать
для решения "можно ли создать новую" статус из уже загруженного
объекта `conference` нельзя (`expire_on_commit=False`, объект мог
устареть за время жизни WS-сессии) — статус перечитывается свежим
SELECT (`ConferenceRepository.get_status_by_id`, минует identity map).
Для `ended`-конференции — `ChatUnavailableError` (close 4404),
сообщение отклоняется, новая "фантомная" сессия НЕ создаётся
(идемпотентность пайплайна). Если открытая
сессия уже есть (обычный случай) — пишем в неё без пересчёта статуса.
Порядок обязателен: сначала INSERT+commit в БД, потом publish —
отправитель получает своё сообщение обратно через pub/sub-echo,
порядок доставки единый у всех подписчиков канала.
"""
session_record = await self._sessions.get_open_by_conference(conference.id)
if session_record is None:
fresh_status = await self._conferences.get_status_by_id(conference.id)
if fresh_status is None or fresh_status == "ended":
raise ChatUnavailableError
session_record = await self._sessions.create(
conference_id=conference.id, title=conference.title, t_start=datetime.now(UTC)
)
message = await self._messages.add(
session_id=session_record.id,
user_id=identity.user_id,
guest_access_id=identity.guest_access_id,
author_name=identity.author_name,
text=text,
)
await self._session.commit()
payload = _to_out(message)
await redis_client.publish(chat_channel(conference.id), payload.model_dump_json())
def _to_out(message: ChatMessage) -> ChatMessageOut:
"""Собрать `ChatMessageOut` из ORM-строки сообщения."""
if message.user_id is not None:
author_id: str | None = str(message.user_id)
elif message.guest_access_id is not None:
author_id = str(message.guest_access_id)
else:
author_id = None
return ChatMessageOut(
id=message.id,
author_id=author_id,
author_name=message.author_name,
is_guest=message.guest_access_id is not None,
text=message.text,
created_at=message.created_at,
)

View File

@@ -0,0 +1,78 @@
"""Проверка права входа в конференцию (статус/пароль) и генерация ответа join.
Заменяет `services/room_access.py`: комнаты больше не конкурируют за
время («правило часа» удалено вместе с бронированием, ADR-001, п.5), но
пароль закрытой конференции по-прежнему проверяется здесь же — для обычного
пользователя и гостя одинаково.
"""
import json
from core.config import get_settings
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):
"""Указанный пароль не совпадает с паролем закрытой конференции."""
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 verify_password(password, conference.password_hash):
raise InvalidPasswordError
def build_join(
conference: Conference,
*,
identity: str,
name: str,
chat_enabled: bool,
avatar_url: str | None = None,
) -> JoinOut:
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
`chat_enabled` — снятый вызывающей стороной тоггл `instance_settings`:
читается здесь параметром, а не заново из БД, чтобы не плодить
отдельный запрос настроек на каждый join. `avatar_url` прокидывается
в метаданные токена как JSON
`{"avatar_url": ...}`; `None` (гость либо пользователь без аватара) —
метаданные не выставляются вовсе.
"""
settings = get_settings()
metadata = json.dumps({"avatar_url": avatar_url}) if avatar_url 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,
)

View File

@@ -0,0 +1,37 @@
"""Генерация номера и постоянной ссылки конференции (ADR-001, п.4).
Оба идентификатора неизменны всё время жизни конференции и не переиспользуются;
уникальность в БД обеспечивают constraint'ы `conferences.number`/`conferences.slug`,
коллизии (крайне маловероятные при выбранной энтропии) обрабатываются повторной
генерацией на уровне репозитория/сервиса, создающего конференцию.
"""
import secrets
# Длина номера конференции в десятичных цифрах.
NUMBER_LENGTH = 9
# Длина slug в байтах энтропии (до base64url-кодирования); 8 байт = 64 бита,
# что даёт 11 символов base64url без padding.
SLUG_ENTROPY_BYTES = 8
def generate_number() -> str:
"""Сгенерировать 9-значный номер конференции.
Первая цифра — 1..9 (число не начинается с нуля), остальные 8 — 0..9.
Используется `secrets.randbelow` — криптографически стойкий генератор,
подбор номера должен быть неугадываем (оценка энтропии — ADR-001, п.4).
"""
first_digit = str(secrets.randbelow(9) + 1)
rest_digits = "".join(str(secrets.randbelow(10)) for _ in range(NUMBER_LENGTH - 1))
return first_digit + rest_digits
def generate_slug() -> str:
"""Сгенерировать slug постоянной ссылки конференции (`/j/{slug}`).
`secrets.token_urlsafe(8)` — 8 байт (64 бита) энтропии, 11 символов
base64url; также используется как имя LiveKit-комнаты.
"""
return secrets.token_urlsafe(SLUG_ENTROPY_BYTES)

View File

@@ -0,0 +1,670 @@
"""Бизнес-логика конференций: создание, «Мои конференции», календарь, резолв, join, правки.
Тонкий API-роутер (`api/conferences.py`) делегирует сюда всю логику; проверки
статуса/пароля и генерация ответа join вынесены в `services/conference_access.py`.
"""
import logging
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, cast
from sqlalchemy import null
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.plugins.config import SummaryRecipientsMode
from core.security import hash_password
from models.conference import Conference
from models.guest import GuestAccess
from models.invitee import ConferenceInvitee
from models.user import User
from repositories.conferences import ConferenceInviteeRepository, ConferenceRepository
from schemas.conferences import (
ConferenceCreateIn,
ConferenceOut,
ConferenceUpdateIn,
GuestJoinIn,
InviteeIn,
InviteeOut,
JoinOut,
OccurrenceOut,
)
from services.avatars import avatar_url as resolve_avatar_url
from services.conference_access import build_join, ensure_joinable
from services.conference_ids import generate_number, generate_slug
from services.instance_settings import InstanceSettingsService
from services.invitations_producer import enqueue_invitations
from services.recurrence import RecurrenceRule, expand_occurrences
logger = logging.getLogger(__name__)
# Число попыток сгенерировать уникальные номер/slug при коллизии unique-constraint
# (крайне маловероятной при выбранной энтропии — см. ADR-001, п.4).
MAX_ID_GENERATION_ATTEMPTS = 5
# Горизонт поиска "следующего вхождения" закреплённой конференции с повторением
# для «Моих конференций»; с запасом покрывает самый частый шаг (weekly/monthly).
NEXT_OCCURRENCE_HORIZON = timedelta(days=400)
# Длительность вхождения календаря по умолчанию, если у разовой плановой
# конференции не указан `duration_minutes`.
DEFAULT_OCCURRENCE_DURATION_MINUTES = 60
class ConferenceNotFoundError(Exception):
"""Конференция с таким id не найдена."""
class NotConferenceOwnerError(Exception):
"""Действие разрешено только владельцу конференции или администратору."""
class ConferenceActiveError(Exception):
"""Нельзя удалить конференцию, которая сейчас активна."""
class InvalidConferenceStateError(Exception):
"""Итоговое состояние конференции после правки нарушает бизнес-инварианты."""
class InviteeUserNotFoundError(Exception):
"""Один или несколько `user_id` в составе приглашённых не существуют (ADR-003)."""
def __init__(self, missing_user_ids: set[uuid.UUID]) -> None:
self.missing_user_ids = missing_user_ids
super().__init__(f"неизвестные user_id приглашённых: {missing_user_ids}")
class ConferenceService:
"""Инкапсулирует сценарии создания/просмотра/входа/правки конференций."""
def __init__(self, session: AsyncSession, *, media_root: Path | None = None) -> None:
self._session = session
self._conferences = ConferenceRepository(session)
self._invitees = ConferenceInviteeRepository(session)
self._media_root = media_root or Path(get_settings().media_root)
async def create(
self, *, owner: User, data: ConferenceCreateIn
) -> tuple[Conference, JoinOut | None]:
"""Создать конференцию.
Мгновенная (`status=active`, создатель входит сразу же — второй
элемент кортежа тогда содержит готовый `JoinOut`) — только если не
указаны ни `scheduled_at`, ни `recurrence`; закреплённая с
повторением без явного `scheduled_at` — плановая конференция,
ожидающая своего первого вхождения, а не мгновенный вход.
"""
password_hash = hash_password(data.password) if data.password else None
is_instant = data.scheduled_at is None and data.recurrence is None
conference_status = "active" if is_instant else "scheduled"
recurrence_json = data.recurrence.model_dump(mode="json") if data.recurrence else None
# Примитивы читаются из `owner` один раз, до цикла retry: после
# `session.rollback()` (при коллизии number/slug) ORM помечает все
# загруженные объекты, включая `owner`, протухшими, а повторное
# обращение к их атрибутам вне greenlet-контекста упало бы с
# `MissingGreenlet` (ленивая подгрузка синхронным геттером).
owner_id = owner.id
owner_name = owner.name_user
owner_email = owner.email
owner_avatar_path = owner.avatar_path
# Раннее падение при неизвестном `user_id` приглашённого — ДО
# создания конференции (см. `InviteeUserNotFoundError`), чтобы не
# оставлять во flush-буфере сессии несохранённую строку `conferences`.
await self._validate_participants(data.participants)
def _factory(number: str, slug: str) -> Conference:
conference = Conference(
number=number,
slug=slug,
title=data.title,
owner_id=owner_id,
status=conference_status,
is_pinned=data.is_pinned,
is_closed=data.is_closed,
password_hash=password_hash,
scheduled_at=data.scheduled_at,
duration_minutes=data.duration_minutes,
summary_recipients=data.summary_recipients,
)
# Атрибут намеренно не устанавливается вовсе, если правила нет:
# JSONB-колонка сериализует явно присвоенный Python `None` в
# JSON-литерал `null`, а не в SQL NULL (нет `none_as_null=True` —
# модель не трогаем, см. блок A); неустановленный атрибут при
# INSERT просто опускается, и колонка получает настоящий NULL.
if recurrence_json is not None:
conference.recurrence = recurrence_json
return conference
conference = await self._create_with_unique_ids(_factory)
await self._apply_participants(conference, data.participants, owner_email=owner_email)
await self._session.commit()
join = None
if is_instant:
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
join = build_join(
conference,
identity=str(owner_id),
name=owner_name,
chat_enabled=chat_enabled,
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
)
else:
# Плановая (разовая) либо закреплённая с повторением/датой — есть
# расписание, на которое имеет смысл прислать .ics-приглашение
# («.ics»). Ставится ПОСЛЕ commit — воркер должен
# видеть уже зафиксированную строку конференции.
self._enqueue_invitations_safely(conference.id)
return conference, join
async def list_my(self, *, owner: User) -> list[ConferenceOut]:
"""Закреплённые конференции + предстоящие разовые владельца ИЛИ приглашённого.
Решение от 2026-07-20 (поверх ADR-003): приглашённый должен видеть
конференцию в своих списках — `owner` здесь означает «текущий
пользователь», а не только фактического владельца конференции.
`participants` НЕ заполняется (пусто) — список не раздувает состав
(ADR-003, п.5); `organizer_name`/`is_owner` теперь честно вычисляются
на конференцию (для приглашённого — имя РЕАЛЬНОГО владельца и
`is_owner=False`), а не берутся из `owner` — небольшой N+1 на строки,
где владелец конференции не совпадает с viewer (`_resolve_organizer_name`),
список короткий.
"""
now = datetime.now(UTC)
conferences = await self._conferences.list_owned(owner.id, email=owner.email, now=now)
result: list[ConferenceOut] = []
for conference in conferences:
organizer_name = await self._resolve_organizer_name(conference, viewer=owner)
result.append(
self.to_out(
conference,
next_occurrence=self._next_occurrence(conference, now=now),
viewer_id=owner.id,
organizer_name=organizer_name,
)
)
return result
async def list_calendar(
self, *, owner: User, t_from: datetime, t_to: datetime
) -> list[OccurrenceOut]:
"""Развернуть вхождения закреплённых/разовых конференций владельца ИЛИ приглашённого
(решение от 2026-07-20 поверх ADR-003 — тот же принцип видимости, что `list_my`)."""
candidates = await self._conferences.list_calendar_candidates(owner.id, email=owner.email)
occurrences: list[OccurrenceOut] = []
for conference in candidates:
if conference.recurrence is not None:
rule = RecurrenceRule.model_validate(conference.recurrence)
for starts_at in expand_occurrences(rule, t_from, t_to):
occurrences.append(
self._occurrence_out(
conference,
starts_at=starts_at,
ends_at=starts_at + timedelta(minutes=rule.duration_minutes),
)
)
elif conference.scheduled_at is not None and t_from <= conference.scheduled_at <= t_to:
duration = conference.duration_minutes or DEFAULT_OCCURRENCE_DURATION_MINUTES
occurrences.append(
self._occurrence_out(
conference,
starts_at=conference.scheduled_at,
ends_at=conference.scheduled_at + timedelta(minutes=duration),
)
)
occurrences.sort(key=lambda occurrence: occurrence.starts_at)
return occurrences
async def resolve(self, q: str) -> Conference | None:
"""Найти конференцию по slug или номеру (пробелы в номере игнорируются)."""
query = q.strip()
if not query:
return None
conference = await self._conferences.get_by_slug(query)
if conference is not None:
return conference
number_candidate = query.replace(" ", "")
if number_candidate.isdigit():
return await self._conferences.get_by_number(number_candidate)
return None
async def join_as_user(
self, conference_id: uuid.UUID, *, user: User, password: str | None
) -> JoinOut:
"""Войти в конференцию зарегистрированным пользователем."""
conference = await self._get_or_raise(conference_id)
ensure_joinable(conference, password=password)
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
return build_join(
conference,
identity=str(user.id),
name=user.name_user,
chat_enabled=chat_enabled,
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
)
async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut:
"""Войти в конференцию гостем: создать `GuestAccess` и выдать токен."""
conference = await self._get_or_raise(conference_id)
ensure_joinable(conference, password=data.password)
guest = GuestAccess(
conference_id=conference.id, display_name=data.display_name, email=data.email
)
self._session.add(guest)
await self._session.flush()
await self._session.commit()
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
return build_join(
conference,
identity=f"guest:{guest.id}",
name=data.display_name,
chat_enabled=chat_enabled,
)
async def update(
self, conference_id: uuid.UUID, *, actor: User, data: ConferenceUpdateIn
) -> Conference:
"""Частично обновить конференцию; разрешено владельцу или администратору."""
conference = await self._get_or_raise(conference_id)
self._ensure_owner_or_admin(conference, actor)
# Раннее падение при неизвестном `user_id` приглашённого — ДО любых
# мутаций конференции (см. `InviteeUserNotFoundError`).
await self._validate_participants(data.participants)
# Снимок «расписательных» полей ДО правки — чтобы после всех мутаций
# (включая recurrence, разрешаемый ниже отдельно) определить, нужно
# ли инкрементировать `ics_sequence` и переслать .ics-приглашение.
title_before = conference.title
scheduled_at_before = conference.scheduled_at
duration_before = conference.duration_minutes
if data.title is not None:
conference.title = data.title
if data.scheduled_at is not None:
conference.scheduled_at = data.scheduled_at
if data.duration_minutes is not None:
conference.duration_minutes = data.duration_minutes
if data.is_closed is not None:
conference.is_closed = data.is_closed
if data.password is not None:
conference.password_hash = hash_password(data.password)
if "summary_recipients" in data.model_fields_set:
# Явная передача (в т.ч. `null`) — сбросить/установить
# переопределение; отсутствие поля в запросе значение не трогает.
conference.summary_recipients = data.summary_recipients
is_pinned = data.is_pinned if data.is_pinned is not None else conference.is_pinned
conference.is_pinned = is_pinned
# Итоговое значение recurrence считаем как обычный Python dict|None —
# чтобы бизнес-проверка ниже не путала ORM-сентинел `null()` (см. далее)
# с «правила нет». `model_fields_set` различает «поле не передано» и
# «передано явным `null`» — оба случая иначе выглядят одинаково
# (`data.recurrence is None`).
current_recurrence = conference.recurrence
recurrence_explicitly_cleared = (
"recurrence" in data.model_fields_set and data.recurrence is None
)
# Открепление обязано снимать и правило повторения: recurrence без
# is_pinned нарушает CHECK-constraint `ck_conferences_recurrence_requires_pinned`,
# даже если PATCH вовсе не упоминал `recurrence` (например, только
# `{"is_pinned": false}`).
must_clear = recurrence_explicitly_cleared or (
not is_pinned and current_recurrence is not None
)
cleared_via_null_sentinel = False
if data.recurrence is not None:
recurrence: dict[str, Any] | None = data.recurrence.model_dump(mode="json")
conference.recurrence = recurrence
elif must_clear:
recurrence = None
# JSONB-колонка без `none_as_null=True` (модель не трогаем, см.
# блок A) сериализует явно присвоенный Python `None` в
# JSON-литерал `null`, а не в SQL NULL — что уронило бы
# CHECK-constraint. `sqlalchemy.null()` форсирует настоящий SQL
# NULL, но помечает атрибут "expired" после flush (ORM не знает
# результат SQL-выражения без похода в БД) — обновляем объект
# явным awaited-рефрешем ниже перед возвратом, иначе синхронное
# чтение `conference.recurrence` вне greenlet (например, в
# `to_out`) упадёт `MissingGreenlet`.
conference.recurrence = cast(Any, null())
cleared_via_null_sentinel = True
else:
# Ничего менять не нужно — атрибут не трогаем вовсе, чтобы не
# провоцировать лишнюю expiry на пустом месте (см. выше).
recurrence = current_recurrence
if conference.is_closed and conference.password_hash is None:
raise InvalidConferenceStateError("closed_conference_requires_password")
if recurrence is not None and not is_pinned:
raise InvalidConferenceStateError("recurrence_requires_pinned")
# Расписание изменилось, если сдвинулись заголовок/дата/длительность
# либо правило повторения (сравниваем уже разрешённое значение
# `recurrence`, а не «сырой» атрибут — иначе сентинел `null()` ниже
# спутал бы сравнение). Такая правка обязана переслать .ics-приглашение
# с новым `SEQUENCE` — иначе уже принятое
# приглашение в календаре адресата разойдётся с фактическим временем.
schedule_changed = (
conference.title != title_before
or conference.scheduled_at != scheduled_at_before
or conference.duration_minutes != duration_before
or recurrence != current_recurrence
)
if schedule_changed:
conference.ics_sequence += 1
# Состав приглашённых — после всех проверок бизнес-инвариантов выше
# (иначе при `InvalidConferenceStateError` DELETE/INSERT состава
# пришлось бы объяснять исполнением, которое всё равно не закоммитится
# — но проще просто не выполнять его до финальной валидации).
# `ics_sequence` НЕ растёт от одной только правки состава
# (анти-спам) — рассылка всё равно ставится в очередь.
owner_email = await self._resolve_owner_email(conference)
participants_changed = await self._apply_participants(
conference, data.participants, owner_email=owner_email
)
await self._session.flush()
await self._session.commit()
if cleared_via_null_sentinel:
await self._session.refresh(conference, attribute_names=["recurrence"])
if schedule_changed or participants_changed:
# Ставится ПОСЛЕ commit — воркер должен видеть уже
# зафиксированные `ics_sequence`/новое расписание/состав.
self._enqueue_invitations_safely(conference.id)
return conference
def _enqueue_invitations_safely(self, conference_id: uuid.UUID) -> None:
"""Поставить рассылку .ics-приглашений в очередь, не роняя запрос при сбое.
Конференция к этому моменту уже закоммичена — создание/правка это
главный результат запроса, а рассылка приглашений вторична. При
недоступности Redis `Celery.send_task` (используется
`enqueue_invitations`) бросает исключение синхронно — без этого перехвата
POST/PATCH `/conferences` отдал бы 500, хотя запись уже сохранена.
Восстановление: пропавшая постановка компенсируется ручной рассылкой
администратором (`POST /admin/conferences/{id}/invitations`) — в
отличие от шагов AI-пайплайна, здесь нет beat-задачи уровня 2 защиты,
т.к. отсутствие приглашения не блокирует использование конференции.
"""
try:
enqueue_invitations(conference_id)
except Exception: # noqa: BLE001 — недоступность брокера не должна ронять запрос
logger.warning(
"ConferenceService: не удалось поставить в очередь рассылку "
"приглашений для конференции %s (Redis недоступен?) — конференция "
"сохранена, разослать приглашения можно вручную из админки",
conference_id,
exc_info=True,
)
async def delete(self, conference_id: uuid.UUID, *, actor: User) -> None:
"""Удалить конференцию; запрещено для активной (409 на уровне роутера)."""
conference = await self._get_or_raise(conference_id)
self._ensure_owner_or_admin(conference, actor)
if conference.status == "active":
raise ConferenceActiveError
await self._conferences.delete(conference)
await self._session.commit()
async def get_detail(self, conference_id: uuid.UUID, *, actor: User) -> Conference:
"""Получить конференцию для детального просмотра.
Разрешено владельцу, администратору ИЛИ приглашённому (решение от
2026-07-20 поверх ADR-003 — иначе ховер-карточка/детальная страница
приглашённого получали бы 403). PATCH/DELETE эту проверку НЕ
переиспользуют — там по-прежнему только владелец/администратор
(`_ensure_owner_or_admin`).
"""
conference = await self._get_or_raise(conference_id)
await self._ensure_viewable(conference, actor)
return conference
async def to_detail_out(
self, conference: Conference, *, viewer: User, join: JoinOut | None = None
) -> ConferenceOut:
"""Собрать `ConferenceOut` с полным составом участников (детальный ответ).
Используется создание/правка/`GET /conferences/{id}` — списочные
эндпоинты (`list_my`) состав не раздувают (ADR-003, п.5) и строят
ответ через `to_out` напрямую.
"""
organizer_name = await self._resolve_organizer_name(conference, viewer=viewer)
participants = await self._build_participants_out(conference)
return self.to_out(
conference,
join=join,
organizer_name=organizer_name,
participants=participants,
viewer_id=viewer.id,
)
def to_out(
self,
conference: Conference,
*,
join: JoinOut | None = None,
next_occurrence: datetime | None = None,
viewer_id: uuid.UUID | None = None,
organizer_name: str | None = None,
participants: list[InviteeOut] | None = None,
) -> ConferenceOut:
"""Собрать `ConferenceOut` из ORM-модели."""
recurrence = (
RecurrenceRule.model_validate(conference.recurrence) if conference.recurrence else None
)
return ConferenceOut(
id=conference.id,
number=conference.number,
slug=conference.slug,
title=conference.title,
status=conference.status,
is_pinned=conference.is_pinned,
is_closed=conference.is_closed,
scheduled_at=conference.scheduled_at,
duration_minutes=conference.duration_minutes,
recurrence=recurrence,
next_occurrence=next_occurrence,
created_at=conference.created_at,
join=join,
summary_recipients=cast("SummaryRecipientsMode | None", conference.summary_recipients),
owner_id=conference.owner_id,
is_owner=viewer_id is not None and conference.owner_id == viewer_id,
organizer_name=organizer_name,
participants=participants or [],
)
async def _resolve_organizer_name(self, conference: Conference, *, viewer: User) -> str | None:
"""Имя организатора; переиспользует уже загруженного `viewer`, если он и есть владелец."""
if conference.owner_id is None:
return None
if conference.owner_id == viewer.id:
return viewer.name_user
return await self._resolve_owner_name(conference)
async def _resolve_owner_name(self, conference: Conference) -> str | None:
if conference.owner_id is None:
return None
owner = await self._session.get(User, conference.owner_id)
return owner.name_user if owner is not None else None
async def _resolve_owner_email(self, conference: Conference) -> str | None:
if conference.owner_id is None:
return None
owner = await self._session.get(User, conference.owner_id)
return owner.email if owner is not None else None
async def _build_participants_out(self, conference: Conference) -> list[InviteeOut]:
"""Состав приглашённых: организатор всегда первым (ADR-003, п.2), затем остальные."""
items: list[InviteeOut] = []
if conference.owner_id is not None:
owner = await self._session.get(User, conference.owner_id)
if owner is not None:
items.append(
InviteeOut(
user_id=owner.id,
email=owner.email,
name=owner.name_user,
avatar_url=resolve_avatar_url(self._media_root, owner.avatar_path),
is_organizer=True,
)
)
rows = await self._invitees.list_with_user(conference.id)
for invitee, user_name, user_email, user_avatar_path in rows:
items.append(
InviteeOut(
user_id=invitee.user_id,
email=invitee.email if invitee.email is not None else user_email,
name=user_name,
avatar_url=(
resolve_avatar_url(self._media_root, user_avatar_path)
if invitee.user_id is not None
else None
),
is_organizer=False,
)
)
return items
async def _validate_participants(self, participants: list[InviteeIn] | None) -> None:
"""Проверить существование всех `user_id` состава — ДО любых мутаций (ADR-003)."""
if participants is None:
return
user_ids = {item.user_id for item in participants if item.user_id is not None}
if not user_ids:
return
missing = user_ids - await self._invitees.existing_user_ids(user_ids)
if missing:
raise InviteeUserNotFoundError(missing)
async def _apply_participants(
self,
conference: Conference,
participants: list[InviteeIn] | None,
*,
owner_email: str | None,
) -> bool:
"""Заменить состав приглашённых; вернуть `True`, если фактический состав изменился.
`participants=None` — не менять (ADR-003, п.3), no-op. Организатор
(по `user_id` владельца или его email) молча дедуплицируется — он и
так всегда в составе (ADR-003, п.2), а неудаляем конструктивно.
`_validate_participants` должен быть вызван заранее.
"""
if participants is None:
return False
owner_id = conference.owner_id
desired: dict[tuple[str, str], ConferenceInvitee] = {}
for item in participants:
if item.user_id is not None:
if item.user_id == owner_id:
continue
key = ("user", str(item.user_id))
desired.setdefault(
key, ConferenceInvitee(conference_id=conference.id, user_id=item.user_id)
)
else:
assert item.email is not None # гарантировано `InviteeIn._validate`
# email пользователя в БД может быть в смешанном регистре,
# InviteeIn.email всегда нормализован в lower — сравниваем без регистра.
if owner_email is not None and item.email == owner_email.lower():
continue
key = ("email", item.email)
desired.setdefault(
key, ConferenceInvitee(conference_id=conference.id, email=item.email)
)
existing_rows = await self._invitees.list_with_user(conference.id)
existing_keys = {
("user", str(invitee.user_id))
if invitee.user_id is not None
else ("email", invitee.email)
for invitee, _, _, _ in existing_rows
}
changed = existing_keys != set(desired.keys())
await self._invitees.replace_all(conference.id, list(desired.values()))
return changed
async def _get_or_raise(self, conference_id: uuid.UUID) -> Conference:
conference = await self._conferences.get_by_id(conference_id)
if conference is None:
raise ConferenceNotFoundError
return conference
def _ensure_owner_or_admin(self, conference: Conference, actor: User) -> None:
if conference.owner_id != actor.id and actor.role != "admin":
raise NotConferenceOwnerError
async def _ensure_viewable(self, conference: Conference, actor: User) -> None:
"""Владелец/администратор/приглашённый — иначе `NotConferenceOwnerError` (403).
Только для чтения (`get_detail`) — решение от 2026-07-20 поверх
ADR-003; PATCH/DELETE используют `_ensure_owner_or_admin` (синхронный,
без приглашённых).
"""
if conference.owner_id == actor.id or actor.role == "admin":
return
if await self._invitees.exists_for_user(conference.id, user_id=actor.id, email=actor.email):
return
raise NotConferenceOwnerError
def _next_occurrence(self, conference: Conference, *, now: datetime) -> datetime | None:
if conference.recurrence is not None:
rule = RecurrenceRule.model_validate(conference.recurrence)
horizon = NEXT_OCCURRENCE_HORIZON
if rule.type == "every_n_days" and rule.interval_days is not None:
horizon = max(horizon, timedelta(days=rule.interval_days + 2))
occurrences = expand_occurrences(rule, now, now + horizon)
return occurrences[0] if occurrences else None
if conference.scheduled_at is not None and conference.scheduled_at >= now:
return conference.scheduled_at
return None
def _occurrence_out(
self, conference: Conference, *, starts_at: datetime, ends_at: datetime
) -> OccurrenceOut:
return OccurrenceOut(
conference_id=conference.id,
title=conference.title,
starts_at=starts_at,
ends_at=ends_at,
number=conference.number,
slug=conference.slug,
is_pinned=conference.is_pinned,
is_closed=conference.is_closed,
)
async def _create_with_unique_ids(
self, factory: Callable[[str, str], Conference]
) -> Conference:
"""Создать конференцию, повторяя генерацию номера/slug при коллизии unique.
До `MAX_ID_GENERATION_ATTEMPTS` попыток; откат до последнего
savepoint обязателен после `IntegrityError` — иначе сессия
SQLAlchemy становится непригодной для дальнейших операций (в т.ч. в
тестах со savepoint).
"""
last_error: IntegrityError | None = None
for _ in range(MAX_ID_GENERATION_ATTEMPTS):
conference = factory(generate_number(), generate_slug())
try:
return await self._conferences.add(conference)
except IntegrityError as exc:
await self._session.rollback()
last_error = exc
assert last_error is not None
raise last_error

View File

@@ -0,0 +1,58 @@
"""Тонкая обёртка над LiveKit `EgressService.start_track_egress` — единственная точка для
мокирования в тестах (по образцу `workers/livekit_client.py::delete_livekit_room`).
Запускает Track Egress для одного аудиотрека: пишет исходный opus без
транскодирования в `.ogg` на общий volume `recordings_dir`. Финализация
результата (итоговый `location`/ошибка) приходит асинхронно через webhook
`egress_ended` — здесь только сам запуск и `egress_id`/`started_at` из ответа.
"""
from dataclasses import dataclass
from datetime import UTC, datetime
from livekit import api
from core.config import get_settings
@dataclass(frozen=True, slots=True)
class EgressStartResult:
"""Результат запуска Track Egress: идентификатор задания и время старта (UTC)."""
egress_id: str
started_at: datetime
async def start_track_egress(room_name: str, track_sid: str, filepath: str) -> EgressStartResult:
"""Запустить запись одного аудиотрека комнаты в файл `filepath` на общем volume.
`DirectFileOutput` без указания облачного хранилища (s3/gcp/azure) пишет
файл напрямую на диск egress-контейнера — тот же volume `recordings_dir`,
что и у воркера транскрибации (см. `deploy/docker-compose.yml`).
"""
settings = get_settings()
lkapi = api.LiveKitAPI(
settings.livekit_url,
api_key=settings.livekit_api_key,
api_secret=settings.livekit_api_secret,
)
try:
info = await lkapi.egress.start_track_egress(
api.TrackEgressRequest(
room_name=room_name,
track_id=track_sid,
file=api.DirectFileOutput(filepath=filepath),
)
)
finally:
await lkapi.aclose()
# `EgressInfo.started_at` — unix-наносекунды (см. документацию livekit/egress,
# `pkg/config/manifest.go`); при отсутствии (ещё не проставлен на момент
# ответа STARTING) считаем стартом текущий момент.
started_at = (
datetime.fromtimestamp(info.started_at / 1_000_000_000, tz=UTC)
if info.started_at
else datetime.now(UTC)
)
return EgressStartResult(egress_id=info.egress_id, started_at=started_at)

202
backend/services/email.py Normal file
View File

@@ -0,0 +1,202 @@
"""Абстракция отправки email: контракт `EmailBackend`, dev- и SMTP-реализации.
Выбор бэкенда (`console`|`smtp`) — переменная окружения `EMAIL_BACKEND`
(`core/config.py`), не настройка в БД: секреты SMTP — только в `.env`,
а `SettingsOut` админки их не должен видеть.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from email.message import EmailMessage
from typing import TYPE_CHECKING, Protocol
import aiosmtplib
if TYPE_CHECKING:
from core.config import Settings
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class EmailAttachment:
"""Вложение письма (например, `.ics`-приглашение, `text/calendar; method=REQUEST`)."""
filename: str
content: bytes
mime_type: str
class EmailSendError(Exception):
"""Ошибка отправки письма транспортом.
`retryable=True` — временный сбой транспорта (сервер недоступен/оборвал
соединение/таймаут): вызывающая Celery-задача должна повторить попытку.
`retryable=False` — конкретный получатель отклонён сервером (повторять
без изменения адреса бессмысленно) — вызывающая сторона пропускает его,
не роняя всю рассылку (см. `workers/tasks/notify.py`).
"""
def __init__(self, message: str, *, retryable: bool) -> None:
super().__init__(message)
self.retryable = retryable
class EmailBackend(Protocol):
"""Контракт отправки email; новые бэкенды подставляются без правки ядра."""
async def send(
self,
*,
to: str,
subject: str,
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
) -> None:
"""Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива)."""
...
class ConsoleEmailBackend:
"""Бэкенд для разработки: пишет письмо в лог вместо реальной отправки."""
async def send(
self,
*,
to: str,
subject: str,
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
) -> None:
"""Залогировать письмо (вложения — только имена файлов, без содержимого)."""
attachment_names = ", ".join(a.filename for a in attachments) or "нет"
logger.info(
"EMAIL to=%s subject=%s attachments=[%s]\n%s",
to,
subject,
attachment_names,
body,
)
class SmtpEmailBackend:
"""Бэкенд реальной отправки email через SMTP (`aiosmtplib.send`)."""
def __init__(
self,
*,
hostname: str,
port: int,
username: str | None,
password: str | None,
start_tls: bool,
use_tls: bool,
timeout: float,
sender: str,
) -> None:
self._hostname = hostname
self._port = port
self._username = username
self._password = password
self._start_tls = start_tls
self._use_tls = use_tls
self._timeout = timeout
self._sender = sender
async def send(
self,
*,
to: str,
subject: str,
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
) -> None:
"""Отправить письмо; ошибки транспорта транслируются в `EmailSendError`."""
message = _build_message(
sender=self._sender,
to=to,
subject=subject,
body=body,
html_body=html_body,
attachments=attachments,
)
try:
await aiosmtplib.send(
message,
hostname=self._hostname,
port=self._port,
username=self._username or None,
password=self._password or None,
start_tls=self._start_tls,
use_tls=self._use_tls,
timeout=self._timeout,
)
except aiosmtplib.SMTPRecipientsRefused as exc:
# Сервер отклонил конкретного получателя — повтор не поможет без
# изменения адреса; вызывающая сторона (notify_session) пропускает
# только его, не роняя рассылку остальным получателям.
logger.warning("SMTP: получатель %s отклонён сервером: %s", to, exc)
raise EmailSendError(f"получатель отклонён сервером: {to}", retryable=False) from exc
except (
aiosmtplib.SMTPConnectError,
aiosmtplib.SMTPServerDisconnected,
aiosmtplib.SMTPTimeoutError,
aiosmtplib.SMTPAuthenticationError,
) as exc:
# Временный сбой транспорта — стоит повторить попытку позже.
# `SMTPTimeoutError` после отправки DATA — доставка неизвестна:
# принят at-least-once, редкий дубль
# предпочтительнее потери письма.
logger.warning("SMTP: временный сбой при отправке на %s: %s", to, exc)
raise EmailSendError(f"временный сбой SMTP: {exc}", retryable=True) from exc
def _build_message(
*,
sender: str,
to: str,
subject: str,
body: str,
html_body: str | None,
attachments: Sequence[EmailAttachment],
) -> EmailMessage:
"""Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения."""
message = EmailMessage()
message["From"] = sender
message["To"] = to
message["Subject"] = subject
message.set_content(body)
if html_body is not None:
message.add_alternative(html_body, subtype="html")
for attachment in attachments:
maintype, _, rest = attachment.mime_type.partition("/")
subtype = rest.split(";", 1)[0].strip() or "octet-stream"
message.add_attachment(
attachment.content,
maintype=maintype or "application",
subtype=subtype,
filename=attachment.filename,
)
return message
def create_email_backend(settings: Settings) -> EmailBackend:
"""Собрать бэкенд отправки email по `settings.email_backend` (`console` по умолчанию)."""
if settings.email_backend == "smtp":
return SmtpEmailBackend(
hostname=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username,
password=settings.smtp_password,
start_tls=settings.smtp_start_tls,
use_tls=settings.smtp_use_tls,
timeout=settings.smtp_timeout_s,
sender=settings.smtp_from,
)
return ConsoleEmailBackend()

View File

@@ -0,0 +1,161 @@
"""Генератор письма с саммари встречи: plaintext + HTML.
HTML-версия — по мотивам макета `design/mockups/email-summary.html`
(упрощённая структура: шапка/участники/тело саммари/футер, цвета и типографика
светлой темы `design/DESIGN_SYSTEM.md`; почтовые клиенты игнорируют внешние
`<style>`, поэтому все стили — инлайн). Тело саммари приходит от LLM
(`conference_sessions.summary_data`) в фиксированном markdown-подобном
формате промпта `workers/summarizer/prompts/summary_reduce_ru.txt` (заголовки
`## ...`, пункты `- ...`) — при рендере разбирается построчно и оборачивается
в HTML-разметку; заголовок конференции, имена участников и сам текст саммари
экранируются `html.escape`.
Plaintext-альтернатива — обязательный минимум для клиентов без HTML.
"""
from __future__ import annotations
import html
from collections.abc import Sequence
from dataclasses import dataclass
@dataclass(frozen=True)
class SummaryEmailContext:
"""Данные для рендера письма — без привязки к ORM (проще тестировать)."""
conference_title: str
date_label: str
"""Дата встречи в `display_timezone`, формат `ДД.ММ.ГГГГ`."""
time_label: str
"""Время встречи в `display_timezone`, формат `ЧЧ:ММ–ЧЧ:ММ`."""
duration_minutes: int
participant_names: Sequence[str]
summary_text: str
def build_summary_email(context: SummaryEmailContext) -> tuple[str, str]:
"""Собрать (plaintext, html) тело письма с саммари встречи."""
return _build_plaintext(context), _build_html(context)
def _build_plaintext(context: SummaryEmailContext) -> str:
"""Простой текстовый вариант — без экранирования (не HTML)."""
participants = ", ".join(context.participant_names) or "участники не определены"
lines = [
f"Саммари встречи «{context.conference_title}»",
f"{context.date_label}, {context.time_label} ({context.duration_minutes} мин)",
"",
f"Участники: {participants}",
"",
context.summary_text.strip(),
"",
"",
"Письмо сформировано автоматически по итогам конференции в VidConf.",
]
return "\n".join(lines)
def _build_html(context: SummaryEmailContext) -> str:
"""HTML-вариант письма — все пользовательские подстановки экранированы."""
title = html.escape(context.conference_title)
date_label = html.escape(context.date_label)
time_label = html.escape(context.time_label)
participants = html.escape(", ".join(context.participant_names) or "не определены")
body_html = _render_summary_body(context.summary_text)
return f"""\
<!doctype html>
<html lang="ru">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body style="margin:0; padding:0; background-color:#F1F1F1; font-family:Helvetica, Arial, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#F1F1F1;">
<tr><td align="center" style="padding: 32px 16px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px; max-width:600px; background-color:#FFFFFF; border-radius:24px; overflow:hidden; border:1px solid #E1E3E9;">
<tr>
<td style="padding: 32px 32px 24px 32px; background-color:#F7F7F8;">
<p style="margin: 0 0 6px; font-family:Helvetica, Arial, sans-serif; font-size:12px; font-weight:bold; letter-spacing:.06em; text-transform:uppercase; color:#6976AC;">
Саммари встречи
</p>
<h1 style="margin:0 0 12px; font-family:Helvetica, Arial, sans-serif; font-size:24px; line-height:1.25; font-weight:bold; color:#2E3454;">
{title}
</h1>
<p style="margin:0; font-family:Helvetica, Arial, sans-serif; font-size:14px; color:#535F94;">
{date_label}, {time_label} &middot; {context.duration_minutes} мин
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 8px 32px;">
<p style="margin:0 0 6px; font-family:Helvetica, Arial, sans-serif; font-size:12px; font-weight:bold; letter-spacing:.06em; text-transform:uppercase; color:#A6AECB;">
Участники
</p>
<p style="margin:0; font-family:Helvetica, Arial, sans-serif; font-size:14px; color:#2E3454;">
{participants}
</p>
</td>
</tr>
<tr><td style="padding: 16px 32px;"><hr style="border:none; border-top:1px solid #E1E3E9; margin:0;"></td></tr>
<tr>
<td style="padding: 8px 32px 24px 32px;">
{body_html}
</td>
</tr>
<tr>
<td style="padding: 20px 32px 32px 32px; background-color:#F7F7F8; border-top:1px solid #E1E3E9;">
<p style="margin:0; font-family:Helvetica, Arial, sans-serif; font-size:12px; line-height:1.6; color:#A6AECB;">
Письмо сформировано автоматически по итогам конференции в VidConf (self-hosted).
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>
"""
def _render_summary_body(summary_text: str) -> str:
"""Разобрать markdown-подобный текст саммари (`## заголовок`, `- пункт`) в HTML.
Формат фиксирован промптом суммаризации; при
отклонении LLM от формата непонятые строки рендерятся как обычные
абзацы — разбор не должен падать на неожиданном вводе. Всё содержимое
экранируется `html.escape` (текст саммари — от LLM, потенциальная
инъекция в HTML-письмо).
"""
parts: list[str] = []
in_list = False
for raw_line in summary_text.strip("\n").splitlines():
line = raw_line.strip()
if not line:
continue
if line.startswith("## "):
if in_list:
parts.append("</ul>")
in_list = False
heading = html.escape(line[3:].strip())
parts.append(
'<p style="margin:16px 0 8px; font-family:Helvetica, Arial, sans-serif; '
'font-size:15px; font-weight:bold; color:#2E3454;">' + heading + "</p>"
)
elif line.startswith("- "):
if not in_list:
parts.append('<ul style="margin:0 0 8px; padding-left:20px;">')
in_list = True
item = html.escape(line[2:].strip())
parts.append(
'<li style="font-family:Helvetica, Arial, sans-serif; font-size:14px; '
'line-height:1.6; color:#2E3454; padding:2px 0;">' + item + "</li>"
)
else:
if in_list:
parts.append("</ul>")
in_list = False
parts.append(
'<p style="margin:0 0 8px; font-family:Helvetica, Arial, sans-serif; '
'font-size:14px; line-height:1.6; color:#2E3454;">' + html.escape(line) + "</p>"
)
if in_list:
parts.append("</ul>")
return "\n ".join(parts)

148
backend/services/ics.py Normal file
View File

@@ -0,0 +1,148 @@
"""Генерация .ics-приглашений на конференцию (VEVENT, `METHOD:REQUEST`).
Разовая (плановая) конференция — `DTSTART`/`DTEND` в переданной таймзоне
отображения (`display_timezone` настройки инстанса);
закреплённая с повторением — `DTSTART` берётся из первого вхождения
`expand_occurrences` (см. `services/recurrence.py`) в таймзоне самого правила
повторения (`rule.timezone`), а рецидив описывается `RRULE`.
`UID` стабилен (`{conference.id}@vidconf`) — календарные клиенты обновляют уже
принятое приглашение по нему же, ориентируясь на растущий `SEQUENCE`
(`conference.ics_sequence`, инкрементируется при правке расписания —
`services/conferences.py::ConferenceService.update`).
"""
from datetime import UTC, datetime, timedelta
from typing import cast
from zoneinfo import ZoneInfo
from icalendar import Calendar, Event
from models.conference import Conference
from services.recurrence import RecurrenceRule, expand_occurrences
# Порядок ровно как в `date.weekday()`/`RecurrenceRule.weekdays` (0 — понедельник).
_WEEKDAY_CODES = ("MO", "TU", "WE", "TH", "FR", "SA", "SU")
# Длительность разового вхождения по умолчанию, если у конференции не указан
# `duration_minutes` (совпадает с `services.conferences.DEFAULT_OCCURRENCE_DURATION_MINUTES`,
# не импортируется напрямую — `ics.py` намеренно не зависит от `conferences.py`).
_DEFAULT_ONE_OFF_DURATION_MINUTES = 60
# Горизонт поиска первого вхождения повторяющейся серии от `anchor_date`
# (совпадает с `services.conferences.NEXT_OCCURRENCE_HORIZON`) — с запасом
# покрывает самый частый шаг повторения (weekly/monthly).
_OCCURRENCE_SEARCH_HORIZON = timedelta(days=400)
_PRODID = "-//VidConf//vidconf.example//RU"
class ConferenceHasNoScheduleError(ValueError):
"""У конференции нет ни `scheduled_at`, ни `recurrence` — приглашение строить не из чего."""
def build_invite(
conference: Conference,
*,
organizer_email: str | None,
join_url: str,
display_timezone: str,
) -> bytes:
"""Собрать .ics-приглашение (`METHOD:REQUEST`) на конференцию и вернуть его байты.
`display_timezone` — таймзона отображения разовых конференций (настройка
инстанса); для повторяющихся используется таймзона самого правила
(`RecurrenceRule.timezone`) — она обязательна в правиле и корректнее
отражает намерение организатора серии.
"""
if conference.recurrence is not None:
rule = RecurrenceRule.model_validate(conference.recurrence)
tz = ZoneInfo(rule.timezone)
dtstart = _first_occurrence_utc(rule).astimezone(tz)
dtend = dtstart + timedelta(minutes=rule.duration_minutes)
rrule_value: str | None = _build_rrule(rule)
elif conference.scheduled_at is not None:
tz = ZoneInfo(display_timezone)
dtstart = conference.scheduled_at.astimezone(tz)
duration = conference.duration_minutes or _DEFAULT_ONE_OFF_DURATION_MINUTES
dtend = dtstart + timedelta(minutes=duration)
rrule_value = None
else:
raise ConferenceHasNoScheduleError(
f"у конференции {conference.id} нет ни scheduled_at, ни recurrence"
)
# `Component.__init__` (общий предок `Event`/`Calendar`) не типизирован в
# `icalendar` — `no-untyped-call` здесь неизбежен без переписывания
# конструктора сторонней библиотеки.
event = Event() # type: ignore[no-untyped-call]
event.add("uid", f"{conference.id}@vidconf")
event.add("sequence", conference.ics_sequence)
event.add("dtstamp", datetime.now(UTC))
event.add("summary", conference.title or f"Конференция {conference.number}")
event.add(
"description",
f"Ссылка для входа: {join_url}\nНомер конференции: {conference.number}",
)
event.add("dtstart", dtstart)
event.add("dtend", dtend)
if organizer_email:
event.add("organizer", f"mailto:{organizer_email}")
if rrule_value is not None:
event.add("rrule", rrule_value)
calendar = Calendar() # type: ignore[no-untyped-call]
calendar.add("prodid", _PRODID)
calendar.add("version", "2.0")
calendar.add("method", "REQUEST")
calendar.add_component(event)
calendar.add_missing_timezones()
return cast(bytes, calendar.to_ical())
def _first_occurrence_utc(rule: RecurrenceRule) -> datetime:
"""Найти первое вхождение серии от `rule.anchor_date` (UTC, aware)."""
horizon = _OCCURRENCE_SEARCH_HORIZON
if rule.type == "every_n_days" and rule.interval_days is not None:
horizon = max(horizon, timedelta(days=rule.interval_days + 2))
t_from = rule.local_datetime(rule.anchor_date).astimezone(UTC)
occurrences = expand_occurrences(rule, t_from, t_from + horizon)
if not occurrences:
raise ConferenceHasNoScheduleError(
"правило повторения не даёт ни одного вхождения в горизонте поиска"
)
return occurrences[0]
def _build_rrule(rule: RecurrenceRule) -> str:
"""Собрать значение `RRULE` из `RecurrenceRule` («.ics»)."""
if rule.type == "weekly":
return f"FREQ=WEEKLY;BYDAY={_byday(rule.weekdays)}"
if rule.type == "biweekly":
return f"FREQ=WEEKLY;INTERVAL=2;BYDAY={_byday(rule.weekdays)};WKST=MO"
if rule.type == "monthly":
assert rule.day_of_month is not None # гарантировано валидацией RecurrenceRule
return f"FREQ=MONTHLY;BYMONTHDAY={_bymonthday(rule.day_of_month)}"
if rule.type == "every_n_days":
assert rule.interval_days is not None # гарантировано валидацией RecurrenceRule
return f"FREQ=DAILY;INTERVAL={rule.interval_days}"
raise AssertionError(f"неизвестный тип повторения: {rule.type}")
def _byday(weekdays: list[int]) -> str:
"""Список дней недели в порядок `BYDAY` (`MO,TU,...`)."""
return ",".join(_WEEKDAY_CODES[weekday] for weekday in sorted(weekdays))
def _bymonthday(day_of_month: int) -> int:
"""Смаппить `day_of_month` правила в `BYMONTHDAY` .ics.
`31` не встречается ни в одном месяце короче — маппится в `-1`
(последний день месяца), что СОВПАДАЕТ с клэмпом `expand_occurrences`.
`29`/`30` НЕ клэмпаются здесь: известное задокументированное расхождение
с поведением `expand_occurrences` — календарный
клиент в короткие месяцы (например, февраль) вхождение пропустит, тогда
как `expand_occurrences` использует клэмп к последнему дню месяца.
"""
return -1 if day_of_month == 31 else day_of_month

View File

@@ -0,0 +1,390 @@
"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап.
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`) — новая настройка
не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
(например, при рестарте backend) не перетирает уже сделанные администратором
правки. Воркеры настройки только читают (`load_effective_config`); если
строк ещё нет (воркер стартовал раньше backend) — fallback на `plugins.yaml`
(т.к. воркеры в БД не пишут).
"""
import re
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import Settings
from core.plugins.config import (
AiLevel,
ChatConfig,
InstanceConfig,
PluginsConfig,
SummarizerConfig,
SummaryRecipientsMode,
TranscriberConfig,
load_plugins_config,
)
from models.instance_setting import InstanceSetting
from services.ai_levels import detect_ai_levels
from services.ai_tiers import TIERS
_KEY_TRANSCRIBER = "transcriber"
_KEY_SUMMARIZER = "summarizer"
_KEY_CHAT = "chat"
_KEY_AI_LEVEL = "ai_level"
_KEY_SUMMARY_RECIPIENTS = "summary_recipients"
_KEY_DISPLAY_TIMEZONE = "display_timezone"
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
_KEY_CHAT,
_KEY_TRANSCRIBER,
_KEY_SUMMARIZER,
_KEY_AI_LEVEL,
)
"""Ключи, которыми управляет матрица «пресет → настройки» инсталлятора —
переиспользуется
`scripts/apply_preset_settings.py`, чтобы не дублировать список строковых
имён ключей `instance_settings`."""
_DEFAULT_AI_LEVEL_VALUE = {"level": "min"}
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domain": None}
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
# без пробелов — валидация после нормализации (strip, «@», lower).
_EMAIL_DOMAIN_PATTERN = re.compile(
r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$"
)
class SettingsUpdateIn(BaseModel):
"""Частичное обновление настроек инстанса — все поля опциональны (PUT-патч).
Используется и сервисным слоем (`InstanceSettingsService.update`), и
(реэкспортом) админ-API Блока C (`api/admin.py`) как тело запроса
`PUT /api/v1/admin/settings` — отдельная API-обёртка не нужна, схема
один в один совпадает с контрактом.
"""
chat_enabled: bool | None = None
transcription_enabled: bool | None = None
ai_level: AiLevel | None = None
summary_recipients: SummaryRecipientsMode | None = None
display_timezone: str | None = None
registration_team_choice: bool | None = None
registration_email_domain_enabled: bool | None = None
registration_email_domain: str | None = None
class BootstrapOverrides(BaseModel):
"""Переопределения дефолтов бутстрапа по пресету инсталлятора (`BOOTSTRAP_*` в `.env`).
Без них бутстрап `instance_settings` импортировал бы `plugins.yaml`,
где всё `enabled: true`, — независимо от выбранного пресета
поставки. Собирается `bootstrap_overrides_from_settings` и применяется
ПОВЕРХ дефолтов `plugins.yaml` перед `INSERT ... ON CONFLICT DO NOTHING`
(`ensure_bootstrapped`) — влияет только на чистую БД (первый запуск);
принудительное обновление уже существующих строк на живой инсталляции —
`scripts/apply_preset_settings.py`.
"""
chat_enabled: bool | None = None
# Единый переключатель «транскрибация+суммаризация» — как `transcription_enabled`
# в `SettingsUpdateIn`, управляет `transcriber.enabled` и `summarizer.enabled` вместе.
ai_enabled: bool | None = None
ai_level: AiLevel | None = None
def bootstrap_overrides_from_settings(settings: Settings) -> BootstrapOverrides:
"""Собрать `BootstrapOverrides` из `BOOTSTRAP_*` полей `core.config.Settings`."""
return BootstrapOverrides(
chat_enabled=settings.bootstrap_chat_enabled,
ai_enabled=settings.bootstrap_transcription_enabled,
ai_level=settings.bootstrap_ai_level,
)
def build_bootstrap_defaults(
plugins: PluginsConfig, overrides: BootstrapOverrides | None = None
) -> dict[str, dict[str, Any]]:
"""Собрать словарь дефолтов всех ключей `instance_settings` из `plugins.yaml`,
применив `overrides` пресета инсталлятора поверх (`chat`/`transcriber`+`summarizer`/`ai_level`).
Переиспользуется `ensure_bootstrapped` (чистая БД) и
`scripts/apply_preset_settings.py` (принудительное обновление живой БД).
"""
defaults: dict[str, dict[str, Any]] = {
_KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"),
_KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"),
_KEY_CHAT: plugins.chat.model_dump(mode="json"),
_KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE),
_KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE),
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
}
if overrides is None:
return defaults
if overrides.chat_enabled is not None:
defaults[_KEY_CHAT] = {**defaults[_KEY_CHAT], "enabled": overrides.chat_enabled}
if overrides.ai_enabled is not None:
defaults[_KEY_TRANSCRIBER] = {
**defaults[_KEY_TRANSCRIBER],
"enabled": overrides.ai_enabled,
}
defaults[_KEY_SUMMARIZER] = {
**defaults[_KEY_SUMMARIZER],
"enabled": overrides.ai_enabled,
}
if overrides.ai_level is not None:
defaults[_KEY_AI_LEVEL] = {"level": overrides.ai_level}
return defaults
class InvalidAiLevelError(ValueError):
"""Запрошенный уровень AI недоступен (см. `services.ai_levels.detect_ai_levels`)."""
class InvalidTimezoneError(ValueError):
"""`display_timezone` не является валидным именем IANA-таймзоны."""
class InvalidEmailDomainError(ValueError):
"""Некорректная настройка верификации домена email при регистрации.
Поднимается при попытке включить верификацию без домена (`enabled=true`
и пустой/отсутствующий домен) либо при домене, не проходящем валидацию
простым паттерном доменного имени.
"""
class InstanceSettingsService:
"""CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def ensure_bootstrapped(
self, yaml_path: str | Path, overrides: BootstrapOverrides | None = None
) -> None:
"""Импортировать дефолты `plugins.yaml` в `instance_settings` (однократно, идемпотентно).
`overrides` (матрица «пресет → настройки» инсталлятора, см.
`bootstrap_overrides_from_settings`) подменяет `chat.enabled`,
`transcriber.enabled`+`summarizer.enabled` и `ai_level` в дефолтах ДО
`INSERT ... ON CONFLICT DO NOTHING` — влияет только на строки, которых
ещё нет (чистая БД/первый запуск инсталлятора); уже существующие
строки (живая инсталляция, возможно с ручными правками администратора)
не трогает — `ON CONFLICT DO NOTHING` сохраняется как есть.
"""
plugins = load_plugins_config(yaml_path)
defaults = build_bootstrap_defaults(plugins, overrides)
for key, value in defaults.items():
stmt = (
pg_insert(InstanceSetting)
.values(key=key, value=value)
.on_conflict_do_nothing(index_elements=["key"])
)
await self._session.execute(stmt)
await self._session.commit()
async def get(self) -> InstanceConfig:
"""Собрать эффективную конфигурацию из текущих строк `instance_settings`."""
rows = await self._load_rows()
return _build_config(rows)
async def update(self, patch: SettingsUpdateIn) -> InstanceConfig:
"""Частично обновить настройки и вернуть новую эффективную конфигурацию.
`transcription_enabled` пишет `enabled` сразу в обе секции
(`transcriber`, `summarizer`) — это единый переключатель
«транскрибация+суммаризация».
"""
rows = await self._load_rows()
cfg = _build_config(rows)
if patch.ai_level is not None:
statuses = {status.level: status for status in detect_ai_levels(cfg)}
if not statuses[patch.ai_level].available:
raise InvalidAiLevelError(
f"уровень AI {patch.ai_level!r} недоступен: {statuses[patch.ai_level].reason}"
)
cfg.ai_level = patch.ai_level
await self._set(_KEY_AI_LEVEL, {"level": patch.ai_level})
if patch.display_timezone is not None:
_validate_timezone(patch.display_timezone)
cfg.display_timezone = patch.display_timezone
await self._set(_KEY_DISPLAY_TIMEZONE, {"tz": patch.display_timezone})
if patch.summary_recipients is not None:
cfg.summary_recipients = patch.summary_recipients
await self._set(_KEY_SUMMARY_RECIPIENTS, {"mode": patch.summary_recipients})
if patch.chat_enabled is not None:
cfg.chat = ChatConfig(enabled=patch.chat_enabled)
await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json"))
if patch.registration_team_choice is not None:
cfg.registration_team_choice = patch.registration_team_choice
await self._set(
_KEY_REGISTRATION_TEAM_CHOICE, {"enabled": patch.registration_team_choice}
)
if (
patch.registration_email_domain_enabled is not None
or patch.registration_email_domain is not None
):
enabled = (
patch.registration_email_domain_enabled
if patch.registration_email_domain_enabled is not None
else cfg.registration_email_domain_enabled
)
raw_domain = (
patch.registration_email_domain
if patch.registration_email_domain is not None
else cfg.registration_email_domain
)
domain = _normalize_email_domain(raw_domain) if raw_domain else None
if enabled and domain is None:
raise InvalidEmailDomainError(
"нельзя включить верификацию домена email без указания домена"
)
cfg.registration_email_domain_enabled = enabled
cfg.registration_email_domain = domain
await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domain": domain})
if patch.transcription_enabled is not None:
cfg.transcriber = cfg.transcriber.model_copy(
update={"enabled": patch.transcription_enabled}
)
cfg.summarizer = cfg.summarizer.model_copy(
update={"enabled": patch.transcription_enabled}
)
await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json"))
await self._set(_KEY_SUMMARIZER, cfg.summarizer.model_dump(mode="json"))
await self._session.commit()
return cfg
async def _load_rows(self) -> dict[str, Any]:
result = await self._session.execute(select(InstanceSetting))
return {row.key: row.value for row in result.scalars().all()}
async def _set(self, key: str, value: dict[str, Any]) -> None:
stmt = (
pg_insert(InstanceSetting)
.values(key=key, value=value)
.on_conflict_do_update(index_elements=["key"], set_={"value": value})
)
await self._session.execute(stmt)
async def load_effective_config(session: AsyncSession) -> InstanceConfig:
"""Загрузить эффективную конфигурацию для воркеров.
Если `instance_settings` ещё пуста (воркер стартовал раньше бутстрапа
backend) — fallback на `config/plugins.yaml` напрямую.
Воркеры в БД не пишут — конкурентной гонки с бутстрапом нет.
Уровни `medium`/`max` (ADR-004) перекрывают `transcriber`/
`summarizer` спекой `TIERS[ai_level]` — см. `_apply_tier_overrides`.
"""
result = await session.execute(select(InstanceSetting))
rows = {row.key: row.value for row in result.scalars().all()}
if not rows:
from core.config import get_settings
plugins = load_plugins_config(get_settings().plugins_config_path)
cfg = InstanceConfig(
transcriber=plugins.transcriber,
summarizer=plugins.summarizer,
chat=plugins.chat,
)
else:
cfg = _build_config(rows)
return _apply_tier_overrides(cfg)
def _apply_tier_overrides(cfg: InstanceConfig) -> InstanceConfig:
"""Подменить `transcriber`/`summarizer` спекой `TIERS[ai_level]` для `medium`/`max`.
`min` не переопределяется — использует дефолты `plugins.yaml`/правки
администратора как есть (обратная совместимость, ADR-004: min
остаётся конфигурируемым через существующий механизм). Флаг `enabled`
(переключатель «транскрибация+суммаризация» в админке) сохраняется из
текущей конфигурации — подмена per-tier затрагивает только
provider/model/options, не должна повторно включать отключённый модуль.
"""
if cfg.ai_level in ("medium", "max"):
spec = TIERS[cfg.ai_level]
cfg.transcriber = spec.transcriber.model_copy(update={"enabled": cfg.transcriber.enabled})
cfg.summarizer = spec.summarizer.model_copy(update={"enabled": cfg.summarizer.enabled})
return cfg
def _validate_timezone(tz: str) -> None:
"""Проверить, что `tz` — валидное имя IANA-таймзоны."""
try:
ZoneInfo(tz)
except ZoneInfoNotFoundError as exc:
raise InvalidTimezoneError(f"неизвестная таймзона: {tz!r}") from exc
def _normalize_email_domain(domain: str) -> str:
"""Нормализовать домен email (strip, убрать ведущую «@», lower) и провалидировать.
Валидация — простым паттерном доменного имени (минимум одна точка,
допустимые символы, без пробелов); иначе `InvalidEmailDomainError`.
"""
normalized = domain.strip()
if normalized.startswith("@"):
normalized = normalized[1:]
normalized = normalized.lower()
if not _EMAIL_DOMAIN_PATTERN.match(normalized):
raise InvalidEmailDomainError(f"некорректный домен email: {domain!r}")
return normalized
def _build_config(rows: dict[str, Any]) -> InstanceConfig:
"""Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей.
Отсутствие отдельного ключа (например, настройка добавлена уже после
бутстрапа существующего инстанса) не должно ронять чтение конфигурации —
используется дефолт соответствующей Pydantic-модели/константы.
"""
return InstanceConfig(
transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})),
summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})),
chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})),
ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"),
summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get(
"mode", "all"
),
display_timezone=rows.get(_KEY_DISPLAY_TIMEZONE, _DEFAULT_DISPLAY_TIMEZONE_VALUE).get(
"tz", "Europe/Moscow"
),
registration_team_choice=rows.get(
_KEY_REGISTRATION_TEAM_CHOICE, _DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE
).get("enabled", False),
registration_email_domain_enabled=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("enabled", False),
registration_email_domain=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("domain"),
)

View File

@@ -0,0 +1,40 @@
"""Постановка задачи `send_invitations` в очередь Celery.
Отправляет задачу по имени через голый Celery-клиент (`broker_url` без
`backend`) — тот же приём, что `services/pipeline_producer.py`: backend не
импортирует пакет `workers` (инвариант разделения слоёв, см. докстринг
`pipeline_producer.py`). Задача регистрируется и обрабатывается в
`workers/tasks/invitations.py` (блок C).
"""
import uuid
from celery import Celery
from core.config import get_settings
SEND_INVITATIONS_TASK_NAME = "workers.tasks.invitations.send_invitations"
NOTIFY_QUEUE = "notify"
def enqueue_invitations(conference_id: uuid.UUID, *, emails: list[str] | None = None) -> None:
"""Поставить в очередь `notify` рассылку .ics-приглашений на конференцию `conference_id`.
`emails=None` — получатели по умолчанию (владелец + для закреплённых
участники прошлых сеансов, см. `workers/tasks/invitations.py`); явный
список — ручная рассылка администратором. Задача сама идемпотентна
(журнал `email_deliveries(kind='invitation')` без unique, повтор
постановки максимум продублирует письмо).
Очередь передаётся явно (`queue=NOTIFY_QUEUE`): этот клиент — отдельный
экземпляр `Celery` без `task_routes` из `workers/celery_app.py` (тот
маршрут действует только внутри процесса воркера, который его
импортирует), поэтому без явного параметра задача ушла бы в дефолтную
очередь `celery` — тот же приём, что `TRANSCRIPTION_QUEUE` в
`pipeline_producer.py`.
"""
settings = get_settings()
client = Celery("vidconf-producer", broker=settings.redis_url)
client.send_task(
SEND_INVITATIONS_TASK_NAME, args=[str(conference_id), emails], queue=NOTIFY_QUEUE
)

View File

@@ -0,0 +1,43 @@
"""Выдача LiveKit access-токенов участникам конференции."""
from datetime import timedelta
from livekit import api
from core.config import get_settings
TOKEN_TTL = timedelta(hours=6)
def create_room_access_token(
*, room_name: str, identity: str, name: str, metadata: str | None = None
) -> str:
"""Создать JWT access-токен LiveKit для входа участника в комнату.
`identity` — произвольная строка identity, используемая webhook-
обработчиками (`services.webhook_handlers`) для сопоставления треков с
участником: `str(user_id)` для зарегистрированного пользователя,
`guest:{guest_access.id}` для гостя (ADR-001, п.6). `name` — отображаемое
имя, показывается клиентским UI. `metadata` — произвольная строка
(JSON), доступная клиенту как `participant.metadata` — используется
для аватара при выключенной камере; `None` (гость либо пользователь
без аватара) — метаданные не выставляются вовсе.
"""
settings = get_settings()
token = (
api.AccessToken(settings.livekit_api_key, settings.livekit_api_secret)
.with_identity(identity)
.with_name(name)
.with_grants(
api.VideoGrants(
room_join=True,
room=room_name,
can_publish=True,
can_subscribe=True,
)
)
.with_ttl(TOKEN_TTL)
)
if metadata is not None:
token = token.with_metadata(metadata)
return token.to_jwt()

View File

@@ -0,0 +1,70 @@
"""Постановка задачи `run_pipeline` в очередь Celery `transcription`.
Отправляет задачу по имени через голый Celery-клиент (`broker_url` без
`backend`) — backend не импортирует пакет `workers` (инвариант разделения
слоёв: API-процесс не должен тянуть зависимости воркеров, в т.ч. `faster-whisper`
через транзитивный импорт `workers.tasks.pipeline`). Задача регистрируется
и обрабатывается в `workers/tasks/pipeline.py` (блок C).
"""
import uuid
from celery import Celery
from kombu.exceptions import OperationalError
from core.config import get_settings
RUN_PIPELINE_TASK_NAME = "workers.tasks.pipeline.run_pipeline"
TRANSCRIPTION_QUEUE = "transcription"
def enqueue_pipeline(session_id: uuid.UUID) -> None:
"""Поставить в очередь `transcription` запуск AI-пайплайна для сеанса `session_id`.
Идемпотентно на стороне задачи (`run_pipeline` продолжает с последнего
успешного шага) — повторная постановка (например,
из `_on_room_finished` при повторной обработке) безопасна.
"""
settings = get_settings()
client = Celery("vidconf-producer", broker=settings.redis_url)
client.send_task(RUN_PIPELINE_TASK_NAME, args=[str(session_id)], queue=TRANSCRIPTION_QUEUE)
def transcription_queue_served(timeout: float = 1.0) -> bool:
"""Проверить, обслуживается ли очередь `transcription` хотя бы одним воркером Celery.
Детект доступности уровня
AI (`services.ai_levels.detect_ai_levels`) смотрит только на железо и
файлы моделей на диске, но не видит, запущен ли вообще воркер
транскрибации, — админка (`GET /admin/settings`) использует эту функцию,
чтобы предупредить «AI включён, но обработка недоступна».
`app.control.inspect(timeout=...).active_queues()` — блокирующий вызов
(ждёт ответа брокера/воркеров); возвращает `{hostname: [{"name": ...}, ...]}`
для ответивших воркеров либо `None`, если за `timeout` не ответил НИ ОДИН
(нет запущенных воркеров либо брокер Redis недоступен) — оба случая здесь
трактуются как «очередь не обслуживается». Вызывающая сторона (API-хендлер)
должна оборачивать в `anyio.to_thread.run_sync`, чтобы не блокировать event loop.
Полностью недоступный брокер (Redis лежит/не резолвится) — отдельный
случай: `kombu`/`redis-py` не возвращают `None`, а бросают исключение,
которое Celery оборачивает в `kombu.exceptions.OperationalError`
(«Recoverable message transport connection error» — проверено
эмпирически: недоступный/несуществующий хост даёт именно этот тип).
По контракту «нет воркеров ИЛИ брокер недоступен → `False`» это тоже
трактуется как «очередь не обслуживается», а не пробрасывается 500-кой
наружу в `GET`/`PUT /admin/settings`.
"""
settings = get_settings()
client = Celery("vidconf-producer", broker=settings.redis_url)
try:
active_queues = client.control.inspect(timeout=timeout).active_queues()
except OperationalError:
return False
if not active_queues:
return False
return any(
queue.get("name") == TRANSCRIPTION_QUEUE
for queues in active_queues.values()
for queue in queues
)

View File

@@ -0,0 +1,70 @@
"""Профиль пользователя: сборка ответа, правка данных, аватар.
Переиспользуется `api/users.py` (свой профиль) и `api/admin.py` (карточка
профиля любого пользователя администратором) — правила одни и те же (задача
4c: «те же данные, редактирование по тем же правилам»).
"""
import uuid
from pathlib import Path
from fastapi import UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from models.team import Team
from models.user import User
from repositories.admin import TeamRepository
from services.avatars import delete_avatar, read_and_validate_avatar, save_avatar
class TeamNotFoundError(Exception):
"""Команда с указанным `team_id` не найдена."""
async def resolve_team_name(session: AsyncSession, team_id: uuid.UUID | None) -> str | None:
"""Имя команды пользователя (`None`, если команда не привязана или была удалена)."""
if team_id is None:
return None
team = await session.get(Team, team_id)
return team.name if team is not None else None
async def update_profile_fields(
session: AsyncSession,
user: User,
*,
name_user: str | None,
team_id: uuid.UUID | None,
team_id_is_set: bool,
) -> User:
"""Изменить ФИО и/или команду пользователя.
`team_id_is_set` — поле присутствовало в теле запроса (в т.ч. явный
`null`, различаем через `model_fields_set` на стороне роутера) — тот же
приём, что и `summary_recipients`/`team_id` в других PATCH-эндпоинтах
(`services/conferences.py`, `api/admin.py`). Email НЕ принимается —
read-only поле профиля.
"""
if name_user is not None:
user.name_user = name_user
if team_id_is_set:
if team_id is not None and await TeamRepository(session).get(team_id) is None:
raise TeamNotFoundError
user.team_id = team_id
return user
async def set_avatar(media_root: Path, user: User, file: UploadFile) -> User:
"""Загрузить, провалидировать и сохранить новый аватар пользователя.
Бросает `AvatarTooLargeError`/`AvatarInvalidTypeError` (см. `services/avatars.py`).
"""
content, ext = await read_and_validate_avatar(file)
user.avatar_path = save_avatar(media_root, user.id, content, ext, old_path=user.avatar_path)
return user
def clear_avatar(media_root: Path, user: User) -> None:
"""Удалить файл аватара пользователя с диска и очистить `avatar_path`."""
delete_avatar(media_root, user.avatar_path)
user.avatar_path = None

View File

@@ -0,0 +1,165 @@
"""Recurrence-ядро для закреплённых конференций (ADR-001, п.3).
Собственная (не RRULE) модель повторения: ровно 4 типа, отображающихся на
форму UI 1:1. Правило хранит локальное время суток и IANA-таймзону —
чтобы корректно учитывать смещение UTC (переход на летнее/зимнее время в тех
зонах, где он есть); развёртка `expand_occurrences` всегда возвращает
timezone-aware метки в UTC (в БД — только UTC).
"""
import calendar
from datetime import UTC, date, datetime, time, timedelta
from typing import Literal, Self
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, Field, field_validator, model_validator
RecurrenceType = Literal["weekly", "biweekly", "monthly", "every_n_days"]
class RecurrenceRule(BaseModel):
"""Правило повторения закреплённой конференции.
Поля, специфичные для типа (`weekdays` для weekly/biweekly,
`day_of_month` для monthly, `interval_days` для every_n_days),
валидируются в `_validate_type_specific_fields` — обязательность
зависит от `type`.
"""
type: RecurrenceType
weekdays: list[int] = Field(default_factory=list)
day_of_month: int | None = None
interval_days: int | None = None
anchor_date: date
time_local: str
timezone: str
duration_minutes: int
@field_validator("weekdays")
@classmethod
def _validate_weekdays(cls, value: list[int]) -> list[int]:
"""Дни недели — 0 (понедельник) .. 6 (воскресенье), как в `date.weekday()`."""
for weekday in value:
if not 0 <= weekday <= 6:
raise ValueError("weekday должен быть в диапазоне 0..6")
return value
@field_validator("day_of_month")
@classmethod
def _validate_day_of_month(cls, value: int | None) -> int | None:
if value is not None and not 1 <= value <= 31:
raise ValueError("day_of_month должен быть в диапазоне 1..31")
return value
@field_validator("interval_days")
@classmethod
def _validate_interval_days(cls, value: int | None) -> int | None:
if value is not None and value < 1:
raise ValueError("interval_days должен быть >= 1")
return value
@field_validator("time_local")
@classmethod
def _validate_time_local(cls, value: str) -> str:
try:
datetime.strptime(value, "%H:%M")
except ValueError as exc:
raise ValueError("time_local должен быть в формате HH:MM") from exc
return value
@field_validator("timezone")
@classmethod
def _validate_timezone(cls, value: str) -> str:
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError) as exc:
raise ValueError(f"неизвестная IANA-таймзона: {value}") from exc
return value
@model_validator(mode="after")
def _validate_type_specific_fields(self) -> Self:
"""Проверить, что поля, обязательные для конкретного `type`, заполнены."""
if self.type in ("weekly", "biweekly"):
if not self.weekdays:
raise ValueError(f"{self.type} требует непустой список weekdays")
elif self.type == "monthly":
if self.day_of_month is None:
raise ValueError("monthly требует day_of_month")
elif self.type == "every_n_days":
if self.interval_days is None:
raise ValueError("every_n_days требует interval_days")
return self
def time_of_day(self) -> time:
"""Разобрать `time_local` в объект `time`."""
hours, minutes = self.time_local.split(":")
return time(int(hours), int(minutes))
def local_datetime(self, on_date: date) -> datetime:
"""Собрать локальный aware datetime для конкретной календарной даты."""
return datetime.combine(on_date, self.time_of_day(), tzinfo=ZoneInfo(self.timezone))
def _clamped_day_of_month(year: int, month: int, day_of_month: int) -> int:
"""Клэмпнуть `day_of_month` к последнему дню месяца, если тот короче (например, 31 в апреле)."""
last_day = calendar.monthrange(year, month)[1]
return min(day_of_month, last_day)
def _week_start(on_date: date) -> date:
"""Начало недели (понедельник), содержащей `on_date`."""
return on_date - timedelta(days=on_date.weekday())
def _matches(rule: RecurrenceRule, on_date: date) -> bool:
"""Проверить, попадает ли календарная дата `on_date` в правило повторения."""
if rule.type == "weekly":
return on_date.weekday() in rule.weekdays
if rule.type == "biweekly":
if on_date.weekday() not in rule.weekdays:
return False
weeks_diff = (_week_start(on_date) - _week_start(rule.anchor_date)).days // 7
return weeks_diff % 2 == 0
if rule.type == "monthly":
assert rule.day_of_month is not None # гарантировано валидацией модели
return on_date.day == _clamped_day_of_month(on_date.year, on_date.month, rule.day_of_month)
if rule.type == "every_n_days":
assert rule.interval_days is not None # гарантировано валидацией модели
# anchor_date — первое вхождение серии: даты раньше него не считаются
# (иначе `%` на отрицательной разнице даст даты «до начала» серии).
if on_date < rule.anchor_date:
return False
return (on_date - rule.anchor_date).days % rule.interval_days == 0
raise AssertionError(f"неизвестный тип повторения: {rule.type}")
def expand_occurrences(rule: RecurrenceRule, t_from: datetime, t_to: datetime) -> list[datetime]:
"""Развернуть правило повторения в список моментов начала вхождений (UTC, aware).
Диапазон `[t_from, t_to]` включителен с обеих сторон. `t_from`/`t_to`
обязаны быть timezone-aware. При `t_from > t_to` возвращается пустой
список. Перебор идёт по календарным датам в локальной таймзоне правила
с суточным запасом с каждой стороны — компенсирует случаи, когда
смещение локальной зоны отличается от зоны границ диапазона настолько,
что вхождение попадает в диапазон, а его календарная дата — нет.
"""
if t_from.tzinfo is None or t_to.tzinfo is None:
raise ValueError("t_from и t_to должны быть timezone-aware")
if t_from > t_to:
return []
tz = ZoneInfo(rule.timezone)
start_date = (t_from.astimezone(tz) - timedelta(days=1)).date()
end_date = (t_to.astimezone(tz) + timedelta(days=1)).date()
occurrences: list[datetime] = []
current = start_date
while current <= end_date:
if _matches(rule, current):
candidate = rule.local_datetime(current).astimezone(UTC)
if t_from <= candidate <= t_to:
occurrences.append(candidate)
current += timedelta(days=1)
occurrences.sort()
return occurrences

View File

@@ -0,0 +1,357 @@
"""Бизнес-логика обработки webhook-событий LiveKit (ADR-001).
Дедупликация по `event.id` выполняется на уровне API-роутера
(`api/livekit_webhook.py`) в одной транзакции с эффектами обработчика —
этим обеспечивается идемпотентность пайплайна. Обработчики здесь
дополнительно используют get-or-create/guard-паттерны в репозитории
конференций, чтобы корректно восстанавливаться после пропущенных событий
(например, потерянного `room_started`).
Комната LiveKit больше не отдельная сущность — её имя всегда равно
`conferences.slug` (ADR-001, п.4), поэтому lookup идёт напрямую по slug.
"""
import logging
import uuid
from datetime import UTC, datetime
from livekit.protocol.egress import EgressStatus
from livekit.protocol.models import TrackSource, TrackType
from livekit.protocol.webhook import WebhookEvent
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from repositories.conferences import (
AudioTrackRepository,
ConferenceRepository,
ConferenceSessionRepository,
)
from services.egress import start_track_egress
from services.instance_settings import InstanceSettingsService
from services.pipeline_producer import enqueue_pipeline
logger = logging.getLogger(__name__)
# Префикс identity гостя в LiveKit-токене (ADR-001, п.6): `guest:{guest_access.id}`.
GUEST_IDENTITY_PREFIX = "guest:"
# Статусы EgressInfo, означающие неуспешное завершение записи (webhook `egress_ended`).
_EGRESS_FAILURE_STATUSES = frozenset(
{EgressStatus.EGRESS_FAILED, EgressStatus.EGRESS_ABORTED, EgressStatus.EGRESS_LIMIT_REACHED}
)
def _egress_ns_to_datetime(nanoseconds: int) -> datetime | None:
"""Перевести unix-наносекунды `EgressInfo.started_at`/`ended_at` в UTC datetime.
`0` (поле не проставлено) — валидное protobuf-значение по умолчанию, не
временная метка.
"""
if not nanoseconds:
return None
return datetime.fromtimestamp(nanoseconds / 1_000_000_000, tz=UTC)
class WebhookDispatcher:
"""Диспатчит `WebhookEvent` на обработчик по типу события."""
def __init__(self, session: AsyncSession) -> None:
self._conferences = ConferenceRepository(session)
self._sessions = ConferenceSessionRepository(session)
self._audio_tracks = AudioTrackRepository(session)
self._instance_settings = InstanceSettingsService(session)
async def dispatch(self, event: WebhookEvent) -> None:
"""Обработать одно webhook-событие; неизвестный тип события — no-op."""
handlers = {
"room_started": self._on_room_started,
"participant_joined": self._on_participant_joined,
"participant_left": self._on_participant_left,
"track_published": self._on_track_published,
"egress_ended": self._on_egress_ended,
"room_finished": self._on_room_finished,
}
handler = handlers.get(event.event)
if handler is None:
# Штатный шум: остальные типы событий (track_unpublished и т.п.) не нужны.
logger.debug("livekit webhook: необрабатываемый тип события %s", event.event)
return
await handler(event)
async def _on_room_started(self, event: WebhookEvent) -> None:
conference = await self._conferences.get_by_slug(event.room.name)
if conference is None:
logger.warning(
"livekit webhook room_started: конференция %s не найдена", event.room.name
)
return
conference.status = "active"
session_record = await self._sessions.get_or_create_open(
conference_id=conference.id, title=conference.title, t_start=datetime.now(UTC)
)
logger.info(
"livekit webhook room_started: конференция=%s сеанс=%s",
conference.id,
session_record.id,
)
async def _on_participant_joined(self, event: WebhookEvent) -> None:
conference = await self._conferences.get_by_slug(event.room.name)
if conference is None:
logger.warning(
"livekit webhook participant_joined: конференция %s не найдена", event.room.name
)
return
identity = _parse_identity(event.participant.identity)
if identity is None:
return
user_id, guest_id = identity
# Fallback на случай, если событие room_started было пропущено.
session_record = await self._sessions.get_or_create_open(
conference_id=conference.id, title=conference.title, t_start=datetime.now(UTC)
)
await self._sessions.add_participant(
session_id=session_record.id,
user_id=user_id,
guest_id=guest_id,
joined_at=datetime.now(UTC),
)
logger.info(
"livekit webhook participant_joined: конференция=%s identity=%s сеанс=%s",
conference.id,
event.participant.identity,
session_record.id,
)
async def _on_participant_left(self, event: WebhookEvent) -> None:
conference = await self._conferences.get_by_slug(event.room.name)
if conference is None:
logger.warning(
"livekit webhook participant_left: конференция %s не найдена", event.room.name
)
return
identity = _parse_identity(event.participant.identity)
if identity is None:
return
user_id, guest_id = identity
session_record = await self._sessions.get_open_by_conference(conference.id)
if session_record is None:
logger.warning(
"livekit webhook participant_left: нет открытого сеанса для конференции %s",
conference.id,
)
return
await self._sessions.mark_participant_left(
session_id=session_record.id,
user_id=user_id,
guest_id=guest_id,
left_at=datetime.now(UTC),
)
logger.info(
"livekit webhook participant_left: конференция=%s identity=%s сеанс=%s",
conference.id,
event.participant.identity,
session_record.id,
)
async def _on_track_published(self, event: WebhookEvent) -> None:
"""Запустить Track Egress для опубликованного аудиотрека микрофона (ADR-002).
Видео/скриншеринг и т.п. — no-op (диаризация не нужна: транскрибируем
только речь, трек = спикер). Идемпотентно: если строка трека уже
существует (гонка повторной доставки), egress повторно не запускается.
"""
if event.track.type != TrackType.AUDIO or event.track.source != TrackSource.MICROPHONE:
return
conference = await self._conferences.get_by_slug(event.room.name)
if conference is None:
logger.warning(
"livekit webhook track_published: конференция %s не найдена", event.room.name
)
return
session_record = await self._sessions.get_open_by_conference(conference.id)
if session_record is None:
logger.warning(
"livekit webhook track_published: нет открытого сеанса для конференции %s",
conference.id,
)
return
existing_track = await self._audio_tracks.get_by_session_and_track(
session_record.id, event.track.sid
)
if existing_track is not None:
logger.debug(
"livekit webhook track_published: трек %s уже записывается (сеанс=%s)",
event.track.sid,
session_record.id,
)
return
identity = _parse_identity(event.participant.identity)
if identity is None:
return
user_id, guest_id = identity
participant = await self._sessions.get_active_participant(
session_record.id, user_id=user_id, guest_id=guest_id
)
if participant is None:
logger.warning(
"livekit webhook track_published: нет активного участника identity=%s сеанса %s",
event.participant.identity,
session_record.id,
)
return
settings = get_settings()
filepath = (
f"{settings.recordings_dir}/{session_record.id}/{participant.id}_{event.track.sid}.ogg"
)
try:
result = await start_track_egress(event.room.name, event.track.sid, filepath)
except Exception as exc: # noqa: BLE001 — недоступность egress не должна ронять webhook
# Деплой-профиль (блок D): egress — необязательный сервис профиля
# `transcribe`; без него запись просто не стартует для этого трека
# (риск «Потерян webhook track_published»).
# Строку `session_audio_tracks` не создаём — у нас нет `egress_id`,
# по которому её мог бы финализировать `egress_ended`.
logger.warning(
"livekit webhook track_published: не удалось запустить egress для трека %s "
"сеанса %s: %s",
event.track.sid,
session_record.id,
exc,
)
return
await self._audio_tracks.create(
session_id=session_record.id,
participant_id=participant.id,
track_sid=event.track.sid,
egress_id=result.egress_id,
file_path=filepath,
started_at=result.started_at,
)
logger.info(
"livekit webhook track_published: сеанс=%s участник=%s трек=%s egress=%s",
session_record.id,
participant.id,
event.track.sid,
result.egress_id,
)
async def _on_egress_ended(self, event: WebhookEvent) -> None:
"""Финализировать строку аудиотрека по результату Track Egress.
Успех (`EGRESS_COMPLETE`) -> `status='recorded'`; ошибка (failed/
aborted/limit_reached) -> `status='failed'`. Отсутствие строки трека
(например, потерянный `track_published`) — предупреждение, не ошибка.
"""
egress_info = event.egress_info
status = "failed" if egress_info.status in _EGRESS_FAILURE_STATUSES else "recorded"
ended_at = _egress_ns_to_datetime(egress_info.ended_at) or datetime.now(UTC)
file_path = egress_info.file.filename or egress_info.file.location or None
record = await self._audio_tracks.finalize(
egress_id=egress_info.egress_id,
status=status,
ended_at=ended_at,
file_path=file_path,
)
if record is None:
logger.warning(
"livekit webhook egress_ended: строка трека для egress %s не найдена",
egress_info.egress_id,
)
return
logger.info(
"livekit webhook egress_ended: трек=%s egress=%s статус=%s",
record.id,
egress_info.egress_id,
status,
)
async def _on_room_finished(self, event: WebhookEvent) -> None:
conference = await self._conferences.get_by_slug(event.room.name)
if conference is None:
logger.warning(
"livekit webhook room_finished: конференция %s не найдена", event.room.name
)
return
session_record = await self._sessions.get_open_by_conference(conference.id)
if session_record is None:
logger.warning(
"livekit webhook room_finished: нет открытого сеанса для конференции %s",
conference.id,
)
return
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)
# Незакреплённая умирает по завершении (история/саммари остаются);
# закреплённая возвращается в ожидание следующего вхождения (ADR-001, п.2).
if conference.is_pinned:
conference.status = "scheduled"
else:
conference.status = "ended"
conference.ended_at = now
logger.info(
"livekit webhook room_finished: конференция=%s сеанс=%s новый статус=%s",
conference.id,
session_record.id,
conference.status,
)
# Постановка AI-пайплайна: запись треков
# завершена (egress ещё может дописывать файлы — это ждёт шаг 3
# `run_pipeline`), сеанс закрыт — можно ставить задачу в очередь.
#
# Guard от зависания сеанса:
# если транскрибация выключена в настройках инстанса (пресеты 1/2
# инсталлятора — без AI, воркеров/LLM в деплое нет), задача `transcribe`
# уйдёт в очередь `transcription`, которую некому обслужить, и сеанс
# навсегда застрянет в `pipeline_status='recording'`. Вместо постановки
# в очередь сразу проставляем терминальный статус без AI-шагов —
# 'notified' (тот же статус, которым штатно завершается полный
# пайплайн; отдельный enum-статус/миграция не нужны).
cfg = await self._instance_settings.get()
if cfg.transcriber.enabled:
enqueue_pipeline(session_record.id)
else:
session_record.pipeline_status = "notified"
logger.info(
"livekit webhook room_finished: транскрибация выключена — "
"сеанс=%s сразу переведён в pipeline_status='notified'",
session_record.id,
)
def _parse_identity(identity: str) -> tuple[uuid.UUID | None, uuid.UUID | None] | None:
"""Распарсить identity участника в пару (`user_id`, `guest_id`) — ровно один заполнен.
`guest:{guest_access.id}` — гость; иначе — `str(user.id)` зарегистрированного
пользователя. Невалидный/пустой identity — предупреждение в лог и пропуск
события (не должно приводить к 500).
"""
try:
if identity.startswith(GUEST_IDENTITY_PREFIX):
guest_id = uuid.UUID(identity.removeprefix(GUEST_IDENTITY_PREFIX))
return None, guest_id
return uuid.UUID(identity), None
except (ValueError, AttributeError):
logger.warning("livekit webhook: невалидный identity участника %r", identity)
return None