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

167 lines
5.0 KiB
Python

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"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"), "emphasis"),
(re.compile(r"`([^`]+)`"), "code"),
(re.compile(r"```(.+?)```", re.DOTALL), "pre"),
]
def _build_markup_spans(text: str) -> 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))