diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..59dd927 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This repository is a small, script-first Python project. `bot.py` is the long-running Twitch IRC bot that controls MPD. `_twitch.py` holds shared Helix auth and token-refresh helpers for the CLI tools. `search-game.py` resolves free-form game queries to Twitch category IDs, and `set-channel.py` updates the channel title and game. `examples/stream.sh` is the interactive pre-stream wrapper that writes `examples/stream.json`. Keep secret-bearing runtime files in `.env` or `.env.broadcaster`; only the `*.example` templates are tracked. + +## Build, Test, and Development Commands + +There is no package build step. Use direct script execution: + +```bash +pip install python-mpd2 +python3 bot.py +TWITCH_BOT_DEBUG=1 python3 bot.py +./search-game.py "DOOM" +./set-channel.py --dry-run --game-id 517520 "Stream title" +./examples/stream.sh +``` + +`python3 bot.py` runs the chat bot. `TWITCH_BOT_DEBUG=1` dumps raw IRC lines for troubleshooting. The two CLI helpers are intended to be run directly, and `stream.sh` composes them into a guided flow. + +## Coding Style & Naming Conventions + +Match the existing style: Python 3.10+, standard library first, minimal dependencies, and small top-level scripts instead of packages. Use 4-space indentation in Python and keep helpers simple and explicit. Follow the current naming pattern: `snake_case` for functions and variables, short module names for single-purpose scripts, and uppercase constants such as `SKIP_COOLDOWN`. Shell scripts should keep `set -euo pipefail`. + +## Testing Guidelines + +There is no automated test suite yet. Before opening a PR, do targeted smoke tests for the code path you changed. Examples: run `python3 bot.py` against a local MPD instance, run `./search-game.py "Heroes"`, or use `./set-channel.py --dry-run ...` to validate argument handling without issuing a PATCH. If you add tests later, place them under a new `tests/` directory and use `test_*.py`. + +## Commit & Pull Request Guidelines + +Git history is minimal; the current commit style is short and imperative (`Initial version`). Keep commits concise, scoped, and written in the imperative mood. PRs should explain the user-visible behavior change, note any `.env` or token-scope impact, and include terminal output or screenshots when changing interactive flows such as `examples/stream.sh`. + +## Security & Configuration Tips + +Do not merge `.env` and `.env.broadcaster`. The bot account and broadcaster account require different scopes and are intentionally separated. Never commit live tokens or generated `examples/stream.json`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5beef89 --- /dev/null +++ b/CLAUDE.md @@ -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: \t 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/` (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+E0000–U+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 `\t` 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.