Joining a channel failed silently: revoice's join() runs an async executor inside `new Promise`, so a rejected join_call never reaches reject() — the promise hangs forever and the real error escapes as an unhandled rejection. The client wrapper now latches API failures and settles the join itself, translating Stoat's error codes (AlreadyConnected, LiveKitUnavailable, UnknownNode, ...) into messages the chat can show. A failed join also used to leave the connection object alive, which kept the bot registered in the channel and made the next attempt fail with AlreadyConnected; it is now destroyed on any failure. The LiveKit node name is configurable via VOICE_NODE for instances that renamed it, and the README documents how to clear a stuck voice state from Redis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
242 lines
15 KiB
Markdown
242 lines
15 KiB
Markdown
# stoat-mbot
|
||
|
||
Музыкальный бот для self-hosted [Stoat](https://github.com/stoatchat/self-hosted) с веб-панелью:
|
||
поиск и воспроизведение в голосовых каналах, очередь, перемотка, громкость — из чата и из браузера,
|
||
состояние синхронизировано в обе стороны через WebSocket.
|
||
|
||
**Источники:** YouTube, SoundCloud, прямые ссылки и интернет-радио, локальная медиатека (опционально).
|
||
**Голос:** LiveKit — тот же, что и в вашем инстансе (`revoice.js` + `@livekit/rtc-node`), звук готовит `ffmpeg`.
|
||
**Вход в панель:** учётными записями вашего же Stoat (пароль уходит прямо в `/auth/session/login`
|
||
вашего инстанса, бот его не хранит) либо одноразовой ссылкой по команде `!panel`.
|
||
|
||
---
|
||
|
||
## Как это устроено
|
||
|
||
```
|
||
Stoat (чат) ──messageCreate──► bot/commands ─┐
|
||
├──► MusicManager ──► GuildPlayer ──► LiveKit
|
||
Браузер ──REST + WebSocket──► api/server ───┘ (очередь, (ffmpeg,
|
||
громкость, yt-dlp)
|
||
повтор)
|
||
```
|
||
|
||
Чат-команды и панель дергают один и тот же `MusicManager`, поэтому «нажал в браузере — увидел в чате»
|
||
работает без рассинхрона. Каждое действие панели повторно проверяет членство и права в Stoat,
|
||
так что доступ живёт в ролях Stoat, а не в отдельной базе бота.
|
||
|
||
| Слой | Файлы |
|
||
| --- | --- |
|
||
| Источники и yt-dlp | [src/sources](src/sources) |
|
||
| Плеер и очередь | [src/core/player.ts](src/core/player.ts), [src/core/manager.ts](src/core/manager.ts) |
|
||
| Чат-бот | [src/bot](src/bot) |
|
||
| REST + WebSocket | [src/api/server.ts](src/api/server.ts) |
|
||
| Веб-панель (React) | [web/src](web/src) |
|
||
|
||
---
|
||
|
||
## Установка рядом со Stoat
|
||
|
||
Предполагается раскладка `/opt/stoat` (инстанс) и `/opt/stoat-mbot` (этот репозиторий).
|
||
|
||
### 1. Клонировать репозиторий
|
||
|
||
`/opt` принадлежит root, поэтому клонируем под `sudo` и сразу возвращаем владение себе:
|
||
|
||
```bash
|
||
sudo git clone https://gitea.hsrv.site/mrleo1nid/stoat-mbot.git /opt/stoat-mbot
|
||
```
|
||
|
||
```bash
|
||
sudo chown -R $USER:$USER /opt/stoat-mbot
|
||
```
|
||
|
||
`chown` здесь не косметика: дальше вы правите `.env` и `compose.yml`, а bind-mount `./data`
|
||
иначе создастся от root. Контейнер работает под пользователем `node` (uid 1000) — если ваш
|
||
пользователь тоже uid 1000 (`id -u`), права на `data/` совпадут и yt-dlp сможет туда писать.
|
||
|
||
### 2. Создать бота в Stoat
|
||
|
||
Settings → **My Bots** → создать бота → скопировать токен → пригласить бота на сервер.
|
||
Боту нужны права: читать сообщения, писать сообщения и подключаться к голосовым каналам.
|
||
|
||
### 3. Заполнить `.env`
|
||
|
||
```bash
|
||
cd /opt/stoat-mbot
|
||
cp .env.example .env
|
||
openssl rand -hex 32 # → JWT_SECRET
|
||
```
|
||
|
||
Минимум, что нужно указать:
|
||
|
||
```dotenv
|
||
STOAT_API_URL=https://stoat.example.com/api
|
||
STOAT_BOT_TOKEN=<токен бота>
|
||
PUBLIC_URL=https://music.example.com
|
||
JWT_SECRET=<случайные 32 байта>
|
||
```
|
||
|
||
`STOAT_API_URL` указывается публичный (`https://домен/api`): бот ведёт себя как обычный клиент —
|
||
ходит в API, gateway и LiveKit через тот же внешний прокси с валидным TLS, что и браузеры.
|
||
|
||
### 4. Указать домен инстанса в `compose.yml`
|
||
|
||
Бот запускается **отдельным compose-проектом** и с внутренним Caddy инстанса никак не связан.
|
||
В [compose.yml](compose.yml) поправьте одну строку — домен вашего Stoat:
|
||
|
||
```yaml
|
||
extra_hosts:
|
||
- "chat.example.com:host-gateway"
|
||
```
|
||
|
||
Эта запись заставляет контейнер резолвить домен инстанса в сам хост, где внешний Caddy держит
|
||
443 с валидным сертификатом. Так бот не зависит от NAT loopback на роутере.
|
||
|
||
### 5. Повесить панель на внешний Caddy
|
||
|
||
Порт панели публикуется только на локальный интерфейс (`127.0.0.1:3005`), домен выдаёт внешний
|
||
Caddy хоста — в `/etc/caddy/Caddyfile` ([deploy/Caddyfile.snippet](deploy/Caddyfile.snippet)):
|
||
|
||
```caddyfile
|
||
music.example.com {
|
||
reverse_proxy 127.0.0.1:3005
|
||
}
|
||
```
|
||
|
||
Затем `sudo systemctl reload caddy`. WebSocket проксируется автоматически.
|
||
|
||
### 6. Запустить
|
||
|
||
```bash
|
||
cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
|
||
```
|
||
|
||
В логах должно появиться `yt-dlp detected`, `bot is ready` и `panel is listening`.
|
||
|
||
---
|
||
|
||
## Использование
|
||
|
||
В чате (префикс по умолчанию `!`):
|
||
|
||
| Команда | Что делает |
|
||
| --- | --- |
|
||
| `!play <ссылка или название>` | добавить трек/плейлист в очередь |
|
||
| `!playnext`, `!playnow` | следующим / немедленно |
|
||
| `!search <запрос>` → `!pick <n>` | поиск с выбором из списка |
|
||
| `!skip [n]`, `!stop`, `!pause`, `!resume` | управление воспроизведением |
|
||
| `!queue [страница]`, `!nowplaying` | очередь и текущий трек |
|
||
| `!volume [0-200]`, `!loop [off\|track\|queue]`, `!shuffle` | звук и порядок |
|
||
| `!remove <n>`, `!clear`, `!seek 1:23` | правка очереди и перемотка |
|
||
| `!join`, `!leave` | зайти в ваш голосовой канал / выйти |
|
||
| `!panel` | личная ссылка на веб-панель (действует 10 минут) |
|
||
| `!help` | список команд |
|
||
|
||
Префиксы поиска: `sc:` — SoundCloud, `yt:` — YouTube, `local:` — локальная медиатека.
|
||
|
||
В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками,
|
||
клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история.
|
||
|
||
---
|
||
|
||
## Если бот не заходит в голосовой канал
|
||
|
||
Порядок диагностики — сверху вниз, каждый шаг отсекает свой слой:
|
||
|
||
1. **`bot is ready` в логах.** Нет — проблема в `STOAT_API_URL`/`STOAT_BOT_TOKEN`. Проверьте, что
|
||
домен из `extra_hosts` совпадает с доменом в `STOAT_API_URL`, и что изнутри контейнера он
|
||
резолвится в хост: `docker compose exec mbot node -e "fetch(process.env.STOAT_API_URL).then(r=>console.log(r.status))"`.
|
||
2. **`joining voice channel`, но нет `voice connection established`.** Значит `join_call` отдал
|
||
токен, а WebSocket до LiveKit не поднялся — смотрите, доступен ли `/livekit` через внешний домен.
|
||
3. **`Stoat считает, что бот уже в этом голосовом канале` (`AlreadyConnected`).** Зависшее
|
||
состояние в Redis после падения бота: `join_call` регистрирует участника, а выйти он не успел.
|
||
Ботам API запрещает `force_disconnect`, поэтому чистим вручную — в каталоге инстанса:
|
||
|
||
```bash
|
||
docker compose exec redis valkey-cli --scan --pattern 'vc:*'
|
||
```
|
||
|
||
```bash
|
||
docker compose exec redis valkey-cli DEL 'vc:<ID_бота>'
|
||
```
|
||
|
||
```bash
|
||
docker compose exec redis valkey-cli SREM 'vc_members:<ID_голосового_канала>' '<ID_бота>'
|
||
```
|
||
|
||
В норме состояние снимает `voice-ingress` по вебхуку от LiveKit — если ситуация повторяется
|
||
после каждого перезапуска, смотрите `docker compose logs voice-ingress`.
|
||
4. **Бот в канале, но звука нет.** Это уже медиа-трафик: LiveKit анонсирует клиентам свой адрес
|
||
из `rtc.node_ip` / `use_external_ip` в `/opt/stoat/livekit.yml` и ждёт UDP на 50000-50100.
|
||
Если анонсируется внешний IP, а роутер не умеет NAT loopback, пакеты от контейнера до него не
|
||
дойдут. Тогда либо включите hairpin на роутере, либо запустите бота внутри compose-проекта
|
||
Stoat — пример в [deploy/compose.stoat-network.yml.example](deploy/compose.stoat-network.yml.example).
|
||
|
||
## Настройки
|
||
|
||
Все параметры — в [.env.example](.env.example). Что стоит знать:
|
||
|
||
- `DELETE_COMMAND_MESSAGES=true` (по умолчанию) — бот удаляет сообщение с командой, чтобы не
|
||
засорять канал. Нужно право `ManageMessages`; без него команда всё равно отработает,
|
||
а неудачное удаление уйдёт в лог на уровне `debug`.
|
||
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
|
||
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
|
||
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
|
||
- `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера,
|
||
тогда заработают `local:` и поиск по медиатеке.
|
||
- `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже.
|
||
- `YTDLP_EXTRACTOR_ARGS` — дополнительные `--extractor-args` через `;`.
|
||
|
||
## Учётка YouTube (cookies)
|
||
|
||
Логин и пароль для YouTube yt-dlp не поддерживает — единственный рабочий способ авторизоваться
|
||
это `cookies.txt`. С ним открываются видео с возрастным ограничением, приватные и «только для
|
||
участников», а также снимается `Sign in to confirm you're not a bot`, которое YouTube любит
|
||
показывать серверным IP.
|
||
|
||
**Заводить лучше отдельный (одноразовый) аккаунт** — за автоматизацию YouTube может его
|
||
заблокировать, терять основной незачем.
|
||
|
||
1. Откройте **приватное окно** браузера и войдите в YouTube этим аккаунтом.
|
||
2. Экспортируйте куки для `youtube.com` расширением в формате Netscape (`Get cookies.txt LOCALLY`
|
||
и аналоги) — либо, если yt-dlp стоит локально: `yt-dlp --cookies-from-browser chrome --cookies cookies.txt`.
|
||
3. **Не закрывая приватное окно, выйдите из аккаунта в нём** (Log out) и только потом закройте
|
||
окно. Так YouTube не отзовёт сессию, к которой привязаны выгруженные куки.
|
||
4. Положите файл в `/opt/stoat-mbot/data/cookies.txt` и убедитесь, что он писабельный для uid 1000:
|
||
yt-dlp перезаписывает файл после каждого запуска, сохраняя обновлённые куки. Без права на запись
|
||
сессия быстро протухнет.
|
||
5. В `.env`: `YTDLP_COOKIES=/data/cookies.txt`, затем `docker compose up -d`.
|
||
|
||
В логах при старте появится `using YouTube cookies`; если файла нет или он только на чтение —
|
||
будет предупреждение с указанием причины.
|
||
|
||
Куки живут не вечно (обычно недели): когда в логах снова полезут ошибки авторизации, повторите
|
||
экспорт. Если YouTube упирается именно в бот-детект, попробуйте дополнительно
|
||
`YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari`.
|
||
|
||
## Разработка
|
||
|
||
```bash
|
||
npm install && npm --prefix web install
|
||
cp .env.example .env # STOAT_API_URL можно указать публичный адрес инстанса
|
||
npm run dev # бот + API на :3005
|
||
npm run web:dev # панель на :5180 с проксированием на :3005
|
||
```
|
||
|
||
Проверки: `npm run typecheck`, `npm run build`, `npm --prefix web run build`.
|
||
Локально нужен `yt-dlp` в `PATH` (или укажите `YTDLP_PATH`); `ffmpeg` приезжает с `ffmpeg-static`.
|
||
|
||
## Ограничения
|
||
|
||
- `revoice.js` — сторонняя библиотека без ретраев на разрыв LiveKit-соединения; при падении войса
|
||
бот выходит из канала, повторный `!join` восстанавливает работу.
|
||
- Перемотка для YouTube/SoundCloud перезапускает поток с нужной позиции (одна лишняя обращение к
|
||
yt-dlp), для локальных файлов и прямых ссылок — мгновенная.
|
||
- Скачивание с YouTube формально противоречит его ToS; используйте на своё усмотрение,
|
||
для «чистого» сценария есть SoundCloud, прямые ссылки и локальная медиатека.
|
||
|
||
## Лицензия
|
||
|
||
MIT — см. [LICENSE](LICENSE).
|