Leonid PershinandClaude Opus 5 380f7bcf31 Check the proxy at startup instead of failing on first use
A proxy pointed at 127.0.0.1 from inside a container reaches the
container itself, and the only sign was a connection error buried in the
first search. Startup now probes the proxy over TCP and says what is
wrong, naming host.docker.internal when loopback was configured.

The README also covers the follow-up trap: even that address fails when
the proxy listens on loopback only, so it shows how to check the bind
address and what to change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:29:36 +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://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 правка очереди и перемотка
!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.

Прокси, когда 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 уже есть). Бот проверяет прокси при старте и пишет в лог понятную причину, если тот недоступен.

  • Прокси должен слушать интерфейс, видимый контейнеру. Даже с 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://.

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