# API Чата Текстовый чат в реальном времени для участников конференции. Реализован через WebSocket с аутентификацией по LiveKit-токену и broadcast через Redis pub/sub. ## WS-эндпоинт ``` WS /api/v1/conferences/{conference_id}/chat ``` Подключение требует явной аутентификации на уровне протокола (первое сообщение). ## Жизненный цикл соединения ### 1. Accept и ожидание auth-сообщения (таймаут 10 с) Сервер принимает WebSocket-соединение и ждёт первого (и только первого) сообщения типа `auth`: ```json { "type": "auth", "token": "" } ``` **Внимание:** токен не передаётся в query-параметре и не логируется nginx'ом (требование безопасности — токены не должны попадать в логи доступа). ### 2. Проверка допуска Сервер выполняет последовательность проверок (порядок важен для единообразной обработки ошибок): 1. **Тоггл чата:** флаг `chat.enabled` из настроек инстанса в БД (`instance_settings`). Проверяется **на каждом подключении**, без рестарта backend. 2. **LiveKit-токен:** верификация подписи и целостности (`livekit.api.TokenVerifier`) 3. **Идентичность:** - Зарегистрированный пользователь: `identity` = строка UUID пользователя; grant `video.room` должен совпадать с `conference.slug` - Гость: `identity` = `guest:{guest_access_id}` (UUID гостевой записи); grant `video.room` должен совпадать с `conference.slug` 4. **Конференция:** существует и статус ≠ `ended` ### 3. Отправка истории При успешной аутентификации сервер отправляет последние **50 сообщений** открытой сессии конференции: ```json { "type": "history", "messages": [ { "id": 1, "author_id": "550e8400-e29b-41d4-a716-446655440000", "author_name": "Иван Петров", "is_guest": false, "text": "Привет, все слышат?", "created_at": "2026-07-18T14:30:00Z" }, ... ] } ``` **Порядок:** Подписка на Redis pub/sub канал происходит **перед** отправкой истории. Это гарантирует, что сообщения, пришедшие от других клиентов в окне между SELECT истории и subscribe, не будут потеряны. На стыке возможен дубликат (одно сообщение и в history, и в первом pub/sub); дедуплицирование по `id`. ### 4. Двусторонний обмен Клиент отправляет сообщения: ```json { "type": "message", "text": "Мое сообщение" } ``` Сервер пишет сообщение в БД, затем публикует его в Redis pub/sub. **Отправитель получает своё сообщение обратно через pub/sub** (echo). Порядок доставки единый для всех подписчиков канала. Полученное сообщение (для всех участников, в т.ч. отправителя): ```json { "type": "message", "message": { "id": 2, "author_id": "550e8400-e29b-41d4-a716-446655440001", "author_name": "Анна Смирнова", "is_guest": true, "text": "Отлично, видно хорошо!", "created_at": "2026-07-18T14:30:15Z" } } ``` ### 5. Обработка ошибок протокола Если клиент отправит невалидное JSON или нарушит протокол (невалидный `type`, отсутствующие поля), сервер отправляет: ```json { "type": "error", "code": "invalid_message" } ``` Соединение остаётся открытым; клиент может отправить следующее сообщение. Полный разрыв соединения происходит только по close-кодам ниже. ## Close-коды Сервер закрывает WebSocket соединение со следующими кодами: | Код | Причина | Детали | |-----|---------|--------| | **4401** | Невалидный auth-токен | Таймаут ожидания auth-сообщения, невалидный JSON, невалидная подпись LiveKit-токена, отсутствие identity/room в token claims или невалидный UUID. Детали причины не раскрываются клиенту. | | **4403** | Неправильная комната | Токен валиден, но выдан не для этой конференции (grant `video.room` ≠ `conference.slug`). | | **4404** | Чат недоступен | Чат выключен в настройках инстанса (`chat.enabled = false`), или конференция не найдена, или конференция завершена (`status = ended`). Единый код для всех случаев — чтобы по коду закрытия нельзя было перебором отличить существующую конференцию от несуществующей. | **Штатное закрытие:** когда клиент сам закрывает соединение — это не ошибка, обработчик корректно завершается. ## Защита от фантомных сессий Если клиент пытается отправить сообщение уже после завершения конференции (`room_finished` от LiveKit): 1. Сервер перечитывает статус конференции свежим SELECT (не полагаясь на закэшированный объект) 2. Если статус = `ended`, отправляет close 4404; сообщение **не записывается** в БД 3. Если статус ≠ `ended` и открытой сессии нет, автоматически создаёт её Это гарантирует, что "фантомные" сообщения (отправленные после `room_finished`, когда сеанс уже завершается) не создаются. ## Схема сообщения чата ### ChatMessageOut (выход) ```typescript { id: number, // BIGINT Identity(always=True), уникален в пределах инстанса author_id: string | null, // UUID пользователя ИЛИ UUID гостевой записи; null не возможно (см. schema.md) author_name: string, // Снапшот отображаемого имени из LiveKit-токена на момент отправки (макс 255 символов) is_guest: boolean, // true если guest_access_id заполнен text: string, // Текст сообщения (1..2000 символов) created_at: string // UTC ISO-8601 с суффиксом Z } ``` **Особенности:** - `author_name` — снапшот, не ссылка на текущее имя пользователя (переживает переименование и удаление гостевой записи) - `author_id` никогда не NULL (проверка на уровне БД: CHECK `user_id IS NOT NULL OR guest_access_id IS NOT NULL`) ## Конфигурация ### Настройка чата в инстансе Флаг `chat.enabled` хранится в таблице `instance_settings` (key = `"chat"`, value = JSONB): ```json { "enabled": true } ``` **Доступ клиенту:** флаг приходит в ответе `join` / `guest-join` (`JoinOut.chat_enabled`), по нему фронт показывает/скрывает UI панели чата. **Изменение:** через админ-панель (`PATCH /api/v1/admin/settings`); без рестарта backend. ### Бутстрап из config/plugins.yaml При первом запуске backend загружает `config/plugins.yaml` и создаёт запись в `instance_settings` (однократный идемпотентный импорт): ```yaml chat: enabled: true ``` Если `instance_settings['chat']` уже существует, бутстрап не перезаписывает. ## Примеры использования ### JavaScript/TypeScript (frontend) ```typescript // Подключение const ws = new WebSocket('ws://localhost:8000/api/v1/conferences/550e8400-e29b-41d4-a716-446655440000/chat') // Отправка auth-токена (должен быть получен с backend при join) ws.onopen = () => { ws.send(JSON.stringify({ type: 'auth', token: livekit_token })) } // Получение истории и новых сообщений ws.onmessage = (event) => { const msg = JSON.parse(event.data) if (msg.type === 'history') { // Отрисовать историю из msg.messages } else if (msg.type === 'message') { // Добавить новое сообщение msg.message } else if (msg.type === 'error') { console.error('Chat error:', msg.code) } } // Отправка сообщения function sendMessage(text: string) { ws.send(JSON.stringify({ type: 'message', text: text })) } // Закрытие ws.onclose = (event) => { if (event.code === 4403) { console.error('Wrong room') } else if (event.code === 4404) { console.error('Chat unavailable or conference ended') } } ``` ### curl (тестирование) WebSocket из curl поддерживается ограниченно; рекомендуется использовать утилиту `wscat`: ```bash npm install -g wscat # Подключение и отправка auth wscat -c ws://localhost:8000/api/v1/conferences/550e8400-e29b-41d4-a716-446655440000/chat # Затем вручную: {"type":"auth","token":"eyJ..."} {"type":"message","text":"Hello"} ``` ## Интеграция с остальной системой ### Redis pub/sub Канал: `chat:{conference_id}` (UUID конференции). **Publisher:** `services/chat.py:ChatService.persist_and_publish()` после INSERT в БД. **Subscribers:** все подключённые WebSocket-клиенты конференции (несколько инстансов backend в балансировке). ### Таблица chat_messages См. [docs/db/schema.md](../db/schema.md#chat_messages). Строка: `(id, session_id, user_id, guest_access_id, author_name, text, created_at)`. ### Тоггл в JoinOut При входе в конференцию (`POST /api/v1/conferences/{id}/join` или `/guest-join`) ответ включает: ```json { "livekit_url": "...", "token": "...", "room_name": "...", "conference_id": "550e8400-e29b-41d4-a716-446655440000", "chat_enabled": true } ``` Фронт проверяет `chat_enabled` перед рендерингом UI панели чата. ## Ссылки - [Database Schema — chat_messages](../db/schema.md#chat_messages) - [Instance Settings](../db/schema.md#instance_settings) - [Backend Code — api/chat.py](../../backend/api/chat.py) - [Backend Code — services/chat.py](../../backend/services/chat.py) - [Frontend Component — useChat.ts](../../frontend/src/hooks/useChat.ts) - [Frontend Component — ChatPanel.tsx](../../frontend/src/components/room/ChatPanel.tsx)