# 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 ` | поиск с выбором из списка | | `!skip [n]`, `!stop`, `!pause`, `!resume` | управление воспроизведением | | `!queue [страница]`, `!nowplaying` | очередь и текущий трек | | `!volume [0-200]`, `!loop [off\|track\|queue]`, `!shuffle` | звук и порядок | | `!remove `, `!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:' ``` ```bash docker compose exec redis valkey-cli SREM 'vc_members:' '' ``` В норме состояние снимает `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://`. ## Учётка 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).