send2streamer/ARCHITECTURE.md

125 lines
9.7 KiB
Markdown
Raw Normal View History

# Архитектура Send2Streamer
## Назначение
Многопользовательский (мульти-стример) сервис для приёма сообщений от
зрителей на стрим. Каждый стример получает две постоянные ссылки:
overlay (для OBS/StreamLabs Browser Source) и sender (публикуется для
зрителей). Зритель логинится через Google на sender-странице и пишет
текст — он попадает только на overlay ЭТОГО стримера, с текстом и
голосовой озвучкой (edge-tts), опционально с ответом от ИИ (DeepSeek).
## Компоненты
```
┌──────────────────────────────────────────────────────────────┐
│ server.js (Express) │
│ │
│ HTTP: Socket.IO (общий httpServer): │
│ / → index.html io.on('connection') с query │
│ /dashboard.html (auth) { role: 'overlay'|'sender', │
│ /overlay/:token → overlay.html token } │
│ /s/:token → send.html │
│ /api/dashboard (auth) room = `streamer-${id}`
│ /api/settings (auth) overlay joins room по token; │
│ /api/viewer-name (auth) sender шлёт send_message с
│ /auth/google, /auth/logout token в payload │
│ │
│ db.js (node:sqlite) ──── streamers, viewer_names │
│ profanity.js ──── censorText(text, replacement) │
│ synthesizeSpeech() ──spawn──> `edge-tts` (Python CLI) │
│ getAiReply() ──HTTPS──> api.deepseek.com (опционально) │
└──────────────────────────────────────────────────────────────┘
```
## Модель данных (SQLite, `db.js`)
`streamers`: `id, google_id (unique), email, name, picture,
overlay_token (unique), sender_token (unique), tts_voice,
display_duration_seconds, created_at`. Строка создаётся лениво при первом
заходе на `/api/dashboard` (`getOrCreateStreamerByGoogle`) — токены
генерируются один раз и живут вечно (`crypto.randomBytes(16).hex`).
`viewer_names`: `(streamer_id, google_id) -> display_name`. Зритель может
задать своё имя (не гугловское) отдельно для каждого стримера; сохраняется
при каждой отправке сообщения (`setViewerName`), подтягивается на
send-странице через `/api/viewer-name`.
Схема мигрируется идемпотентно через `PRAGMA table_info` + `ALTER TABLE`
(см. `display_duration_seconds` в `db.js`) — так надо делать для любых
новых колонок, т.к. БД персистентна между деплоями (volume `./data`).
## Поток сообщения
1. `send.js``socket.emit('send_message', { token, text, name, aiReply })`.
2. Сервер находит стримера по `sender_token`, **перечитывает сессию**
(`socket.request.session.reload`, см. "Грабли" ниже), цензурит текст
(`profanity.js`) — отдельно для экрана (`...`) и для озвучки (`бип`).
3. Если `aiReply` не запрошен — сразу `broadcastMessage` (эмит
`display_message` + асинхронный `synthesizeSpeech``display_audio`,
каждое с `id = crypto.randomUUID()`).
4. Если `aiReply` запрошен и есть `DEEPSEEK_API_KEY`**сначала**
дожидаемся `getAiReply()`, и только потом эмитим оригинальное сообщение
и следом ответ ИИ (иначе они приходили бы вразнобой — так было раньше,
намеренно исправлено). При ошибке DeepSeek оригинальное сообщение всё
равно показывается.
5. `overlay.js` держит клиентскую очередь (`queue`/`current`) — каждое
`display_message` играет полностью назначенное время
(`display_duration_seconds` стримера) прежде чем показать следующее.
`display_audio` приходит асинхронно и сопоставляется с сообщением по
`id` (может прийти раньше, чем сообщение дойдёт до начала очереди —
тогда аудио складывается в `queue[i].audio` и проигрывается, когда
сообщение станет текущим).
## Грабли, найденные на практике (не наступать снова)
- **Сессия сокета фиксируется в момент коннекта.** Если браузер открывает
`io()` до логина (а он открывается сразу при загрузке страницы), сокет
навсегда получает "анонимную" `socket.request.session`, даже после
успешного `/auth/google`. Решение — два слоя: (1) клиент переподключает
сокет (`socket.disconnect(); socket.connect();`) сразу после успешного
логина; (2) сервер всё равно делает `session.reload()` перед каждой
проверкой `send_message`, на случай если reconnect не случился.
- **`edge-tts-node` (npm) сломан** — Microsoft меняет анти-абузную подпись
запроса (`Sec-MS-GEC`), пакет отдаёт 403. Поэтому TTS идёт через
`execFile('edge-tts', ...)` — питоновский CLI (`pip install edge-tts` в
Dockerfile), тот же движок, что использует `t2sTelegramBot`, и он
реально работает. Если TTS снова начнёт падать — сначала проверить,
жив ли `edge-tts` вообще (`docker exec <container> edge-tts -t test -v
ru-RU-DmitryNeural --write-media /tmp/t.mp3`), а не чинить JS-клиент.
- **Google OAuth origin-специфичен и жёстко привязан к конкретному
Client ID.** Не гадать, какой домен к какому Client ID привязан — это
путает даже владельца проекта (было минимум 2 инцидента). Проверять
фактически: `curl https://<домен>/config` покажет, какой
`googleClientId` реально отдаёт сервер за этим доменом, дальше сверять
Authorized JavaScript origins именно этого клиента в Google Console.
Также: OAuth consent screen должен быть в статусе **"In production"**,
иначе логинятся только явно добавленные Test users — сторонние
пользователи просто не увидят кнопку/не смогут войти.
- **`docker compose restart` не подхватывает новый `.env`** — он не
пересоздаёт контейнер. Нужно `docker compose up -d` (без `--build`,
если образ не менялся).
- **CI на self-hosted раннере иногда падает с `failed to prepare
extraction snapshot ... does not exist`** — это повреждённый кэш
buildkit/containerd на самом раннере, не связано с кодом. Лечится
ретраем (пустой коммит или Re-run в Forgejo Actions).
- **`pkill -f "node server.js"` может убить сам себя** — если команда
запускается через обёртку вида `bash -c '... node server.js ...'`,
`-f` матчит по всей командной строке процесса, включая саму обёртку.
Лучше убивать по PID, найденному через `ss -ltnp`/`lsof` на нужном
порту.
## Деплой / инфраструктура
Актуальная топология (домены, хосты, Client ID) хранится не здесь, а в
памяти агента (`send2streamer-cicd` в `~/.claude/projects/.../memory/`),
т.к. меняется чаще, чем архитектура. Коротко: push в `main`
`.forgejo/workflows/deploy.yml` (self-hosted "shell" runner) → билд →
push в Forgejo Container Registry → SSH на прод-хост → `docker pull` +
`docker compose up -d`. `.env` на проде не версионируется, создаётся
руками по образцу `.env.example`.
**Пуш в `main` = немедленный деплой на прод.** Всегда спрашивать
подтверждение перед `git push`, даже если разрешение уже давалось раньше
в разговоре (пользователь просил именно так после инцидента).