新增小红书、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
332 lines
12 KiB
Python
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},
|
|
)
|