新增IRC协议相关的全套工具模块,包括: - 核心协议解析与CTCP处理 - 消息发送缓存与文本 sanitize - 账号配置管理与运行时状态 - 命令处理与权限控制 - 服务发现与诊断工具 - 多账号网关与配置加载
292 lines
11 KiB
Python
292 lines
11 KiB
Python
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")
|