import logging from urllib.parse import urlparse import httpx from yuxi.channel.capabilities import ChannelCapabilities from yuxi.channel.context import ChannelContext from yuxi.channel.extensions.base import BaseChannelPlugin from yuxi.channel.extensions.xmpp.accounts import ( has_configured_state, list_xmpp_account_ids, resolve_xmpp_account, ) from yuxi.channel.extensions.xmpp.errors import classify_xmpp_error, is_retryable as _is_xmpp_retryable from yuxi.channel.extensions.xmpp.gateway import XmppGateway from yuxi.channel.extensions.xmpp.outbound import send_xmpp_text, send_xmpp_typing from yuxi.channel.extensions.xmpp.pairing import ( generate_xmpp_pairing_code, normalize_xmpp_allow_entry, verify_xmpp_pairing_code, ) from yuxi.channel.extensions.xmpp.security import ( check_xmpp_allowlist, collect_xmpp_security_warnings, ) from yuxi.channel.extensions.xmpp.status import build_xmpp_status_summary, probe_xmpp from yuxi.channel.extensions.xmpp.streaming import chunk_text from yuxi.channel.extensions.xmpp.types import ResolvedXmppAccount logger = logging.getLogger("yuxi.channel.xmpp") class XmppPlugin(BaseChannelPlugin): id = "xmpp" name = "XMPP / Jabber" order = 85 label = "XMPP / Jabber (JID + 密码)" aliases = ["jabber", "xmpp-chat"] def __init__(self): self._config: dict = {} self._gateway: XmppGateway | None = None self._account: ResolvedXmppAccount | None = None @property def capabilities(self) -> ChannelCapabilities: return ChannelCapabilities( chat_types=["direct", "group"], message_types=["text"], reactions=False, typing_indicator=True, threads=True, edit=False, unsend=False, reply=True, media=True, native_commands=True, polls=False, streaming=True, streaming_mode="block", block_streaming=True, block_streaming_chunk_min_chars=800, block_streaming_chunk_max_chars=2000, ) # ── ConfigProtocol ────────────────────────────────── def list_account_ids(self, config: dict) -> list[str]: xmpp_cfg = config.get("channels", {}).get("xmpp", {}) return list_xmpp_account_ids(xmpp_cfg) async def resolve_account(self, account_id: str) -> dict: xmpp_cfg = self._config.get("channels", {}).get("xmpp", {}) account = resolve_xmpp_account(xmpp_cfg, account_id) return { "account_id": account.account_id, "enabled": account.enabled, "jid": account.jid, "host": account.host, "port": account.port, "use_ssl": account.use_ssl, "resource": account.resource, "nick": account.nick, "configured": account.is_configured, "dm_policy": account.dm_policy, "group_policy": account.group_policy, "rooms": account.rooms, "allow_from": account.allow_from, } def is_configured(self, account: dict) -> bool: return bool(account.get("jid") and account.get("configured")) def is_enabled(self, account: dict) -> bool: return account.get("enabled", True) def describe_account(self, account: dict, config: dict = None) -> dict: jid = account.get("jid", "") host = account.get("host", "") result = { "account_id": account.get("account_id", "default"), "jid": jid, "host": host or "(SRV auto-discovery)", "nick": account.get("nick", "Bot"), } if account.get("rooms"): result["rooms"] = account["rooms"] return result def disabled_reason(self, account: dict, config: dict = None) -> str: if not account.get("enabled", True): return "XMPP account is disabled in configuration" if not account.get("jid"): return "XMPP JID is not configured" if not account.get("configured"): return "XMPP account is not fully configured (jid + password required)" return "" def has_configured_state(self, config: dict) -> bool: xmpp_cfg = config.get("channels", {}).get("xmpp", {}) return has_configured_state(xmpp_cfg) def resolve_allow_from(self, config: dict, account_id: str | None = None) -> list[str] | None: xmpp_cfg = config.get("channels", {}).get("xmpp", {}) account = resolve_xmpp_account(xmpp_cfg, account_id or "default") if account.allow_from: return account.allow_from return None def config_schema(self) -> dict: from yuxi.channel.extensions.xmpp.config import build_config_schema return build_config_schema() # ── GatewayProtocol ───────────────────────────────── async def start(self, ctx: ChannelContext) -> object: self._config = ctx.config xmpp_cfg = ctx.config.get("channels", {}).get("xmpp", {}) account = resolve_xmpp_account(xmpp_cfg, ctx.account_id) if not account.is_configured: raise RuntimeError(f"XMPP account '{ctx.account_id}' not configured (jid + password required)") self._account = account self._gateway = XmppGateway(account) async def on_inbound(unified_msg): if unified_msg is None: return if not _check_xmpp_security(unified_msg, account): return if ctx.queue is not None: await ctx.queue.put(unified_msg) self._gateway.on_message = on_inbound await self._gateway.connect() logger.info("XMPP gateway started for %s", account.jid) return self._gateway async def stop(self, ctx: ChannelContext) -> None: if self._gateway is not None: await self._gateway.disconnect() self._gateway = None logger.info("XMPP gateway stopped") # ── 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: gateway = self._gateway if gateway is None: logger.error("XMPP gateway not available for send_text") return await send_xmpp_text( gateway, target_id, content, reply_to_id=reply_to_id, thread_id=thread_id, ) async def send_media( self, target_id: str, media_url: str, media_type: str = "file", *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> None: gateway = self._gateway if gateway is None: logger.error("XMPP gateway not available for send_media") return from yuxi.channel.extensions.xmpp.outbound import send_xmpp_file, send_xmpp_text file_path = None try: if media_url.startswith(("http://", "https://")): import tempfile import os async with httpx.AsyncClient() as client: resp = await client.get(media_url) resp.raise_for_status() with tempfile.NamedTemporaryFile(delete=False, suffix=_ext_from_url(media_url)) as f: f.write(resp.content) file_path = f.name else: file_path = media_url result = await send_xmpp_file( gateway, target_id, file_path, reply_to_id=reply_to_id, thread_id=thread_id, ) if not result.ok: logger.warning("XMPP file upload failed, falling back to URL text: %s", result.error) await send_xmpp_text( gateway, target_id, f"[{media_type}]\n{media_url}", reply_to_id=reply_to_id, thread_id=thread_id, ) except Exception as e: logger.warning("XMPP file upload failed, falling back to URL text: %s", e) await send_xmpp_text( gateway, target_id, f"[{media_type}]\n{media_url}", reply_to_id=reply_to_id, thread_id=thread_id, ) finally: if file_path and media_url.startswith(("http://", "https://")): import os try: os.unlink(file_path) except OSError: pass async def send_typing(self, target_id: str, thread_id: str | None = None) -> None: gateway = self._gateway if gateway is None: return await send_xmpp_typing(gateway, target_id, composing=True) # ── StatusProtocol ────────────────────────────────── async def probe(self, account: dict | None = None) -> bool: if account is not None: return await probe_xmpp( account.get("jid", ""), account.get("host", ""), account.get("port", 5222), account.get("use_ssl", False), ) if self._gateway is not None: return self._gateway.is_connected return False def build_summary(self, snapshot: object) -> dict: return build_xmpp_status_summary(snapshot) # ── SecurityProtocol ──────────────────────────────── async def check_allowlist(self, peer_id: str, channel_type: str) -> bool: account = self._account if account is None: return False allowlist = account.allow_from if channel_type == "direct" else account.group_allow_from return check_xmpp_allowlist(peer_id, allowlist) def resolve_dm_policy(self) -> dict: account = self._account if account is None: return {"mode": "pairing", "allow_from": []} return { "mode": account.dm_policy, "allow_from": account.allow_from, } def collect_warnings(self, config: dict, account_id: str | None = None, account: dict | None = None) -> list[str]: warnings = [] acct = self._account if acct is None and account is not None: jid = account.get("jid", "") if not jid: warnings.append("XMPP JID is not configured — DM and group chat will not work") elif "@" not in jid: warnings.append(f"XMPP JID '{jid}' does not appear to be a valid JID (missing @)") elif acct is not None: warnings.extend(collect_xmpp_security_warnings(acct)) return warnings # ── PairingProtocol ───────────────────────────────── @property def id_label(self) -> str: return "xmppJid" async def generate_code(self, peer_id: str) -> str: return await generate_xmpp_pairing_code(peer_id) async def verify_code(self, peer_id: str, code: str) -> bool: return await verify_xmpp_pairing_code(peer_id, code) def normalize_allow_entry(self, entry: str) -> str: return normalize_xmpp_allow_entry(entry) # ── GroupsProtocol ────────────────────────────────── def resolve_require_mention(self, ctx) -> bool | None: account = self._account if account is None: return True room_jid = getattr(ctx, "group_id", "") or getattr(ctx, "group_channel", "") if not room_jid: return True room_config = account.room_configs.get(room_jid) if room_config is not None: return room_config.get("require_mention", True) return True async def list_groups(self, config: dict) -> list: from yuxi.channel.protocols import DirectoryGroup xmpp_cfg = config.get("channels", {}).get("xmpp", {}) account = resolve_xmpp_account(xmpp_cfg) groups = [] seen = set() for room in account.rooms: if room in seen: continue seen.add(room) groups.append(DirectoryGroup(id=room, display_name=room, kind="group")) for room_jid in account.room_configs: if room_jid in seen: continue seen.add(room_jid) groups.append(DirectoryGroup(id=room_jid, display_name=room_jid, kind="group")) return groups # ── DirectoryProtocol ─────────────────────────────── async def list_peers(self, config: dict) -> list: from yuxi.channel.protocols import DirectoryPeer xmpp_cfg = config.get("channels", {}).get("xmpp", {}) account = resolve_xmpp_account(xmpp_cfg) peers = [] seen = set() for entry in account.allow_from: if entry in seen or entry == "*": continue seen.add(entry) peers.append(DirectoryPeer(id=entry, display_name=entry, kind="user")) return peers # ── MessagingProtocol ─────────────────────────────── def extract_thread_id(self, msg: object) -> str | None: if hasattr(msg, "metadata") and isinstance(msg.metadata, dict): tid = msg.metadata.get("thread_id") if tid: return tid if hasattr(msg, "message_thread_id"): return msg.message_thread_id return None def resolve_session(self, msg: object): from yuxi.channel.message.models import PeerKind from yuxi.channel.protocols import SessionResolution if hasattr(msg, "sender") and hasattr(msg.sender, "kind"): sender_id = msg.sender.id if hasattr(msg.sender, "id") else "" label = msg.sender.display_name if hasattr(msg.sender, "display_name") else "" if msg.sender.kind == PeerKind.DIRECT: account = self._account scope = account.dm_session_scope if account else "per-user" if scope == "per-user": conv_id = sender_id else: room_jid = "" if hasattr(msg, "metadata") and isinstance(msg.metadata, dict): room_jid = msg.metadata.get("room_jid", "") conv_id = f"{sender_id}:{room_jid}" if room_jid else sender_id return SessionResolution(kind="direct", conversation_id=conv_id, label=label) gid = "" if hasattr(msg, "group") and msg.group: if hasattr(msg.group, "id"): gid = msg.group.id if hasattr(msg, "metadata") and isinstance(msg.metadata, dict): gid = msg.metadata.get("room_jid", gid) return SessionResolution(kind="group", conversation_id=gid or "unknown") # ── AgentPromptProtocol ────────────────────────────── def build_system_prompt(self, context) -> str | None: group_name = getattr(context, "group_name", "") or "" peer_name = getattr(context, "peer_name", "") or "" lines = [ "You are interacting on XMPP (Jabber), a federated chat protocol.", "XMPP supports plain text messages. Use simple text formatting.", "Messages are split into chunks of 800-2000 characters for block streaming.", "In MUC group chats, users must @mention the bot to trigger a reply.", ] if peer_name: lines.append(f"You are in a DM with: {peer_name}") if group_name: lines.append(f"Current MUC room: {group_name}") return "\n".join(lines) def build_context_note(self, context) -> str: group_name = getattr(context, "group_name", "") or "" peer_name = getattr(context, "peer_name", "") or "" if group_name and peer_name: return f"[XMPP | {group_name} | {peer_name}]" if group_name: return f"[XMPP | {group_name}]" if peer_name: return f"[XMPP | DM: {peer_name}]" return "[XMPP]" @property def channel_format_instructions(self) -> str | None: return ( "XMPP supports plain text messages. Use simple text formatting with " "paragraphs separated by blank lines. Messages are split into chunks " "of 800-2000 characters. No HTML or Markdown rendering is guaranteed." ) # ── LifecycleProtocol ─────────────────────────────── @property def config_prefixes(self) -> list[str]: return ["channels.xmpp"] async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None: self._config = next_cfg # ── ErrorHandlingProtocol ──────────────────────────── def classify_error(self, error: BaseException): from yuxi.channel.protocols import ClassifiedError, ErrorSeverity condition = getattr(error, "condition", "") text = getattr(error, "text", str(error)) kind, message, retry_after = classify_xmpp_error(str(condition), str(text)) severity = { "retryable": ErrorSeverity.RETRYABLE, "auth": ErrorSeverity.FORBIDDEN, "forbidden": ErrorSeverity.FORBIDDEN, "not_found": ErrorSeverity.FATAL, "not_acceptable": ErrorSeverity.FATAL, "remote_server_error": ErrorSeverity.RETRYABLE, "service_unavailable": ErrorSeverity.RETRYABLE, "resource_constraint": ErrorSeverity.RATE_LIMITED, "rate_limited": ErrorSeverity.RATE_LIMITED, "fatal": ErrorSeverity.FATAL, }.get(kind, ErrorSeverity.FATAL) return ClassifiedError( severity=severity, retry_after_ms=int((retry_after or 0) * 1000), error_message=message, original_error=error, ) def is_retryable(self, error: BaseException) -> bool: condition = getattr(error, "condition", "") kind, _, _ = classify_xmpp_error(str(condition), str(error)) return _is_xmpp_retryable(kind) # ── DedupeProtocol ─────────────────────────────────── def is_duplicate(self, key: str) -> bool: if self._gateway is not None: return self._gateway.dedupe.has(key) return False def mark_seen(self, key: str) -> None: if self._gateway is not None: self._gateway.dedupe.add(key) def reset(self) -> None: if self._gateway is not None: self._gateway.dedupe.__init__(max_size=2000, ttl_seconds=300) @property def ttl_seconds(self) -> int: return 300 # ── FormatProtocol ─────────────────────────────────── def sanitize_text(self, text: str, payload: object | None = None) -> str: return text.strip() def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: return chunk_text(text, min_chars=limit // 2, max_chars=limit) def markdown_to_native(self, md_text: str) -> dict | str: import re result = re.sub(r"\*\*(.+?)\*\*", r"\1", md_text) result = re.sub(r"(? list[str]: from yuxi.channel.extensions.xmpp.stanza_utils import extract_muc_info msg_type = raw_message.get("type", "") _, muc_nick = extract_muc_info(raw_message, msg_type) body = raw_message.get("body", "") from_jid = str(raw_message.get("from", "")) from_bare = from_jid.split("/")[0] mentions = [] if muc_nick and body: import re found = re.findall(r"@(\S+)", body) mentions.extend(found) if "@" in from_bare: mentions.append(from_bare) return mentions def strip_mentions(self, text: str, ctx: object, config: dict | None = None, agent_id: str | None = None) -> str: import re return re.sub(r"@\S+\s*", "", text).strip() def _check_xmpp_security(unified_msg, account: ResolvedXmppAccount) -> bool: sender_id = unified_msg.sender.id if hasattr(unified_msg, "sender") else "" if unified_msg.group is None: policy = account.dm_policy if policy == "disabled": logger.debug("XMPP DM rejected: DM policy is disabled") return False if policy == "allowlist": if not check_xmpp_allowlist(sender_id, account.allow_from): logger.debug("XMPP DM rejected: '%s' not in allowlist", sender_id) return False return True policy = account.group_policy if policy == "disabled": logger.debug("XMPP group message rejected: group policy is disabled") return False was_mentioned = unified_msg.metadata.get("was_mentioned", False) if hasattr(unified_msg, "metadata") else False if not was_mentioned: logger.debug("XMPP group message rejected: @mention required") return False if policy == "allowlist": gid = unified_msg.group.id if unified_msg.group else "" allowlist = account.group_allow_from room_config = account.room_configs.get(gid, {}) if room_config.get("allow_from"): allowlist = room_config["allow_from"] if not check_xmpp_allowlist(sender_id, allowlist): logger.debug("XMPP group message rejected: '%s' not in allowlist for %s", sender_id, gid) return False return True def _ext_from_url(url: str) -> str: path = urlparse(url).path ext = path.rsplit(".", 1)[-1] if "." in path else "" return f".{ext}" if ext else ".bin"