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