2026-04-27 23:21:39 +02:00
2026-04-27 23:21:39 +02:00
2026-04-27 23:21:39 +02:00
2026-04-27 23:21:39 +02:00
2026-04-27 23:21:39 +02:00
2026-04-27 23:21:39 +02:00
2026-04-27 23:21:39 +02:00
2026-04-27 23:21:39 +02:00
2026-04-27 23:21:39 +02:00
2026-04-27 23:21:39 +02:00

twitch-bot

Lightweight Twitch chat bot that controls a local MPD music daemon, plus optional pre-stream automation and going-live notifications.

Built for a personal Twitch rig (ophi118) and extracted here as a stand-alone bot. Stdlib + python-mpd2, no other dependencies.

What it does

  • Chat-driven music control: viewers run !skip, !queue, !info and the bot drives MPD on your machine. Per-user 60s cooldown on !skip so a single viewer can't spam.
  • Pre-stream helper: examples/stream.sh walks you through one prompt, resolves the game name to a Twitch category id, updates your channel title + game in one PATCH, and writes a stream.json manifest any overlay/timer can read.
  • Going-live notifier (optional): when MM_HOOK is set the bot polls a public status JSON every 60s and POSTs to the webhook on each offline → live transition. Works against any Mattermost/Slack-shape incoming webhook.

Requirements

  • Python 3.10+
  • MPD reachable on localhost:6600 (override with MPD_HOST / MPD_PORT). Only needed for the chat commands — the pre-stream helper and notifier work standalone.
  • One Twitch token for the bot account (chat:read + chat:edit scopes)
  • One Twitch token for the broadcaster account if you want examples/stream.sh (channel:manage:broadcast scope)
  • Easiest way to mint tokens: https://twitchtokengenerator.com
pip install python-mpd2

Layout

twitch-bot/
├── bot.py                     # the chat bot (long-running)
├── _twitch.py                 # shared Helix auth helpers (urllib only)
├── search-game.py             # CLI: free-form game query → "<id>\t<name>"
├── set-channel.py             # CLI: PATCH /helix/channels (title + game)
├── .env.example               # template for bot.py
├── .env.broadcaster.example   # template for set-channel.py / stream.sh
└── examples/
    └── stream.sh              # interactive pre-stream helper

The bot reads .env. The pre-stream tools read .env.broadcaster. Two files because they hold credentials for two different Twitch accounts with different scopes — do not merge them.

Quick start

1. Configure the bot

cp .env.example .env
$EDITOR .env

Minimum required:

TWITCH_ACCESS_TOKEN=<oauth token from twitchtokengenerator>
TWITCH_NICK=mybot          # bot account login (lowercase)
TWITCH_CHANNEL=mychannel   # channel to join, no leading '#'

2. Run

python3 bot.py

Open chat in your channel and try !skip, !queue, !info. The bot replies in chat and the music skip lands on stream because MPD is the audio source — the bot just sends next to it.

3. (Optional) pre-stream helper

cp .env.broadcaster.example .env.broadcaster
$EDITOR .env.broadcaster
./examples/stream.sh

See Pre-stream helper below.

Chat commands

Command Effect
!skip Advance MPD to the next track. Per-user 60s cooldown (SKIP_COOLDOWN in bot.py).
!queue Print the next 3 queued tracks (QUEUE_PEEK).
!info Print full metadata for now-playing — artist · title · album · year · genre.

There is intentionally no moderator gate. Anyone in chat can !skip. The 60s per-user cooldown is the only abuse mitigation. If you want stricter controls, fork — the dispatch lives in bot.py:TwitchBot._handle.

Pre-stream helper

examples/stream.sh is an interactive prompt that:

  1. Asks you for a game name and resolves it to a Twitch category id via search-game.py (you pick from up to 20 matches; auto-picks when there's only one).
  2. Asks you for a stream title and a countdown duration.
  3. Writes stream.json next to the script with the resolved values.
  4. PATCHes your Twitch channel — sets title and game in one call.

Re-running uses your previous answers as defaults.

$ ./examples/stream.sh
── stream manifest ──
  (press Enter to keep [defaults])

  Game (search): doom
── 5 matches for 'doom' ──
   1) DOOM
   2) DOOM Eternal
   3) DOOM (1993)
   ...
   0) cancel
Pick: 2
  Title (Twitch stream title): Slayer is back — Nightmare run
  Countdown (minutes)  [5]:

  ✓ wrote /home/you/twitch-bot/examples/stream.json
  ✓ Twitch channel updated → game_id=517520, title='Slayer is back — Nightmare run'

Resulting stream.json:

{
  "compiledAt":   "2026-04-27T22:55:11Z",
  "game":         "DOOM Eternal",
  "gameId":       "517520",
  "title":        "Slayer is back — Nightmare run",
  "countdownMin": 5
}

Read it from any starting-soon overlay, OBS browser source, OBS text source, or static HTML page that wants to show what's coming up. Override the output path with STREAM_JSON=/some/where.json ./examples/stream.sh.

If set-channel.py or .env.broadcaster aren't present, the script soft-fails the Twitch sync step — the local stream.json is still written.

Token refresh

set-channel.py and search-game.py both go through _twitch.py, which auto-refreshes the access token via twitchtokengenerator on a 401, persists the new token back to .env.broadcaster, and retries. You don't need to babysit token expiry.

Direct invocation

The Python helpers are fine to call directly without the shell wrapper:

./search-game.py "Heroes"               # stdout: "<id>\t<canonical_name>"
./set-channel.py --game-id 517520 "..."
./set-channel.py "DOOM Eternal" "..."   # exact-match lookup, no fuzzy search
./set-channel.py --dry-run --game-id 517520 "..."

Going-live notifications

If MM_HOOK is set in .env, the bot starts a second async task (live_watch) that polls a status JSON every 60 seconds and POSTs to the webhook on every offline → live transition.

MM_HOOK=https://chat.example.com/hooks/abc123
STATUS_URL=https://your-status-page.example.com/api/status.json

The status URL must serve a JSON document of this shape:

{
  "live": true,
  "started_at": "2026-04-27T18:30:00Z",
  "title": "...",
  "game_name": "..."
}

Transition detection keys on started_at, not live alone. Twitch Helix occasionally flaps the live flag mid-stream; ignoring those phantom edges is the whole point of polling a stable upstream value.

Cold start mid-stream is silent on purpose: the bot records the current started_at on its first poll and only fires when it changes. Restarting the bot during an active stream will not double-post.

You provide the status endpoint — the bot is a consumer, not a server. Easy ways to roll one:

  • A small worker that calls Helix GET /streams?user_login=... every 60s and writes a JSON file to your web root.
  • A tiny webapp serving the same shape from any framework.
  • Skip notifications entirely by leaving MM_HOOK unset.

Running as a systemd user service

# ~/.config/systemd/user/twitch-bot.service
[Unit]
Description=Twitch chat → MPD control bot
After=mpd.service mpd.socket network-online.target
Wants=mpd.service network-online.target

[Service]
Type=simple
WorkingDirectory=%h/path/to/twitch-bot
ExecStart=/usr/bin/python3 %h/path/to/twitch-bot/bot.py
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now twitch-bot.service
journalctl --user -u twitch-bot -f

Restart=on-failure covers Twitch-IRC drops and MPD restarts. The bot's reconnect loop also handles transient drops without exiting; the systemd restart is the ultimate safety net.

Troubleshooting

Symptom What to check
Bot connects but never sees chat Brand-new Twitch accounts can be silently throttled. Set TWITCH_BOT_DEBUG=1 in .env to dump every received IRC line. If you see JOIN lines but no PRIVMSG, the account is the problem — not the bot.
!skip says "MPD is not playing anything" but it is Wrong host/port. The bot connects to localhost:6600 by default; override with MPD_HOST / MPD_PORT env vars.
set-channel.py returns 401 / 403 The broadcaster token must (a) carry the channel:manage:broadcast scope and (b) belong to the same account whose numeric id is in TWITCH_BROADCASTER_ID. Twitch enforces both.
Mattermost notifier never fires Hit your STATUS_URL with curl first — it must serve live: true with a stable started_at for the bot to detect a transition. The bot logs [live] lines to stdout/journal on each poll if you need to confirm it's actually polling.
auth_call keeps refreshing every request Your TWITCH_ACCESS_TOKEN is wrong or revoked but the refresh token still mints a working one. Regenerate the access token at twitchtokengenerator and update .env.broadcaster.

Notes on the code

  • All HTTP is urllib.request. No requests. No async HTTP. The bot's IRC loop is async (asyncio + raw TLS sockets), the helpers are not — keeps the helpers usable as plain CLI scripts.
  • _twitch.py looks for .env.broadcaster first, falls back to .env.ophi118 (legacy filename from the rig this was extracted from). Either works.
  • bot.py:load_env is a separate 12-line .env parser instead of pulling in python-dotenv. Three vars don't justify the dependency.
  • bot.py does not currently use the bot account's refresh token. If access tokens start expiring mid-stream, refresh would land in the IRC reconnect handler.

License

No license file ships in this repo yet — add one before redistributing. MIT or 0BSD if you want maximally permissive; AGPL if you want forks to stay open.

Description
No description provided
Readme 57 KiB
Languages
Python 100%