Первоначальная версия VidConf
This commit is contained in:
272
docs/api/chat.md
Normal file
272
docs/api/chat.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# 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": "<LiveKit access-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)
|
||||
Reference in New Issue
Block a user