send2streamer/ARCHITECTURE.md
vrubelroman 9ec6a9d1df
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 28s
Add README and ARCHITECTURE docs
README.md for humans (setup, config, deploy). ARCHITECTURE.md for
future AI sessions — data model, message flow, and a "gotchas"
section documenting real bugs/incidents from this session (session
capture timing, broken edge-tts-node package, OAuth origin/client-id
mixups, docker compose restart vs up -d, self-matching pkill) so they
don't get rediscovered the hard way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 18:11:53 +00:00

124 lines
9.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Архитектура 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`, даже если разрешение уже давалось раньше
в разговоре (пользователь просил именно так после инцидента).