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

9.7 KiB
Raw Blame 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.jssocket.emit('send_message', { token, text, name, aiReply }).
  2. Сервер находит стримера по sender_token, перечитывает сессию (socket.request.session.reload, см. "Грабли" ниже), цензурит текст (profanity.js) — отдельно для экрана (...) и для озвучки (бип).
  3. Если aiReply не запрошен — сразу broadcastMessage (эмит display_message + асинхронный synthesizeSpeechdisplay_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, даже если разрешение уже давалось раньше в разговоре (пользователь просил именно так после инцидента).