From a34cd5fab761f80110f59ebd693b8eba4f877635 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 26 Apr 2026 17:16:51 +0200 Subject: [PATCH] =?UTF-8?q?Twitch=20chat=20bot:=20!skip=20=E2=86=92=20MPD?= =?UTF-8?q?=20next-track=20via=20IRC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosted async Python bot, runs as systemd --user unit (obs-twitch-bot.service, sibling to obs-mpd-bridge). Connects to Twitch IRC over TLS as vault118, listens in #ophi118, advances MPD on !skip and posts the new track back to chat. 5s cooldown swallows skip-spam silently. Strips Unicode Tags-block codepoints (U+E0000–U+E007F) before matching the command so Twitch's anti-duplicate suffix doesn't break every-other !skip when the broadcaster fires the same command twice in a row. Bot account is separate (vault118) to keep the broadcaster's display name out of bot replies; .env holds access token + refresh token + client id (refresh + client id stored for future token rotation, unused today). --- twitch-bot/.env.example | 28 +++++ twitch-bot/.gitignore | 10 ++ twitch-bot/bot.py | 251 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 twitch-bot/.env.example create mode 100644 twitch-bot/.gitignore create mode 100644 twitch-bot/bot.py diff --git a/twitch-bot/.env.example b/twitch-bot/.env.example new file mode 100644 index 0000000..ff143ab --- /dev/null +++ b/twitch-bot/.env.example @@ -0,0 +1,28 @@ +# 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 diff --git a/twitch-bot/.gitignore b/twitch-bot/.gitignore new file mode 100644 index 0000000..ce67129 --- /dev/null +++ b/twitch-bot/.gitignore @@ -0,0 +1,10 @@ +# Defensive — root .gitignore already blocks `*.env` and `.env*`, but listing +# `.env` here makes the intent explicit at the bot dir. +.env +.env.ophi118 +# Root's `.env*` glob also catches `.env.example`; un-ignore the template so +# it stays tracked. +!.env.example + +# Python bytecode cache +__pycache__/ diff --git a/twitch-bot/bot.py b/twitch-bot/bot.py new file mode 100644 index 0000000..2a81cbb --- /dev/null +++ b/twitch-bot/bot.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +""" +Twitch chat → MPD control bot. + +Connects to Twitch IRC over TLS, joins the configured channel, and on `!skip` +advances MPD to the next track. The new track shows up on stream because +bridges/mpd-state.py is already broadcasting MPD state to the loading overlay. + +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 + +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 os +import ssl +import sys +import time +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 = 5.0 # seconds between successful !skip invocations + +# 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 skip(self) -> str: + async with self._lock: + last = None + for attempt in (1, 2): + try: + await self._ensure() + await self._cli.next() + return _fmt_song(await self._cli.currentsong()) + except Exception as e: + last = e + self._cli = None # force reconnect on retry + raise last # type: ignore[misc] + + +def _fmt_song(song: dict) -> str: + artist = song.get("artist") or song.get("albumartist") or "" + title = song.get("title") or song.get("file", "").rsplit("/", 1)[-1] + if isinstance(artist, list): artist = ", ".join(a for a in artist if a) + if isinstance(title, list): title = title[0] if title else "" + out = f"{artist} — {title}".strip(" —") + return out or "(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 + self._last_skip = 0.0 + + 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) + + async def _cmd_skip(self, writer: asyncio.StreamWriter, user: str): + # Silent rate-limit so a flurry of !skip from chat doesn't multi-skip + # or spam the channel with replies. First click within the window wins. + now = time.monotonic() + if now - self._last_skip < SKIP_COOLDOWN: + 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._last_skip = now + print(f"[skip] @{user} → {now_playing}", flush=True) + await self._say(writer, f"⏭ skipped by @{user} → now: {now_playing}") + + +# ─── 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() + + 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()) + await bot.run() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + sys.exit(0)