diff --git a/plugins/limnoria/NtrPlaylist/config.py b/plugins/limnoria/NtrPlaylist/config.py index 55d039b..59c3766 100644 --- a/plugins/limnoria/NtrPlaylist/config.py +++ b/plugins/limnoria/NtrPlaylist/config.py @@ -43,3 +43,21 @@ conf.registerGlobalValue( """IANA timezone for displaying dates in IRC (e.g. America/New_York, America/Chicago).""", ), ) + +conf.registerGlobalValue( + NtrPlaylist, + "wsUrl", + registry.String( + "ws://127.0.0.1:8000/ws/announce", + """WebSocket URL for receiving announce commands from the dashboard.""", + ), +) + +conf.registerGlobalValue( + NtrPlaylist, + "announceChannel", + registry.String( + "#sewerchat", + """IRC channel to send announce messages to.""", + ), +) diff --git a/plugins/limnoria/NtrPlaylist/plugin.py b/plugins/limnoria/NtrPlaylist/plugin.py index 65cc7e4..6240cf1 100644 --- a/plugins/limnoria/NtrPlaylist/plugin.py +++ b/plugins/limnoria/NtrPlaylist/plugin.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import logging +import threading import re import urllib.error import urllib.request @@ -121,6 +122,71 @@ _NUMBER_RE = re.compile(r"^!(\d+)$") class NtrPlaylist(callbacks.Plugin): """Query the NtR SoundCloud Fetcher API from IRC.""" + def __init__(self, irc): + super().__init__(irc) + self._irc = irc + self._ws_stop = threading.Event() + self._ws_thread = threading.Thread(target=self._ws_listener, daemon=True) + self._ws_thread.start() + + def die(self): + self._ws_stop.set() + super().die() + + def _ws_listener(self): + import websocket + from supybot import ircmsgs + + backoff = 5 + max_backoff = 60 + + while not self._ws_stop.is_set(): + ws_url = self.registryValue("wsUrl") + token = self.registryValue("adminToken") + channel = self.registryValue("announceChannel") + + if not ws_url or not token: + LOGGER.warning("wsUrl or adminToken not configured, WS listener sleeping") + self._ws_stop.wait(30) + continue + + ws = None + try: + ws = websocket.WebSocket() + ws.connect(ws_url, timeout=10) + ws.send(json.dumps({"type": "subscribe", "token": token})) + LOGGER.info("Connected to announce WebSocket at %s", ws_url) + backoff = 5 + + while not self._ws_stop.is_set(): + ws.settimeout(5) + try: + raw = ws.recv() + if not raw: + break + data = json.loads(raw) + if data.get("type") == "announce" and "message" in data: + msg = ircmsgs.privmsg(channel, data["message"]) + self._irc.queueMsg(msg) + LOGGER.info("Announced to %s: %s", channel, data["message"]) + except websocket.WebSocketTimeoutException: + continue + except websocket.WebSocketConnectionClosedException: + break + except Exception: + LOGGER.exception("WS listener error") + finally: + if ws: + try: + ws.close() + except Exception: + pass + + if not self._ws_stop.is_set(): + LOGGER.info("Reconnecting in %ds", backoff) + self._ws_stop.wait(backoff) + backoff = min(backoff * 2, max_backoff) + def _is_admin(self, nick: str) -> bool: admin_nicks = self.registryValue("adminNicks") if not admin_nicks: diff --git a/plugins/sopel/ntr_playlist.py b/plugins/sopel/ntr_playlist.py index e54fa4e..0898d81 100644 --- a/plugins/sopel/ntr_playlist.py +++ b/plugins/sopel/ntr_playlist.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import logging +import threading import urllib.error import urllib.request from datetime import datetime @@ -21,10 +22,79 @@ class NtrPlaylistSection(types.StaticSection): admin_token = types.ValidatedAttribute("admin_token", default="") admin_nicks = types.ListAttribute("admin_nicks") display_timezone = types.ValidatedAttribute("display_timezone", default="America/New_York") + ws_url = types.ValidatedAttribute("ws_url", default="ws://127.0.0.1:8000/ws/announce") + announce_channel = types.ValidatedAttribute("announce_channel", default="#sewerchat") + + +_ws_stop = None +_ws_thread = None def setup(bot): + global _ws_stop, _ws_thread bot.settings.define_section("ntr_playlist", NtrPlaylistSection) + _ws_stop = threading.Event() + _ws_thread = threading.Thread(target=_ws_listener, args=(bot,), daemon=True) + _ws_thread.start() + + +def shutdown(bot): + global _ws_stop + if _ws_stop: + _ws_stop.set() + + +def _ws_listener(bot): + import websocket + + backoff = 5 + max_backoff = 60 + + while not _ws_stop.is_set(): + ws_url = bot.settings.ntr_playlist.ws_url + token = bot.settings.ntr_playlist.admin_token + channel = bot.settings.ntr_playlist.announce_channel + + if not ws_url or not token: + LOGGER.warning("ws_url or admin_token not configured, WS listener sleeping") + _ws_stop.wait(30) + continue + + ws = None + try: + ws = websocket.WebSocket() + ws.connect(ws_url, timeout=10) + ws.send(json.dumps({"type": "subscribe", "token": token})) + LOGGER.info("Connected to announce WebSocket at %s", ws_url) + backoff = 5 + + while not _ws_stop.is_set(): + ws.settimeout(5) + try: + raw = ws.recv() + if not raw: + break + data = json.loads(raw) + if data.get("type") == "announce" and "message" in data: + bot.say(data["message"], channel) + LOGGER.info("Announced to %s: %s", channel, data["message"]) + except websocket.WebSocketTimeoutException: + continue + except websocket.WebSocketConnectionClosedException: + break + except Exception: + LOGGER.exception("WS listener error") + finally: + if ws: + try: + ws.close() + except Exception: + pass + + if not _ws_stop.is_set(): + LOGGER.info("Reconnecting in %ds", backoff) + _ws_stop.wait(backoff) + backoff = min(backoff * 2, max_backoff) def configure(config):