From baeea1c41b3f90870230c38d55a4622d61684e0d Mon Sep 17 00:00:00 2001 From: Interitio Date: Sat, 1 Nov 2025 06:45:36 +1000 Subject: [PATCH] Remove extraneous IRC client --- hyperfocus/twitch/component.py | 67 +------ hyperfocus/twitch/focuschannel.py | 291 ------------------------------ 2 files changed, 7 insertions(+), 351 deletions(-) delete mode 100644 hyperfocus/twitch/focuschannel.py diff --git a/hyperfocus/twitch/component.py b/hyperfocus/twitch/component.py index ded5349..1ef8622 100644 --- a/hyperfocus/twitch/component.py +++ b/hyperfocus/twitch/component.py @@ -1,6 +1,7 @@ from string import punctuation import datetime as dt from datetime import datetime, timedelta +from math import ceil import time import twitchio @@ -15,7 +16,7 @@ from utils.lib import parse_dur, strfdelta, utc_now from . import logger from ..data import HyperfocusData, Hyperfocuser -from .focuschannel import FocusChannel + # Default requested scopes for joining a channel CHANNEL_SCOPES = Scopes( @@ -33,55 +34,12 @@ class FocusComponent(cmds.Component): self.bot = bot self.data = bot.dbconn.load_registry(HyperfocusData()) - self.channels: dict[str, FocusChannel] = {} self._last_deleted: dict[int, datetime] = {} # ----- API ----- async def component_load(self): await self.data.init() - async def get_focus_channel(self, channelid: str, channel: str): - """ - Get the logged in FocusChannel for the given channel, if possible. - - This is an expensive operation when we need to login to a new channel, - so don't call this unless we are sure we can log in or need it. - """ - if not (ch := self.channels.get(channelid)) or not ch.is_alive: - # Create and login to a new focuschannel, if possible. - token = await self.get_token_for(channelid) - session = self.bot._http._session - ch = FocusChannel(channel, token, session) - try: - await ch.connect() - except Exception: - logger.exception( - f"Hyperfocus channel connect failure for '{channel}' ('{channelid}')" - ) - raise - self.channels[channelid] = ch - - return ch - - async def get_token_for(self, channelid: str): - """ - Get the hyperfocus-capable token for the given channel. - """ - # TODO: Token invalidation method if it fails. - # Also should be in a transaction - # Might technically need to refresh token as well - scope_rows = await self.bot.data.user_auth_scopes.select_where(userid=channelid) - scopes = Scopes([row["scope"] for row in scope_rows]) - - has_required = all(scope in scopes for scope in CHANNEL_SCOPES) - if not has_required: - # TODO: Better auth exception - raise ValueError("Channel '%s' does not have required scopes." % channelid) - - auth_row = await UserAuth.fetch(channelid) - assert auth_row is not None - return auth_row.token - async def get_hyperfocus(self, profileid: int) -> Hyperfocuser | None: """ Get the Hyperfocuser if the user is hyperfocused. @@ -91,25 +49,11 @@ class FocusComponent(cmds.Component): return row async def focus_delete_message(self, message: twitchio.ChatMessage): - """Delete the given message. Uses the API if possible, otherwise opens an IRC channel.""" + """Delete the given message.""" # This should be impossible, but just in case. # None id could cause chat to be wiped assert message.id is not None - # badge_sets = {badge.set_id for badge in message.badges} - # if "moderator" in badge_sets or "broadcaster" in badge_sets: - # # We need to use the focus channel - # - # assert message.broadcaster.name is not None - # chan = await self.get_focus_channel( - # str(message.broadcaster.id), message.broadcaster.name - # ) - # await chan.delete_msg(str(message.id)) - # else: - # await message.broadcaster.delete_chat_messages( - # moderator=self.bot.bot_id, - # message_id=message.id, - # ) await message.broadcaster.delete_chat_messages( moderator=message.broadcaster, message_id=message.id, @@ -159,7 +103,9 @@ class FocusComponent(cmds.Component): sender=self.bot.bot_id, ) except Exception: - logger.warning(f"Failed to delete a hyperfocus message: {payload!r}") + logger.warning( + f"Failed to delete a hyperfocus message: {payload!r}", exc_info=True + ) if notify: await payload.broadcaster.send_message( f"@{payload.chatter.name} Stay focused! ", @@ -212,6 +158,7 @@ class FocusComponent(cmds.Component): ) # TODO: Update channel + minutes = ceil(dur / 60) await ctx.reply( f"{ctx.chatter.name} has gone into HYPERFOCUS! " f"They will be in emote and command only mode for the next {minutes} minutes! " diff --git a/hyperfocus/twitch/focuschannel.py b/hyperfocus/twitch/focuschannel.py deleted file mode 100644 index 7287bd5..0000000 --- a/hyperfocus/twitch/focuschannel.py +++ /dev/null @@ -1,291 +0,0 @@ -""" -Minimal IRC client intended to connect to one channel and send a message. -""" - -import asyncio -import aiohttp -import time -import re - -from . import logger - - -ACTIONS = ( - "JOIN", - "PART", - "PING", - "PRIVMSG", - "PRIVMSG(ECHO)", - "USERSTATE", - "MODE", - "WHISPER", - "USERNOTICE", - "NOTICE", -) -ACTIONS2 = ("USERSTATE", "ROOMSTATE", "PRIVMSG", "USERNOTICE", "WHISPER") -USER_SUB = re.compile(r":(?P.*)!") -MESSAGE_RE = re.compile( - r":(?P\S+) (?P\S+) (?P\S+)( :(?P.*))?$" -) -FAST_RETURN = { - "RECONNECT": {"code": 0, "action": "RECONNECT"}, - "PING": {"action": "PING"}, -} - - -def parser(data: str, nick: str): - groups = data.split() - action = groups[1] if groups[1] == "JOIN" else groups[-2] - channel = None - message = None - user = None - badges = None - - _group_len = len(groups) - - if action in FAST_RETURN: - return FAST_RETURN[action] - - elif groups[1] in FAST_RETURN: - return FAST_RETURN[groups[1]] - - elif ( - groups[1] in ACTIONS - or (_group_len > 2 and groups[2] in ACTIONS) - or (_group_len > 3 and groups[3] in {"PRIVMSG", "PRIVMSG(ECHO)"}) - ): - result = re.search(MESSAGE_RE, data) - if not result: - logger.error("****** MESSAGE_RE Failed! ******") - raise ValueError("Could not parse message '%s' as IRC message" % data) - user = result.group("useraddr").split("!")[0] - action = result.group("action") - channel = result.group("channel").lstrip("#") - message = result.group("message") - - if action == "WHISPER": - channel = None - - if action in ACTIONS2: - prebadge = groups[0].split(";") - badges = {} - - for badge in prebadge: - badge = badge.split("=") - - try: - badges[badge[0]] = badge[1] - except IndexError: - pass - - if action == "USERSTATE" and badges.get("display-name"): - user = badges["display-name"].lower() - - if action == "USERNOTICE" and badges.get("login"): - user = badges["login"].lower() - elif action not in ACTIONS: - action = None - - if not user: - try: - user = re.search(USER_SUB, groups[0]).group("user") - except (AttributeError, ValueError): - pass - - try: - code = int(groups[1]) - except ValueError: - code = 0 - - batches = [] - if code == 353: - channel = groups[4] - if channel[0] == "#": - channel = channel[1:] - else: - logger.warning(f" (353) parse failed? ||{channel}||") - - if user is None: - user = groups[-1][1:].lower() - for b in groups[5:-1]: - if b[0] == ":": - b = b[1:] - - if "\r\n:" in b: - batches.append(b.split("\r\n:")[0]) - break - else: - batches.append(b) - - return dict( - data=data, - nick=nick, - groups=groups, - action=action, - channel=channel, - user=user, - badges=badges, - code=code, - message=message, - batches=batches, - ) - - -class FocusChannel: - IRC_URI = "wss://irc-ws.chat.twitch.tv:443" - - def __init__(self, channel: str, token: str, session: aiohttp.ClientSession): - self.channel = channel.lower() - self.token = token - self.session = session - - self._socket = None - self._keeper: asyncio.Task | None = None - self._ws_ready_event: asyncio.Event = asyncio.Event() - self._joined: asyncio.Event = asyncio.Event() - - self.is_ready: asyncio.Event = asyncio.Event() - - self.modes = ("commands",) - self._last_ping = 0 - self._reconnect_requested = False - - @property - def is_alive(self): - return self._socket is not None and not self._socket.closed - - async def wait_until_ready(self): - await self.is_ready.wait() - - async def connect(self): - self.is_ready.clear() - - if self._keeper: - self._keeper.cancel() - if self.is_alive: - await self._socket.close() - - try: - self._socket = await self.session.ws_connect(url=self.IRC_URI) - except Exception as e: - logger.error(f"FocusChannel IRC connection failed: {e}") - raise - - self._reconnect_requested = False - self._keeper = asyncio.create_task(self._keep_alive()) - self._ws_ready_event.set() - - async def authenticate(self): - if not self.is_alive: - raise ValueError("Cannot authenticate before connection.") - await self.send(f"PASS oauth:{self.token}\r\n") - await self.send(f"NICK {self.channel}\r\n") - - for cap in self.modes: - await self.send(f"CAP REQ :twitch.tv/{cap}") - - async def join(self): - if not self.is_alive: - raise ValueError("Cannot join before connection.") - - self._joined.clear() - - await self.send(f"JOIN #{self.channel}\r\n") - await self._joined.wait() - - async def send(self, message: str): - await self._socket.send_str(message + "\r\n") - - async def _process_data(self, data: str): - data = data.rstrip() - parsed = parser(data, self.channel) - - if parsed["action"] == "PING": - return await self._ping() - elif parsed["action"] == "RECONNECT": - return await self._reconnect(parsed) - elif parsed["code"] != 0: - return await self._code(parsed, parsed["code"]) - elif data.startswith(":tmi.twitch.tv NOTICE * :Login unsuccessful"): - logger.error(f"Failed to login to Twitch IRC channel {self.channel}") - return await self._close() - else: - # TODO: We could handle other actions here - return None - - async def _code(self, parsed, code: int): - logger.info(f"{self!r} received '{code}': {parsed}") - - if code == 1: - logger.info(f"FocusChannel logged into '{self.channel}'") - elif code == 353: - pass - elif code in (2, 3, 4, 366, 372, 375): - pass - elif code == 376: - pass - else: - pass - - async def _ping(self): - self._last_ping = time.monotonic() - await self.send("PONG :tmi.twitch.tv\r\n") - - async def _close(self): - if self._keeper: - self._keeper.cancel() - - self.is_ready.clear() - if self._socket: - await self._socket.close() - - async def _join(self, parsed): - channel = parsed["channel"] - logger.info(f"ACTION: JOIN:: {channel}") - - if self.channel != channel.lower(): - logger.info( - f"FocusChannel '{self.channel}' got join event for '{channel}'.. ignoring" - ) - else: - self._joined.set() - - async def _keep_alive(self): - await self._ws_ready_event.wait() - self._ws_ready_event.clear() - - if not self._last_ping: - self._last_ping = time.monotonic() - while not self._socket.closed and not self._reconnect_requested: - msg = await self._socket.receive() - - if msg.type is aiohttp.WSMsgType.CLOSED: - logger.error(f"Websocket connection closed: {msg.extra}") - break - data = msg.data - if data: - logger.debug(f" < {data}") - events = data.split("\r\n") - for event in events: - if not event: - continue - try: - await self._process_data(event) - except Exception: - logger.exception( - "Websocket message processing failed: %s", str(event) - ) - - async def _reconnect(self, parsed): - logger.info(f"ACTION: {self!r} recconecting") - self._reconnect_requested = True - if self._keeper: - self._keeper.cancel() - asyncio.create_task(self.connect()) - - async def send_msg(self, content: str): - logger.debug(f"Sending: {content}") - await self._socket.send_str(content) - - async def delete_msg(self, msgid: str): - return await self.send_msg(f"/delete {msgid}")