ForcePilot/backend/package/yuxi/channel/extensions/xmpp/plugin.py

602 lines
23 KiB
Python
Raw Normal View History

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"