ForcePilot/backend/package/yuxi/channel/extensions/xmpp/plugin.py
Kris 5946478772 feat(channel): 添加小红书、XMPP、元宝和 Zalo 渠道扩展
新增小红书、XMPP、元宝、Zalo 四个渠道扩展。

小红书渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, media, status, window

XMPP 渠道扩展主要模块:plugin, config, gateway, outbound, streaming, pairing, security, dedupe, accounts, commands, muc, rate_limiter, stanza_utils, status, monitor

元宝渠道扩展主要模块:plugin, client, config_schema, gateway, outbound(chunk/queue/transport), inbound(dispatcher), streaming, pairing, security, accounts, actions, commands, codec(biz/conn), session, shared, utils

Zalo 渠道扩展主要模块:api, config, gateway, webhook, outbound, pairing, security, session, polling, monitor, status
2026-05-21 12:04:05 +08:00

602 lines
23 KiB
Python

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"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"\1", result)
result = re.sub(r"`([^`]+)`", r"\1", result)
result = re.sub(r"```[\s\S]*?```", "", result)
result = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", result)
result = re.sub(r"^#{1,6}\s+", "", result, flags=re.MULTILINE)
result = re.sub(r"^\s*[-*+]\s+", "", result, flags=re.MULTILINE)
result = re.sub(r"\n{3,}", "\n\n", result)
return result.strip()
# ── MentionsProtocol ─────────────────────────────────
def extract_mentions(self, raw_message: dict) -> 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"