import logging import mimetypes import re import uuid from pathlib import Path import httpx from yuxi.channel.extensions.xmpp.rate_limiter import xmpp_rate_limiter from yuxi.channel.extensions.xmpp.types import SendXmppResult logger = logging.getLogger("yuxi.channel.xmpp.outbound") _MARKDOWN_PATTERNS = [ (re.compile(r"\*\*(.+?)\*\*"), "strong"), (re.compile(r"(? list[dict]: spans = [] for pattern, span_type in _MARKDOWN_PATTERNS: new_text = "" pos = 0 for match in pattern.finditer(text): new_text += text[pos : match.start()] start = len(new_text) span_content = match.group(1) new_text += span_content end = len(new_text) spans.append({"start": start, "end": end, "type": span_type}) if span_type == "pre" and span_content.startswith("\n"): spans[-1]["start"] += 1 spans[-1]["end"] -= 1 if span_content.endswith("\n") else 0 pos = match.end() new_text += text[pos:] text = new_text return spans async def send_xmpp_text( gateway, target_id: str, content: str, reply_to_id: str | None = None, thread_id: str | None = None, ) -> SendXmppResult: bot = gateway.client if bot is None or not gateway.is_connected: return SendXmppResult(ok=False, error="XMPP client not connected") try: is_group = "@conference." in target_id mtype = "groupchat" if is_group else "chat" msg = bot.make_message(mto=target_id, mbody=content, mtype=mtype) if reply_to_id: msg["reply"]["id"] = reply_to_id if thread_id: msg["thread"] = thread_id msg["request_receipt"] = True if "xep_0394" in bot.plugin: spans = _build_markup_spans(content) if spans: try: markup = bot.plugin["xep_0394"].make_markup(msg, spans) msg["markup"] = markup except Exception: pass await xmpp_rate_limiter.acquire() msg.send() return SendXmppResult(ok=True, message_id=str(uuid.uuid4())) except Exception as e: logger.error("XMPP send_text to %s failed: %s", target_id, e) return SendXmppResult(ok=False, error=str(e)) async def send_xmpp_typing(gateway, target_id: str, composing: bool = True) -> None: bot = gateway.client if bot is None or not gateway.is_connected: return try: if not hasattr(bot, "plugin") or "xep_0085" not in bot.plugin: return if composing: bot.plugin["xep_0085"].chat_state_composing(target_id) else: bot.plugin["xep_0085"].chat_state_active(target_id) except Exception as e: logger.debug("XMPP typing indicator failed for %s: %s", target_id, e) async def send_xmpp_file( gateway, target_id: str, file_path: str, *, content_type: str | None = None, reply_to_id: str | None = None, thread_id: str | None = None, ) -> SendXmppResult: bot = gateway.client if bot is None or not gateway.is_connected: return SendXmppResult(ok=False, error="XMPP client not connected") if "xep_0363" not in bot.plugin: return SendXmppResult(ok=False, error="XEP-0363 HTTP Upload not available") path = Path(file_path) if not path.exists(): return SendXmppResult(ok=False, error=f"File not found: {file_path}") try: if content_type is None: content_type, _ = mimetypes.guess_type(file_path) content_type = content_type or "application/octet-stream" upload_plugin = bot.plugin["xep_0363"] size = path.stat().st_size data = path.read_bytes() slot = await upload_plugin.request_slot( target_id, filename=path.name, size=size, content_type=content_type, ) async with httpx.AsyncClient() as client: put_url = slot["put"] resp = await client.put( put_url, content=data, headers={"Content-Type": content_type}, ) resp.raise_for_status() get_url = slot["get"] is_group = "@conference." in target_id mtype = "groupchat" if is_group else "chat" msg = bot.make_message(mto=target_id, mbody=get_url, mtype=mtype) if reply_to_id: msg["reply"]["id"] = reply_to_id if thread_id: msg["thread"] = thread_id oob = msg["oob"] if oob is not None: oob["url"] = get_url await xmpp_rate_limiter.acquire() msg.send() return SendXmppResult(ok=True, message_id=str(uuid.uuid4())) except Exception as e: logger.error("XMPP file upload to %s failed: %s", target_id, e) return SendXmppResult(ok=False, error=str(e))