Files
stoat-mbot/README.md
T
Leonid PershinandClaude Opus 5 a9b680c418 Make video a per-server toggle instead of an env-wide setting
VIDEO_ENABLED is now permission rather than behaviour: it decides whether
the feature exists at all, while turning it on for a server is a toggle in
the panel or !video in chat, off by default. Video costs real CPU for
every playing channel, so that should be a deliberate choice rather than
something a config flag switches on everywhere.

With the env flag off the panel renders no toggle at all and !video says
so, and the switch applies from the next track — swapping tracks mid-play
would cut the current one.

The loop button no longer reads "выкл" either: it sat next to the video
button showing the same word, so the two states were indistinguishable.
Both now name what they do and rely on highlighting for state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:13:08 +03:00

28 KiB
Raw Blame History

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://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 править не нужно, иначе git pull будет конфликтовать с обновлениями.

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 правка очереди и перемотка
!video [on|off] показывать клип как демонстрацию экрана
!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: играть может любой участник сервера.
  • 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 как 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.

Видео: клип как демонстрация экрана

VIDEO_ENABLED=true разрешает боту публиковать картинку клипа отдельной дорожкой (screen share), которую видно в голосовом канале. Само включение — тумблер 📺 видео в панели или команда !video on|off, и по умолчанию оно выключено: видео стоит дороже звука, поэтому платить за него нужно осознанно. Переключение применяется со следующего трека — менять дорожки на лету значило бы прервать текущий. При VIDEO_ENABLED=false тумблер в панели не показывается вовсе, а !video отвечает, что видео выключено в настройках.

VIDEO_ENABLED=true
VIDEO_WIDTH=640
VIDEO_HEIGHT=360
VIDEO_FPS=24

Что нужно на стороне Stoat: у роли бота — право Video в канале, а на инстансе включённое видео (VIDEO_ENABLED при генерации конфига, он же video_resolution в Revolt.toml). Токен на вход в звонок выдаёт разрешение публиковать screen_share только при обоих условиях; иначе бот сообщит в чат, что видео недоступно, и продолжит играть звук.

Как это устроено и почему так:

  • Только YouTube и только 360p. Стримить можно лишь прогрессивный формат — один файл со звуком и картинкой. Раздельные дорожки (720p и выше) yt-dlp обязан сначала скачать целиком и лишь потом склеить, то есть воспроизведение началось бы после полной загрузки. Отдавать ffmpeg прямые ссылки на CDN тоже нельзя: YouTube их для сторонних клиентов подвешивает.
  • Один процесс ffmpeg, два выхода: PCM для голосовой дорожки и сырые I420-кадры для видео. Темп задаёт -re, иначе кадры улетали бы вперёд звука и съедали память — распакованный кадр 720p весит 1.4 МБ.
  • Цена. Кодирование видео ложится на CPU сервера и держится всё время трека, в отличие от почти бесплатного звука. Ставьте VIDEO_FPS пониже, если нагрузка мешает.
  • Живые трансляции и не-YouTube источники играют звуком, как раньше.

Прокси, когда YouTube недоступен

YTDLP_PROXY пропускает через прокси все обращения yt-dlp — поиск, метаданные и сам аудиопоток, и для YouTube, и для SoundCloud:

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:

    docker network ls | grep psiphon
    cp deploy/compose.proxy.yml.example compose.override.yml
    

    затем в .envYTDLP_PROXY=socks5h://psiphon:1080 (имя контейнера прокси) и, если сеть называется иначе, PROXY_NETWORK=<имя>. Файл compose.override.yml в .gitignore, так что обновления с ним не конфликтуют.

  • Прокси должен слушать интерфейс, видимый контейнеру. Даже с host.docker.internal соединение упрётся в ECONNREFUSED, если клиент забинден только на loopback. Проверить:

    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 — см. раздел ниже. Проверить файл прямо в контейнере:

    docker compose exec mbot yt-dlp --cookies /data/cookies.txt --simulate https://www.youtube.com/watch?v=dQw4w9WgXcQ
    
  3. Подозрительный IP. Выходные узлы Psiphon и прочих публичных прокси YouTube знает и проверяет чаще. Если без прокси скачивание работало, а с ним пошли бот-проверки — оставьте прокси только поиску:

    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.

Разработка

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.