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