From 061cc7076bdcf932f5806f50252da70ef481d9fb Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Thu, 21 May 2026 10:59:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel):=20=E6=B7=BB=E5=8A=A0=20IRC=20?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E6=89=A9=E5=B1=95=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 IRC 渠道的完整扩展实现,包括客户端连接管理、消息收发与流式处理、安全策略与配对验证、消息去重与规范化、状态监控与健康探测、配置模型与账户管理、错误处理等模块。 --- .../yuxi/channel/extensions/irc/__init__.py | 4 + .../yuxi/channel/extensions/irc/accounts.py | 190 ++++++ .../yuxi/channel/extensions/irc/client.py | 196 ++++++ .../yuxi/channel/extensions/irc/config.py | 78 +++ .../yuxi/channel/extensions/irc/dedupe.py | 37 ++ .../yuxi/channel/extensions/irc/errors.py | 92 +++ .../yuxi/channel/extensions/irc/monitor.py | 367 +++++++++++ .../yuxi/channel/extensions/irc/normalize.py | 33 + .../yuxi/channel/extensions/irc/plugin.json | 29 + .../yuxi/channel/extensions/irc/plugin.py | 616 ++++++++++++++++++ .../yuxi/channel/extensions/irc/probe.py | 74 +++ .../yuxi/channel/extensions/irc/protocol.py | 117 ++++ .../yuxi/channel/extensions/irc/security.py | 163 +++++ .../yuxi/channel/extensions/irc/send.py | 78 +++ .../yuxi/channel/extensions/irc/status.py | 58 ++ .../yuxi/channel/extensions/irc/streaming.py | 35 + .../yuxi/channel/extensions/irc/types.py | 58 ++ 17 files changed, 2225 insertions(+) create mode 100644 backend/package/yuxi/channel/extensions/irc/__init__.py create mode 100644 backend/package/yuxi/channel/extensions/irc/accounts.py create mode 100644 backend/package/yuxi/channel/extensions/irc/client.py create mode 100644 backend/package/yuxi/channel/extensions/irc/config.py create mode 100644 backend/package/yuxi/channel/extensions/irc/dedupe.py create mode 100644 backend/package/yuxi/channel/extensions/irc/errors.py create mode 100644 backend/package/yuxi/channel/extensions/irc/monitor.py create mode 100644 backend/package/yuxi/channel/extensions/irc/normalize.py create mode 100644 backend/package/yuxi/channel/extensions/irc/plugin.json create mode 100644 backend/package/yuxi/channel/extensions/irc/plugin.py create mode 100644 backend/package/yuxi/channel/extensions/irc/probe.py create mode 100644 backend/package/yuxi/channel/extensions/irc/protocol.py create mode 100644 backend/package/yuxi/channel/extensions/irc/security.py create mode 100644 backend/package/yuxi/channel/extensions/irc/send.py create mode 100644 backend/package/yuxi/channel/extensions/irc/status.py create mode 100644 backend/package/yuxi/channel/extensions/irc/streaming.py create mode 100644 backend/package/yuxi/channel/extensions/irc/types.py diff --git a/backend/package/yuxi/channel/extensions/irc/__init__.py b/backend/package/yuxi/channel/extensions/irc/__init__.py new file mode 100644 index 00000000..5c8d7e73 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/__init__.py @@ -0,0 +1,4 @@ +from yuxi.channel.extensions.irc.plugin import IrcPlugin +from yuxi.channel.plugins.registry import ChannelPluginRegistry + +irc_plugin = ChannelPluginRegistry.register(IrcPlugin()) \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/irc/accounts.py b/backend/package/yuxi/channel/extensions/irc/accounts.py new file mode 100644 index 00000000..c29333c6 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/accounts.py @@ -0,0 +1,190 @@ +import logging +import os +from pathlib import Path + +from yuxi.channel.extensions.irc.config import IrcAccountConfig, IrcConfig, IrcNickServConfig +from yuxi.channel.extensions.irc.types import ResolvedIrcAccount + +logger = logging.getLogger(__name__) + + +def _read_password_file(file_path: str) -> str: + p = Path(file_path) + if not p.is_file(): + logger.warning("Password file not found: %s", file_path) + return "" + if p.is_symlink(): + logger.warning("Password file is a symlink, refusing to read: %s", file_path) + return "" + try: + content = p.read_text(encoding="utf-8").strip() + return content + except Exception as e: + logger.warning("Failed to read password file %s: %s", file_path, e) + return "" + + +def _resolve_password(password: str, password_file: str | None, account_id: str) -> tuple[str, str]: + if account_id == "default": + env_val = os.environ.get("IRC_PASSWORD") + if env_val: + return env_val, "env" + if password_file: + content = _read_password_file(password_file) + if content: + return content, "passwordFile" + if password: + return password, "config" + return "", "none" + + +def _resolve_nickserv_password(nickserv: IrcNickServConfig, account_id: str) -> tuple[str, str]: + if nickserv.password: + return nickserv.password, "config" + if account_id == "default": + env_val = os.environ.get("IRC_NICKSERV_PASSWORD") + if env_val: + return env_val, "env" + if nickserv.password_file: + content = _read_password_file(nickserv.password_file) + if content: + return content, "passwordFile" + return "", "none" + + +def _merge_channels(global_channels: list[str], account_channels: list[str]) -> list[str]: + if account_channels: + return account_channels + return global_channels + + +def _merge_groups( + global_groups: dict[str, object], + account_groups: dict[str, object], +) -> dict[str, object]: + merged = dict(global_groups) + merged.update(account_groups) + return merged + + +def resolve_irc_account(cfg: IrcConfig, account_id: str = "default") -> ResolvedIrcAccount: + account_cfg = cfg.accounts.get(account_id) + if account_cfg is None: + if account_id == "default": + account_cfg = IrcAccountConfig( + host=cfg.host, + port=cfg.port, + tls=cfg.tls, + nick=cfg.nick, + username=cfg.username, + realname=cfg.realname, + password=cfg.password, + password_file=cfg.password_file, + nickserv=cfg.nickserv, + dm_policy=cfg.dm_policy, + allow_from=cfg.allow_from, + group_policy=cfg.group_policy, + group_allow_from=cfg.group_allow_from, + groups=cfg.groups, + channels=cfg.channels, + dangerously_allow_name_matching=cfg.dangerously_allow_name_matching, + text_chunk_limit=cfg.text_chunk_limit, + ) + else: + raise KeyError(f"Account '{account_id}' not found") + + host = account_cfg.host or cfg.host + port = account_cfg.port or cfg.port + tls = account_cfg.tls if account_cfg.host else cfg.tls + nick = account_cfg.nick or cfg.nick + username = account_cfg.username or cfg.username + realname = account_cfg.realname or cfg.realname + + password, password_source = _resolve_password( + account_cfg.password or cfg.password, + account_cfg.password_file or cfg.password_file, + account_id, + ) + + nickserv_cfg = account_cfg.nickserv + if not nickserv_cfg.password and not nickserv_cfg.password_file: + nickserv_cfg = cfg.nickserv + + nickserv_password, nickserv_password_source = _resolve_nickserv_password( + nickserv_cfg, account_id + ) + + channels = _merge_channels(cfg.channels, account_cfg.channels) + groups = _merge_groups(cfg.groups, account_cfg.groups) + dm_policy = account_cfg.dm_policy if account_cfg.host else cfg.dm_policy + allow_from = account_cfg.allow_from or cfg.allow_from + group_policy = account_cfg.group_policy if account_cfg.host else cfg.group_policy + group_allow_from = account_cfg.group_allow_from or cfg.group_allow_from + dangerously_allow_name_matching = ( + account_cfg.dangerously_allow_name_matching or cfg.dangerously_allow_name_matching + ) + + resolved_config = IrcAccountConfig( + name=account_cfg.name or account_id, + enabled=account_cfg.enabled, + dangerously_allow_name_matching=dangerously_allow_name_matching, + host=host, + port=port, + tls=tls, + nick=nick, + username=username, + realname=realname, + password=password, + password_file=account_cfg.password_file or cfg.password_file, + nickserv=nickserv_cfg, + dm_policy=dm_policy, + allow_from=allow_from, + group_policy=group_policy, + group_allow_from=group_allow_from, + groups=groups, + channels=channels, + text_chunk_limit=account_cfg.text_chunk_limit, + block_streaming=account_cfg.block_streaming, + ) + + configured = bool(host and nick) + if not configured and account_id == "default": + env_host = os.environ.get("IRC_HOST") + env_nick = os.environ.get("IRC_NICK") + if env_host and env_nick: + configured = True + + return ResolvedIrcAccount( + account_id=account_id, + enabled=account_cfg.enabled, + name=resolved_config.name, + configured=configured, + host=host, + port=port, + tls=tls, + nick=nick, + username=username, + realname=realname, + password=password, + password_source=password_source, + nickserv_password=nickserv_password, + nickserv_password_source=nickserv_password_source, + config=resolved_config, + ) + + +def list_irc_account_ids(cfg: IrcConfig) -> list[str]: + if cfg.accounts: + return list(cfg.accounts.keys()) + return ["default"] + + +def has_configured_state(cfg: IrcConfig) -> bool: + if cfg.host and cfg.nick: + return True + for acc in cfg.accounts.values(): + if acc.host and acc.nick: + return True + if os.environ.get("IRC_HOST") and os.environ.get("IRC_NICK"): + return True + return False \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/irc/client.py b/backend/package/yuxi/channel/extensions/irc/client.py new file mode 100644 index 00000000..3bce8c10 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/client.py @@ -0,0 +1,196 @@ +import asyncio +import logging +import ssl +import time +from dataclasses import dataclass + +from yuxi.channel.extensions.irc.protocol import parse_irc_line +from yuxi.channel.extensions.irc.types import ResolvedIrcAccount + +logger = logging.getLogger(__name__) + + +class IRCFatalError(Exception): + pass + + +@dataclass +class IrcClient: + account: ResolvedIrcAccount + reader: asyncio.StreamReader + writer: asyncio.StreamWriter + current_nick: str + connected_at: float = 0.0 + + +async def _negotiate_caps( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + requested: list[str], + timeout: float, +) -> set[str]: + acked: set[str] = set() + send_line(writer, f"CAP REQ :{' '.join(requested)}") + await writer.drain() + + try: + while True: + line_bytes = await asyncio.wait_for(reader.readline(), timeout=timeout) + if not line_bytes: + break + line = line_bytes.decode("utf-8", errors="replace").rstrip("\r\n") + parsed = parse_irc_line(line) + if parsed.command == "CAP": + subcmd = parsed.params[1] if len(parsed.params) > 1 else "" + if subcmd in ("ACK", "NAK"): + cap_list = parsed.trailing or (parsed.params[2] if len(parsed.params) > 2 else "") + caps = {c.strip() for c in cap_list.split()} + if subcmd == "ACK": + acked |= caps + return acked + except asyncio.TimeoutError: + logger.warning("CAP negotiation timed out") + return acked + + +async def connect_irc_client(account: ResolvedIrcAccount, timeout: float = 15.0) -> IrcClient: + ssl_context = ssl.create_default_context() if account.tls else None + reader, writer = await asyncio.wait_for( + asyncio.open_connection(account.host, account.port, ssl=ssl_context), + timeout=timeout, + ) + + caps_to_request = [] + if account.config.sasl_enabled and account.nickserv_password: + caps_to_request.append("sasl") + + caps_ack: set[str] = set() + if caps_to_request: + send_line(writer, "CAP LS 302") + await writer.drain() + caps_ack = await _negotiate_caps(reader, writer, caps_to_request, timeout) + + if account.password: + send_line(writer, f"PASS {account.password}") + + if "sasl" in caps_ack and account.nickserv_password: + send_line(writer, "AUTHENTICATE PLAIN") + await writer.drain() + import base64 + auth_str = f"\x00{account.username or account.nick}\x00{account.nickserv_password}" + auth_b64 = base64.b64encode(auth_str.encode()).decode() + send_line(writer, f"AUTHENTICATE {auth_b64}") + await writer.drain() + + if caps_to_request: + send_line(writer, "CAP END") + await writer.drain() + + send_line(writer, f"NICK {account.nick}") + send_line(writer, f"USER {account.username} 0 * :{account.realname}") + await writer.drain() + + connected_at = time.monotonic() + logger.info( + "IRC connected to %s:%d (tls=%s) as %s", + account.host, account.port, account.tls, account.nick, + ) + + return IrcClient( + account=account, + reader=reader, + writer=writer, + current_nick=account.nick, + connected_at=connected_at, + ) + + +async def read_lines(reader: asyncio.StreamReader, cancel_event: asyncio.Event): + while not cancel_event.is_set(): + try: + line_bytes = await asyncio.wait_for(reader.readline(), timeout=300.0) + except asyncio.TimeoutError: + continue + if not line_bytes: + break + line = line_bytes.decode("utf-8", errors="replace").rstrip("\r\n") + yield line + + +def send_line(writer: asyncio.StreamWriter, line: str) -> None: + writer.write(f"{line}\r\n".encode()) + + +async def send_privmsg(writer: asyncio.StreamWriter, target: str, text: str) -> None: + send_line(writer, f"PRIVMSG {target} :{text}") + await writer.drain() + + +async def handle_ping(writer: asyncio.StreamWriter, payload: str | None) -> None: + if payload: + send_line(writer, f"PONG :{payload}") + else: + send_line(writer, "PONG") + await writer.drain() + + +async def join_channel(writer: asyncio.StreamWriter, channel: str) -> None: + send_line(writer, f"JOIN {channel}") + await writer.drain() + + +async def part_channel(writer: asyncio.StreamWriter, channel: str, reason: str = "") -> None: + if reason: + send_line(writer, f"PART {channel} :{reason}") + else: + send_line(writer, f"PART {channel}") + await writer.drain() + + +async def identify_nickserv(writer: asyncio.StreamWriter, password: str) -> None: + send_line(writer, f"PRIVMSG NickServ :IDENTIFY {password}") + await writer.drain() + + +async def ghost_nick(writer: asyncio.StreamWriter, nick: str, password: str) -> None: + send_line(writer, f"PRIVMSG NickServ :GHOST {nick} {password}") + await writer.drain() + + +async def handle_nick_collision( + writer: asyncio.StreamWriter, + account: ResolvedIrcAccount, + current_nick: str, +) -> str: + base_nick = account.nick + + if account.nickserv_password: + try: + await ghost_nick(writer, base_nick, account.nickserv_password) + await asyncio.sleep(1) + send_line(writer, f"NICK {base_nick}") + await writer.drain() + logger.info("Nick collision: GHOST sent for %s, reclaiming %s", base_nick, base_nick) + return base_nick + except Exception as e: + logger.warning("Nick collision: GHOST failed for %s: %s", base_nick, e) + + fallback = f"{base_nick}_"[:30] + send_line(writer, f"NICK {fallback}") + await writer.drain() + logger.info("Nick collision: fallback nick %s", fallback) + return fallback + + +async def graceful_disconnect(client: IrcClient) -> None: + try: + send_line(client.writer, "QUIT :shutdown") + await client.writer.drain() + except Exception: + pass + try: + client.writer.close() + await client.writer.wait_closed() + except Exception: + pass + logger.info("IRC disconnected from %s:%d", client.account.host, client.account.port) \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/irc/config.py b/backend/package/yuxi/channel/extensions/irc/config.py new file mode 100644 index 00000000..d3f30e38 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/config.py @@ -0,0 +1,78 @@ +from enum import Enum + +from pydantic import BaseModel + + +class DmPolicy(str, Enum): + PAIRING = "pairing" + OPEN = "open" + DISABLED = "disabled" + + +class GroupPolicy(str, Enum): + OPEN = "open" + ALLOWLIST = "allowlist" + DISABLED = "disabled" + + +class IrcNickServConfig(BaseModel): + enabled: bool = False + service: str = "NickServ" + password: str = "" + password_file: str | None = None + register_nick: bool = False + register_email: str = "" + + +class IrcChannelConfig(BaseModel): + require_mention: bool = True + enabled: bool = True + allow_from: list[str] = [] + system_prompt: str | None = None + + +class IrcAccountConfig(BaseModel): + name: str | None = None + enabled: bool = True + dangerously_allow_name_matching: bool = False + host: str = "" + port: int = 6697 + tls: bool = True + nick: str = "" + username: str = "openclaw" + realname: str = "OpenClaw" + password: str = "" + password_file: str | None = None + nickserv: IrcNickServConfig = IrcNickServConfig() + dm_policy: DmPolicy = DmPolicy.PAIRING + allow_from: list[str] = [] + group_policy: GroupPolicy = GroupPolicy.ALLOWLIST + group_allow_from: list[str] = [] + groups: dict[str, IrcChannelConfig] = {} + channels: list[str] = [] + text_chunk_limit: int = 350 + block_streaming: bool = True + sasl_enabled: bool = False + + +class IrcConfig(BaseModel): + enabled: bool = True + accounts: dict[str, IrcAccountConfig] = {} + default_account: str = "default" + host: str = "" + port: int = 6697 + tls: bool = True + nick: str = "" + username: str = "openclaw" + realname: str = "OpenClaw" + password: str = "" + password_file: str | None = None + nickserv: IrcNickServConfig = IrcNickServConfig() + dm_policy: DmPolicy = DmPolicy.PAIRING + allow_from: list[str] = [] + group_policy: GroupPolicy = GroupPolicy.ALLOWLIST + group_allow_from: list[str] = [] + groups: dict[str, IrcChannelConfig] = {} + channels: list[str] = [] + dangerously_allow_name_matching: bool = False + text_chunk_limit: int = 350 \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/irc/dedupe.py b/backend/package/yuxi/channel/extensions/irc/dedupe.py new file mode 100644 index 00000000..038e1c32 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/dedupe.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import time +from collections import OrderedDict + + +class IrcDedupeCache: + + def __init__(self, maxsize: int = 5000, ttl_seconds: int = 300): + self._maxsize = maxsize + self._ttl = ttl_seconds + self._cache: OrderedDict[str, float] = OrderedDict() + + def __contains__(self, key: str) -> bool: + now = time.monotonic() + if key in self._cache: + if now - self._cache[key] < self._ttl: + self._cache.move_to_end(key) + return True + del self._cache[key] + return False + + def add(self, key: str) -> None: + now = time.monotonic() + self._evict_expired(now) + self._cache[key] = now + self._cache.move_to_end(key) + if len(self._cache) > self._maxsize: + self._cache.popitem(last=False) + + def _evict_expired(self, now: float) -> None: + expired = [k for k, ts in self._cache.items() if now - ts >= self._ttl] + for k in expired: + del self._cache[k] + + def __len__(self) -> int: + return len(self._cache) diff --git a/backend/package/yuxi/channel/extensions/irc/errors.py b/backend/package/yuxi/channel/extensions/irc/errors.py new file mode 100644 index 00000000..f943841d --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/errors.py @@ -0,0 +1,92 @@ +import logging +from enum import Enum + +from yuxi.channel.extensions.irc.client import IRCFatalError +from yuxi.channel.protocols import ClassifiedError, ErrorSeverity + +logger = logging.getLogger(__name__) + + +class IrcErrorType(str, Enum): + AUTH = "auth" + NETWORK = "network" + CHANNEL = "channel" + NICK = "nick" + RATE_LIMIT = "rate_limit" + PERMISSION = "permission" + TARGET = "target" + UNKNOWN = "unknown" + + +NUMERIC_ERROR_MAP: dict[str, tuple[IrcErrorType, str]] = { + "401": (IrcErrorType.TARGET, "No such nick/channel"), + "403": (IrcErrorType.TARGET, "No such channel"), + "404": (IrcErrorType.PERMISSION, "Cannot send to channel"), + "405": (IrcErrorType.CHANNEL, "Too many channels"), + "432": (IrcErrorType.NICK, "Erroneous nickname"), + "433": (IrcErrorType.NICK, "Nickname in use"), + "436": (IrcErrorType.NICK, "Nick collision"), + "464": (IrcErrorType.AUTH, "Password mismatch"), + "465": (IrcErrorType.AUTH, "You are banned"), + "471": (IrcErrorType.CHANNEL, "Channel is full"), + "473": (IrcErrorType.CHANNEL, "Invite only channel"), + "474": (IrcErrorType.PERMISSION, "Banned from channel"), + "475": (IrcErrorType.CHANNEL, "Bad channel key"), + "482": (IrcErrorType.PERMISSION, "ChanOp privileges needed"), +} + + +def classify_irc_error(numeric: str) -> tuple[IrcErrorType, str]: + return NUMERIC_ERROR_MAP.get(numeric, (IrcErrorType.UNKNOWN, f"Unknown error ({numeric})")) + + +_DEFAULT_RETRY_AFTER_MS = 2000 + +_IRC_NUMERIC_TO_SEVERITY: dict[str, ErrorSeverity] = { + IrcErrorType.AUTH: ErrorSeverity.FATAL, + IrcErrorType.NETWORK: ErrorSeverity.NETWORK, + IrcErrorType.NICK: ErrorSeverity.FATAL, + IrcErrorType.RATE_LIMIT: ErrorSeverity.RATE_LIMITED, + IrcErrorType.PERMISSION: ErrorSeverity.FORBIDDEN, + IrcErrorType.TARGET: ErrorSeverity.FATAL, + IrcErrorType.CHANNEL: ErrorSeverity.FATAL, + IrcErrorType.UNKNOWN: ErrorSeverity.FATAL, +} + + +def classify_error(error: BaseException) -> ClassifiedError: + if isinstance(error, IRCFatalError): + msg = str(error).upper() + for numeric, (error_type, _) in NUMERIC_ERROR_MAP.items(): + if numeric in msg: + severity = _IRC_NUMERIC_TO_SEVERITY.get(error_type, ErrorSeverity.FATAL) + return ClassifiedError( + severity=severity, + original_error=error, + error_message=str(error), + ) + return ClassifiedError( + severity=ErrorSeverity.FATAL, + original_error=error, + error_message=str(error), + ) + + if isinstance(error, (ConnectionError, OSError)): + return ClassifiedError( + severity=ErrorSeverity.NETWORK, + original_error=error, + error_message=str(error), + ) + + if isinstance(error, TimeoutError): + return ClassifiedError( + severity=ErrorSeverity.NETWORK, + original_error=error, + error_message=str(error), + ) + + return ClassifiedError( + severity=ErrorSeverity.RETRYABLE, + original_error=error, + error_message=str(error), + ) diff --git a/backend/package/yuxi/channel/extensions/irc/monitor.py b/backend/package/yuxi/channel/extensions/irc/monitor.py new file mode 100644 index 00000000..e5988b4b --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/monitor.py @@ -0,0 +1,367 @@ +import asyncio +import logging +import time +import uuid +from datetime import datetime, timezone + +from yuxi.channel.extensions.irc.client import ( + IRCFatalError, + connect_irc_client, + graceful_disconnect, + handle_nick_collision, + handle_ping, + identify_nickserv, + join_channel, + read_lines, + send_line, +) +from yuxi.channel.extensions.irc.dedupe import IrcDedupeCache +from yuxi.channel.extensions.irc.protocol import ( + parse_ctcp, + parse_irc_line, + parse_irc_prefix, + sanitize_irc_text, +) +from yuxi.channel.extensions.irc.types import IrcInboundMessage, ResolvedIrcAccount +from yuxi.channel.message.models import GroupContext, MessageType, PeerInfo, UnifiedMessage +from yuxi.channel.routing.models import PeerKind + +logger = logging.getLogger(__name__) + +RECONNECT_BASE_DELAY = 2.0 +RECONNECT_MAX_DELAY = 120.0 +RECONNECT_BACKOFF_FACTOR = 2.0 +RECONNECT_JITTER = 0.5 + +_CHANTYPES = {"#", "&"} + + +async def _handle_ctcp_query( + writer: asyncio.StreamWriter, + parsed: object, + ctcp_cmd: str, + ctcp_args: str | None, +) -> None: + sender_nick = parse_irc_prefix(parsed.prefix).nick if parsed.prefix else None + if not sender_nick: + return + + match ctcp_cmd: + case "VERSION": + reply = "ForcePilot IRC Bot (https://forcepilot.ai)" + case "PING": + reply = ctcp_args or str(int(time.time())) + case "TIME": + reply = datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + case "FINGER": + reply = "ForcePilot AI Bot" + case "SOURCE": + reply = "https://github.com/ForcePilot" + case _: + return + + send_line(writer, f"NOTICE {sender_nick} :\x01{ctcp_cmd} {reply}\x01") + await writer.drain() + + +def _parse_server_time(tags: dict[str, str | bool]) -> datetime | None: + time_val = tags.get("time") + if not isinstance(time_val, str): + return None + try: + if time_val.endswith("Z"): + time_val = time_val[:-1] + "+00:00" + return datetime.fromisoformat(time_val) + except ValueError: + return None + + +def create_irc_inbound_message( + parsed: object, + current_nick: str, +) -> IrcInboundMessage | None: + raw_text = parsed.trailing or "" + ctcp_cmd, ctcp_args = parse_ctcp(raw_text) + + if ctcp_cmd == "ACTION": + text = f"/me {ctcp_args or ''}" + elif ctcp_cmd is not None: + return None + else: + text = sanitize_irc_text(raw_text) + + if not text.strip(): + return None + + raw_target = parsed.params[0] if parsed.params else "" + is_group = any(raw_target.startswith(c) for c in _CHANTYPES) + + prefix_info = parse_irc_prefix(parsed.prefix) if parsed.prefix else None + sender_nick = prefix_info.nick if prefix_info else "unknown" + sender_user = prefix_info.user if prefix_info else None + sender_host = prefix_info.host if prefix_info else None + + if sender_nick.lower() == current_nick.lower(): + return None + + target = raw_target if is_group else sender_nick + + server_time = _parse_server_time(parsed.tags) + account = parsed.tags.get("account") + account = account if isinstance(account, str) else None + msg_id = parsed.tags.get("msgid") + msg_id = msg_id if isinstance(msg_id, str) else None + + return IrcInboundMessage( + message_id=str(uuid.uuid4()), + target=target, + raw_target=raw_target, + sender_nick=sender_nick, + sender_user=sender_user, + sender_host=sender_host, + text=text, + timestamp=time.monotonic(), + is_group=is_group, + server_time=server_time, + account=account, + msg_id=msg_id, + ) + + +def irc_inbound_to_unified(msg: IrcInboundMessage, account: ResolvedIrcAccount) -> UnifiedMessage: + sender_id = msg.sender_nick + if msg.sender_user and msg.sender_host: + sender_id = f"{msg.sender_nick}!{msg.sender_user}@{msg.sender_host}" + + peer = PeerInfo( + kind=PeerKind.DIRECT if not msg.is_group else PeerKind.GROUP, + id=msg.target, + display_name=msg.sender_nick, + username=msg.sender_nick, + ) + + group = None + if msg.is_group: + group = GroupContext( + id=msg.target, + name=msg.target, + ) + + return UnifiedMessage( + msg_id=msg.message_id, + channel_type="irc", + account_id=account.account_id, + content=msg.text, + sender=peer, + message_type=MessageType.TEXT, + group=group, + timestamp=datetime.now(), + raw_payload={ + "sender_id": sender_id, + "raw_target": msg.raw_target, + "is_group": msg.is_group, + }, + conversation_label=msg.sender_nick if not msg.is_group else msg.target, + surface="irc", + originating_channel="irc", + ) + + +async def monitor_irc_provider( + account: ResolvedIrcAccount, + cancel_event: asyncio.Event, + status_sink, + on_inbound, + client_ref: list | None = None, + dedupe_cache: IrcDedupeCache | None = None, +) -> None: + attempt = 0 + while not cancel_event.is_set(): + try: + await _monitor_irc_session(account, cancel_event, status_sink, on_inbound, client_ref, dedupe_cache) + except IRCFatalError as e: + logger.error("IRC fatal error: %s", e) + except (ConnectionError, OSError) as e: + logger.warning("IRC connection error: %s", e) + except asyncio.CancelledError: + break + + if cancel_event.is_set(): + break + + delay = min( + RECONNECT_BASE_DELAY * (RECONNECT_BACKOFF_FACTOR ** attempt), + RECONNECT_MAX_DELAY, + ) + delay += RECONNECT_JITTER * (time.monotonic() % 1.0) + attempt += 1 + logger.info("IRC reconnecting in %.1fs (attempt %d)", delay, attempt) + try: + await asyncio.wait_for(cancel_event.wait(), timeout=delay) + break + except asyncio.TimeoutError: + pass + + status_sink(connected=False) + + +async def _monitor_irc_session( + account: ResolvedIrcAccount, + cancel_event: asyncio.Event, + status_sink, + on_inbound, + client_ref: list | None = None, + dedupe_cache: IrcDedupeCache | None = None, +) -> None: + client = await connect_irc_client(account) + if client_ref is not None: + client_ref[0] = client + status_sink(connected=True) + + current_nick = account.nick + + try: + async for line in read_lines(client.reader, cancel_event): + parsed = parse_irc_line(line) + match parsed.command: + case "PING": + await handle_ping(client.writer, parsed.trailing) + case "001": + if account.nickserv_password: + await identify_nickserv(client.writer, account.nickserv_password) + logger.info("NickServ IDENTIFY sent for %s", current_nick) + for channel in account.config.channels: + await join_channel(client.writer, channel) + logger.info("Joined channel %s", channel) + case "PRIVMSG": + raw_text = parsed.trailing or "" + ctcp_cmd, ctcp_args = parse_ctcp(raw_text) + if ctcp_cmd in ("VERSION", "PING", "TIME", "FINGER", "SOURCE"): + await _handle_ctcp_query(client.writer, parsed, ctcp_cmd, ctcp_args) + continue + is_echo = parsed.tags.get("msgid") and parsed.tags.get("account") == account.nick + if is_echo: + logger.debug("Echo-message ignored") + continue + dedupe_key = f"{account.account_id}:{parsed.prefix}:{parsed.trailing}" + if dedupe_cache is not None and dedupe_key in dedupe_cache: + continue + if dedupe_cache is not None: + dedupe_cache.add(dedupe_key) + inbound_msg = create_irc_inbound_message(parsed, current_nick) + if inbound_msg: + await on_inbound(inbound_msg) + case "NOTICE": + notice_text = sanitize_irc_text(parsed.trailing or "") + source = parsed.params[0] if parsed.params else "" + prefix_info = parse_irc_prefix(parsed.prefix) if parsed.prefix else None + sender_nick = prefix_info.nick if prefix_info else source + + is_server_notice = ( + prefix_info is None + or prefix_info.server is not None + or (sender_nick or "").lower() in ( + "nickserv", "chanserv", "authserv", "memoserv", "operserv", "hostserv" + ) + ) + + if is_server_notice and notice_text: + um = UnifiedMessage( + msg_id=str(uuid.uuid4()), + channel_type="irc", + account_id=account.account_id, + content=f"[IRC Notice] {notice_text}", + sender=PeerInfo( + kind=PeerKind.DIRECT, + id=sender_nick, + display_name=sender_nick, + username=sender_nick, + ), + message_type=MessageType.SYSTEM, + timestamp=datetime.now(), + surface="irc", + originating_channel="irc", + ) + await on_inbound(um) + else: + logger.debug("NOTICE from %s: %s", source, notice_text[:100]) + case "KICK": + kicked_nick = parsed.params[1] if len(parsed.params) > 1 else "" + if kicked_nick.lower() == current_nick.lower(): + logger.warning( + "Kicked from %s by %s: %s", + parsed.params[0] if parsed.params else "?", + parse_irc_prefix(parsed.prefix).nick if parsed.prefix else "server", + parsed.trailing or "", + ) + status_sink(connected=True, warning=f"Kicked from {parsed.params[0]}") + case "005": + for param in parsed.params[:-1] if len(parsed.params) > 1 else []: + if "=" in param: + key, val = param.split("=", 1) + if key == "CHANTYPES": + global _CHANTYPES + _CHANTYPES = set(val) + logger.debug("IRC ISUPPORT CHANTYPES=%s", val) + elif key == "CASEMAPPING": + logger.debug("IRC ISUPPORT CASEMAPPING=%s", val) + elif key == "UTF8ONLY": + logger.debug("IRC ISUPPORT UTF8ONLY") + case "INVITE": + inviter = parse_irc_prefix(parsed.prefix).nick if parsed.prefix else "unknown" + channel = parsed.params[1] if len(parsed.params) > 1 else "" + logger.info("Invited to %s by %s", channel, inviter) + if channel: + await join_channel(client.writer, channel) + logger.info("Auto-joined invited channel %s", channel) + case "TOPIC": + channel = parsed.params[0] if parsed.params else "" + topic = parsed.trailing or "" + logger.info("Topic for %s: %s", channel, topic[:100]) + case "ACCOUNT": + nick = parse_irc_prefix(parsed.prefix).nick if parsed.prefix else "unknown" + account_name = parsed.params[0] if parsed.params else "" + logger.debug("ACCOUNT %s -> %s", nick, account_name) + case "AWAY": + nick = parse_irc_prefix(parsed.prefix).nick if parsed.prefix else "unknown" + is_away = parsed.trailing is not None + logger.debug("AWAY %s away=%s", nick, is_away) + case "CHGHOST": + nick = parse_irc_prefix(parsed.prefix).nick if parsed.prefix else "unknown" + new_user = parsed.params[0] if parsed.params else "" + new_host = parsed.params[1] if len(parsed.params) > 1 else "" + logger.debug("CHGHOST %s -> %s@%s", nick, new_user, new_host) + case "471" | "473" | "474" | "475": + channel = parsed.params[1] if len(parsed.params) > 1 else "?" + logger.warning( + "IRC JOIN failed for %s: %s (%s)", + channel, parsed.trailing, parsed.command, + ) + status_sink(connected=True, warning=f"JOIN {channel} failed: {parsed.trailing}") + case "401" | "403" | "404" | "405": + target = parsed.params[1] if len(parsed.params) > 1 else "?" + logger.warning( + "IRC send failed to %s: %s (%s)", + target, parsed.trailing, parsed.command, + ) + case "433" | "436": + current_nick = await handle_nick_collision(client.writer, account, current_nick) + client.current_nick = current_nick + case "432" | "464" | "465": + raise IRCFatalError(f"IRC fatal error {parsed.command}: {parsed.trailing}") + case "ERROR": + raise IRCFatalError(f"Server sent ERROR: {parsed.trailing or 'unknown'}") + case "NICK": + if parsed.prefix: + prefix_info = parse_irc_prefix(parsed.prefix) + if prefix_info.nick and prefix_info.nick.lower() == current_nick.lower(): + current_nick = parsed.params[0] if parsed.params else current_nick + client.current_nick = current_nick + logger.info("Nick changed to %s", current_nick) + except asyncio.CancelledError: + pass + except IRCFatalError: + raise + finally: + await graceful_disconnect(client) + status_sink(connected=False) diff --git a/backend/package/yuxi/channel/extensions/irc/normalize.py b/backend/package/yuxi/channel/extensions/irc/normalize.py new file mode 100644 index 00000000..f9d11b9b --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/normalize.py @@ -0,0 +1,33 @@ +import re + + +def normalize_irc_target(target: str) -> str: + target = target.strip() + target = re.sub(r"^irc:", "", target) + target = re.sub(r"^(channel|user):", "", target) + if not re.match(r"^[^\s:@!]+$", target) and not target.startswith(("#", "&")): + target = target.split("!")[0] + return target + + +def is_channel_target(target: str) -> bool: + return target.startswith("#") or target.startswith("&") + + +def build_irc_allowlist_candidates( + nick: str, + user: str | None, + host: str | None, + allow_name_matching: bool = False, +) -> list[str]: + candidates = [] + nick_lower = nick.lower() + if user and host: + candidates.append(f"{nick_lower}!{user.lower()}@{host.lower()}") + if user: + candidates.append(f"{nick_lower}!{user.lower()}") + if host: + candidates.append(f"{nick_lower}@{host.lower()}") + if allow_name_matching: + candidates.append(nick_lower) + return candidates \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/irc/plugin.json b/backend/package/yuxi/channel/extensions/irc/plugin.json new file mode 100644 index 00000000..d48b59eb --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/plugin.json @@ -0,0 +1,29 @@ +{ + "id": "irc", + "name": "IRC", + "version": "1.0.0", + "label": "IRC", + "aliases": ["internet-relay-chat"], + "description": "IRC (Internet Relay Chat) channel plugin for ForcePilot", + "author": "ForcePilot", + "order": 80, + "dependencies": [], + "enabled": true, + "python_requires": ">=3.12", + "capabilities": { + "chat_types": ["direct", "group"], + "message_types": ["text"], + "reactions": false, + "typing_indicator": false, + "threads": false, + "edit": false, + "unsend": false, + "reply": true, + "media": false, + "native_commands": false, + "polls": false, + "streaming": true, + "streaming_mode": "block", + "block_streaming": true + } +} \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/irc/plugin.py b/backend/package/yuxi/channel/extensions/irc/plugin.py new file mode 100644 index 00000000..30cec7fb --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/plugin.py @@ -0,0 +1,616 @@ +import logging +import uuid + +from yuxi.channel.capabilities import ChannelCapabilities +from yuxi.channel.context import ChannelContext +from yuxi.channel.extensions.base import BaseChannelPlugin +from yuxi.channel.extensions.irc.accounts import ( + has_configured_state, + list_irc_account_ids, + resolve_irc_account, +) +from yuxi.channel.extensions.irc.config import DmPolicy, GroupPolicy, IrcConfig +from yuxi.channel.extensions.irc.monitor import ( + irc_inbound_to_unified, + monitor_irc_provider, +) +from yuxi.channel.extensions.irc.normalize import ( + normalize_irc_target, +) +from yuxi.channel.extensions.irc.probe import probe_irc +from yuxi.channel.extensions.irc.security import ( + check_allowlist_match, + collect_irc_security_warnings, + collect_mutable_allowlist_warnings, + generate_irc_pairing_code, + is_mentioned, + match_irc_group, + normalize_irc_allow_entry, + resolve_dm_policy, + resolve_group_policy, + resolve_irc_require_mention, + verify_irc_pairing_code, +) +from yuxi.channel.extensions.irc.send import send_irc_media, send_irc_message +from yuxi.channel.extensions.irc.status import build_irc_status_summary +from yuxi.channel.extensions.irc.dedupe import IrcDedupeCache +from yuxi.channel.extensions.irc.streaming import IrcStreamBuffer +from yuxi.channel.extensions.irc.types import IrcInboundMessage, ResolvedIrcAccount + +logger = logging.getLogger(__name__) + + +class IrcPlugin(BaseChannelPlugin): + id = "irc" + name = "IRC" + order = 80 + label = "IRC (Server + Nick)" + aliases = ["internet-relay-chat"] + + def __init__(self): + super().__init__() + self._stream_buffers: dict[str, IrcStreamBuffer] = {} + self._dedupe_cache = IrcDedupeCache() + + @property + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities( + chat_types=["direct", "group"], + message_types=["text"], + interactions=[], + reactions=False, + typing_indicator=False, + threads=False, + edit=False, + unsend=False, + reply=True, + media=False, + native_commands=False, + polls=False, + streaming=True, + streaming_mode="block", + block_streaming=True, + ) + + def _get_account(self) -> ResolvedIrcAccount: + cfg = getattr(self, "_config", None) + if cfg is None: + irc_config = IrcConfig() + else: + irc_config = IrcConfig(**cfg.get("channels", {}).get("irc", {})) + return resolve_irc_account(irc_config) + + # ── ConfigProtocol ────────────────────────────────── + + def list_account_ids(self, config: dict) -> list[str]: + irc_cfg = config.get("channels", {}).get("irc", {}) + irc_config = IrcConfig(**irc_cfg) + return list_irc_account_ids(irc_config) + + def resolve_allow_from(self, config: dict, account_id: str | None = None) -> list[str | int] | None: + irc_cfg = config.get("channels", {}).get("irc", {}) + irc_config = IrcConfig(**irc_cfg) + account = resolve_irc_account(irc_config, account_id or "default") + if account.config.allow_from: + return account.config.allow_from + return None + + async def resolve_account(self, account_id: str) -> dict: + cfg = getattr(self, "_config", None) or {} + irc_cfg = cfg.get("channels", {}).get("irc", {}) + irc_config = IrcConfig(**irc_cfg) + account = resolve_irc_account(irc_config, account_id) + return { + "account_id": account.account_id, + "enabled": account.enabled, + "name": account.name, + "configured": account.configured, + "host": account.host, + "port": account.port, + "tls": account.tls, + "nick": account.nick, + "username": account.username, + "realname": account.realname, + "dm_policy": account.config.dm_policy.value if account.config.dm_policy else "pairing", + "group_policy": account.config.group_policy.value if account.config.group_policy else "allowlist", + "channels": account.config.channels, + "allow_from": account.config.allow_from, + "group_allow_from": account.config.group_allow_from, + } + + def is_configured(self, account: dict) -> bool: + return bool(account.get("host") and account.get("nick")) + + def has_configured_state(self, config: dict) -> bool: + irc_cfg = config.get("channels", {}).get("irc", {}) + irc_config = IrcConfig(**irc_cfg) + return has_configured_state(irc_config) + + def config_schema(self) -> dict: + return { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": True}, + "host": {"type": "string", "description": "IRC server hostname"}, + "port": {"type": "integer", "default": 6697, "description": "Server port (6697 TLS, 6667 plain)"}, + "tls": {"type": "boolean", "default": True, "description": "Enable TLS encryption"}, + "nick": {"type": "string", "description": "IRC nickname"}, + "username": {"type": "string", "default": "openclaw", "description": "IRC username (ident)"}, + "realname": {"type": "string", "default": "OpenClaw", "description": "IRC real name (GECOS)"}, + "password": {"type": "string", "description": "Server connection password (PASS command)"}, + "password_file": {"type": "string", "description": "Path to file containing server password"}, + "channels": { + "type": "array", + "items": {"type": "string"}, + "description": "Channels to auto-join on connect", + }, + "dm_policy": { + "type": "string", + "enum": ["pairing", "open", "disabled"], + "default": "pairing", + "description": "DM access policy", + }, + "allow_from": { + "type": "array", + "items": {"type": "string"}, + "description": "DM allowlist entries (nick!user@host format recommended)", + }, + "group_policy": { + "type": "string", + "enum": ["open", "allowlist", "disabled"], + "default": "allowlist", + "description": "Group/channel access policy", + }, + "group_allow_from": { + "type": "array", + "items": {"type": "string"}, + "description": "Group sender allowlist entries", + }, + "text_chunk_limit": { + "type": "integer", + "default": 350, + "description": "Max characters per IRC message chunk", + }, + "dangerously_allow_name_matching": { + "type": "boolean", + "default": False, + "description": "Allow bare nick matching in allowlist (insecure)", + }, + "nickserv": { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": False}, + "service": {"type": "string", "default": "NickServ"}, + "password": {"type": "string", "description": "NickServ IDENTIFY password"}, + "password_file": {"type": "string", "description": "Path to NickServ password file"}, + "register_nick": {"type": "boolean", "default": False}, + "register_email": {"type": "string"}, + }, + }, + "groups": { + "type": "object", + "description": "Per-channel config (keyed by channel name, e.g. '#mychannel')", + "additionalProperties": { + "type": "object", + "properties": { + "require_mention": {"type": "boolean", "default": True}, + "enabled": {"type": "boolean", "default": True}, + "allow_from": {"type": "array", "items": {"type": "string"}}, + "system_prompt": {"type": "string"}, + }, + }, + }, + "accounts": { + "type": "object", + "description": "Named accounts (keyed by account ID)", + "additionalProperties": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "enabled": {"type": "boolean", "default": True}, + "host": {"type": "string"}, + "port": {"type": "integer", "default": 6697}, + "tls": {"type": "boolean", "default": True}, + "nick": {"type": "string"}, + "username": {"type": "string", "default": "openclaw"}, + "realname": {"type": "string", "default": "OpenClaw"}, + "password": {"type": "string"}, + "password_file": {"type": "string"}, + "dm_policy": {"type": "string", "enum": ["pairing", "open", "disabled"]}, + "allow_from": {"type": "array", "items": {"type": "string"}}, + "group_policy": {"type": "string", "enum": ["open", "allowlist", "disabled"]}, + "group_allow_from": {"type": "array", "items": {"type": "string"}}, + "channels": {"type": "array", "items": {"type": "string"}}, + "text_chunk_limit": {"type": "integer", "default": 350}, + "dangerously_allow_name_matching": {"type": "boolean", "default": False}, + }, + }, + }, + }, + } + + # ── GatewayProtocol ───────────────────────────────── + + async def start(self, ctx: ChannelContext) -> object: + irc_cfg = ctx.config.get("channels", {}).get("irc", {}) + irc_config = IrcConfig(**irc_cfg) + account = resolve_irc_account(irc_config, ctx.account_id) + + if not account.configured: + raise RuntimeError(f"IRC account '{ctx.account_id}' not configured (host + nick required)") + + self._account = account + client_ref: list = [None] + self._client_ref = client_ref + + async def status_sink(**kwargs): + pass + + async def on_inbound(inbound_msg: IrcInboundMessage): + if not _check_irc_security(inbound_msg, account, ctx.logger): + return + + unified = irc_inbound_to_unified(inbound_msg, account) + if ctx.queue is not None: + await ctx.queue.put(unified) + if ctx.logger: + ctx.logger.debug("IRC inbound: %s from %s", inbound_msg.text[:50], inbound_msg.sender_nick) + + await monitor_irc_provider(account, ctx.cancel_event, status_sink, on_inbound, client_ref=client_ref, dedupe_cache=self._dedupe_cache) + self._client = client_ref[0] + + async def stop(self, ctx: ChannelContext) -> None: + ctx.cancel_event.set() + client = getattr(self, "_client", None) + if client is not None: + try: + from yuxi.channel.extensions.irc.client import graceful_disconnect + + await graceful_disconnect(client) + except Exception: + pass + self._client = None + + # ── OutboundProtocol ──────────────────────────────── + + async def send_text( + self, + target_id: str, + content: str, + *, + reply_to_id: str | None = None, + thread_id: str | None = None, + account_id: str | None = None, + ) -> None: + account = getattr(self, "_account", None) or self._get_account() + client_ref = getattr(self, "_client_ref", [None]) + client = getattr(self, "_client", None) or client_ref[0] + result = await send_irc_message(target_id, content, account, reply_to_id=reply_to_id, client=client) + if not result.ok: + logger.error("IRC send_text failed: %s", result.error) + + async def send_media( + self, + target_id: str, + media_url: str, + media_type: str, + reply_to_id: str | None = None, + thread_id: str | None = None, + ) -> None: + account = getattr(self, "_account", None) or self._get_account() + client_ref = getattr(self, "_client_ref", [None]) + client = getattr(self, "_client", None) or client_ref[0] + result = await send_irc_media(target_id, media_url, media_type, account, reply_to_id=reply_to_id, client=client) + if not result.ok: + logger.error("IRC send_media failed: %s", result.error) + + async def create_draft_stream_session(self, target_id: str) -> str: + session_id = f"irc:stream:{target_id}:{uuid.uuid4().hex[:8]}" + self._stream_buffers[session_id] = IrcStreamBuffer() + return session_id + + async def feed_stream_chunk(self, session_id: str, chunk: str) -> None: + buf = self._stream_buffers.get(session_id) + if buf: + buf.feed(chunk) + + async def finalize_stream(self, session_id: str) -> str: + buf = self._stream_buffers.pop(session_id, None) + if buf: + return buf.flush() + return "" + + async def leave_channel(self, channel: str, reason: str = "") -> bool: + from yuxi.channel.extensions.irc.client import part_channel + + client = getattr(self, "_client", None) + if client is None or client.writer is None: + logger.warning("Cannot PART %s: no active IRC connection", channel) + return False + + try: + await part_channel(client.writer, channel, reason) + logger.info("Left channel %s", channel) + return True + except Exception: + logger.exception("Failed to PART %s", channel) + return False + + # ── StatusProtocol ────────────────────────────────── + + async def probe(self, account: dict | None = None) -> bool: + resolved: ResolvedIrcAccount + if account is None: + resolved = getattr(self, "_account", None) or self._get_account() + else: + cfg = getattr(self, "_config", None) or {} + irc_cfg = cfg.get("channels", {}).get("irc", {}) + irc_config = IrcConfig(**irc_cfg) + resolved = resolve_irc_account(irc_config, account.get("account_id", "default")) + result = await probe_irc(resolved.host, resolved.port, resolved.tls, resolved.nick) + return result.ok + + def build_summary(self, snapshot: object) -> dict: + return build_irc_status_summary(snapshot) + + # ── ErrorHandlingProtocol ─────────────────────────── + + def classify_error(self, error: BaseException) -> object: + from yuxi.channel.extensions.irc.errors import classify_error as irc_classify + + return irc_classify(error) + + def is_retryable(self, error: BaseException) -> bool: + from yuxi.channel.extensions.irc.errors import classify_error as irc_classify + + result = irc_classify(error) + return result.severity in {"retryable", "rate_limited", "network"} + + # ── DedupeProtocol ────────────────────────────────── + + def is_duplicate(self, key: str) -> bool: + return key in self._dedupe_cache + + def mark_seen(self, key: str) -> None: + self._dedupe_cache.add(key) + + def reset(self) -> None: + self._dedupe_cache = IrcDedupeCache() + + @property + def ttl_seconds(self) -> int: + return int(self._dedupe_cache._ttl) + + @property + def max_entries(self) -> int: + return self._dedupe_cache._maxsize + + # ── FormatProtocol ────────────────────────────────── + + def sanitize_text(self, text: str, payload: object | None = None) -> str: + from yuxi.channel.extensions.irc.protocol import sanitize_irc_text + + return sanitize_irc_text(text) + + def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: + from yuxi.channel.extensions.irc.protocol import split_irc_text + + return split_irc_text(text, limit) + + def markdown_to_native(self, md_text: str) -> dict | str: + return md_text + + def native_to_markdown(self, native_content: dict | str) -> str: + if isinstance(native_content, dict): + return native_content.get("text", "") + return str(native_content) + + def strip_mentions(self, text: str, ctx: object | None = None) -> str: + return text + + # ── AgentPromptProtocol ───────────────────────────── + + def build_system_prompt(self, context) -> str | None: + return ( + "You are an AI assistant communicating via IRC. " + "IRC messages are plain text only — no Markdown, no HTML, no rich formatting. " + "Keep replies concise and readable as plain text. " + "Use *asterisks* for emphasis, /me for actions, and conventional IRC formatting. " + "Avoid long Unicode sequences or emoji that may not render correctly on all IRC clients." + ) + + def build_context_note(self, context) -> str: + if context is None: + return "" + peer_name = getattr(context, "peer_name", None) or getattr(context, "peer_id", "") + group_name = getattr(context, "group_name", None) + if group_name: + return f"[IRC channel #{group_name} | from {peer_name}]" + return f"[IRC DM from {peer_name}]" + + @property + def channel_format_instructions(self) -> str | None: + return ( + "IRC messages are plain text only. " + "Max ~350 chars per message; longer replies are split into chunks. " + "Use /me for actions and *text* for emphasis." + ) + + # ── SecurityProtocol ──────────────────────────────── + + async def check_allowlist(self, peer_id: str, channel_type: str) -> bool: + account = getattr(self, "_account", None) or self._get_account() + allowlist = account.config.allow_from + if channel_type == "group": + allowlist = account.config.group_allow_from or allowlist + if not allowlist: + return False + parts = peer_id.split("!") + nick = parts[0] + user_host = parts[1] if len(parts) > 1 else "" + user = None + host = None + if "@" in user_host: + user, host = user_host.split("@", 1) + return check_allowlist_match( + nick, user, host, allowlist, account.config.dangerously_allow_name_matching + ) + + def resolve_dm_policy(self) -> dict: + account = getattr(self, "_account", None) + if account is None: + return {"mode": "pairing", "allow_from": []} + return { + "mode": resolve_dm_policy(account).value, + "allow_from": account.config.allow_from, + } + + def collect_warnings( + self, config: dict, account_id: str | None = None, account: dict | None = None + ) -> list[str]: + account_obj = getattr(self, "_account", None) or self._get_account() + return collect_irc_security_warnings(account_obj) + + def collect_mutable_allowlist_warnings(self, config: dict) -> list[str]: + irc_cfg = config.get("channels", {}).get("irc", {}) + irc_config = IrcConfig(**irc_cfg) + return collect_mutable_allowlist_warnings(irc_config) + + # ── GroupsProtocol ────────────────────────────────── + + def resolve_require_mention(self, ctx) -> bool | None: + account = getattr(self, "_account", None) or self._get_account() + return resolve_irc_require_mention( + ctx.group_channel or ctx.group_id or "", + account.config.groups, + account.config.channels, + ) + + # ── PairingProtocol ───────────────────────────────── + + @property + def id_label(self) -> str: + return "ircNick" + + async def generate_code(self, peer_id: str) -> str: + return await generate_irc_pairing_code(peer_id) + + async def verify_code(self, peer_id: str, code: str) -> bool: + return await verify_irc_pairing_code(peer_id, code) + + def normalize_allow_entry(self, entry: str) -> str: + return normalize_irc_allow_entry(entry) + + # ── MessagingProtocol ─────────────────────────────── + + def normalize_target(self, raw: str) -> str | None: + return normalize_irc_target(raw) + + # ── LifecycleProtocol ─────────────────────────────── + + @property + def config_prefixes(self) -> list[str]: + return ["channels.irc"] + + async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None: + self._config = next_cfg + + # ── DirectoryProtocol ─────────────────────────────── + + async def list_peers(self, config: dict) -> list: + from yuxi.channel.protocols import DirectoryPeer + + irc_cfg = config.get("channels", {}).get("irc", {}) + irc_config = IrcConfig(**irc_cfg) + account = resolve_irc_account(irc_config) + peers = [] + seen = set() + for entry in account.config.allow_from: + if entry in seen or entry == "*": + continue + seen.add(entry) + parts = entry.split("!") + nick = parts[0] if parts else entry + peers.append(DirectoryPeer(id=nick, display_name=nick, kind="user")) + return peers + + async def list_groups(self, config: dict) -> list: + from yuxi.channel.protocols import DirectoryGroup + + irc_cfg = config.get("channels", {}).get("irc", {}) + irc_config = IrcConfig(**irc_cfg) + account = resolve_irc_account(irc_config) + groups = [] + seen = set() + for channel in account.config.channels: + if channel in seen: + continue + seen.add(channel) + groups.append(DirectoryGroup(id=channel, display_name=channel, kind="group")) + for group_key in account.config.groups: + if group_key in seen or group_key == "*": + continue + seen.add(group_key) + groups.append(DirectoryGroup(id=group_key, display_name=group_key, kind="group")) + return groups + + +def _check_irc_security( + msg: IrcInboundMessage, + account: ResolvedIrcAccount, + log, +) -> bool: + if msg.is_group: + group_policy = resolve_group_policy(account) + if group_policy == GroupPolicy.DISABLED: + log and log.debug("IRC group message rejected: group policy is disabled") + return False + + require_mention = resolve_irc_require_mention( + msg.raw_target, account.config.groups, account.config.channels + ) + if require_mention and not is_mentioned(account.nick, msg.text): + log and log.debug("IRC group message rejected: @mention required for %s", msg.raw_target) + return False + + if group_policy == GroupPolicy.ALLOWLIST: + group_match = match_irc_group( + msg.raw_target, account.config.groups, account.config.channels, account.nick + ) + effective_allowlist = account.config.group_allow_from + if group_match is not None: + group_key, group_cfg = group_match + if group_cfg is not None and hasattr(group_cfg, "allow_from") and group_cfg.allow_from: + effective_allowlist = group_cfg.allow_from + if not check_allowlist_match( + msg.sender_nick, + msg.sender_user, + msg.sender_host, + effective_allowlist, + account.config.dangerously_allow_name_matching, + ): + log and log.debug( + "IRC group message rejected: '%s' not in allowlist for %s", + msg.sender_nick, msg.raw_target, + ) + return False + else: + dm_policy = resolve_dm_policy(account) + if dm_policy == DmPolicy.DISABLED: + log and log.debug("IRC DM rejected: DM policy is disabled") + return False + + if dm_policy == DmPolicy.PAIRING: + if not check_allowlist_match( + msg.sender_nick, + msg.sender_user, + msg.sender_host, + account.config.allow_from, + account.config.dangerously_allow_name_matching, + ): + log and log.info( + "IRC DM pairing required: '%s' not in allowlist, letting through for pairing", + msg.sender_nick, + ) + + return True diff --git a/backend/package/yuxi/channel/extensions/irc/probe.py b/backend/package/yuxi/channel/extensions/irc/probe.py new file mode 100644 index 00000000..8dc4ed1b --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/probe.py @@ -0,0 +1,74 @@ +import asyncio +import logging +import ssl +import time + +from yuxi.channel.extensions.irc.client import send_line +from yuxi.channel.extensions.irc.protocol import parse_irc_line +from yuxi.channel.extensions.irc.types import IrcProbe + +logger = logging.getLogger(__name__) + + +async def probe_irc( + host: str, + port: int, + tls: bool, + nick: str, + timeout: float = 8.0, +) -> IrcProbe: + try: + ssl_context = ssl.create_default_context() if tls else None + start = time.monotonic() + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host, port, ssl=ssl_context), + timeout=timeout, + ) + + send_line(writer, f"NICK {nick}") + send_line(writer, f"USER probe 0 * :Probe") + await writer.drain() + + got_001 = False + while not got_001: + try: + line_bytes = await asyncio.wait_for(reader.readline(), timeout=timeout) + except asyncio.TimeoutError: + break + if not line_bytes: + break + line = line_bytes.decode("utf-8", errors="replace").rstrip("\r\n") + parsed = parse_irc_line(line) + if parsed.command == "001": + got_001 = True + break + if parsed.command in ("432", "433", "464", "465"): + break + + send_line(writer, "QUIT :probe") + await writer.drain() + writer.close() + await writer.wait_closed() + + end = time.monotonic() + latency_ms = (end - start) * 1000 + + return IrcProbe( + ok=got_001, + host=host, + port=port, + tls=tls, + nick=nick, + latency_ms=latency_ms, + ) + + except Exception as e: + logger.warning("IRC probe failed for %s:%d: %s", host, port, e) + return IrcProbe( + ok=False, + host=host, + port=port, + tls=tls, + nick=nick, + error=str(e), + ) \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/irc/protocol.py b/backend/package/yuxi/channel/extensions/irc/protocol.py new file mode 100644 index 00000000..abb6f6b1 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/protocol.py @@ -0,0 +1,117 @@ +import re +from dataclasses import dataclass, field + + +@dataclass +class ParsedIrcLine: + raw: str + prefix: str | None + command: str + params: list[str] + trailing: str | None + tags: dict[str, str | bool] = field(default_factory=dict) + + +@dataclass +class ParsedIrcPrefix: + nick: str | None = None + user: str | None = None + host: str | None = None + server: str | None = None + + +CONTROL_CHARS = set(range(0x00, 0x20)) | {0x7F} + + +def parse_irc_line(line: str) -> ParsedIrcLine: + raw = line.strip() + tags: dict[str, str | bool] = {} + if raw.startswith("@"): + space_idx = raw.find(" ") + if space_idx == -1: + return ParsedIrcLine(raw=raw, prefix=None, command="", params=[], trailing=None) + tags_str = raw[1:space_idx] + raw = raw[space_idx + 1 :] + for tag_pair in tags_str.split(";"): + if "=" in tag_pair: + key, val = tag_pair.split("=", 1) + tags[key] = val + else: + tags[tag_pair] = True + + prefix = None + if raw.startswith(":"): + space_idx = raw.find(" ") + if space_idx == -1: + return ParsedIrcLine(raw=line.strip(), prefix=None, command=raw[1:], params=[], trailing=None, tags=tags) + prefix = raw[1:space_idx] + raw = raw[space_idx + 1 :] + + trail_idx = raw.find(" :") + trailing = None + if trail_idx != -1: + trailing = raw[trail_idx + 2 :] + raw = raw[:trail_idx] + + parts = raw.split() + command = parts[0].upper() if parts else "" + params = parts[1:] if len(parts) > 1 else [] + + return ParsedIrcLine(raw=line.strip(), prefix=prefix, command=command, params=params, trailing=trailing, tags=tags) + + +def parse_irc_prefix(prefix: str) -> ParsedIrcPrefix: + if "!" in prefix or "@" in prefix: + nick, rest = prefix.split("!", 1) if "!" in prefix else (prefix.split("@", 1)[0], prefix) + user = None + host = None + if "!" in prefix: + user_host = prefix.split("!", 1)[1] + if "@" in user_host: + user, host = user_host.split("@", 1) + else: + user = user_host + elif "@" in prefix: + host = prefix.split("@", 1)[1] + return ParsedIrcPrefix(nick=nick, user=user, host=host) + return ParsedIrcPrefix(server=prefix) + + +def sanitize_irc_text(text: str) -> str: + text = text.encode("utf-8", errors="surrogateescape").decode("unicode_escape") + text = re.sub(r"\r?\n", " ", text) + text = "".join(c for c in text if ord(c) not in CONTROL_CHARS) + return text.strip() + + +def split_irc_text(text: str, max_chars: int = 350) -> list[str]: + chunks = [] + remaining = text + while len(remaining) > max_chars: + split_at = remaining.rfind(" ", 0, max_chars) + if split_at < max_chars * 0.5: + split_at = max_chars + chunks.append(remaining[:split_at].strip()) + remaining = remaining[split_at:].strip() + if remaining: + chunks.append(remaining) + return chunks + + +CTCP_RE = re.compile(r"\x01([^\x01]*)\x01") + + +def parse_ctcp(text: str) -> tuple[str | None, str | None]: + m = CTCP_RE.fullmatch(text.strip()) + if not m: + return None, None + inner = m.group(1).strip() + parts = inner.split(" ", 1) + cmd = parts[0].upper() + args = parts[1] if len(parts) > 1 else None + return cmd, args + + +def strip_ctcp(text: str) -> str: + text = CTCP_RE.sub(r"\1", text) + return text.strip() \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/irc/security.py b/backend/package/yuxi/channel/extensions/irc/security.py new file mode 100644 index 00000000..151d1c32 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/security.py @@ -0,0 +1,163 @@ +import logging +import re +import secrets +import time + +from yuxi.channel.extensions.irc.config import DmPolicy, GroupPolicy, IrcConfig +from yuxi.channel.extensions.irc.normalize import build_irc_allowlist_candidates +from yuxi.channel.extensions.irc.types import ResolvedIrcAccount + +logger = logging.getLogger(__name__) + +_PAIRING_CODES: dict[str, tuple[str, float]] = {} +_CODE_TTL_SECONDS = 300 +_ID_LABEL = "ircNick" + + +def build_irc_mention_regex(nick: str) -> re.Pattern: + escaped = re.escape(nick) + return re.compile(rf"\b{escaped}\b[:,]?", re.IGNORECASE) + + +def is_mentioned(nick: str, text: str) -> bool: + pattern = build_irc_mention_regex(nick) + return bool(pattern.search(text)) + + +def resolve_dm_policy(account: ResolvedIrcAccount) -> DmPolicy: + return account.config.dm_policy + + +def resolve_group_policy(account: ResolvedIrcAccount) -> GroupPolicy: + return account.config.group_policy + + +def match_irc_group( + target: str, + groups: dict[str, object], + channels: list[str], + account_nick: str, +) -> object | None: + normalized = target.lower() + for channel in channels: + if channel.lower() == normalized: + key = channel + if key in groups: + return (key, groups[key]) + return (key, None) + for group_key, group_cfg in groups.items(): + if group_key.lower() == normalized: + return (group_key, group_cfg) + if group_key == "*": + return (group_key, group_cfg) + return None + + +def check_allowlist_match( + nick: str, + user: str | None, + host: str | None, + allowlist: list[str], + allow_name_matching: bool = False, +) -> bool: + if "*" in allowlist: + return True + + candidates = build_irc_allowlist_candidates(nick, user, host, allow_name_matching) + allowlist_lower = {entry.lower() for entry in allowlist} + for candidate in candidates: + if candidate in allowlist_lower: + return True + return False + + +def resolve_irc_require_mention( + target: str, + groups: dict[str, object], + channels: list[str], +) -> bool: + normalized = target.lower() + for channel in channels: + if channel.lower() == normalized: + match = groups.get(channel) + if match is not None and hasattr(match, "require_mention"): + return match.require_mention + break + wildcard = groups.get("*") + if wildcard is not None and hasattr(wildcard, "require_mention"): + return wildcard.require_mention + return True + + +def collect_irc_security_warnings(account: ResolvedIrcAccount) -> list[str]: + warnings = [] + if not account.tls: + warnings.append( + "IRC TLS is disabled — traffic and credentials are transmitted in plaintext" + ) + nickserv = account.config.nickserv + if nickserv.register_nick: + if not account.nickserv_password: + warnings.append( + "NickServ registration is enabled but no NickServ password is configured" + ) + else: + warnings.append( + "NickServ registration is enabled — disable 'register' after initial registration" + ) + if account.config.group_policy == GroupPolicy.OPEN: + warnings.append( + "Group policy is set to 'open' — all channels can receive messages (mention-gated). " + "Consider setting to 'allowlist'." + ) + for entry in account.config.allow_from: + if "!" not in entry and "@" not in entry and entry != "*": + warnings.append( + f"Allowlist entry '{entry}' is a bare nick without !user@host — " + "it could be spoofed. Use full nick!user@host format for security." + ) + return warnings + + +def collect_mutable_allowlist_warnings(cfg: IrcConfig) -> list[str]: + warnings = [] + for account_cfg in cfg.accounts.values(): + for entry in account_cfg.allow_from: + if "!" not in entry and "@" not in entry and entry != "*": + warnings.append( + f"Account '{account_cfg.name or 'unknown'}': mutable allowlist entry '{entry}' " + "(bare nick, could be spoofed)" + ) + return warnings + + +async def generate_irc_pairing_code(peer_id: str) -> str: + code = f"{secrets.randbelow(1_000_000):06d}" + _PAIRING_CODES[peer_id] = (code, time.monotonic()) + logger.info("IRC pairing code generated for peer %s", peer_id) + return code + + +async def verify_irc_pairing_code(peer_id: str, code: str) -> bool: + stored = _PAIRING_CODES.get(peer_id) + if stored is None: + return False + stored_code, created_at = stored + if time.monotonic() - created_at > _CODE_TTL_SECONDS: + _PAIRING_CODES.pop(peer_id, None) + return False + if not secrets.compare_digest(stored_code, code): + return False + _PAIRING_CODES.pop(peer_id, None) + logger.info("IRC pairing code verified for peer %s", peer_id) + return True + + +def normalize_irc_allow_entry(entry: str) -> str: + stripped = entry.strip() + for prefix in ("irc:", "IRC:"): + if stripped.startswith(prefix): + stripped = stripped[len(prefix):] + if "!" in stripped or "@" in stripped: + return stripped.lower() + return stripped diff --git a/backend/package/yuxi/channel/extensions/irc/send.py b/backend/package/yuxi/channel/extensions/irc/send.py new file mode 100644 index 00000000..15ba70d3 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/send.py @@ -0,0 +1,78 @@ +import asyncio +import logging +import time +import uuid + +from yuxi.channel.extensions.irc.client import ( + connect_irc_client, + graceful_disconnect, + send_privmsg, +) +from yuxi.channel.extensions.irc.normalize import normalize_irc_target +from yuxi.channel.extensions.irc.protocol import sanitize_irc_text, split_irc_text +from yuxi.channel.extensions.irc.types import ResolvedIrcAccount, SendIrcResult + +logger = logging.getLogger(__name__) + +IRC_SEND_DELAY = 0.3 + + +async def send_irc_message( + to: str, + text: str, + account: ResolvedIrcAccount, + reply_to_id: str | None = None, + client=None, +) -> SendIrcResult: + target = normalize_irc_target(to) + text = sanitize_irc_text(text) + + if reply_to_id: + text = f"{text}\n\n[reply:{reply_to_id}]" + + chunk_limit = 350 + if account.config is not None: + chunk_limit = account.config.text_chunk_limit or 350 + chunks = split_irc_text(text, chunk_limit) + + writer = None + own_client = None + + try: + if client is not None and client.writer is not None: + writer = client.writer + else: + own_client = await connect_irc_client(account, timeout=12.0) + await asyncio.sleep(1.5) + writer = own_client.writer + + for i, chunk in enumerate(chunks): + if i > 0: + await asyncio.sleep(IRC_SEND_DELAY) + await send_privmsg(writer, target, chunk) + + except Exception as e: + logger.error("Failed to send IRC message to %s: %s", target, e) + return SendIrcResult(ok=False, error=str(e), chunk_count=0) + finally: + if own_client is not None: + await graceful_disconnect(own_client) + + return SendIrcResult( + ok=True, + message_id=str(uuid.uuid4()), + chunk_count=len(chunks), + ) + + +async def send_irc_media( + to: str, + media_url: str, + media_type: str, + account: ResolvedIrcAccount, + reply_to_id: str | None = None, + client=None, +) -> SendIrcResult: + caption = f"[{media_type}]" if media_type else "[Attachment]" + text = f"{caption}\n{media_url}" + return await send_irc_message(to, text, account, reply_to_id=reply_to_id, client=client) \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/irc/status.py b/backend/package/yuxi/channel/extensions/irc/status.py new file mode 100644 index 00000000..72bc297b --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/status.py @@ -0,0 +1,58 @@ +import time + +from yuxi.channel.extensions.irc.types import IrcProbe + + +def build_irc_status_summary(snapshot: object) -> dict: + if snapshot is None: + return { + "channel": "irc", + "account_id": "default", + "connected": False, + } + + has_states = hasattr(snapshot, "_states") or hasattr(snapshot, "states") + if not has_states: + return { + "channel": getattr(snapshot, "channel_type", "irc"), + "account_id": getattr(snapshot, "account_id", "default"), + "connected": getattr(snapshot, "connected", False), + "host": getattr(snapshot, "host", ""), + "nick": getattr(snapshot, "nick", ""), + "probe": None, + "last_probe_at": None, + "last_inbound_at": None, + "last_outbound_at": None, + } + + states = getattr(snapshot, "_states", None) or getattr(snapshot, "states", {}) + if not states: + return {"channel": "irc", "connected": False} + + summaries = {} + for account_id, state in states.items(): + probe = getattr(state, "probe", None) + probe_dict = None + if isinstance(probe, IrcProbe): + probe_dict = { + "ok": probe.ok, + "host": probe.host, + "port": probe.port, + "tls": probe.tls, + "nick": probe.nick, + "latency_ms": probe.latency_ms, + "error": probe.error, + } + summaries[account_id] = { + "channel": "irc", + "account_id": account_id, + "host": getattr(state, "host", ""), + "nick": getattr(state, "nick", ""), + "connected": getattr(state, "connected", False), + "probe": probe_dict, + "last_probe_at": getattr(state, "last_probe_at", None), + "last_inbound_at": getattr(state, "last_inbound_at", None), + "last_outbound_at": getattr(state, "last_outbound_at", None), + "error": getattr(state, "error", None), + } + return summaries \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/irc/streaming.py b/backend/package/yuxi/channel/extensions/irc/streaming.py new file mode 100644 index 00000000..53530e73 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/streaming.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +class IrcStreamBuffer: + + def __init__(self): + self._chunks: list[str] = [] + self._finished = False + + def feed(self, chunk: str) -> None: + self._chunks.append(chunk) + + def flush(self) -> str: + content = "".join(self._chunks) + self._chunks.clear() + return content + + @property + def content(self) -> str: + return "".join(self._chunks) + + @property + def is_empty(self) -> bool: + return len(self._chunks) == 0 + + def mark_finished(self) -> None: + self._finished = True + + @property + def finished(self) -> bool: + return self._finished diff --git a/backend/package/yuxi/channel/extensions/irc/types.py b/backend/package/yuxi/channel/extensions/irc/types.py new file mode 100644 index 00000000..abcfdad3 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/irc/types.py @@ -0,0 +1,58 @@ +from dataclasses import dataclass +from datetime import datetime + +from yuxi.channel.extensions.irc.config import IrcAccountConfig + + +@dataclass +class ResolvedIrcAccount: + account_id: str + enabled: bool + name: str | None + configured: bool + host: str + port: int + tls: bool + nick: str + username: str + realname: str + password: str + password_source: str + nickserv_password: str + nickserv_password_source: str + config: IrcAccountConfig + + +@dataclass +class IrcInboundMessage: + message_id: str + target: str + raw_target: str + sender_nick: str + sender_user: str | None + sender_host: str | None + text: str + timestamp: float + is_group: bool + server_time: datetime | None = None + account: str | None = None + msg_id: str | None = None + + +@dataclass +class IrcProbe: + ok: bool + host: str + port: int + tls: bool + nick: str + latency_ms: float | None = None + error: str | None = None + + +@dataclass +class SendIrcResult: + ok: bool + message_id: str | None = None + error: str | None = None + chunk_count: int = 0 \ No newline at end of file