Initial version

This commit is contained in:
Jakub Zych
2026-04-27 23:21:39 +02:00
commit 1af250ae1a
9 changed files with 1241 additions and 0 deletions

225
README.md Normal file
View File

@@ -0,0 +1,225 @@
# 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.