Agents Files

This commit is contained in:
Jakub Zych
2026-04-27 23:41:28 +02:00
parent 1af250ae1a
commit 7eb91acd73
3 changed files with 138 additions and 0 deletions

102
CLAUDE.md Normal file
View File

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