From 9ec6a9d1dffa7420f7fc8c328f0989f198839cf3 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 1 Jul 2026 18:11:53 +0000 Subject: [PATCH] Add README and ARCHITECTURE docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ARCHITECTURE.md | 124 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 108 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 ARCHITECTURE.md create mode 100644 README.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..1db9436 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,124 @@ +# Архитектура 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`, даже если разрешение уже давалось раньше +в разговоре (пользователь просил именно так после инцидента). diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa68218 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# Send2Streamer + +Сервис для стримеров: зрители пишут сообщения через веб-страницу (вход +через Google) — сообщения всплывают у стримера на оверлее в OBS/StreamLabs, +с текстом и голосовой озвучкой. Опционально на сообщение может ответить ИИ +(DeepSeek), тоже с озвучкой. Мат автоматически цензурится. + +## Быстрый старт + +```bash +cp .env.example .env # и заполнить реальными значениями (см. таблицу ниже) +docker compose up -d --build +docker compose logs -f +``` + +Сервис поднимется на `http://localhost:3000`. + +### Первичная настройка + +1. Создать OAuth 2.0 Client ID (Web application) в + [Google Cloud Console](https://console.cloud.google.com/apis/credentials), + добавить в Authorized JavaScript origins домен, с которого открывается + сайт (`http://localhost:3000` для локальной разработки, плюс боевой + домен). **OAuth consent screen должен быть в статусе "In production"**, + иначе войти смогут только явно добавленные test users. +2. (Опционально) Получить ключ [DeepSeek API](https://platform.deepseek.com) + для функции "ответ от ИИ". +3. Открыть сайт → войти через Google как стример → на дашборде появятся + две ссылки: + - **Ссылка для OBS/StreamLabs** — вставить в свойства Browser Source. + - **Ссылка для зрителей** — опубликовать, зрители сами авторизуются + через Google и пишут сообщения. + +## Как это работает + +```mermaid +graph LR + Viewer[Зритель] -->|логин Google + текст| Send["/s/:token (send.js)"] + Send -->|WebSocket send_message| Server[server.js] + Server -->|censorText| Server + Server -->|display_message| Overlay["/overlay/:token (overlay.js)"] + Server -->|edge-tts CLI| TTS[edge-tts] + TTS -->|mp3 base64| Server + Server -->|display_audio| Overlay + Server -.->|если включена галка| DeepSeek[DeepSeek API] + DeepSeek -.->|текст ответа| Server + Overlay -->|очередь: текст + голос| OBS[OBS / StreamLabs Browser Source] +``` + +- **Один процесс на несколько стримеров.** Каждый стример получает пару + уникальных токенов (overlay + sender), сообщения маршрутизируются через + комнаты Socket.IO (`streamer-`) — зрители одного стримера не видят + сообщений другого. +- **Очередь на оверлее.** Сообщения показываются по одному, каждое — + заданное стримером время (по умолчанию 10 сек), а не перебивают друг + друга. +- **Озвучка** — `edge-tts` (бесплатный Microsoft Edge Read Aloud, тот же + движок, что в `t2sTelegramBot`), CLI вызывается из Node дочерним + процессом. +- **ИИ-ответ** — опционален, только если у стримера настроен + `DEEPSEEK_API_KEY`. Промпт-инструкция для ИИ лежит в `ai-prompt.txt` и + редактируется без изменения кода. +- **Цензура мата** (`profanity.js`) — при отправке применяется всегда: на + экране матное слово превращается в `...`, в озвучке — в "бип". + +## Структура проекта + +``` +. +├── server.js # Express + Socket.IO, вся серверная логика +├── db.js # SQLite (node:sqlite) — стримеры, имена зрителей +├── profanity.js # Цензура мата +├── ai-prompt.txt # Системный промпт для ИИ-ответов (редактируется свободно) +├── public/ +│ ├── index.html # Лендинг + вход для стримера +│ ├── dashboard.html # Личный кабинет стримера (ссылки, настройки) +│ ├── send.html/.js # Страница отправки сообщений (для зрителей) +│ └── overlay.html/.js # Страница для Browser Source в OBS/StreamLabs +├── Dockerfile +├── docker-compose.yml # Локальная разработка (build: .) +├── docker-compose.prod.yml # Прод (только image:, без build) +└── .forgejo/workflows/deploy.yml # CI/CD: билд → push в registry → деплой по SSH +``` + +## Конфигурация (.env) + +| Переменная | Описание | +|---|---| +| `PORT` | Порт сервера (по умолчанию 3000) | +| `GOOGLE_CLIENT_ID` | OAuth Client ID для входа через Google | +| `SESSION_SECRET` | Случайная строка для подписи сессионной cookie | +| `TTS_VOICE` | Голос по умолчанию для новых стримеров (`ru-RU-DmitryNeural`) | +| `DEEPSEEK_API_KEY` | Опционально — включает функцию "ответ от ИИ" | +| `AI_VOICE` | Голос для ИИ-ответов (`ru-RU-SvetlanaNeural` по умолчанию, отличается от голоса стримера) | + +## Деплой + +Пуш в `main` триггерит `.forgejo/workflows/deploy.yml`: сборка образа, +smoke-test, push в локальный registry, затем SSH на прод-хост +(`docker pull` + `docker compose up -d`). Прод-хост сам хранит свой `.env` +(не через git) — при первом деплое его нужно создать вручную рядом с +`docker-compose.prod.yml`. + +## Требования + +- Docker + Docker Compose +- Доступ к `accounts.google.com` (логин), `api.deepseek.com` (если включён + ИИ-ответ) и Microsoft edge-tts CDN (озвучка)