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}, )