ForcePilot/backend/package/yuxi/channels/adapters/irc/monitor.py
Kris 4e07292a81 refactor(irc): 整理导入并修复代码风格
1.  调整导入顺序和移除多余空行
2.  重构IRC NAMES命令的成员解析逻辑,正确剥离所有前缀
3.  更新配置schema:修改默认消息块大小为350字节,新增disableBlockStreaming配置项
4.  完善CTCP处理:新增ACTION支持,提取CTCP动作文本
5.  新增IRC线程上下文模拟器类
6.  新增SRV记录解析支持,自动解析IRC服务器域名
7.  新增多种IRC通知处理:ACCOUNT、AWAY、INVITE、CAP
8.  重构消息发送逻辑,添加熔断器和重试机制
9.  重写流式消息合并逻辑,新增智能合并缓冲
10. 扩展IRC CAP支持,新增多个常用扩展能力
11. 修复配置读取逻辑,适配新的配置结构
2026-05-13 16:10:40 +08:00

356 lines
13 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 extract_ctcp_action, handle_ctcp
from .protocol import extract_nick, is_ctcp_message, parse_irc_line, parse_irc_prefix, 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 == "ACCOUNT":
self._handle_account_notify(prefix, args)
continue
if command == "AWAY":
self._handle_away_notify(prefix, args)
continue
if command == "INVITE":
self._handle_invite_notify(prefix, args)
continue
if command == "CAP":
self._handle_cap_notify(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):
action_text = extract_ctcp_action(text)
if action_text is not None:
text = f"* {sender_nick} {action_text}"
logger.debug(f"IRC CTCP ACTION from {sender_nick}: {action_text}")
else:
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 _handle_account_notify(self, prefix: str | None, args: list[str]) -> None:
if len(args) < 2:
return
nick = args[0]
account = args[1].lstrip(":")
if account == "*":
logger.debug(f"IRC account-notify: {nick} logged out")
else:
logger.debug(f"IRC account-notify: {nick} logged in as {account}")
def _handle_away_notify(self, prefix: str | None, args: list[str]) -> None:
if not args:
return
nick = extract_nick(prefix)
if len(args) >= 1 and args[0].startswith(":"):
away_msg = " ".join(args).lstrip(":")
logger.debug(f"IRC away-notify: {nick} is away ({away_msg})")
else:
logger.debug(f"IRC away-notify: {nick} is back")
def _handle_invite_notify(self, prefix: str | None, args: list[str]) -> None:
if len(args) < 2:
return
inviter = extract_nick(prefix)
target = args[0]
channel = args[1].lstrip(":")
logger.debug(f"IRC invite-notify: {inviter} invited {target} to {channel}")
def _handle_cap_notify(self, args: list[str]) -> None:
if len(args) < 3:
return
target = args[0]
subcmd = args[1]
cap_name = args[2].lstrip(":")
if subcmd == "NEW":
logger.info(f"IRC cap-notify: {target} added CAP {cap_name}")
elif subcmd == "DEL":
logger.info(f"IRC cap-notify: {target} removed CAP {cap_name}")
elif subcmd == "ACK":
pass
elif subcmd == "NAK":
logger.warning(f"IRC cap-notify: {target} NAK for {cap_name}")
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")