ForcePilot/backend/package/yuxi/channel/extensions/irc/plugin.py
Kris 061cc7076b feat(channel): 添加 IRC 渠道扩展插件
新增 IRC 渠道的完整扩展实现,包括客户端连接管理、消息收发与流式处理、安全策略与配对验证、消息去重与规范化、状态监控与健康探测、配置模型与账户管理、错误处理等模块。
2026-05-21 10:59:12 +08:00

617 lines
25 KiB
Python

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