"""Pydantic-схемы протокола WS-чата конференции (`api/chat.py`). Входящий протокол — дискриминированное объединение по полю `type`: первым сообщением клиент обязан прислать `auth` (LiveKit access-токен, не query-параметр — не палим токен в логах nginx), далее — произвольное число `message`. Исходящий протокол — `history` (один раз, сразу после успешной аутентификации), `message` (broadcast через Redis pub/sub) и `error`. """ from datetime import UTC, datetime from typing import Annotated, Any, Literal from pydantic import BaseModel, Field, TypeAdapter, field_serializer, field_validator # Ограничение длины текста сообщения. MAX_MESSAGE_LENGTH = 2000 def _to_iso_z(value: datetime) -> str: """Отформатировать aware-datetime как UTC ISO-строку с суффиксом `Z`.""" return value.astimezone(UTC).isoformat().replace("+00:00", "Z") class ChatAuthIn(BaseModel): """Первое сообщение клиента — аутентификация LiveKit access-токеном.""" type: Literal["auth"] token: str class ChatMessageIn(BaseModel): """Сообщение клиента с текстом чата — text обрезается по пробелам и не должен быть пустым.""" type: Literal["message"] text: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH) @field_validator("text", mode="before") @classmethod def _strip(cls, value: Any) -> Any: return value.strip() if isinstance(value, str) else value # Дискриминированное объединение входящих сообщений клиента по полю `type`. ChatClientEnvelope = Annotated[ChatAuthIn | ChatMessageIn, Field(discriminator="type")] chat_client_envelope_adapter: TypeAdapter[ChatAuthIn | ChatMessageIn] = TypeAdapter( ChatClientEnvelope ) class ChatMessageOut(BaseModel): """Одно сообщение чата в исходящем протоколе (history/broadcast).""" id: int author_id: str | None author_name: str is_guest: bool text: str created_at: datetime @field_serializer("created_at") def _serialize_created_at(self, value: datetime) -> str: return _to_iso_z(value) class ChatHistoryOut(BaseModel): """История последних сообщений открытой сессии — отправляется один раз после auth.""" type: Literal["history"] = "history" messages: list[ChatMessageOut] class ChatMessageEventOut(BaseModel): """Одно новое сообщение чата — broadcast через Redis pub/sub (в т.ч. отправителю).""" type: Literal["message"] = "message" message: ChatMessageOut class ChatErrorOut(BaseModel): """Сообщение об ошибке протокола (например, невалидный текст) без разрыва соединения.""" type: Literal["error"] = "error" code: str