Initial version
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user