YouTube's bot checks started once traffic went through a proxy exit IP, while direct downloads had worked. YTDLP_PROXY_SCOPE=search keeps the proxy on search and metadata — where it is needed to get past filtered results — and lets the audio stream go out directly. Default stays "all", so nothing changes unless it is set. Searches also now run against a throwaway copy of the cookie file: they run in parallel and yt-dlp rewrites that file on exit, so two of them could clobber the jar the downloads depend on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
357 lines
24 KiB
Markdown
357 lines
24 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://chat.example.com/api
|
||
STOAT_DOMAIN=chat.example.com
|
||
STOAT_BOT_TOKEN=<токен бота>
|
||
PUBLIC_URL=https://music.example.com
|
||
JWT_SECRET=<случайные 32 байта>
|
||
```
|
||
|
||
`STOAT_API_URL` указывается публичный (`https://домен/api`): бот ведёт себя как обычный клиент —
|
||
ходит в API, gateway и LiveKit через тот же внешний прокси с валидным TLS, что и браузеры.
|
||
|
||
### 4. Про `STOAT_DOMAIN`
|
||
|
||
Бот запускается **отдельным compose-проектом** и с внутренним Caddy инстанса никак не связан.
|
||
`STOAT_DOMAIN` из `.env` подставляется в `extra_hosts`, и контейнер резолвит домен инстанса
|
||
в сам хост, где внешний Caddy держит 443 с валидным сертификатом — так бот не зависит от
|
||
NAT loopback на роутере. **Сам [compose.yml](compose.yml) править не нужно**, иначе `git pull`
|
||
будет конфликтовать с обновлениями.
|
||
|
||
### 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` (по умолчанию) — бот удаляет сообщение с командой, чтобы не
|
||
засорять канал. Для этого роли бота нужно право **Manage Messages** в настройках сервера
|
||
(Settings → Roles → роль бота) или в правах самого канала. Без него команда всё равно
|
||
отработает, а в логе будет `could not delete command message` с причиной отказа.
|
||
- `REQUIRE_LISTENER=true` (по умолчанию) — позвать бота можно только в тот голосовой канал, где
|
||
вы сами сидите: музыка идёт за слушателем, отправить бота «куда-то ещё» из панели нельзя.
|
||
Поставьте `false`, если хотите выбирать канал вручную.
|
||
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
|
||
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
|
||
- `MAX_PLAYLIST_TRACKS` — сколько треков максимум добавит одна ссылка на плейлист или микс
|
||
(по умолчанию 100). Любая ссылка с `v=` — в том числе скопированная из открытого микса
|
||
`watch?v=…&list=RD…` — добавляет **только сам трек**: обычно имеют в виду именно его.
|
||
Чтобы добавить весь список, вставьте ссылку вида `/playlist?list=…`.
|
||
- `EMPTY_TIMEOUT_SECONDS` — через сколько секунд после ухода последнего человека бот покидает
|
||
голосовой канал (по умолчанию 120, `0` — не выходить никогда). Пустая очередь поводом уйти
|
||
не считается: пока в канале кто-то есть, бот ждёт следующий трек.
|
||
- `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера,
|
||
тогда заработают `local:` и поиск по медиатеке.
|
||
- `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже.
|
||
- `YTDLP_EXTRACTOR_ARGS` — дополнительные `--extractor-args` через `;`.
|
||
|
||
## Поиск иногда не находит то, что видно в браузере
|
||
|
||
YouTube может вернуть пустой список там, где в залогиненном браузере результаты есть: для
|
||
программного доступа применяется фильтрация (ограниченный режим), причём молча — yt-dlp
|
||
завершается успешно и без единого сообщения, куки не помогают. Признак: тот же запрос без
|
||
«спорного» слова находится нормально.
|
||
|
||
Что делает бот: если YouTube ничего не отдал, поиск автоматически повторяется в SoundCloud,
|
||
а если пусто и там — сообщает об этом прямо, а не показывает пустой список. Надёжный обход —
|
||
вставить прямую ссылку на трек: по ссылке фильтр не применяется.
|
||
|
||
## Обновление yt-dlp
|
||
|
||
YouTube регулярно ломает экстракторы, и симптом всегда один: трек находится, но не играет, а в
|
||
логах — `ERROR: [youtube] ...: The page needs to be reloaded` или подобное. Лечится обновлением
|
||
yt-dlp: версия зашита в [Dockerfile](Dockerfile) как `ARG YTDLP_VERSION`.
|
||
|
||
```bash
|
||
cd /opt/stoat-mbot && git pull && docker compose up -d --build
|
||
```
|
||
|
||
Если свежая версия вышла, а обновления репозитория ещё нет — можно указать её сразу:
|
||
|
||
```bash
|
||
docker compose build --build-arg YTDLP_VERSION=$(date +%Y.%m.%d) && docker compose up -d
|
||
```
|
||
|
||
Актуальный тег — на [странице релизов yt-dlp](https://github.com/yt-dlp/yt-dlp/releases).
|
||
|
||
## Прокси, когда YouTube недоступен
|
||
|
||
`YTDLP_PROXY` пропускает через прокси **все** обращения yt-dlp — поиск, метаданные и сам
|
||
аудиопоток, и для YouTube, и для SoundCloud:
|
||
|
||
```dotenv
|
||
YTDLP_PROXY=socks5h://127.0.0.1:1080
|
||
```
|
||
|
||
Схемы: `http://`, `https://`, `socks5://`, `socks5h://`; можно с логином и паролем
|
||
(`socks5h://user:pass@host:1080`). `socks5h` резолвит DNS на стороне прокси — обычно нужен
|
||
именно он, иначе имена доменов всё равно уходят в локальную сеть. При старте бот пишет в лог,
|
||
через какой прокси работает, скрывая учётные данные.
|
||
|
||
Два подводных камня:
|
||
|
||
- **`127.0.0.1` внутри контейнера — это сам контейнер.** Если Psiphon или ваш SOCKS запущен на
|
||
хосте, укажите `socks5h://host.docker.internal:1080` (эта запись в `extra_hosts` уже есть).
|
||
Бот проверяет прокси при старте и пишет в лог понятную причину, если тот недоступен.
|
||
- **Прокси в соседнем контейнере — подключайтесь по имени, а не через хост.** Если прокси
|
||
запущен в другом compose-проекте и публикует порт как `127.0.0.1:1080`, из контейнера бота он
|
||
недоступен вообще: публикация на loopback видна только самому хосту. Заведите общую сеть —
|
||
готовый пример в [deploy/compose.proxy.yml.example](deploy/compose.proxy.yml.example):
|
||
|
||
```bash
|
||
docker network ls | grep psiphon
|
||
cp deploy/compose.proxy.yml.example compose.override.yml
|
||
```
|
||
|
||
затем в `.env` — `YTDLP_PROXY=socks5h://psiphon:1080` (имя контейнера прокси) и, если сеть
|
||
называется иначе, `PROXY_NETWORK=<имя>`. Файл `compose.override.yml` в `.gitignore`, так что
|
||
обновления с ним не конфликтуют.
|
||
- **Прокси должен слушать интерфейс, видимый контейнеру.** Даже с `host.docker.internal`
|
||
соединение упрётся в `ECONNREFUSED`, если клиент забинден только на loopback. Проверить:
|
||
|
||
```bash
|
||
ss -lntp | grep 1080
|
||
```
|
||
|
||
Если там `127.0.0.1:1080`, разрешите прослушивание всех интерфейсов (в конфиге
|
||
psiphon-tunnel-core за это отвечает `ListenInterface`) и закройте порт снаружи файрволом.
|
||
Альтернатива без правки прокси — добавить сервису `network_mode: host`, тогда `127.0.0.1:1080`
|
||
работает как есть; но при этом перестают действовать `ports` и `extra_hosts`, то есть
|
||
`STOAT_DOMAIN` снова начнёт резолвиться публично, а панель нужно будет привязать к
|
||
`HOST=127.0.0.1`.
|
||
- **ffmpeg не умеет SOCKS.** Поэтому при заданном прокси аудио всегда идёт через yt-dlp, а не
|
||
напрямую из CDN — трафик не утекает мимо прокси. Побочный эффект: перемотка становится
|
||
медленнее, так как позиция отыгрывается декодированием, а не HTTP-запросом с диапазоном.
|
||
Прямые ссылки и интернет-радио ffmpeg скачивает сам, и там прокси применяется только для схем
|
||
`http://` и `https://`.
|
||
|
||
## «Sign in to confirm you're not a bot»
|
||
|
||
Три причины, по которым YouTube так отвечает, в порядке частоты:
|
||
|
||
1. **Нет JS-рантайма.** Современный yt-dlp обязан исполнять JavaScript плеера YouTube; без этого
|
||
извлечение деградирует и запросы выглядят как ботовые. В образе уже есть Node, и бот включает
|
||
его флагом `--js-runtimes node` (`YTDLP_JS_RUNTIME`). При старте в логе видно, что рантайм
|
||
подхвачен: `"jsRuntime":"node"`. Если там `null` — рантайм не определился, будет отдельное
|
||
предупреждение.
|
||
2. **Нет или протухли cookies** — см. раздел ниже. Проверить файл прямо в контейнере:
|
||
|
||
```bash
|
||
docker compose exec mbot yt-dlp --cookies /data/cookies.txt --simulate https://www.youtube.com/watch?v=dQw4w9WgXcQ
|
||
```
|
||
|
||
3. **Подозрительный IP.** Выходные узлы Psiphon и прочих публичных прокси YouTube знает и
|
||
проверяет чаще. Если без прокси скачивание работало, а с ним пошли бот-проверки — оставьте
|
||
прокси только поиску:
|
||
|
||
```dotenv
|
||
YTDLP_PROXY_SCOPE=search
|
||
```
|
||
|
||
Тогда через прокси идут поиск и метаданные (ради обхода фильтрации выдачи), а аудиопоток
|
||
качается напрямую. Второй путь — cookies, экспортированные из браузера, работающего через
|
||
тот же прокси, чтобы сессия не выглядела прыгающей между странами.
|
||
|
||
## Учётка 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).
|