Initial version
This commit is contained in:
225
README.md
Normal file
225
README.md
Normal 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.
|
||||
Reference in New Issue
Block a user