新增小红书、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
167 lines
5.0 KiB
Python
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))
|