226 lines
9.5 KiB
Markdown
226 lines
9.5 KiB
Markdown
# 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: <https://twitchtokengenerator.com>
|
|
|
|
```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 → "<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
|
|
|
|
```bash
|
|
cp .env.example .env
|
|
$EDITOR .env
|
|
```
|
|
|
|
Minimum required:
|
|
|
|
```env
|
|
TWITCH_ACCESS_TOKEN=<oauth token from twitchtokengenerator>
|
|
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: "<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.
|
|
|
|
```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.
|