Files
twitch-bot/CLAUDE.md
2026-04-27 23:41:28 +02:00

103 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Standalone Twitch chat bot that controls a local MPD music daemon, with optional pre-stream channel-update tooling and going-live notifications. Extracted from the `ophi118` OBS rig and now a self-contained repo (its own `.git`, its own `.gitignore`).
The README.md is the user-facing doc — read it once. This file captures the parts a future Claude needs that aren't obvious from reading individual files.
## Run / develop
```bash
pip install python-mpd2 # only runtime dep beyond stdlib
python3 bot.py # runs the chat bot in foreground
TWITCH_BOT_DEBUG=1 python3 bot.py # dump every received IRC line
./search-game.py "Heroes" # CLI: <id>\t<name> on stdout
./set-channel.py --dry-run --game-id 517520 "title" # no PATCH, just prints
./examples/stream.sh # interactive pre-stream wrapper
```
There are no tests, no linter, no build step. The two Twitch helpers (`search-game.py`, `set-channel.py`) are direct-callable; `examples/stream.sh` just composes them with `read` prompts.
## Two-account / two-env split (do not merge)
This is the single most load-bearing design decision in the repo:
| File | Account | Used by | Required scopes |
|---|---|---|---|
| `.env` | **Bot** account (`vault118`) | `bot.py` only | `chat:read`, `chat:edit` |
| `.env.broadcaster` | **Broadcaster** account (`ophi118`) | `set-channel.py`, `search-game.py`, `examples/stream.sh` | `channel:manage:broadcast` |
Twitch enforces `broadcaster_id == token user_id` on `PATCH /helix/channels`, so the broadcaster token cannot be replaced with the bot token even if the bot account is moderator. Anything that walks new code through `_twitch.py` reads `.env.broadcaster`; `bot.py` has its own 12-line env loader and reads `.env`.
`_twitch.py` looks for `.env.broadcaster` first, falls back to `.env.ophi118` if present (legacy name from the original rig). New deployments should use `.env.broadcaster`.
## Auth model: 401 → refresh → retry, transparently
`_twitch.py:auth_call` is the only thing in the codebase that touches Twitch Helix. On a 401 it:
1. POSTs the refresh token to `twitchtokengenerator.com/api/refresh/<refresh>` (NOT Twitch's OAuth endpoint — twitchtokengenerator owns the client secret for tokens it minted).
2. Persists `TWITCH_ACCESS_TOKEN` + `TWITCH_REFRESH_TOKEN` to `.env.broadcaster` via `update_env` (atomic temp-file rewrite, comments preserved).
3. Mutates the in-process `env` dict so subsequent calls in the same process see the new token.
4. Retries the original request once.
If you add a new Helix-touching script, route it through `auth_call` — never call `urllib.request` against Helix directly, or token expiry will surface as a hard 401 instead of a transparent recovery.
`bot.py` does NOT currently use the bot account's refresh token. Twitch IRC tokens are long-lived enough that this hasn't mattered, but if access tokens start dying mid-stream, refresh would land in the IRC reconnect handler in `TwitchBot.run` (the `RuntimeError` "auth failed" branch).
## Going-live watcher: transition detection on `started_at`, not `live`
`bot.py:live_watch` polls `STATUS_URL` (default `https://ophi118.com/api/status.json`) every 60s when `MM_HOOK` is set. The non-obvious behavior:
- Transitions key on `twitch.started_at` changing, NOT on `twitch.live` going false→true. Twitch Helix occasionally flaps the `live` flag inside one stream session — same `started_at`, different `live` reads. Keying on `started_at` ignores those phantom edges.
- **Cold start mid-stream is silent on purpose.** If the bot starts up and sees `live=true` without ever having seen `live=false`, it records the current `started_at` so it won't fire for it later, but it doesn't announce. The `seen_offline` flag gates announcements; a real offline→live transition is required after process start.
- The expected JSON shape is `{twitch: {live, started_at, title, game}}` — note the wrapping `twitch` key. Any consumer-side endpoint must serve that shape, not the flat shape shown in the README's high-level overview.
If you change the status JSON shape, update `live_watch`'s `(data or {}).get("twitch") or {}` accordingly — the bot tolerates a missing `twitch` key by treating it as not-live.
## Twitch chat duplicate-message codepoint
`TwitchBot._handle` strips characters in U+E0000U+E007F before dispatch:
```python
body = "".join(c for c in body if not (0xE0000 <= ord(c) <= 0xE007F))
```
Twitch appends an invisible Tags-block codepoint to back-to-back identical messages from the same sender within ~30s as anti-duplicate protection. Without stripping it, every other `!skip` from a viewer in a series silently fails to match `head == "!skip"`. Do not remove this.
## search-game.py stdout discipline
`search-game.py` is callable from shell pipelines. **Only** the resolved `<id>\t<name>` line goes to stdout; every prompt, every error, every numbered list goes to stderr. The picker uses `print("Pick: ", file=sys.stderr); input()` instead of `input("Pick: ")` because `input`'s prompt argument writes to stdout and would corrupt the captured tuple.
A past bug: `examples/stream.sh` cached a stdout-leaked `Pick: 506462` string as `gameId` and passed it straight to `set-channel.py`. The script now validates `prev_game_id =~ ^[0-9]+$` before reusing — that regex is the post-mortem fix, not paranoia.
## stdlib-only
Both Twitch helpers and `_twitch.py` use only `urllib.request`. `bot.py` adds `python-mpd2` (its own `mpd.asyncio` async client) and nothing else. Reasons:
- The bot dir is intentionally venv-free; users should be able to `python3 bot.py` after one `pip install`.
- `_twitch.py:load_env` / `bot.py:load_env` are 12-line `.env` parsers — pulling in `python-dotenv` for three vars wasn't worth the dependency.
- `urllib.request` quirks: `auth_call` returns `(status, body)` and never raises on HTTP errors so callers can branch on 401 without `try/except HTTPError` boilerplate. Preserve that contract if you touch `http()`.
If a new caller really needs richer behavior (async HTTP, retries with backoff, etc.), revisit; until then resist adding requests/httpx/aiohttp.
## Per-user `!skip` cooldown, no moderator gate
`SKIP_COOLDOWN = 60.0` is per-chatter, keyed on lowercased nick in `TwitchBot._skip_seen` (a dict that grows with viewer count and is never pruned — fine at typical chat sizes, one float per chatter per session). There is intentionally **no moderator-only or VIP gate**: anyone in chat can skip. The 60s per-user cooldown is the only abuse mitigation.
If a future change adds role-gating, do it in `TwitchBot._cmd_skip` — the IRC PRIVMSG line carries `@badges=` tags but `bot.py` currently parses messages as plain `:user!user@... PRIVMSG` without the IRCv3 tag prefix. You'd need to re-enable tag capability (`CAP REQ :twitch.tv/tags`) in the connect handshake first.
## What this dir is NOT
- Not the OBS config dir. The parent dir `~/.config/obs-studio/` has its own CLAUDE.md covering OBS, scenes, audio architecture, MPD bridge (`bridges/mpd-state.py`), overlays, etc. This bot only consumes MPD; it does not publish to OBS WebSocket.
- Not a packaged module. There is no `setup.py`, no `pyproject.toml`, no entry point. Run scripts directly.
- Not where the now-playing display logic lives. Track names appear in stream overlays via `bridges/mpd-state.py` (in the parent OBS repo) broadcasting `mpd:state` events to OBS WebSocket. The bot only fires `mpd next` and reports the post-skip currentsong back to chat.
## Common pitfalls
- **Editing `.env*` while the systemd unit is running:** the bot reads `.env` at startup only. After changing tokens, `systemctl --user restart obs-twitch-bot.service` (or whatever you named the unit). Token-refresh writes via `update_env` go to `.env.broadcaster`, NOT `.env` — the bot's own access token is never auto-rewritten.
- **Brand-new bot accounts:** Twitch silently throttles unverified accounts. Bot connects, sees `JOIN`, never sees `PRIVMSG`. `TWITCH_BOT_DEBUG=1` will show this. Not a code bug.
- **`examples/stream.sh` soft-fails Twitch sync.** If `set-channel.py` returns non-zero, `stream.json` is still written. This is intentional — the local manifest is decoupled from Twitch availability — but it means a green exit from `stream.sh` does not guarantee the Twitch channel was updated. Watch for the `! Twitch sync failed` line on stderr.