本次提交包含多项改进: 1. 修复钉钉、WhatsApp、Telegram等适配器的线程动作映射名称 2. 为SynologyChat、iMessage、Urbit等多款适配器新增配置Schema 3. 优化日志输出格式,合并多行日志调用为单行 4. 修复指数退避计算中的空格问题 5. 为QQBot凭证备份模块添加弃用警告 6. 新增多款适配器的凭证持久化存储逻辑 7. 优化Matrix、Nostr、DingDing等适配器的状态存储实现 8. 完善Discord、Slack、Signal等适配器的动作注册逻辑 9. 优化WhatsApp桥接器的QR码获取逻辑 10. 修复IRC适配器的配置比对与重连逻辑
311 lines
9.0 KiB
Python
311 lines
9.0 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from yuxi.channels.adapters.wechat.errors import is_token_expired, parse_mp_error
|
|
from yuxi.channels.adapters.wechat.format import truncate_text
|
|
from yuxi.channels.adapters.wechat.retry import retry_with_backoff
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
from .client import MPClient
|
|
|
|
XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>'
|
|
|
|
|
|
async def send_mp_custom_message(
|
|
client: MPClient,
|
|
http_client: httpx.AsyncClient,
|
|
to_user: str,
|
|
content: str,
|
|
reply_to_msg_id: str | None = None,
|
|
reply_to_user: str | None = None,
|
|
) -> DeliveryResult:
|
|
token = await client.get_access_token()
|
|
|
|
display_content = content
|
|
if reply_to_msg_id and reply_to_user:
|
|
quoted_prefix = f"「回复 @{reply_to_user}」\n"
|
|
if len(quoted_prefix + content) <= 2048:
|
|
display_content = quoted_prefix + content
|
|
|
|
payload = {
|
|
"touser": to_user,
|
|
"msgtype": "text",
|
|
"text": {"content": truncate_text(display_content, 2048)},
|
|
}
|
|
if reply_to_msg_id:
|
|
payload["_reply_to_msg_id"] = reply_to_msg_id
|
|
|
|
try:
|
|
result = await _post_with_token_retry(client, http_client, token, payload)
|
|
return result
|
|
except httpx.HTTPError as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
|
|
async def send_mp_template_message(
|
|
client: MPClient,
|
|
http_client: httpx.AsyncClient,
|
|
to_user: str,
|
|
template_id: str,
|
|
data: dict[str, Any],
|
|
url: str | None = None,
|
|
) -> DeliveryResult:
|
|
token = await client.get_access_token()
|
|
|
|
payload: dict[str, Any] = {
|
|
"touser": to_user,
|
|
"template_id": template_id,
|
|
"data": data,
|
|
}
|
|
if url:
|
|
payload["url"] = url
|
|
|
|
try:
|
|
result = await _post_with_token_retry(client, http_client, token, payload)
|
|
return result
|
|
except httpx.HTTPError as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
|
|
_MP_CUSTOM_SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/custom/send"
|
|
_MP_TEMPLATE_SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/template/send"
|
|
|
|
|
|
async def _post_with_token_retry(
|
|
client: MPClient,
|
|
http_client: httpx.AsyncClient,
|
|
token: str,
|
|
payload: dict[str, Any],
|
|
) -> DeliveryResult:
|
|
api_url = f"{_MP_CUSTOM_SEND_URL}?access_token={token}"
|
|
if "template_id" in payload:
|
|
api_url = f"{_MP_TEMPLATE_SEND_URL}?access_token={token}"
|
|
|
|
async def _post():
|
|
return await http_client.post(api_url, json=payload)
|
|
|
|
resp = await retry_with_backoff(_post)
|
|
data = resp.json()
|
|
|
|
if data.get("errcode") == 0:
|
|
return DeliveryResult(success=True, message_id=str(data.get("msgid", "")))
|
|
|
|
if is_token_expired(data.get("errcode", 0)):
|
|
client.invalidate_token()
|
|
token = await client.get_access_token()
|
|
api_url = f"{_MP_CUSTOM_SEND_URL}?access_token={token}"
|
|
if "template_id" in payload:
|
|
api_url = f"{_MP_TEMPLATE_SEND_URL}?access_token={token}"
|
|
|
|
async def _retry_post():
|
|
return await http_client.post(api_url, json=payload)
|
|
|
|
resp = await retry_with_backoff(_retry_post)
|
|
data = resp.json()
|
|
if data.get("errcode") == 0:
|
|
return DeliveryResult(success=True, message_id=str(data.get("msgid", "")))
|
|
|
|
_, err_detail = parse_mp_error(data)
|
|
return DeliveryResult(success=False, error=err_detail)
|
|
|
|
|
|
async def send_mp_image(
|
|
client: MPClient,
|
|
http_client: httpx.AsyncClient,
|
|
to_user: str,
|
|
image_data: bytes,
|
|
filename: str = "image.png",
|
|
) -> DeliveryResult:
|
|
try:
|
|
media_id = await client.upload_temp_media(image_data, filename, "image")
|
|
except Exception as e:
|
|
return DeliveryResult(success=False, error=f"MP image upload failed: {e}")
|
|
|
|
token = await client.get_access_token()
|
|
payload = {
|
|
"touser": to_user,
|
|
"msgtype": "image",
|
|
"image": {"media_id": media_id},
|
|
}
|
|
try:
|
|
return await _post_with_token_retry(client, http_client, token, payload)
|
|
except httpx.HTTPError as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
|
|
async def send_mp_voice(
|
|
client: MPClient,
|
|
http_client: httpx.AsyncClient,
|
|
to_user: str,
|
|
voice_data: bytes,
|
|
) -> DeliveryResult:
|
|
try:
|
|
media_id = await client.upload_temp_media(voice_data, "voice.amr", "voice")
|
|
except Exception as e:
|
|
return DeliveryResult(success=False, error=f"MP voice upload failed: {e}")
|
|
|
|
token = await client.get_access_token()
|
|
payload = {
|
|
"touser": to_user,
|
|
"msgtype": "voice",
|
|
"voice": {"media_id": media_id},
|
|
}
|
|
try:
|
|
return await _post_with_token_retry(client, http_client, token, payload)
|
|
except httpx.HTTPError as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
|
|
async def send_mp_video(
|
|
client: MPClient,
|
|
http_client: httpx.AsyncClient,
|
|
to_user: str,
|
|
video_data: bytes,
|
|
title: str = "",
|
|
description: str = "",
|
|
) -> DeliveryResult:
|
|
try:
|
|
media_id = await client.upload_temp_media(video_data, "video.mp4", "video")
|
|
except Exception as e:
|
|
return DeliveryResult(success=False, error=f"MP video upload failed: {e}")
|
|
|
|
token = await client.get_access_token()
|
|
payload = {
|
|
"touser": to_user,
|
|
"msgtype": "video",
|
|
"video": {"media_id": media_id, "title": title, "description": description},
|
|
}
|
|
try:
|
|
return await _post_with_token_retry(client, http_client, token, payload)
|
|
except httpx.HTTPError as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
|
|
def build_mp_news_payload(
|
|
to_user: str,
|
|
articles: list[dict[str, Any]],
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"touser": to_user,
|
|
"msgtype": "news",
|
|
"news": {"articles": articles},
|
|
}
|
|
|
|
|
|
async def send_mp_news(
|
|
client: MPClient,
|
|
http_client: httpx.AsyncClient,
|
|
payload: dict[str, Any],
|
|
) -> DeliveryResult:
|
|
token = await client.get_access_token()
|
|
try:
|
|
return await _post_with_token_retry(client, http_client, token, payload)
|
|
except httpx.HTTPError as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
|
|
def _escape_cdata(text: str) -> str:
|
|
return f"<![CDATA[{text.replace(']]>', ']]]]><![CDATA[>')}]]>"
|
|
|
|
|
|
def build_text_passive_reply(
|
|
to_user: str,
|
|
from_user: str,
|
|
content: str,
|
|
) -> str:
|
|
timestamp = int(time.time())
|
|
return (
|
|
f"{XML_HEADER}\n"
|
|
f"<xml>\n"
|
|
f" <ToUserName>{_escape_cdata(to_user)}</ToUserName>\n"
|
|
f" <FromUserName>{_escape_cdata(from_user)}</FromUserName>\n"
|
|
f" <CreateTime>{timestamp}</CreateTime>\n"
|
|
f" <MsgType>{_escape_cdata('text')}</MsgType>\n"
|
|
f" <Content>{_escape_cdata(content[:2048])}</Content>\n"
|
|
f"</xml>"
|
|
)
|
|
|
|
|
|
def build_image_passive_reply(
|
|
to_user: str,
|
|
from_user: str,
|
|
media_id: str,
|
|
) -> str:
|
|
timestamp = int(time.time())
|
|
return (
|
|
f"{XML_HEADER}\n"
|
|
f"<xml>\n"
|
|
f" <ToUserName>{_escape_cdata(to_user)}</ToUserName>\n"
|
|
f" <FromUserName>{_escape_cdata(from_user)}</FromUserName>\n"
|
|
f" <CreateTime>{timestamp}</CreateTime>\n"
|
|
f" <MsgType>{_escape_cdata('image')}</MsgType>\n"
|
|
f" <Image><MediaId>{_escape_cdata(media_id)}</MediaId></Image>\n"
|
|
f"</xml>"
|
|
)
|
|
|
|
|
|
def build_voice_passive_reply(
|
|
to_user: str,
|
|
from_user: str,
|
|
media_id: str,
|
|
) -> str:
|
|
timestamp = int(time.time())
|
|
return (
|
|
f"{XML_HEADER}\n"
|
|
f"<xml>\n"
|
|
f" <ToUserName>{_escape_cdata(to_user)}</ToUserName>\n"
|
|
f" <FromUserName>{_escape_cdata(from_user)}</FromUserName>\n"
|
|
f" <CreateTime>{timestamp}</CreateTime>\n"
|
|
f" <MsgType>{_escape_cdata('voice')}</MsgType>\n"
|
|
f" <Voice><MediaId>{_escape_cdata(media_id)}</MediaId></Voice>\n"
|
|
f"</xml>"
|
|
)
|
|
|
|
|
|
def build_video_passive_reply(
|
|
to_user: str,
|
|
from_user: str,
|
|
media_id: str,
|
|
title: str = "",
|
|
description: str = "",
|
|
) -> str:
|
|
timestamp = int(time.time())
|
|
return (
|
|
f"{XML_HEADER}\n"
|
|
f"<xml>\n"
|
|
f" <ToUserName>{_escape_cdata(to_user)}</ToUserName>\n"
|
|
f" <FromUserName>{_escape_cdata(from_user)}</FromUserName>\n"
|
|
f" <CreateTime>{timestamp}</CreateTime>\n"
|
|
f" <MsgType>{_escape_cdata('video')}</MsgType>\n"
|
|
f" <Video>\n"
|
|
f" <MediaId>{_escape_cdata(media_id)}</MediaId>\n"
|
|
f" <Title>{_escape_cdata(title)}</Title>\n"
|
|
f" <Description>{_escape_cdata(description)}</Description>\n"
|
|
f" </Video>\n"
|
|
f"</xml>"
|
|
)
|
|
|
|
|
|
def build_transfer_customer_service_reply(
|
|
to_user: str,
|
|
from_user: str,
|
|
) -> str:
|
|
timestamp = int(time.time())
|
|
return (
|
|
f"{XML_HEADER}\n"
|
|
f"<xml>\n"
|
|
f" <ToUserName>{_escape_cdata(to_user)}</ToUserName>\n"
|
|
f" <FromUserName>{_escape_cdata(from_user)}</FromUserName>\n"
|
|
f" <CreateTime>{timestamp}</CreateTime>\n"
|
|
f" <MsgType>{_escape_cdata('transfer_customer_service')}</MsgType>\n"
|
|
f"</xml>"
|
|
)
|
|
|
|
|
|
def build_empty_passive_reply() -> str:
|
|
return ""
|