feat(channel): 添加 IRC 渠道扩展插件

新增 IRC 渠道的完整扩展实现,包括客户端连接管理、消息收发与流式处理、安全策略与配对验证、消息去重与规范化、状态监控与健康探测、配置模型与账户管理、错误处理等模块。
This commit is contained in:
Kris 2026-05-21 10:59:12 +08:00
parent 78370e0158
commit 061cc7076b
17 changed files with 2225 additions and 0 deletions

View 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())

View 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

View 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)

View 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

View 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)

View 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),
)

View 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)

View 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

View 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
}
}

View 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

View 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),
)

View 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()

View 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

View 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)

View 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

View 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

View 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