8.4 KiB
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
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:
- POSTs the refresh token to
twitchtokengenerator.com/api/refresh/<refresh>(NOT Twitch's OAuth endpoint — twitchtokengenerator owns the client secret for tokens it minted). - Persists
TWITCH_ACCESS_TOKEN+TWITCH_REFRESH_TOKENto.env.broadcasterviaupdate_env(atomic temp-file rewrite, comments preserved). - Mutates the in-process
envdict so subsequent calls in the same process see the new token. - 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_atchanging, NOT ontwitch.livegoing false→true. Twitch Helix occasionally flaps theliveflag inside one stream session — samestarted_at, differentlivereads. Keying onstarted_atignores those phantom edges. - Cold start mid-stream is silent on purpose. If the bot starts up and sees
live=truewithout ever having seenlive=false, it records the currentstarted_atso it won't fire for it later, but it doesn't announce. Theseen_offlineflag 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 wrappingtwitchkey. 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+E0000–U+E007F before dispatch:
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.pyafter onepip install. _twitch.py:load_env/bot.py:load_envare 12-line.envparsers — pulling inpython-dotenvfor three vars wasn't worth the dependency.urllib.requestquirks:auth_callreturns(status, body)and never raises on HTTP errors so callers can branch on 401 withouttry/except HTTPErrorboilerplate. Preserve that contract if you touchhttp().
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, nopyproject.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) broadcastingmpd:stateevents to OBS WebSocket. The bot only firesmpd nextand reports the post-skip currentsong back to chat.
Common pitfalls
- Editing
.env*while the systemd unit is running: the bot reads.envat startup only. After changing tokens,systemctl --user restart obs-twitch-bot.service(or whatever you named the unit). Token-refresh writes viaupdate_envgo 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 seesPRIVMSG.TWITCH_BOT_DEBUG=1will show this. Not a code bug. examples/stream.shsoft-fails Twitch sync. Ifset-channel.pyreturns non-zero,stream.jsonis still written. This is intentional — the local manifest is decoupled from Twitch availability — but it means a green exit fromstream.shdoes not guarantee the Twitch channel was updated. Watch for the! Twitch sync failedline on stderr.