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>
9.7 KiB
Архитектура 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).
Поток сообщения
send.js→socket.emit('send_message', { token, text, name, aiReply }).- Сервер находит стримера по
sender_token, перечитывает сессию (socket.request.session.reload, см. "Грабли" ниже), цензурит текст (profanity.js) — отдельно для экрана (...) и для озвучки (бип). - Если
aiReplyне запрошен — сразуbroadcastMessage(эмитdisplay_message+ асинхронныйsynthesizeSpeech→display_audio, каждое сid = crypto.randomUUID()). - Если
aiReplyзапрошен и естьDEEPSEEK_API_KEY— сначала дожидаемсяgetAiReply(), и только потом эмитим оригинальное сообщение и следом ответ ИИ (иначе они приходили бы вразнобой — так было раньше, намеренно исправлено). При ошибке DeepSeek оригинальное сообщение всё равно показывается. 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, даже если разрешение уже давалось раньше
в разговоре (пользователь просил именно так после инцидента).