Add README and ARCHITECTURE docs
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 28s
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 28s
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>
This commit is contained in:
parent
fd6aa24a1d
commit
9ec6a9d1df
2 changed files with 232 additions and 0 deletions
124
ARCHITECTURE.md
Normal file
124
ARCHITECTURE.md
Normal file
|
|
@ -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 <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`, даже если разрешение уже давалось раньше
|
||||||
|
в разговоре (пользователь просил именно так после инцидента).
|
||||||
108
README.md
Normal file
108
README.md
Normal file
|
|
@ -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-<id>`) — зрители одного стримера не видят
|
||||||
|
сообщений другого.
|
||||||
|
- **Очередь на оверлее.** Сообщения показываются по одному, каждое —
|
||||||
|
заданное стримером время (по умолчанию 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 (озвучка)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue