# twitch-bot Lightweight Twitch chat bot that controls a local [MPD](https://www.musicpd.org/) 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](https://www.musicpd.org/) 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: ```bash 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 → "\t" ├── 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 ```bash cp .env.example .env $EDITOR .env ``` Minimum required: ```env TWITCH_ACCESS_TOKEN= TWITCH_NICK=mybot # bot account login (lowercase) TWITCH_CHANNEL=mychannel # channel to join, no leading '#' ``` ### 2. Run ```bash 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 ```bash cp .env.broadcaster.example .env.broadcaster $EDITOR .env.broadcaster ./examples/stream.sh ``` See [Pre-stream helper](#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. ```text $ ./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`: ```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: ```bash ./search-game.py "Heroes" # stdout: "\t" ./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. ```env 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: ```json { "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 ```ini # ~/.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 ``` ```bash 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.