# 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 ` | поиск с выбором из списка | | `!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. **Бот в канале, но звука нет.** Это уже медиа-трафик: 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). Что стоит знать: - `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer` и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера. - `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса. - `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера, тогда заработают `local:` и поиск по медиатеке. - `YTDLP_COOKIES` — путь к `cookies.txt`, если YouTube просит подтверждения возраста или логина. ## Разработка ```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).