from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any from yuxi.channels.models import ChannelMessage from yuxi.utils.logging_config import logger from .commands import ( check_command_access, handle_command, has_control_command, parse_command, ) from .ctcp import handle_ctcp from .protocol import extract_nick, is_ctcp_message, parse_irc_line, parse_irc_tags_dict from .sanitize import sanitize_inbound_text if TYPE_CHECKING: from .adapter import IRCAdapter _MONITOR_REPLIES = frozenset(["730", "731", "732", "733", "734"]) class IRCMonitor: def __init__(self, config: dict[str, Any], adapter: IRCAdapter): self._config = config self._adapter = adapter self._message_handler: Callable[[ChannelMessage], Awaitable[None]] | None = None self._raw_message_handler: Callable[[dict[str, Any]], Awaitable[bool | None]] | None = None self._task: asyncio.Task | None = None self._running = False self._dedup_window: set[str] = set() self._dedup_max = config.get("dedup", {}).get("max_entries", 1000) self._on_record_error: Callable[[Exception, dict[str, Any]], Awaitable[None]] | None = None self._on_dispatch_error: Callable[[Exception, ChannelMessage], Awaitable[None]] | None = None def on_message(self, handler: Callable[[ChannelMessage], Awaitable[None]]) -> None: self._message_handler = handler def on_raw_message(self, handler: Callable[[dict[str, Any]], Awaitable[bool | None]]) -> None: self._raw_message_handler = handler def on_record_error(self, handler: Callable[[Exception, dict[str, Any]], Awaitable[None]]) -> None: self._on_record_error = handler def on_dispatch_error(self, handler: Callable[[Exception, ChannelMessage], Awaitable[None]]) -> None: self._on_dispatch_error = handler async def start(self) -> None: if self._task and not self._task.done(): return self._running = True self._task = asyncio.create_task(self._read_loop()) logger.info("IRC monitor started") async def stop(self) -> None: self._running = False if self._task: self._task.cancel() try: await self._task except asyncio.CancelledError: pass self._task = None logger.info("IRC monitor stopped") async def _read_loop(self) -> None: while self._running: try: line = await self._adapter.read_line() if not line: continue except (ConnectionError, asyncio.CancelledError): logger.warning("IRC read loop interrupted") break except Exception as e: logger.error(f"IRC read error: {e}") await asyncio.sleep(1) continue if line.startswith("PING "): token = line[5:].lstrip(":") self._adapter.send_line(f"PONG :{token}") continue if line.startswith("PONG "): self._adapter.mark_pong() continue prefix, command, args = parse_irc_line(line) tags = parse_irc_tags_dict(line) if command == "005": self._adapter.isupport.feed_line(line) continue if command in ("JOIN", "PART", "KICK", "MODE", "TOPIC", "332", "333", "353", "366", "QUIT", "NICK"): self._adapter._handle_channel_event(command, prefix, args) continue if command in _MONITOR_REPLIES: self._handle_monitor_reply(command, args) continue if command == "PRIVMSG": if len(args) < 2: continue target = args[0] text = sanitize_inbound_text(args[1]) sender_nick = extract_nick(prefix) msgid = tags.get("msgid", "") if msgid and msgid in self._dedup_window: logger.debug(f"IRC dedup: skipping duplicate msgid={msgid}") continue if is_ctcp_message(text): await handle_ctcp(self._adapter.send_line, sender_nick, text) continue if sender_nick == self._adapter.nick: self._handle_echo_message(tags, msgid, text) continue if has_control_command(text, self._config): command, args = parse_command(text) if check_command_access(sender_nick, command, self._config): response = await handle_command( command, args, sender_nick, target, self._config, self._adapter.send_line, ) if response: from .send import send_privmsg await send_privmsg( self._adapter.send_line, target, response, nick=self._adapter.nick, ) await self._adapter._connection.drain() continue if not self._message_handler and not self._raw_message_handler: continue raw = { "prefix": prefix, "command": command, "target": target, "text": text, "sender_nick": sender_nick, "raw_line": line, } if "time" in tags: raw["time"] = tags["time"] if "account" in tags: raw["account"] = tags["account"] if msgid: raw["msgid"] = msgid self._add_dedup(msgid) if self._raw_message_handler: try: handled = await self._raw_message_handler(raw) if handled: continue except Exception as e: logger.warning(f"IRC raw message handler error: {e}") if not self._message_handler: continue msg = self._adapter.normalize_inbound(raw) if msg: try: await self._message_handler(msg) except Exception as e: logger.warning(f"IRC message dispatch error: {e}") if self._on_dispatch_error: await self._on_dispatch_error(e, msg) if self._on_record_error: await self._on_record_error(e, raw) elif command == "NOTICE": if len(args) < 2: continue sender_nick = extract_nick(prefix) text = sanitize_inbound_text(args[1]) target = args[0] if is_ctcp_message(text): await handle_ctcp(self._adapter.send_line, sender_nick, text) continue logger.info(f"IRC NOTICE from {sender_nick} to {target}: {text}") if sender_nick == self._adapter.nick: continue if not self._message_handler and not self._raw_message_handler: continue raw = { "prefix": prefix, "command": command, "target": target, "text": text, "sender_nick": sender_nick, "raw_line": line, } if "time" in tags: raw["time"] = tags["time"] if "account" in tags: raw["account"] = tags["account"] if self._raw_message_handler: try: handled = await self._raw_message_handler(raw) if handled: continue except Exception as e: logger.warning(f"IRC raw message handler error: {e}") if not self._message_handler: continue msg = self._adapter.normalize_inbound(raw) if msg: try: await self._message_handler(msg) except Exception as e: logger.warning(f"IRC NOTICE dispatch error: {e}") if self._on_dispatch_error: await self._on_dispatch_error(e, msg) if self._on_record_error: await self._on_record_error(e, raw) def _handle_monitor_reply(self, command: str, args: list[str]) -> None: if not args: return target_str = args[-1] if len(args) > 1 else args[0] targets = [t.lstrip(":") for t in target_str.split(",")] match command: case "730": logger.debug(f"MONITOR online: {targets}") case "731": logger.debug(f"MONITOR offline: {targets}") case "732": logger.debug(f"MONITOR list: {targets}") case "733": logger.debug("MONITOR list end") case "734": logger.warning(f"MONITOR list full: {targets}") def _add_dedup(self, msgid: str) -> None: if len(self._dedup_window) >= self._dedup_max: self._dedup_window.clear() self._dedup_window.add(msgid) def _handle_echo_message(self, tags: dict, msgid: str, text: str) -> None: if msgid: self._add_dedup(msgid) if self._adapter._delivery_tracker: self._adapter._delivery_tracker.confirm(msgid) logger.debug(f"IRC echo-message: msgid={msgid}") def add_monitor(self, *nicks: str) -> None: if nicks: self._adapter.send_line(f"MONITOR + {','.join(nicks)}") def remove_monitor(self, *nicks: str) -> None: if nicks: self._adapter.send_line(f"MONITOR - {','.join(nicks)}") def clear_monitor(self) -> None: self._adapter.send_line("MONITOR C") def list_monitor(self) -> None: self._adapter.send_line("MONITOR L") def sync_monitor(self) -> None: self._adapter.send_line("MONITOR S")