新增微信客服、微信公众号、微信支付通知三个渠道扩展。 微信客服渠道扩展功能模块: - account: 账户管理 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - crypto: 加解密处理 - dedupe: 消息去重 - customer: 客户管理 - servicer: 客服管理 - session: 会话管理 - status: 会话状态管理 - media: 媒体资源处理 - statistics: 统计功能 - sync: 数据同步 - upgrade: 升级处理 微信公众号渠道扩展功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - crypto: 加解密处理 - dedupe: 消息去重 - passive_reply: 被动回复 - message: 消息处理 - broadcast: 群发消息 - template: 模板消息 - menu: 菜单管理 - qrcode: 二维码管理 - user: 用户管理 - media: 媒体资源处理 - status: 会话状态管理 微信支付通知渠道扩展功能模块: - config: 渠道配置管理 - webhook: Webhook 事件处理 - crypto: 加解密与签名校验 - cert_manager: 证书管理 - event_router: 事件路由 - dedupe: 消息去重 - pay_repo: 支付数据仓库 - query_client: 查询客户端 - arq_tasks: 异步任务 - callback_compensator: 回调补偿
322 lines
10 KiB
Python
322 lines
10 KiB
Python
import asyncio
|
|
import logging
|
|
import time
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.wechat_mp.format import (
|
|
MAX_UTF8_LEN,
|
|
remove_markdown,
|
|
split_utf8,
|
|
split_utf8_safe,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CUSTOM_SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/custom/send"
|
|
|
|
|
|
class WeChatMPOutbound:
|
|
|
|
def __init__(self, gateway=None):
|
|
self._gateway = gateway
|
|
self._http: httpx.AsyncClient | None = None
|
|
|
|
async def send_text(
|
|
self,
|
|
target_id: str,
|
|
content: str,
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> None:
|
|
if not content:
|
|
return
|
|
|
|
texts = split_utf8(content)
|
|
for i, text in enumerate(texts):
|
|
payload = {
|
|
"touser": target_id,
|
|
"msgtype": "text",
|
|
"text": {"content": text},
|
|
}
|
|
await self._send_custom(payload)
|
|
if i < len(texts) - 1:
|
|
await asyncio.sleep(0.5)
|
|
|
|
async def send_media(
|
|
self,
|
|
target_id: str,
|
|
media_url: str,
|
|
media_type: str,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
extra: dict | None = None,
|
|
) -> None:
|
|
payload = {"touser": target_id, "msgtype": media_type}
|
|
field = (extra or {}).copy()
|
|
if media_type in ("image", "voice"):
|
|
field.setdefault("media_id", media_url)
|
|
elif media_type == "video":
|
|
field.setdefault("media_id", media_url)
|
|
field.setdefault("thumb_media_id", field.get("thumb_media_id", ""))
|
|
field.setdefault("title", field.get("title", ""))
|
|
field.setdefault("description", field.get("description", ""))
|
|
elif media_type == "music":
|
|
field.setdefault("thumb_media_id", media_url)
|
|
else:
|
|
field.setdefault("media_id", media_url)
|
|
payload[media_type] = field
|
|
await self._send_custom(payload)
|
|
|
|
async def send_image(self, to_user: str, media_id: str) -> bool:
|
|
return await self._send_media(to_user, "image", media_id)
|
|
|
|
async def send_voice(self, to_user: str, media_id: str) -> bool:
|
|
return await self._send_media(to_user, "voice", media_id)
|
|
|
|
async def send_video(
|
|
self,
|
|
to_user: str,
|
|
media_id: str,
|
|
thumb_media_id: str = "",
|
|
title: str = "",
|
|
description: str = "",
|
|
) -> bool:
|
|
return await self._send_media(
|
|
to_user,
|
|
"video",
|
|
media_id,
|
|
extra={
|
|
"thumb_media_id": thumb_media_id,
|
|
"title": title,
|
|
"description": description,
|
|
},
|
|
)
|
|
|
|
async def send_music(
|
|
self,
|
|
to_user: str,
|
|
title: str,
|
|
description: str,
|
|
music_url: str,
|
|
hq_music_url: str,
|
|
thumb_media_id: str,
|
|
) -> bool:
|
|
payload = {
|
|
"touser": to_user,
|
|
"msgtype": "music",
|
|
"music": {
|
|
"title": title,
|
|
"description": description,
|
|
"musicurl": music_url,
|
|
"hqmusicurl": hq_music_url,
|
|
"thumb_media_id": thumb_media_id,
|
|
},
|
|
}
|
|
return await self._send_custom(payload)
|
|
|
|
async def send_news(self, to_user: str, articles: list[dict]) -> bool:
|
|
payload = {
|
|
"touser": to_user,
|
|
"msgtype": "news",
|
|
"news": {"articles": articles[:8]},
|
|
}
|
|
return await self._send_custom(payload)
|
|
|
|
async def send_menu(self, to_user: str, buttons: list[dict]) -> bool:
|
|
payload = {
|
|
"touser": to_user,
|
|
"msgtype": "msgmenu",
|
|
"msgmenu": {"head_content": "", "list": buttons[:10], "tail_content": ""},
|
|
}
|
|
return await self._send_custom(payload)
|
|
|
|
async def send_miniprogram_page(
|
|
self,
|
|
to_user: str,
|
|
title: str,
|
|
appid: str,
|
|
pagepath: str,
|
|
thumb_media_id: str,
|
|
) -> bool:
|
|
payload = {
|
|
"touser": to_user,
|
|
"msgtype": "miniprogrampage",
|
|
"miniprogrampage": {
|
|
"title": title,
|
|
"appid": appid,
|
|
"pagepath": pagepath,
|
|
"thumb_media_id": thumb_media_id,
|
|
},
|
|
}
|
|
return await self._send_custom(payload)
|
|
|
|
async def _send_media(self, to_user: str, media_type: str, media_id: str, extra: dict | None = None) -> bool:
|
|
payload = {
|
|
"touser": to_user,
|
|
"msgtype": media_type,
|
|
media_type: {"media_id": media_id},
|
|
}
|
|
if extra:
|
|
payload[media_type].update(extra)
|
|
return await self._send_custom(payload)
|
|
|
|
async def _send_custom(self, payload: dict) -> bool:
|
|
token = self._gateway.access_token if self._gateway else None
|
|
if not token:
|
|
logger.error("No valid access_token for custom send")
|
|
return False
|
|
|
|
url = f"{CUSTOM_SEND_URL}?access_token={token}"
|
|
|
|
if self._http is None:
|
|
self._http = httpx.AsyncClient(timeout=15.0)
|
|
|
|
for attempt in range(3):
|
|
try:
|
|
resp = await self._http.post(url, json=payload)
|
|
data = resp.json()
|
|
errcode = data.get("errcode", -1)
|
|
|
|
if errcode == 0:
|
|
return True
|
|
if errcode == 45015:
|
|
logger.warning("48h window expired for %s", payload.get("touser"))
|
|
return False
|
|
if errcode == 40001:
|
|
logger.warning("Token expired, will retry")
|
|
else:
|
|
logger.warning("Custom send failed: errcode=%s errmsg=%s", errcode, data.get("errmsg"))
|
|
|
|
except Exception as e:
|
|
logger.warning("Custom send attempt %s failed: %s", attempt + 1, e)
|
|
|
|
await asyncio.sleep(attempt + 1)
|
|
|
|
return False
|
|
|
|
async def send_stream(self, target_id: str, text_generator, chunk_size: int = 200):
|
|
buffer = ""
|
|
async for chunk in text_generator:
|
|
buffer += chunk
|
|
if len(buffer) >= chunk_size:
|
|
await self.send_text(target_id, buffer)
|
|
buffer = ""
|
|
await asyncio.sleep(0.5)
|
|
if buffer:
|
|
await self.send_text(target_id, buffer)
|
|
|
|
@staticmethod
|
|
def build_passive_text_reply(to_user: str, from_user: str, content: str) -> str:
|
|
return (
|
|
'<xml>'
|
|
f'<ToUserName><![CDATA[{to_user}]]></ToUserName>'
|
|
f'<FromUserName><![CDATA[{from_user}]]></FromUserName>'
|
|
f'<CreateTime>{int(time.time())}</CreateTime>'
|
|
'<MsgType><![CDATA[text]]></MsgType>'
|
|
f'<Content><![CDATA[{content}]]></Content>'
|
|
'</xml>'
|
|
)
|
|
|
|
@staticmethod
|
|
def build_passive_image_reply(to_user: str, from_user: str, media_id: str) -> str:
|
|
return (
|
|
'<xml>'
|
|
f'<ToUserName><![CDATA[{to_user}]]></ToUserName>'
|
|
f'<FromUserName><![CDATA[{from_user}]]></FromUserName>'
|
|
f'<CreateTime>{int(time.time())}</CreateTime>'
|
|
'<MsgType><![CDATA[image]]></MsgType>'
|
|
'<Image>'
|
|
f'<MediaId><![CDATA[{media_id}]]></MediaId>'
|
|
'</Image>'
|
|
'</xml>'
|
|
)
|
|
|
|
@staticmethod
|
|
def build_passive_voice_reply(to_user: str, from_user: str, media_id: str) -> str:
|
|
return (
|
|
'<xml>'
|
|
f'<ToUserName><![CDATA[{to_user}]]></ToUserName>'
|
|
f'<FromUserName><![CDATA[{from_user}]]></FromUserName>'
|
|
f'<CreateTime>{int(time.time())}</CreateTime>'
|
|
'<MsgType><![CDATA[voice]]></MsgType>'
|
|
'<Voice>'
|
|
f'<MediaId><![CDATA[{media_id}]]></MediaId>'
|
|
'</Voice>'
|
|
'</xml>'
|
|
)
|
|
|
|
@staticmethod
|
|
def build_passive_video_reply(
|
|
to_user: str, from_user: str, media_id: str, title: str = "", description: str = ""
|
|
) -> str:
|
|
return (
|
|
'<xml>'
|
|
f'<ToUserName><![CDATA[{to_user}]]></ToUserName>'
|
|
f'<FromUserName><![CDATA[{from_user}]]></FromUserName>'
|
|
f'<CreateTime>{int(time.time())}</CreateTime>'
|
|
'<MsgType><![CDATA[video]]></MsgType>'
|
|
'<Video>'
|
|
f'<MediaId><![CDATA[{media_id}]]></MediaId>'
|
|
f'<Title><![CDATA[{title}]]></Title>'
|
|
f'<Description><![CDATA[{description}]]></Description>'
|
|
'</Video>'
|
|
'</xml>'
|
|
)
|
|
|
|
@staticmethod
|
|
def build_passive_music_reply(
|
|
to_user: str,
|
|
from_user: str,
|
|
title: str,
|
|
description: str,
|
|
music_url: str,
|
|
hq_music_url: str,
|
|
thumb_media_id: str,
|
|
) -> str:
|
|
return (
|
|
'<xml>'
|
|
f'<ToUserName><![CDATA[{to_user}]]></ToUserName>'
|
|
f'<FromUserName><![CDATA[{from_user}]]></FromUserName>'
|
|
f'<CreateTime>{int(time.time())}</CreateTime>'
|
|
'<MsgType><![CDATA[music]]></MsgType>'
|
|
'<Music>'
|
|
f'<Title><![CDATA[{title}]]></Title>'
|
|
f'<Description><![CDATA[{description}]]></Description>'
|
|
f'<MusicUrl><![CDATA[{music_url}]]></MusicUrl>'
|
|
f'<HQMusicUrl><![CDATA[{hq_music_url}]]></HQMusicUrl>'
|
|
f'<ThumbMediaId><![CDATA[{thumb_media_id}]]></ThumbMediaId>'
|
|
'</Music>'
|
|
'</xml>'
|
|
)
|
|
|
|
@staticmethod
|
|
def build_passive_news_reply(to_user: str, from_user: str, articles: list[dict]) -> str:
|
|
articles_xml = ""
|
|
for article in articles[:8]:
|
|
articles_xml += (
|
|
"<item>"
|
|
f'<Title><![CDATA[{article.get("title", "")}]]></Title>'
|
|
f'<Description><![CDATA[{article.get("description", "")}]]></Description>'
|
|
f'<PicUrl><![CDATA[{article.get("picurl", "")}]]></PicUrl>'
|
|
f'<Url><![CDATA[{article.get("url", "")}]]></Url>'
|
|
"</item>"
|
|
)
|
|
return (
|
|
'<xml>'
|
|
f'<ToUserName><![CDATA[{to_user}]]></ToUserName>'
|
|
f'<FromUserName><![CDATA[{from_user}]]></FromUserName>'
|
|
f'<CreateTime>{int(time.time())}</CreateTime>'
|
|
'<MsgType><![CDATA[news]]></MsgType>'
|
|
f'<ArticleCount>{len(articles[:8])}</ArticleCount>'
|
|
f'<Articles>{articles_xml}</Articles>'
|
|
'</xml>'
|
|
)
|
|
|
|
async def close(self):
|
|
if self._http:
|
|
await self._http.aclose()
|
|
self._http = None
|