Initial version
This commit is contained in:
27
.env.broadcaster.example
Normal file
27
.env.broadcaster.example
Normal file
@@ -0,0 +1,27 @@
|
||||
# Copy to `.env.broadcaster` and fill in. `.env.broadcaster` is gitignored;
|
||||
# this template is not.
|
||||
#
|
||||
# Used by:
|
||||
# set-channel.py — PATCH /helix/channels (title + game)
|
||||
# search-game.py — GET /helix/search/categories
|
||||
# examples/stream.sh — wraps both into one interactive pre-stream prompt
|
||||
#
|
||||
# This file holds BROADCASTER credentials (the channel-owner account). The
|
||||
# chat bot's separate .env holds the BOT account credentials — different
|
||||
# scopes, different tokens. Keep them separate; do not merge.
|
||||
|
||||
# ─── Twitch broadcaster auth ────────────────────────────────────────────────
|
||||
# Generate at https://twitchtokengenerator.com — sign in as the broadcaster
|
||||
# (the channel owner) and select the scope `channel:manage:broadcast`.
|
||||
# Twitch enforces broadcaster_id == token user_id on PATCH /helix/channels,
|
||||
# so the token MUST belong to the broadcaster account.
|
||||
|
||||
TWITCH_ACCESS_TOKEN=
|
||||
TWITCH_REFRESH_TOKEN=
|
||||
TWITCH_CLIENT_ID=
|
||||
|
||||
# Numeric Twitch user id for the broadcaster. Find it with any "twitch user
|
||||
# id lookup" tool, or run:
|
||||
# curl -H "Authorization: Bearer <token>" -H "Client-Id: <client-id>" \
|
||||
# "https://api.twitch.tv/helix/users?login=<your_login>"
|
||||
TWITCH_BROADCASTER_ID=
|
||||
38
.env.example
Normal file
38
.env.example
Normal file
@@ -0,0 +1,38 @@
|
||||
# Copy to `.env` and fill in. `.env` is gitignored; this template is not.
|
||||
|
||||
# ─── Twitch chat auth ───────────────────────────────────────────────────────
|
||||
# Generated together at https://twitchtokengenerator.com
|
||||
# (scopes: chat:read + chat:edit). Access token is what the bot uses today;
|
||||
# refresh + client id are stored so we can rotate the access token later
|
||||
# without re-running the generator.
|
||||
|
||||
# Chat OAuth token. Paste the raw token — the bot adds the `oauth:` prefix
|
||||
# automatically if you omit it.
|
||||
TWITCH_ACCESS_TOKEN=
|
||||
|
||||
# Refresh token. Currently unused by bot.py; kept here so the value survives
|
||||
# alongside the access token it's paired with.
|
||||
TWITCH_REFRESH_TOKEN=
|
||||
|
||||
# App client ID shown by twitchtokengenerator. Needed together with the
|
||||
# refresh token to mint a new access token via Twitch's OAuth endpoint.
|
||||
# Currently unused by bot.py.
|
||||
TWITCH_CLIENT_ID=
|
||||
|
||||
# ─── Identity / where to connect ────────────────────────────────────────────
|
||||
# Bot login name (lowercase). Posting as your own streamer account is fine
|
||||
# for a personal bot — set this to your channel name in that case.
|
||||
TWITCH_NICK=ophi118
|
||||
|
||||
# Channel to join (no leading #). Defaults to TWITCH_NICK if omitted.
|
||||
TWITCH_CHANNEL=ophi118
|
||||
|
||||
# ─── Mattermost: going-live notifications (optional) ────────────────────────
|
||||
# When set, the bot polls https://ophi118.com/api/status.json (the public
|
||||
# status service in station/server/) and POSTs a single message to this
|
||||
# Mattermost incoming-webhook URL on every offline→live transition. Unset
|
||||
# (or blank) disables notifications entirely; the bot still runs.
|
||||
#
|
||||
# Override the polled endpoint with STATUS_URL if running against a private
|
||||
# status service.
|
||||
MM_HOOK=
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# This is now a standalone repo, so the gitignore has to do its own work
|
||||
# (no parent .gitignore to fall back to). The `.env*` glob catches every
|
||||
# secret-bearing variant; the `!*.example` re-include keeps templates tracked.
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.broadcaster.example
|
||||
|
||||
# Output of examples/stream.sh — generated per session, no value in tracking.
|
||||
examples/stream.json
|
||||
|
||||
# Python bytecode cache
|
||||
__pycache__/
|
||||
225
README.md
Normal file
225
README.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# twitch-bot
|
||||
|
||||
Lightweight Twitch chat bot that controls a local [MPD](https://www.musicpd.org/) music daemon, plus optional pre-stream automation and going-live notifications.
|
||||
|
||||
Built for a personal Twitch rig (`ophi118`) and extracted here as a stand-alone bot. Stdlib + `python-mpd2`, no other dependencies.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Chat-driven music control:** viewers run `!skip`, `!queue`, `!info` and the bot drives MPD on your machine. Per-user 60s cooldown on `!skip` so a single viewer can't spam.
|
||||
- **Pre-stream helper:** `examples/stream.sh` walks you through one prompt, resolves the game name to a Twitch category id, updates your channel title + game in one PATCH, and writes a `stream.json` manifest any overlay/timer can read.
|
||||
- **Going-live notifier (optional):** when `MM_HOOK` is set the bot polls a public status JSON every 60s and POSTs to the webhook on each offline → live transition. Works against any Mattermost/Slack-shape incoming webhook.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- [MPD](https://www.musicpd.org/) reachable on `localhost:6600` (override with `MPD_HOST` / `MPD_PORT`). Only needed for the chat commands — the pre-stream helper and notifier work standalone.
|
||||
- One Twitch token for the **bot account** (`chat:read` + `chat:edit` scopes)
|
||||
- One Twitch token for the **broadcaster account** if you want `examples/stream.sh` (`channel:manage:broadcast` scope)
|
||||
- Easiest way to mint tokens: <https://twitchtokengenerator.com>
|
||||
|
||||
```bash
|
||||
pip install python-mpd2
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
twitch-bot/
|
||||
├── bot.py # the chat bot (long-running)
|
||||
├── _twitch.py # shared Helix auth helpers (urllib only)
|
||||
├── search-game.py # CLI: free-form game query → "<id>\t<name>"
|
||||
├── set-channel.py # CLI: PATCH /helix/channels (title + game)
|
||||
├── .env.example # template for bot.py
|
||||
├── .env.broadcaster.example # template for set-channel.py / stream.sh
|
||||
└── examples/
|
||||
└── stream.sh # interactive pre-stream helper
|
||||
```
|
||||
|
||||
The bot reads `.env`. The pre-stream tools read `.env.broadcaster`. Two files because they hold credentials for two **different** Twitch accounts with **different** scopes — do not merge them.
|
||||
|
||||
## Quick start
|
||||
|
||||
### 1. Configure the bot
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
$EDITOR .env
|
||||
```
|
||||
|
||||
Minimum required:
|
||||
|
||||
```env
|
||||
TWITCH_ACCESS_TOKEN=<oauth token from twitchtokengenerator>
|
||||
TWITCH_NICK=mybot # bot account login (lowercase)
|
||||
TWITCH_CHANNEL=mychannel # channel to join, no leading '#'
|
||||
```
|
||||
|
||||
### 2. Run
|
||||
|
||||
```bash
|
||||
python3 bot.py
|
||||
```
|
||||
|
||||
Open chat in your channel and try `!skip`, `!queue`, `!info`. The bot replies in chat and the music skip lands on stream because MPD is the audio source — the bot just sends `next` to it.
|
||||
|
||||
### 3. (Optional) pre-stream helper
|
||||
|
||||
```bash
|
||||
cp .env.broadcaster.example .env.broadcaster
|
||||
$EDITOR .env.broadcaster
|
||||
./examples/stream.sh
|
||||
```
|
||||
|
||||
See [Pre-stream helper](#pre-stream-helper) below.
|
||||
|
||||
## Chat commands
|
||||
|
||||
| Command | Effect |
|
||||
| -------- | ------ |
|
||||
| `!skip` | Advance MPD to the next track. Per-user 60s cooldown (`SKIP_COOLDOWN` in `bot.py`). |
|
||||
| `!queue` | Print the next 3 queued tracks (`QUEUE_PEEK`). |
|
||||
| `!info` | Print full metadata for now-playing — `artist · title · album · year · genre`. |
|
||||
|
||||
There is intentionally **no moderator gate**. Anyone in chat can `!skip`. The 60s per-user cooldown is the only abuse mitigation. If you want stricter controls, fork — the dispatch lives in `bot.py:TwitchBot._handle`.
|
||||
|
||||
## Pre-stream helper
|
||||
|
||||
`examples/stream.sh` is an interactive prompt that:
|
||||
|
||||
1. Asks you for a game name and resolves it to a Twitch category id via `search-game.py` (you pick from up to 20 matches; auto-picks when there's only one).
|
||||
2. Asks you for a stream title and a countdown duration.
|
||||
3. Writes `stream.json` next to the script with the resolved values.
|
||||
4. PATCHes your Twitch channel — sets title and game in one call.
|
||||
|
||||
Re-running uses your previous answers as defaults.
|
||||
|
||||
```text
|
||||
$ ./examples/stream.sh
|
||||
── stream manifest ──
|
||||
(press Enter to keep [defaults])
|
||||
|
||||
Game (search): doom
|
||||
── 5 matches for 'doom' ──
|
||||
1) DOOM
|
||||
2) DOOM Eternal
|
||||
3) DOOM (1993)
|
||||
...
|
||||
0) cancel
|
||||
Pick: 2
|
||||
Title (Twitch stream title): Slayer is back — Nightmare run
|
||||
Countdown (minutes) [5]:
|
||||
|
||||
✓ wrote /home/you/twitch-bot/examples/stream.json
|
||||
✓ Twitch channel updated → game_id=517520, title='Slayer is back — Nightmare run'
|
||||
```
|
||||
|
||||
Resulting `stream.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compiledAt": "2026-04-27T22:55:11Z",
|
||||
"game": "DOOM Eternal",
|
||||
"gameId": "517520",
|
||||
"title": "Slayer is back — Nightmare run",
|
||||
"countdownMin": 5
|
||||
}
|
||||
```
|
||||
|
||||
Read it from any starting-soon overlay, OBS browser source, OBS text source, or static HTML page that wants to show what's coming up. Override the output path with `STREAM_JSON=/some/where.json ./examples/stream.sh`.
|
||||
|
||||
If `set-channel.py` or `.env.broadcaster` aren't present, the script soft-fails the Twitch sync step — the local `stream.json` is still written.
|
||||
|
||||
### Token refresh
|
||||
|
||||
`set-channel.py` and `search-game.py` both go through `_twitch.py`, which auto-refreshes the access token via twitchtokengenerator on a 401, persists the new token back to `.env.broadcaster`, and retries. You don't need to babysit token expiry.
|
||||
|
||||
### Direct invocation
|
||||
|
||||
The Python helpers are fine to call directly without the shell wrapper:
|
||||
|
||||
```bash
|
||||
./search-game.py "Heroes" # stdout: "<id>\t<canonical_name>"
|
||||
./set-channel.py --game-id 517520 "..."
|
||||
./set-channel.py "DOOM Eternal" "..." # exact-match lookup, no fuzzy search
|
||||
./set-channel.py --dry-run --game-id 517520 "..."
|
||||
```
|
||||
|
||||
## Going-live notifications
|
||||
|
||||
If `MM_HOOK` is set in `.env`, the bot starts a second async task (`live_watch`) that polls a status JSON every 60 seconds and POSTs to the webhook on every offline → live transition.
|
||||
|
||||
```env
|
||||
MM_HOOK=https://chat.example.com/hooks/abc123
|
||||
STATUS_URL=https://your-status-page.example.com/api/status.json
|
||||
```
|
||||
|
||||
The status URL must serve a JSON document of this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"live": true,
|
||||
"started_at": "2026-04-27T18:30:00Z",
|
||||
"title": "...",
|
||||
"game_name": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Transition detection keys on `started_at`, **not** `live` alone. Twitch Helix occasionally flaps the `live` flag mid-stream; ignoring those phantom edges is the whole point of polling a stable upstream value.
|
||||
|
||||
Cold start mid-stream is silent on purpose: the bot records the current `started_at` on its first poll and only fires when it changes. Restarting the bot during an active stream will not double-post.
|
||||
|
||||
You provide the status endpoint — the bot is a consumer, not a server. Easy ways to roll one:
|
||||
|
||||
- A small worker that calls Helix `GET /streams?user_login=...` every 60s and writes a JSON file to your web root.
|
||||
- A tiny webapp serving the same shape from any framework.
|
||||
- Skip notifications entirely by leaving `MM_HOOK` unset.
|
||||
|
||||
## Running as a systemd user service
|
||||
|
||||
```ini
|
||||
# ~/.config/systemd/user/twitch-bot.service
|
||||
[Unit]
|
||||
Description=Twitch chat → MPD control bot
|
||||
After=mpd.service mpd.socket network-online.target
|
||||
Wants=mpd.service network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/path/to/twitch-bot
|
||||
ExecStart=/usr/bin/python3 %h/path/to/twitch-bot/bot.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now twitch-bot.service
|
||||
journalctl --user -u twitch-bot -f
|
||||
```
|
||||
|
||||
`Restart=on-failure` covers Twitch-IRC drops and MPD restarts. The bot's reconnect loop also handles transient drops without exiting; the systemd restart is the ultimate safety net.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | What to check |
|
||||
| ------- | ------------- |
|
||||
| Bot connects but never sees chat | Brand-new Twitch accounts can be silently throttled. Set `TWITCH_BOT_DEBUG=1` in `.env` to dump every received IRC line. If you see `JOIN` lines but no `PRIVMSG`, the account is the problem — not the bot. |
|
||||
| `!skip` says "MPD is not playing anything" but it is | Wrong host/port. The bot connects to `localhost:6600` by default; override with `MPD_HOST` / `MPD_PORT` env vars. |
|
||||
| `set-channel.py` returns 401 / 403 | The broadcaster token must (a) carry the `channel:manage:broadcast` scope **and** (b) belong to the same account whose numeric id is in `TWITCH_BROADCASTER_ID`. Twitch enforces both. |
|
||||
| Mattermost notifier never fires | Hit your `STATUS_URL` with `curl` first — it must serve `live: true` with a stable `started_at` for the bot to detect a transition. The bot logs `[live]` lines to stdout/journal on each poll if you need to confirm it's actually polling. |
|
||||
| `auth_call` keeps refreshing every request | Your `TWITCH_ACCESS_TOKEN` is wrong or revoked but the refresh token still mints a working one. Regenerate the access token at twitchtokengenerator and update `.env.broadcaster`. |
|
||||
|
||||
## Notes on the code
|
||||
|
||||
- All HTTP is `urllib.request`. No `requests`. No async HTTP. The bot's IRC loop is async (`asyncio` + raw TLS sockets), the helpers are not — keeps the helpers usable as plain CLI scripts.
|
||||
- `_twitch.py` looks for `.env.broadcaster` first, falls back to `.env.ophi118` (legacy filename from the rig this was extracted from). Either works.
|
||||
- `bot.py:load_env` is a separate 12-line `.env` parser instead of pulling in `python-dotenv`. Three vars don't justify the dependency.
|
||||
- `bot.py` does **not** currently use the bot account's refresh token. If access tokens start expiring mid-stream, refresh would land in the IRC reconnect handler.
|
||||
|
||||
## License
|
||||
|
||||
No license file ships in this repo yet — add one before redistributing. MIT or 0BSD if you want maximally permissive; AGPL if you want forks to stay open.
|
||||
121
_twitch.py
Normal file
121
_twitch.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Shared helpers for set-channel.py and search-game.py.
|
||||
|
||||
Stdlib-only on purpose — the bot dir intentionally avoids a venv. If a third
|
||||
caller appears that needs richer behavior (retries with backoff, async, etc.),
|
||||
revisit; until then minimal urllib is fine.
|
||||
|
||||
Auth model: every Helix call goes through `auth_call`, which runs the request,
|
||||
refreshes the access token via twitchtokengenerator on 401, persists the new
|
||||
tokens to .env.ophi118, mutates the in-memory env dict, and retries once.
|
||||
Callers stay agnostic about whether a refresh happened.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
HELIX = "https://api.twitch.tv/helix"
|
||||
REFRESH_URL = "https://twitchtokengenerator.com/api/refresh/{refresh}"
|
||||
|
||||
# Default broadcaster env file. Looks for `.env.broadcaster` (the documented
|
||||
# OSS-style filename) first, then falls back to the legacy `.env.ophi118`
|
||||
# kept around for the original ophi118 rig that birthed this code. Whichever
|
||||
# exists wins; if neither exists yet, ENV_FILE points at `.env.broadcaster`
|
||||
# so error messages tell the user the right file to create.
|
||||
_DIR = Path(__file__).resolve().parent
|
||||
ENV_FILE = _DIR / ".env.broadcaster"
|
||||
if not ENV_FILE.exists() and (_DIR / ".env.ophi118").exists():
|
||||
ENV_FILE = _DIR / ".env.ophi118"
|
||||
|
||||
|
||||
# ─── env file I/O ───────────────────────────────────────────────────────────
|
||||
|
||||
def load_env(path: Path = ENV_FILE) -> dict:
|
||||
out = {}
|
||||
if not path.exists():
|
||||
return out
|
||||
for raw in path.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, _, v = line.partition("=")
|
||||
out[k.strip()] = v.strip().strip('"').strip("'")
|
||||
return out
|
||||
|
||||
|
||||
def update_env(updates: dict, path: Path = ENV_FILE) -> None:
|
||||
"""Atomic in-place rewrite of KEY=VALUE lines, comments preserved."""
|
||||
lines = path.read_text().splitlines() if path.exists() else []
|
||||
seen, out_lines = set(), []
|
||||
for line in lines:
|
||||
s = line.strip()
|
||||
if s and not s.startswith("#") and "=" in s:
|
||||
k = s.split("=", 1)[0].strip()
|
||||
if k in updates:
|
||||
out_lines.append(f"{k}={updates[k]}")
|
||||
seen.add(k)
|
||||
continue
|
||||
out_lines.append(line)
|
||||
for k, v in updates.items():
|
||||
if k not in seen:
|
||||
out_lines.append(f"{k}={v}")
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text("\n".join(out_lines) + "\n")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
# ─── HTTP ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def http(method: str, url: str, *, headers=None, body=None):
|
||||
"""Returns (status, parsed_json_or_text). Never raises on HTTP errors —
|
||||
surfaces them as the status code so callers can branch on 401 cleanly."""
|
||||
data = body.encode("utf-8") if isinstance(body, str) else body
|
||||
req = urllib.request.Request(url, method=method, headers=headers or {}, data=data)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
raw = r.read().decode("utf-8")
|
||||
try: return r.status, (json.loads(raw) if raw else None)
|
||||
except json.JSONDecodeError: return r.status, raw
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode("utf-8", errors="replace")
|
||||
try: return e.code, (json.loads(raw) if raw else None)
|
||||
except json.JSONDecodeError: return e.code, raw
|
||||
|
||||
|
||||
# ─── auth ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def refresh_token(env: dict) -> dict:
|
||||
"""Mint new tokens via twitchtokengenerator. Returns the merge dict for
|
||||
`update_env` — caller persists. Raises on refresh failure (no point
|
||||
retrying — the refresh token is dead, the user must re-generate)."""
|
||||
refresh = env["TWITCH_REFRESH_TOKEN"]
|
||||
url = REFRESH_URL.format(refresh=urllib.parse.quote(refresh, safe=""))
|
||||
status, body = http("GET", url)
|
||||
if status != 200 or not isinstance(body, dict) or not body.get("success"):
|
||||
raise RuntimeError(f"token refresh failed (status={status}): {body}")
|
||||
return {
|
||||
"TWITCH_ACCESS_TOKEN": body["token"],
|
||||
"TWITCH_REFRESH_TOKEN": body["refresh"],
|
||||
}
|
||||
|
||||
|
||||
def auth_call(method: str, url: str, env: dict, *, body=None):
|
||||
"""Authenticated Helix request with 401 → refresh → retry. Mutates `env`
|
||||
in place on refresh so subsequent calls in the same process see the new
|
||||
token without another reload."""
|
||||
def headers_for(tok):
|
||||
h = {"Authorization": f"Bearer {tok}", "Client-Id": env["TWITCH_CLIENT_ID"]}
|
||||
if body is not None:
|
||||
h["Content-Type"] = "application/json"
|
||||
return h
|
||||
|
||||
status, resp = http(method, url, headers=headers_for(env["TWITCH_ACCESS_TOKEN"]), body=body)
|
||||
if status == 401:
|
||||
new = refresh_token(env)
|
||||
update_env(new)
|
||||
env.update(new)
|
||||
status, resp = http(method, url, headers=headers_for(env["TWITCH_ACCESS_TOKEN"]), body=body)
|
||||
return status, resp
|
||||
462
bot.py
Normal file
462
bot.py
Normal file
@@ -0,0 +1,462 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Twitch chat → MPD control bot, plus optional "going live" notifications.
|
||||
|
||||
Connects to Twitch IRC over TLS, joins the configured channel, and responds to:
|
||||
!skip advance MPD to the next track (per-user cooldown — see SKIP_COOLDOWN)
|
||||
!queue print the next few queued tracks
|
||||
!info print full metadata (artist · title · album · year · genre) for now-playing
|
||||
The new track shows up on stream because bridges/mpd-state.py is already
|
||||
broadcasting MPD state to the loading / game / music-box overlays.
|
||||
|
||||
If MM_HOOK is set, a second task polls the public ophi118.com status service
|
||||
(which already polls Twitch Helix for live/title/game) and POSTs a single
|
||||
Mattermost notification per offline→live transition. See live_watch().
|
||||
|
||||
Reads .env from the same directory:
|
||||
TWITCH_ACCESS_TOKEN chat OAuth token (with or without `oauth:` prefix)
|
||||
TWITCH_NICK bot login name (lowercase)
|
||||
TWITCH_CHANNEL channel to join, no leading `#` — defaults to TWITCH_NICK
|
||||
MM_HOOK optional Mattermost incoming-webhook URL; enables
|
||||
going-live notifications when set
|
||||
STATUS_URL optional override for the status API endpoint
|
||||
(default: https://ophi118.com/api/status.json)
|
||||
|
||||
TWITCH_REFRESH_TOKEN and TWITCH_CLIENT_ID are also stored in .env (paired with
|
||||
the access token at generation time) but the bot doesn't use them yet — token
|
||||
refresh would land here later if access tokens start expiring mid-stream.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from mpd.asyncio import MPDClient
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ENV = HERE / ".env"
|
||||
|
||||
MPD_HOST = os.environ.get("MPD_HOST", "localhost")
|
||||
MPD_PORT = int(os.environ.get("MPD_PORT", "6600"))
|
||||
|
||||
IRC_HOST = "irc.chat.twitch.tv"
|
||||
IRC_PORT = 6697
|
||||
PING_TIMEOUT = 360.0 # twitch PINGs ~every 5min; reconnect if silent longer
|
||||
SKIP_COOLDOWN = 60.0 # per-user seconds between successful !skip invocations
|
||||
QUEUE_PEEK = 3 # how many upcoming tracks !queue should print
|
||||
|
||||
# Going-live watcher: poll cadence matches the server's Twitch poll (60s) so we
|
||||
# never lag the upstream signal by more than ~2 polls.
|
||||
STATUS_URL = os.environ.get("STATUS_URL", "https://ophi118.com/api/status.json")
|
||||
STATUS_POLL_S = 60.0
|
||||
|
||||
# Set TWITCH_BOT_DEBUG=1 (env or .env) to dump every received IRC line. Useful
|
||||
# when a brand-new bot account looks "connected" but doesn't see chat — Twitch
|
||||
# silently degrades unverified accounts in ways that don't surface as errors.
|
||||
DEBUG_RAW = os.environ.get("TWITCH_BOT_DEBUG", "").strip() not in ("", "0", "false")
|
||||
|
||||
|
||||
# ─── tiny .env loader ───────────────────────────────────────────────────────
|
||||
|
||||
def load_env(path: Path) -> dict:
|
||||
"""KEY=VALUE lines, # comments, optional surrounding quotes. Avoids a
|
||||
python-dotenv dependency for three vars."""
|
||||
out = {}
|
||||
if not path.exists():
|
||||
return out
|
||||
for raw in path.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, _, v = line.partition("=")
|
||||
out[k.strip()] = v.strip().strip('"').strip("'")
|
||||
return out
|
||||
|
||||
|
||||
# ─── MPD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Mpd:
|
||||
"""One shared MPDClient with reconnect-on-failure.
|
||||
|
||||
python-mpd2's asyncio client doesn't auto-reconnect, so each command is
|
||||
wrapped to retry once after dropping the connection — covers MPD
|
||||
restarts and idle-socket timeouts without surfacing transient errors.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._cli = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _ensure(self):
|
||||
if self._cli is None:
|
||||
cli = MPDClient()
|
||||
await cli.connect(MPD_HOST, MPD_PORT)
|
||||
self._cli = cli
|
||||
|
||||
async def _retry(self, op):
|
||||
"""Run an MPD coroutine with one reconnect-on-failure retry."""
|
||||
async with self._lock:
|
||||
last = None
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
await self._ensure()
|
||||
return await op(self._cli)
|
||||
except Exception as e:
|
||||
last = e
|
||||
self._cli = None # force reconnect on retry
|
||||
raise last # type: ignore[misc]
|
||||
|
||||
async def skip(self) -> str:
|
||||
async def op(cli):
|
||||
await cli.next()
|
||||
return _fmt_song(await cli.currentsong())
|
||||
return await self._retry(op)
|
||||
|
||||
async def currentsong(self) -> dict:
|
||||
return await self._retry(lambda cli: cli.currentsong())
|
||||
|
||||
async def status(self) -> dict:
|
||||
return await self._retry(lambda cli: cli.status())
|
||||
|
||||
async def playlistinfo(self, pos: int) -> list:
|
||||
return await self._retry(lambda cli: cli.playlistinfo(pos))
|
||||
|
||||
|
||||
def _flat(v):
|
||||
"""MPD tags can be lists (multi-value); flatten to a comma-joined string
|
||||
with empties removed. Returns None for missing/empty so callers can skip
|
||||
fields without empty parens."""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, list):
|
||||
v = ", ".join(x for x in v if x)
|
||||
v = (v or "").strip()
|
||||
return v or None
|
||||
|
||||
|
||||
def _fmt_song(song: dict) -> str:
|
||||
artist = _flat(song.get("artist") or song.get("albumartist")) or ""
|
||||
title = _flat(song.get("title")) or (song.get("file") or "").rsplit("/", 1)[-1]
|
||||
out = f"{artist} — {title}".strip(" —")
|
||||
return out or "(unknown track)"
|
||||
|
||||
|
||||
def _fmt_info(song: dict) -> str:
|
||||
"""Compose the !info reply: 'Artist — Title · Album (Year) · Genre'.
|
||||
Skip any segment whose underlying tag is missing."""
|
||||
head = _fmt_song(song)
|
||||
parts = [head] if head != "(unknown track)" else []
|
||||
album = _flat(song.get("album"))
|
||||
year = _flat(song.get("date"))
|
||||
if year:
|
||||
# MPD often stores ISO dates ('2018-04-12') — show year only.
|
||||
year = year.split("-", 1)[0]
|
||||
if album and year:
|
||||
parts.append(f"{album} ({year})")
|
||||
elif album:
|
||||
parts.append(album)
|
||||
elif year:
|
||||
parts.append(f"({year})")
|
||||
genre = _flat(song.get("genre"))
|
||||
if genre:
|
||||
parts.append(genre)
|
||||
return " · ".join(parts) if parts else "(unknown track)"
|
||||
|
||||
|
||||
# ─── Twitch IRC ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TwitchBot:
|
||||
def __init__(self, nick: str, token: str, channel: str, mpd: Mpd):
|
||||
self.nick = nick.lower()
|
||||
self.token = token if token.startswith("oauth:") else f"oauth:{token}"
|
||||
self.channel = "#" + channel.lower().lstrip("#")
|
||||
self.mpd = mpd
|
||||
# Per-user !skip cooldown — keyed by lowercased nick. The dict grows
|
||||
# unboundedly with viewer count over a stream session; not worth
|
||||
# pruning at typical chat sizes (one float per chatter).
|
||||
self._skip_seen: dict[str, float] = {}
|
||||
|
||||
async def _send(self, writer: asyncio.StreamWriter, raw: str):
|
||||
writer.write((raw + "\r\n").encode("utf-8"))
|
||||
await writer.drain()
|
||||
|
||||
async def _say(self, writer: asyncio.StreamWriter, msg: str):
|
||||
# twitch chat lines max ~500 chars; truncate defensively, strip newlines
|
||||
msg = msg.replace("\r", " ").replace("\n", " ")[:480]
|
||||
await self._send(writer, f"PRIVMSG {self.channel} :{msg}")
|
||||
|
||||
async def run(self):
|
||||
"""Connect/auth/listen loop with quiet exponential backoff.
|
||||
|
||||
Mirrors the connect-quietly pattern in bridges/mpd-state.py:
|
||||
first failure prints once, then silent retry until the connection
|
||||
comes back. Auth failure is fatal — no point retrying with a bad token.
|
||||
"""
|
||||
backoff = 2
|
||||
BACKOFF_MAX = 30
|
||||
prev_status = None # None | "up" | "down"
|
||||
ctx = ssl.create_default_context()
|
||||
|
||||
while True:
|
||||
try:
|
||||
reader, writer = await asyncio.open_connection(
|
||||
IRC_HOST, IRC_PORT, ssl=ctx,
|
||||
)
|
||||
try:
|
||||
await self._send(writer, f"PASS {self.token}")
|
||||
await self._send(writer, f"NICK {self.nick}")
|
||||
await self._send(writer, f"JOIN {self.channel}")
|
||||
|
||||
if prev_status != "up":
|
||||
print(f"[twitch] connected → {self.channel} as {self.nick}",
|
||||
flush=True)
|
||||
prev_status = "up"
|
||||
backoff = 2
|
||||
|
||||
while True:
|
||||
line_bytes = await asyncio.wait_for(
|
||||
reader.readline(), timeout=PING_TIMEOUT,
|
||||
)
|
||||
if not line_bytes:
|
||||
raise ConnectionError("server closed connection")
|
||||
line = line_bytes.decode("utf-8", errors="replace").rstrip("\r\n")
|
||||
await self._handle(writer, line)
|
||||
finally:
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
except RuntimeError as e:
|
||||
# auth-failed and similar fatals — let systemd flag it
|
||||
print(f"[twitch] fatal: {e}", file=sys.stderr, flush=True)
|
||||
raise
|
||||
except (asyncio.TimeoutError, ConnectionError, OSError) as e:
|
||||
if prev_status == "up":
|
||||
print(f"[twitch] disconnected ({type(e).__name__}: {e}) — retrying",
|
||||
flush=True)
|
||||
elif prev_status is None:
|
||||
print(f"[twitch] unreachable ({type(e).__name__}) — will retry silently",
|
||||
flush=True)
|
||||
prev_status = "down"
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, BACKOFF_MAX)
|
||||
|
||||
async def _handle(self, writer: asyncio.StreamWriter, line: str):
|
||||
if DEBUG_RAW:
|
||||
print(f"[rx] {line}", flush=True)
|
||||
|
||||
# PING/PONG keepalive — twitch sends `PING :tmi.twitch.tv` periodically
|
||||
if line.startswith("PING "):
|
||||
await self._send(writer, "PONG " + line[5:])
|
||||
return
|
||||
|
||||
# NOTICE on bad creds: ":tmi.twitch.tv NOTICE * :Login authentication failed"
|
||||
if " NOTICE " in line and "authentication failed" in line.lower():
|
||||
raise RuntimeError("twitch auth failed — check TWITCH_KEY / TWITCH_NICK")
|
||||
|
||||
# PRIVMSG format: ":user!user@user.tmi.twitch.tv PRIVMSG #chan :body"
|
||||
if " PRIVMSG " not in line:
|
||||
return
|
||||
try:
|
||||
prefix, rest = line.split(" PRIVMSG ", 1)
|
||||
user = prefix[1:].split("!", 1)[0]
|
||||
_, _, body = rest.partition(":")
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
# Twitch's anti-duplicate logic appends an invisible Tags-block
|
||||
# codepoint (U+E0000–U+E007F) to back-to-back identical messages
|
||||
# from the same sender within ~30s. Without stripping it, every
|
||||
# other `!skip` in a series silently fails to match.
|
||||
body = "".join(c for c in body if not (0xE0000 <= ord(c) <= 0xE007F))
|
||||
head = body.strip().split(" ", 1)[0].lower()
|
||||
if head == "!skip":
|
||||
await self._cmd_skip(writer, user)
|
||||
elif head == "!queue":
|
||||
await self._cmd_queue(writer, user)
|
||||
elif head == "!info":
|
||||
await self._cmd_info(writer, user)
|
||||
|
||||
async def _cmd_skip(self, writer: asyncio.StreamWriter, user: str):
|
||||
# Per-user cooldown: each chatter gets one skip per SKIP_COOLDOWN
|
||||
# seconds. Reject with a wait-time hint so the user knows it's their
|
||||
# own gate, not the bot being broken.
|
||||
now = time.monotonic()
|
||||
ukey = user.lower()
|
||||
last = self._skip_seen.get(ukey, 0.0)
|
||||
wait = SKIP_COOLDOWN - (now - last)
|
||||
if wait > 0:
|
||||
await self._say(
|
||||
writer,
|
||||
f"@{user} you can only !skip once per minute (wait {int(wait)+1}s)",
|
||||
)
|
||||
return
|
||||
try:
|
||||
now_playing = await self.mpd.skip()
|
||||
except Exception as e:
|
||||
print(f"[skip] failed for @{user}: {type(e).__name__}: {e}", flush=True)
|
||||
await self._say(writer, f"@{user} couldn't skip — MPD didn't respond")
|
||||
return
|
||||
self._skip_seen[ukey] = now
|
||||
print(f"[skip] @{user} → {now_playing}", flush=True)
|
||||
await self._say(writer, f"⏭ skipped by @{user} → now: {now_playing}")
|
||||
|
||||
async def _cmd_queue(self, writer: asyncio.StreamWriter, user: str):
|
||||
# Read-only; no cooldown. Pulls current pos from status, then peeks
|
||||
# the next QUEUE_PEEK tracks one-by-one (small N — full playlistinfo
|
||||
# would dump the whole queue).
|
||||
try:
|
||||
status = await self.mpd.status()
|
||||
pos = int(status.get("song", -1))
|
||||
total = int(status.get("playlistlength") or 0)
|
||||
except Exception as e:
|
||||
print(f"[queue] failed for @{user}: {type(e).__name__}: {e}", flush=True)
|
||||
await self._say(writer, f"@{user} couldn't read queue — MPD didn't respond")
|
||||
return
|
||||
if pos < 0 or pos + 1 >= total:
|
||||
await self._say(writer, "🎵 nothing else queued")
|
||||
return
|
||||
upcoming = []
|
||||
for i, p in enumerate(range(pos + 1, min(pos + 1 + QUEUE_PEEK, total)), start=1):
|
||||
try:
|
||||
rows = await self.mpd.playlistinfo(p)
|
||||
if rows:
|
||||
upcoming.append(f"{i}) {_fmt_song(rows[0])}")
|
||||
except Exception:
|
||||
continue
|
||||
if not upcoming:
|
||||
await self._say(writer, "🎵 nothing else queued")
|
||||
return
|
||||
await self._say(writer, "🎵 next: " + " · ".join(upcoming))
|
||||
|
||||
async def _cmd_info(self, writer: asyncio.StreamWriter, user: str):
|
||||
try:
|
||||
song = await self.mpd.currentsong()
|
||||
except Exception as e:
|
||||
print(f"[info] failed for @{user}: {type(e).__name__}: {e}", flush=True)
|
||||
await self._say(writer, f"@{user} couldn't read track info — MPD didn't respond")
|
||||
return
|
||||
if not song:
|
||||
await self._say(writer, "ℹ nothing playing")
|
||||
return
|
||||
await self._say(writer, "ℹ " + _fmt_info(song))
|
||||
|
||||
|
||||
# ─── Mattermost "going live" notifications ──────────────────────────────────
|
||||
|
||||
def _fetch_status(url: str) -> dict:
|
||||
"""GET the status JSON. Raises on transport/parse errors so live_watch()
|
||||
can log + back off uniformly."""
|
||||
req = urllib.request.Request(url, method="GET",
|
||||
headers={"User-Agent": "obs-twitch-bot/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _post_mattermost(hook_url: str, channel: str, title: str, game: str) -> None:
|
||||
"""POST a single going-live notification. Markdown renders in Mattermost."""
|
||||
parts = [f"🔴 **{channel}** is live on Twitch"]
|
||||
if title:
|
||||
parts.append(f"> {title}")
|
||||
if game:
|
||||
parts.append(f"_{game}_")
|
||||
parts.append(f"https://twitch.tv/{channel}")
|
||||
body = json.dumps({"text": "\n".join(parts)}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
hook_url, method="POST", data=body,
|
||||
headers={"Content-Type": "application/json",
|
||||
"User-Agent": "obs-twitch-bot/1.0"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
if r.status >= 300:
|
||||
raise RuntimeError(f"mattermost hook returned HTTP {r.status}")
|
||||
|
||||
|
||||
async def live_watch(mm_hook: str, channel: str, status_url: str = STATUS_URL):
|
||||
"""Poll the status service; POST to Mattermost on offline→live transitions.
|
||||
|
||||
Transition detection is keyed on `started_at`, not just the `live` flag, so
|
||||
a brief Helix hiccup (live=true → false → true within one stream session,
|
||||
same `started_at`) doesn't double-fire.
|
||||
|
||||
Cold-start during an ongoing stream is intentionally silent: we record the
|
||||
current `started_at` so we won't fire for it later, but we don't announce
|
||||
it (we can't tell "they just went live" from "the bot restarted mid-stream"
|
||||
without prior offline observation). Once we see live=False at least once,
|
||||
the next started_at change becomes a real transition we'll announce.
|
||||
"""
|
||||
last_announced = None # started_at we've already handled (announced or recorded)
|
||||
seen_offline = False # have we observed live=False since last_announced?
|
||||
|
||||
while True:
|
||||
try:
|
||||
data = await asyncio.to_thread(_fetch_status, status_url)
|
||||
twitch = (data or {}).get("twitch") or {}
|
||||
live = bool(twitch.get("live"))
|
||||
started = (twitch.get("started_at") or None) if live else None
|
||||
except Exception as e:
|
||||
print(f"[live] poll failed: {type(e).__name__}: {e}", flush=True)
|
||||
await asyncio.sleep(STATUS_POLL_S)
|
||||
continue
|
||||
|
||||
if not live:
|
||||
seen_offline = True
|
||||
elif started:
|
||||
if last_announced is None and not seen_offline:
|
||||
# cold start mid-stream: lock in this started_at so we won't
|
||||
# falsely announce it later
|
||||
last_announced = started
|
||||
elif started != last_announced and seen_offline:
|
||||
title = twitch.get("title") or ""
|
||||
game = twitch.get("game") or ""
|
||||
try:
|
||||
await asyncio.to_thread(_post_mattermost, mm_hook, channel, title, game)
|
||||
print(f"[live] {channel} went live → posted to mattermost "
|
||||
f"(title={title!r}, game={game!r})", flush=True)
|
||||
except Exception as e:
|
||||
# log but don't retry — a missed notification is better
|
||||
# than a duplicate; the next genuine transition will fire
|
||||
print(f"[live] mattermost post failed: {type(e).__name__}: {e}",
|
||||
flush=True)
|
||||
last_announced = started
|
||||
seen_offline = False
|
||||
|
||||
await asyncio.sleep(STATUS_POLL_S)
|
||||
|
||||
|
||||
# ─── entrypoint ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def main():
|
||||
env = {**load_env(ENV), **os.environ} # process env wins over .env
|
||||
token = env.get("TWITCH_ACCESS_TOKEN", "").strip()
|
||||
nick = env.get("TWITCH_NICK", "").strip()
|
||||
channel = env.get("TWITCH_CHANNEL", nick).strip()
|
||||
mm_hook = env.get("MM_HOOK", "").strip()
|
||||
|
||||
missing = [k for k, v in (("TWITCH_ACCESS_TOKEN", token), ("TWITCH_NICK", nick)) if not v]
|
||||
if missing:
|
||||
print(f"[err] missing in {ENV}: {', '.join(missing)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
bot = TwitchBot(nick=nick, token=token, channel=channel, mpd=Mpd())
|
||||
tasks = [bot.run()]
|
||||
if mm_hook:
|
||||
print(f"[live] watching {STATUS_URL} → mattermost on offline→live", flush=True)
|
||||
tasks.append(live_watch(mm_hook, channel))
|
||||
else:
|
||||
print("[live] MM_HOOK unset — going-live notifications disabled", flush=True)
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
150
examples/stream.sh
Executable file
150
examples/stream.sh
Executable file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# stream.sh — interactive pre-stream helper.
|
||||
#
|
||||
# ./stream.sh
|
||||
#
|
||||
# Prompts for game + title + countdown, resolves the game to a Twitch
|
||||
# category id via ../search-game.py, updates the channel via ../set-channel.py,
|
||||
# and writes stream.json next to this script for any overlay / starting-soon
|
||||
# timer / status page that wants to read the manifest.
|
||||
#
|
||||
# Re-running uses your previous answers as defaults — press Enter to keep them.
|
||||
#
|
||||
# Output path can be overridden with STREAM_JSON=/path/to/file.json ./stream.sh
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BOT_DIR="$(cd "$DIR/.." && pwd)"
|
||||
JSON="${STREAM_JSON:-$DIR/stream.json}"
|
||||
|
||||
SEARCH_GAME="$BOT_DIR/search-game.py"
|
||||
SET_CHANNEL="$BOT_DIR/set-channel.py"
|
||||
|
||||
# Reads a top-level field from the previous stream.json. Always exits 0;
|
||||
# prints empty string if file/field is missing.
|
||||
prev() {
|
||||
[[ -f "$JSON" ]] || return 0
|
||||
python3 - "$JSON" "$1" <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(open(sys.argv[1]))
|
||||
v = d.get(sys.argv[2])
|
||||
print('' if v is None else v)
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
}
|
||||
|
||||
ask() {
|
||||
# ask "question" "default" → echoes user input or default if blank
|
||||
local q="$1" def="${2:-}"
|
||||
local prompt=" $q"
|
||||
[[ -n "$def" ]] && prompt+=" [$def]"
|
||||
prompt+=": "
|
||||
local v
|
||||
read -rp "$prompt" v
|
||||
[[ -z "$v" && -n "$def" ]] && v="$def"
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
ask_int() {
|
||||
local v
|
||||
while :; do
|
||||
v=$(ask "$1" "${2:-}")
|
||||
[[ "$v" =~ ^[0-9]+$ ]] && { printf '%s' "$v"; return; }
|
||||
echo " ✗ must be a whole number" >&2
|
||||
done
|
||||
}
|
||||
|
||||
sanitize() {
|
||||
printf '%s' "${1:-}" \
|
||||
| tr -d '"\\' \
|
||||
| tr '\n\r\t' ' ' \
|
||||
| sed 's/ */ /g; s/^ //; s/ $//'
|
||||
}
|
||||
emit_str() { [[ -z "${1:-}" ]] && printf 'null' || printf '"%s"' "$1"; }
|
||||
|
||||
# ── Pull previous answers as defaults ───────────────
|
||||
prev_game=$(prev game)
|
||||
prev_game_id=$(prev gameId)
|
||||
prev_title=$(prev title)
|
||||
prev_count=$(prev countdownMin); [[ -z "$prev_count" ]] && prev_count="5"
|
||||
|
||||
echo "── stream manifest ──"
|
||||
echo " (press Enter to keep [defaults])"
|
||||
echo
|
||||
|
||||
# Resolve Game via Twitch search → exact directory entry. The picker prints
|
||||
# "<id>\t<name>" to stdout on success, or non-zero on no-match / cancel.
|
||||
# Re-running and accepting the previous game unchanged reuses the cached id
|
||||
# so we don't burn an API call to re-resolve a known answer.
|
||||
have_twitch=0
|
||||
[[ -x "$SEARCH_GAME" ]] && have_twitch=1
|
||||
|
||||
game=""; game_id=""
|
||||
while :; do
|
||||
query=$(ask "Game (search)" "$prev_game")
|
||||
if [[ -z "$query" ]]; then
|
||||
echo " ✗ Game is required" >&2
|
||||
continue
|
||||
fi
|
||||
if (( have_twitch )); then
|
||||
# Reuse cached id only if it looks like a real Twitch numeric id. A past
|
||||
# bug (search-game.py prompt leaking into stdout) wrote "Pick: 506462"
|
||||
# here; the regex makes sure such corruption falls back to a fresh search
|
||||
# instead of getting passed straight to PATCH /helix/channels.
|
||||
if [[ -n "$prev_game_id" && "$query" == "$prev_game" && "$prev_game_id" =~ ^[0-9]+$ ]]; then
|
||||
game="$prev_game"; game_id="$prev_game_id"
|
||||
echo " ↻ reusing cached: $game (id=$game_id)"
|
||||
break
|
||||
fi
|
||||
if line=$("$SEARCH_GAME" "$query"); then
|
||||
IFS=$'\t' read -r game_id game <<<"$line"
|
||||
break
|
||||
fi
|
||||
# search-game.py already printed its own error; loop and re-ask
|
||||
else
|
||||
# no twitch helper available — accept the raw input, no id resolution
|
||||
game="$query"; game_id=""
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
title=$(ask "Title (Twitch stream title)" "$prev_title")
|
||||
countdown=$(ask_int "Countdown (minutes)" "$prev_count")
|
||||
|
||||
game=$(sanitize "$game")
|
||||
title=$(sanitize "$title")
|
||||
|
||||
now_iso=$(date -u +%FT%TZ)
|
||||
tmp="$JSON.tmp.$$"
|
||||
cat > "$tmp" <<JSON
|
||||
{
|
||||
"compiledAt": "$now_iso",
|
||||
"game": $(emit_str "$game"),
|
||||
"gameId": $(emit_str "$game_id"),
|
||||
"title": $(emit_str "$title"),
|
||||
"countdownMin": $countdown
|
||||
}
|
||||
JSON
|
||||
|
||||
if ! python3 -m json.tool "$tmp" >/dev/null 2>&1; then
|
||||
echo " ✗ produced invalid JSON, leaving $tmp for inspection" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv -f "$tmp" "$JSON"
|
||||
|
||||
echo
|
||||
echo " ✓ wrote $JSON"
|
||||
|
||||
# ── Push to Twitch (game + title) via the broadcaster token ─────────────
|
||||
# Soft-fail: a Twitch hiccup must not block the local manifest from being
|
||||
# written — anything reading stream.json can still load offline.
|
||||
# Title falls back to the game name when the user left it blank.
|
||||
if (( have_twitch )) && [[ -x "$SET_CHANNEL" && -n "$game_id" ]]; then
|
||||
twitch_title="${title:-$game}"
|
||||
echo
|
||||
if ! "$SET_CHANNEL" --game-id "$game_id" "$twitch_title"; then
|
||||
echo " ! Twitch sync failed (manifest still saved) — fix and re-run if needed" >&2
|
||||
fi
|
||||
fi
|
||||
104
search-game.py
Executable file
104
search-game.py
Executable file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Interactive Twitch category search. Used by loading.sh to resolve a free-form
|
||||
query into an exact directory entry.
|
||||
|
||||
./search-game.py "Heroes"
|
||||
stderr: numbered list of up to 20 matches + "Pick: " prompt
|
||||
stdout: a single line "<game_id>\\t<canonical_name>" (only on success)
|
||||
exit: 0 success | 1 no-match / cancel / error | 130 ctrl-c
|
||||
|
||||
Auto-picks when there's exactly one result. Tempting to also auto-pick on an
|
||||
exact-name match within a larger result set (e.g. typing "Doom Eternal" and
|
||||
having that exact entry near the top), but a real test surfaced the failure:
|
||||
typing "Heroes" returns "Heroes" (TV show) plus all the Heroes-* games, and
|
||||
auto-picking the standalone match silently steals from the user — who is
|
||||
*using search* precisely because the query is ambiguous. So: only one rule.
|
||||
|
||||
Endpoint: /helix/search/categories — same one Twitch's dashboard autocomplete
|
||||
uses. Accepts any user/app token; we reuse the broadcaster token from
|
||||
.env.ophi118 because it's already there and refreshable.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
import _twitch as tw
|
||||
|
||||
MAX_RESULTS = 20
|
||||
|
||||
|
||||
def err(msg=""):
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def search(env: dict, query: str) -> list:
|
||||
qs = urllib.parse.urlencode({"query": query, "first": MAX_RESULTS})
|
||||
status, body = tw.auth_call("GET", f"{tw.HELIX}/search/categories?{qs}", env)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"search failed (status={status}): {body}")
|
||||
return (body or {}).get("data") or []
|
||||
|
||||
|
||||
def pick(results: list, query: str) -> dict:
|
||||
if not results:
|
||||
raise RuntimeError(f"no Twitch categories match {query!r}")
|
||||
|
||||
if len(results) == 1:
|
||||
err(f" ✓ only match: {results[0]['name']}")
|
||||
return results[0]
|
||||
|
||||
err(f"── {len(results)} matches for {query!r} ──")
|
||||
for i, g in enumerate(results, 1):
|
||||
err(f" {i:2}) {g['name']}")
|
||||
err(" 0) cancel")
|
||||
|
||||
while True:
|
||||
# input()'s prompt arg writes to stdout — fatal here because bash
|
||||
# captures stdout for the resolved <id>\t<name> line. Print to stderr
|
||||
# explicitly, then call input() with no prompt.
|
||||
print("Pick: ", end="", file=sys.stderr, flush=True)
|
||||
try:
|
||||
raw = input().strip()
|
||||
except EOFError:
|
||||
raise RuntimeError("cancelled (EOF)")
|
||||
if not raw.isdigit():
|
||||
err(" ! enter a number")
|
||||
continue
|
||||
n = int(raw)
|
||||
if n == 0:
|
||||
raise RuntimeError("cancelled")
|
||||
if 1 <= n <= len(results):
|
||||
return results[n - 1]
|
||||
err(f" ! must be 0..{len(results)}")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2 or not sys.argv[1].strip():
|
||||
print("usage: search-game.py <query>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
query = sys.argv[1].strip()
|
||||
|
||||
env = tw.load_env()
|
||||
needed = ("TWITCH_ACCESS_TOKEN", "TWITCH_REFRESH_TOKEN", "TWITCH_CLIENT_ID")
|
||||
missing = [k for k in needed if not env.get(k)]
|
||||
if missing:
|
||||
err(f"[err] missing in {tw.ENV_FILE.name}: {', '.join(missing)}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
results = search(env, query)
|
||||
chosen = pick(results, query)
|
||||
except Exception as e:
|
||||
err(f"[err] {type(e).__name__}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# ONLY the resolved tuple goes to stdout — bash captures this.
|
||||
print(f"{chosen['id']}\t{chosen['name']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
102
set-channel.py
Executable file
102
set-channel.py
Executable file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Update Twitch channel info (game + title) for the broadcaster account.
|
||||
|
||||
./set-channel.py --game-id 517520 "Slayer is back — Nightmare run"
|
||||
./set-channel.py "Doom Eternal" "..." # exact-name lookup, fails if no exact hit
|
||||
./set-channel.py --dry-run --game-id 517520 "..."
|
||||
|
||||
For free-form / fuzzy game queries, use search-game.py first to resolve the
|
||||
id (loading.sh does this automatically). The /helix/games?name= fallback in
|
||||
this script is exact-and-case-sensitive.
|
||||
|
||||
Loads `.env.ophi118` — see _twitch.py for the env contract. The token MUST
|
||||
belong to the broadcaster (Twitch enforces broadcaster_id == token user_id
|
||||
on PATCH /helix/channels), so this script intentionally targets that file
|
||||
and not `.env` (which holds the chat-bot's account).
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
import _twitch as tw
|
||||
|
||||
TITLE_LIMIT = 140
|
||||
|
||||
|
||||
def lookup_game_id(env: dict, name: str) -> str:
|
||||
qs = urllib.parse.urlencode({"name": name})
|
||||
status, body = tw.auth_call("GET", f"{tw.HELIX}/games?{qs}", env)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"games lookup failed (status={status}): {body}")
|
||||
data = (body or {}).get("data") or []
|
||||
if not data:
|
||||
raise RuntimeError(
|
||||
f"no Twitch game matches {name!r} exactly. /helix/games?name= is "
|
||||
f"case-sensitive — use search-game.py for fuzzy lookup."
|
||||
)
|
||||
return data[0]["id"]
|
||||
|
||||
|
||||
def patch_channel(env: dict, game_id: str, title: str) -> None:
|
||||
qs = urllib.parse.urlencode({"broadcaster_id": env["TWITCH_BROADCASTER_ID"]})
|
||||
payload = json.dumps({"game_id": game_id, "title": title})
|
||||
status, body = tw.auth_call("PATCH", f"{tw.HELIX}/channels?{qs}", env, body=payload)
|
||||
if status not in (200, 204):
|
||||
raise RuntimeError(f"channel update failed (status={status}): {body}")
|
||||
|
||||
|
||||
def usage_and_die():
|
||||
print("usage: set-channel.py [--dry-run] (--game-id <id> | <game-name>) <title>",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
dry = False
|
||||
if args and args[0] == "--dry-run":
|
||||
dry, args = True, args[1:]
|
||||
|
||||
game_id = None
|
||||
if args and args[0] == "--game-id":
|
||||
if len(args) < 3:
|
||||
usage_and_die()
|
||||
game_id, args = args[1], args[2:]
|
||||
|
||||
expected = 1 if game_id else 2
|
||||
if len(args) != expected:
|
||||
usage_and_die()
|
||||
|
||||
if game_id:
|
||||
title = args[0][:TITLE_LIMIT]
|
||||
else:
|
||||
game_name = args[0]
|
||||
title = args[1][:TITLE_LIMIT]
|
||||
|
||||
env = tw.load_env()
|
||||
needed = ("TWITCH_ACCESS_TOKEN", "TWITCH_REFRESH_TOKEN",
|
||||
"TWITCH_CLIENT_ID", "TWITCH_BROADCASTER_ID")
|
||||
missing = [k for k in needed if not env.get(k)]
|
||||
if missing:
|
||||
print(f"[err] missing in {tw.ENV_FILE.name}: {', '.join(missing)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if game_id is None:
|
||||
game_id = lookup_game_id(env, game_name)
|
||||
|
||||
if dry:
|
||||
print(f" ✓ dry-run: would set game_id={game_id}, title={title!r} (no PATCH issued)")
|
||||
return
|
||||
|
||||
patch_channel(env, game_id, title)
|
||||
print(f" ✓ Twitch channel updated → game_id={game_id}, title={title!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
print(f"[err] {type(e).__name__}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user