feat(channel): 添加 IRC 渠道扩展插件
新增 IRC 渠道的完整扩展实现,包括客户端连接管理、消息收发与流式处理、安全策略与配对验证、消息去重与规范化、状态监控与健康探测、配置模型与账户管理、错误处理等模块。
This commit is contained in:
parent
78370e0158
commit
061cc7076b
4
backend/package/yuxi/channel/extensions/irc/__init__.py
Normal file
4
backend/package/yuxi/channel/extensions/irc/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from yuxi.channel.extensions.irc.plugin import IrcPlugin
|
||||
from yuxi.channel.plugins.registry import ChannelPluginRegistry
|
||||
|
||||
irc_plugin = ChannelPluginRegistry.register(IrcPlugin())
|
||||
190
backend/package/yuxi/channel/extensions/irc/accounts.py
Normal file
190
backend/package/yuxi/channel/extensions/irc/accounts.py
Normal file
@ -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
|
||||
196
backend/package/yuxi/channel/extensions/irc/client.py
Normal file
196
backend/package/yuxi/channel/extensions/irc/client.py
Normal file
@ -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)
|
||||
78
backend/package/yuxi/channel/extensions/irc/config.py
Normal file
78
backend/package/yuxi/channel/extensions/irc/config.py
Normal file
@ -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
|
||||
37
backend/package/yuxi/channel/extensions/irc/dedupe.py
Normal file
37
backend/package/yuxi/channel/extensions/irc/dedupe.py
Normal file
@ -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)
|
||||
92
backend/package/yuxi/channel/extensions/irc/errors.py
Normal file
92
backend/package/yuxi/channel/extensions/irc/errors.py
Normal file
@ -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),
|
||||
)
|
||||
367
backend/package/yuxi/channel/extensions/irc/monitor.py
Normal file
367
backend/package/yuxi/channel/extensions/irc/monitor.py
Normal file
@ -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)
|
||||
33
backend/package/yuxi/channel/extensions/irc/normalize.py
Normal file
33
backend/package/yuxi/channel/extensions/irc/normalize.py
Normal file
@ -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
|
||||
29
backend/package/yuxi/channel/extensions/irc/plugin.json
Normal file
29
backend/package/yuxi/channel/extensions/irc/plugin.json
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
616
backend/package/yuxi/channel/extensions/irc/plugin.py
Normal file
616
backend/package/yuxi/channel/extensions/irc/plugin.py
Normal file
@ -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
|
||||
74
backend/package/yuxi/channel/extensions/irc/probe.py
Normal file
74
backend/package/yuxi/channel/extensions/irc/probe.py
Normal file
@ -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),
|
||||
)
|
||||
117
backend/package/yuxi/channel/extensions/irc/protocol.py
Normal file
117
backend/package/yuxi/channel/extensions/irc/protocol.py
Normal file
@ -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()
|
||||
163
backend/package/yuxi/channel/extensions/irc/security.py
Normal file
163
backend/package/yuxi/channel/extensions/irc/security.py
Normal file
@ -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
|
||||
78
backend/package/yuxi/channel/extensions/irc/send.py
Normal file
78
backend/package/yuxi/channel/extensions/irc/send.py
Normal file
@ -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)
|
||||
58
backend/package/yuxi/channel/extensions/irc/status.py
Normal file
58
backend/package/yuxi/channel/extensions/irc/status.py
Normal file
@ -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
|
||||
35
backend/package/yuxi/channel/extensions/irc/streaming.py
Normal file
35
backend/package/yuxi/channel/extensions/irc/streaming.py
Normal file
@ -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
|
||||
58
backend/package/yuxi/channel/extensions/irc/types.py
Normal file
58
backend/package/yuxi/channel/extensions/irc/types.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user