ForcePilot/backend/package/yuxi/channel/extensions/wechat-mp/outbound.py
Kris 87a8931db3 feat(channel): 添加微信客服、微信公众号和微信支付通知渠道扩展
新增微信客服、微信公众号、微信支付通知三个渠道扩展。

微信客服渠道扩展功能模块:
- 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: 回调补偿
2026-05-21 12:00:30 +08:00

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