ForcePilot/backend/package/yuxi/channel/extensions/xmpp/gateway.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

332 lines
12 KiB
Python

import asyncio
import logging
import uuid
from datetime import datetime, UTC
from yuxi.channel.extensions.xmpp.dedupe import DedupeTracker
from yuxi.channel.extensions.xmpp.errors import classify_xmpp_error
from yuxi.channel.extensions.xmpp.monitor import xmpp_stanza_to_unified
from yuxi.channel.extensions.xmpp.muc import join_muc_with_fallback
from yuxi.channel.extensions.xmpp.stanza_utils import (
bare_jid_from,
check_bot_mention,
extract_muc_info,
extract_stanza_id,
extract_thread_id,
is_delayed_message,
)
from yuxi.channel.extensions.xmpp.types import InboundXmppMessage, ResolvedXmppAccount
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger("yuxi.channel.xmpp.gateway")
class XmppGateway:
def __init__(self, account: ResolvedXmppAccount):
self._account = account
self._bot = None
self._connect_event = asyncio.Event()
self._dedupe = DedupeTracker(max_size=2000, ttl_seconds=300)
self.on_message = None
@property
def is_connected(self) -> bool:
return self._bot is not None and self._bot.session_started
@property
def dedupe(self) -> DedupeTracker:
return self._dedupe
@property
def client(self):
return self._bot
async def connect(self) -> None:
from slixmpp import ClientXMPP
class ForcePilotXmppBot(ClientXMPP):
def __init__(self, jid, password, resource="ForcePilot"):
super().__init__(jid, password)
self.resource = resource
self.session_started = False
self.gateway = None
self.account = None
self.register_plugin("xep_0004")
self.register_plugin("xep_0030")
self.register_plugin("xep_0045")
self.register_plugin("xep_0048")
self.register_plugin("xep_0050")
self.register_plugin("xep_0085")
self.register_plugin("xep_0115")
self.register_plugin("xep_0184")
self.register_plugin("xep_0198")
self.register_plugin("xep_0199")
self.register_plugin("xep_0203")
self.register_plugin("xep_0313")
self.register_plugin("xep_0333")
self.register_plugin("xep_0359")
self.register_plugin("xep_0363")
self.register_plugin("xep_0394")
self.auto_reconnect = True
self._bot = ForcePilotXmppBot(
self._account.jid,
self._account.password,
self._account.resource,
)
self._bot.gateway = self
self._bot.account = self._account
self._bot.add_event_handler("session_start", self._on_session_start)
self._bot.add_event_handler("message", self._on_message)
self._bot.add_event_handler("presence_subscribe", self._on_presence_subscribe)
self._bot.add_event_handler("presence_unsubscribe", self._on_presence_unsubscribe)
if self._account.host:
self._bot.connect(
address=(self._account.host, self._account.port),
use_ssl=self._account.use_ssl,
)
else:
self._bot.connect()
asyncio.create_task(self._bot.process(forever=False))
await asyncio.wait_for(self._connect_event.wait(), timeout=30.0)
logger.info("XMPP connected: %s", self._account.bare_jid)
async def disconnect(self) -> None:
if self._bot is not None:
self._bot.disconnect()
self._bot = None
self._connect_event.clear()
logger.info("XMPP disconnected")
async def _on_session_start(self, event):
self._bot.send_presence(pshow="chat", pstatus="ForcePilot Bot")
await self._bot.get_roster()
for room in self._account.rooms:
nicks = [self._account.nick, f"{self._account.nick}-{uuid.uuid4().hex[:6]}"]
room_password = self._account.room_passwords.get(room)
try:
room_jid, nick = await join_muc_with_fallback(self._bot, room, nicks, password=room_password)
logger.info("XMPP joined MUC: %s as %s", room_jid, nick)
except Exception as e:
logger.error("XMPP failed to join MUC %s: %s", room, e)
self._bot.session_started = True
self._connect_event.set()
from yuxi.channel.extensions.xmpp.commands import register_xmpp_commands
register_xmpp_commands(self._bot, self._account)
logger.info("XMPP session started: %s", self._bot.boundjid.full)
async def _on_message(self, msg):
if msg["type"] == "error":
await self._handle_error(msg)
return
try:
inbound = _parse_xmpp_stanza(msg, self._bot, self._account)
except Exception as e:
logger.warning("XMPP stanza parse error: %s", e)
return
if inbound is None:
return
if inbound.is_delay:
logger.debug("XMPP delayed message skipped: %s", inbound.stanza_id)
return
dedupe_key = inbound.stanza_id or inbound.msg_id
if self._dedupe.has(dedupe_key):
return
self._dedupe.add(dedupe_key)
if inbound.is_own_message:
return
if inbound.msg_type not in ("chat", "groupchat", "normal"):
return
if not inbound.body or not inbound.body.strip():
return
unified = xmpp_stanza_to_unified(inbound, self._account)
if unified and self.on_message:
await self.on_message(unified)
async def _handle_error(self, msg) -> None:
condition = str(msg["error"]["condition"]) if msg["error"] else ""
text = str(msg["error"]["text"]) if msg["error"] else ""
kind, message, _ = classify_xmpp_error(condition, text)
logger.warning("XMPP error stanza [%s] from %s: %s", kind, msg["from"], message)
unified = UnifiedMessage(
msg_id=f"error-{msg['id']}",
channel_type="xmpp",
account_id=self._account.account_id,
content=f"[XMPP Error: {kind}] {message}",
sender=PeerInfo(kind=PeerKind.DIRECT, id=str(msg["from"])),
message_type=MessageType.EVENT,
timestamp=datetime.now(UTC),
metadata={
"event_kind": "error",
"error_kind": str(kind),
"error_condition": condition,
"error_text": text,
"from_jid": str(msg["from"]),
},
raw_payload={"condition": condition, "text": text, "from": str(msg["from"])},
)
if self.on_message:
await self.on_message(unified)
async def _on_presence_subscribe(self, presence) -> None:
from_jid = str(presence["from"])
logger.info("XMPP presence subscribe from %s — auto-accepting", from_jid)
self._bot.send_presence(pto=from_jid, ptype="subscribed")
self._bot.send_presence(pto=from_jid, ptype="subscribe")
async def _on_presence_unsubscribe(self, presence) -> None:
from_jid = str(presence["from"])
logger.info("XMPP presence unsubscribe from %s — auto-acknowledging", from_jid)
self._bot.send_presence(pto=from_jid, ptype="unsubscribed")
async def fetch_mam_messages(
self,
target_jid: str | None = None,
*,
limit: int = 20,
before_id: str | None = None,
after_id: str | None = None,
) -> list[dict]:
if self._bot is None or "xep_0313" not in self._bot.plugin:
return []
mam = self._bot.plugin["xep_0313"]
try:
results = await mam.retrieve(
jid=target_jid,
reverse=True,
limit=min(limit, 100),
before_id=before_id,
after_id=after_id,
)
return results or []
except Exception as e:
logger.warning("XMPP MAM query failed for %s: %s", target_jid or "self", e)
return []
async def get_bookmarked_rooms(self) -> list[dict]:
if self._bot is None or "xep_0048" not in self._bot.plugin:
return []
bookmarks = self._bot.plugin["xep_0048"]
result = []
for c in bookmarks.get_bookmarks() or []:
if c.get("type") == "conference":
result.append(
{
"jid": c["jid"],
"name": c.get("name", ""),
"nick": c.get("nick", ""),
"autojoin": c.get("autojoin", False),
}
)
return result
async def add_bookmark(self, room_jid: str, *, nick: str | None = None, autojoin: bool = True) -> None:
if self._bot is None or "xep_0048" not in self._bot.plugin:
raise RuntimeError("XEP-0048 Bookmarks not available")
bookmarks = self._bot.plugin["xep_0048"]
conference = {"jid": room_jid, "autojoin": autojoin}
if nick:
conference["nick"] = nick
bookmarks.add_bookmark(conference)
async def remove_bookmark(self, room_jid: str) -> None:
if self._bot is None or "xep_0048" not in self._bot.plugin:
raise RuntimeError("XEP-0048 Bookmarks not available")
bookmarks = self._bot.plugin["xep_0048"]
bookmarks.remove_bookmark(room_jid)
async def set_room_subject(self, room_jid: str, subject: str) -> None:
if self._bot is None or "xep_0045" not in self._bot.plugin:
raise RuntimeError("XEP-0045 MUC not available")
muc = self._bot.plugin["xep_0045"]
muc.set_subject(room_jid, subject)
logger.info("XMPP set MUC subject for %s", room_jid)
async def get_room_config(self, room_jid: str) -> dict:
if self._bot is None or "xep_0045" not in self._bot.plugin:
raise RuntimeError("XEP-0045 MUC not available")
muc = self._bot.plugin["xep_0045"]
config = await muc.get_room_config(room_jid)
return config
async def set_room_config(self, room_jid: str, config: dict) -> None:
if self._bot is None or "xep_0045" not in self._bot.plugin:
raise RuntimeError("XEP-0045 MUC not available")
muc = self._bot.plugin["xep_0045"]
await muc.set_room_config(room_jid, config)
logger.info("XMPP set MUC config for %s", room_jid)
async def create_room(
self,
room_jid: str,
*,
nick: str | None = None,
config: dict | None = None,
) -> None:
if self._bot is None or "xep_0045" not in self._bot.plugin:
raise RuntimeError("XEP-0045 MUC not available")
muc = self._bot.plugin["xep_0045"]
nickname = nick or self._account.nick
await muc.join_muc_wait(room_jid, nickname, maxstanzas=0)
if config:
await muc.set_room_config(room_jid, config)
logger.info("XMPP MUC room created: %s", room_jid)
def _parse_xmpp_stanza(msg, bot, account: ResolvedXmppAccount) -> InboundXmppMessage | None:
stanza_id = extract_stanza_id(msg)
msg_id = msg["id"]
from_jid = str(msg["from"])
from_bare = bare_jid_from(msg)
body = msg["body"]
msg_type = msg["type"]
thread_id = extract_thread_id(msg)
room_jid, muc_nick = extract_muc_info(msg, msg_type)
is_delay = is_delayed_message(msg)
mentions_bot = False
if msg_type == "groupchat" and account.nick:
mentions_bot = check_bot_mention(body, account.nick)
is_own_message = from_bare == bot.boundjid.bare if bot.boundjid else False
return InboundXmppMessage(
stanza_id=stanza_id,
msg_id=msg_id,
from_jid=from_jid,
from_bare=from_bare,
body=body,
msg_type=msg_type,
thread_id=thread_id,
room_jid=room_jid,
muc_nick=muc_nick,
is_delay=is_delay,
mentions_bot=mentions_bot,
is_own_message=is_own_message,
timestamp=datetime.now(UTC),
raw={"from": from_jid, "type": msg_type, "id": msg_id},
)