Leonid PershinandClaude Opus 5 3e53f34374 Tie playback to the listener and keep the panel's view live
Four things people hit while using the panel:

- The bot could be sent into a channel the requester was not in, and
  playback could be started from nowhere. Playback now follows the
  listener (REQUIRE_LISTENER, on by default), and the voice row shows
  where you and the bot are instead of offering a free channel picker.
- Voice presence only refreshed on reload, because the SDK updates
  channel participants without emitting an event. The socket now watches
  that view and pushes changes.
- The bot left the channel whenever the queue ran dry. It now leaves only
  after the last person does, EMPTY_TIMEOUT_SECONDS later (120 by
  default), and stays put while anyone is still listening.
- A search that yielded nothing said nothing: yt-dlp can exit 0 with an
  empty result, so that case now reports the reason (or "nothing found"),
  and searches are logged with their result count.

The queue moved under the player so search owns the left column, and
elapsed time no longer renders as "LIVE" — formatDuration treated 0 as a
live stream, which also affected the chat's progress bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:59:17 +03:00
2026-09-08 19:37:00 +00:00

stoat-mbot

Музыкальный бот для self-hosted Stoat с веб-панелью: поиск и воспроизведение в голосовых каналах, очередь, перемотка, громкость — из чата и из браузера, состояние синхронизировано в обе стороны через 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/core/player.ts, src/core/manager.ts
Чат-бот src/bot
REST + WebSocket src/api/server.ts
Веб-панель (React) web/src

Установка рядом со Stoat

Предполагается раскладка /opt/stoat (инстанс) и /opt/stoat-mbot (этот репозиторий).

1. Клонировать репозиторий

/opt принадлежит root, поэтому клонируем под sudo и сразу возвращаем владение себе:

sudo git clone https://gitea.hsrv.site/mrleo1nid/stoat-mbot.git /opt/stoat-mbot
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

cd /opt/stoat-mbot
cp .env.example .env
openssl rand -hex 32   # → JWT_SECRET

Минимум, что нужно указать:

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 поправьте одну строку — домен вашего Stoat:

    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):

music.example.com {
	reverse_proxy 127.0.0.1:3005
}

Затем sudo systemctl reload caddy. WebSocket проксируется автоматически.

6. Запустить

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, поэтому чистим вручную — в каталоге инстанса:

    docker compose exec redis valkey-cli --scan --pattern 'vc:*'
    
    docker compose exec redis valkey-cli DEL 'vc:<ID_бота>'
    
    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.

Настройки

Все параметры — в .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: играть может любой участник сервера.
  • EMPTY_TIMEOUT_SECONDS — через сколько секунд после ухода последнего человека бот покидает голосовой канал (по умолчанию 120, 0 — не выходить никогда). Пустая очередь поводом уйти не считается: пока в канале кто-то есть, бот ждёт следующий трек.
  • LOCAL_MEDIA_DIR — примонтируйте том с музыкой и укажите путь внутри контейнера, тогда заработают local: и поиск по медиатеке.
  • YTDLP_COOKIES — путь к cookies.txt, см. раздел ниже.
  • YTDLP_EXTRACTOR_ARGS — дополнительные --extractor-args через ;.

Обновление yt-dlp

YouTube регулярно ломает экстракторы, и симптом всегда один: трек находится, но не играет, а в логах — ERROR: [youtube] ...: The page needs to be reloaded или подобное. Лечится обновлением yt-dlp: версия зашита в Dockerfile как ARG YTDLP_VERSION.

cd /opt/stoat-mbot && git pull && docker compose up -d --build

Если свежая версия вышла, а обновления репозитория ещё нет — можно указать её сразу:

docker compose build --build-arg YTDLP_VERSION=$(date +%Y.%m.%d) && docker compose up -d

Актуальный тег — на странице релизов yt-dlp.

Учётка 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.

Разработка

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.

S
Description
No description provided
Readme MIT
928 KiB
Languages
TypeScript 92.5%
CSS 4.1%
JavaScript 2.1%
Dockerfile 0.9%
Vim Snippet 0.2%
Other 0.2%