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

8.4 KiB
Raw Blame History

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:

  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:

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.