ForcePilot/backend/package/yuxi/channel/extensions/wechat-mp/webhook.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

299 lines
10 KiB
Python

import asyncio
import logging
import random
import string
import time
import xml.etree.ElementTree as ET
from datetime import datetime, UTC
from fastapi import APIRouter, Query, Request
from fastapi.responses import PlainTextResponse, Response
from yuxi.channel.extensions.wechat_mp.config import WeChatMPConfig
from yuxi.channel.extensions.wechat_mp.crypto import WeChatCrypto
from yuxi.channel.extensions.wechat_mp.message import extract_content, parse_xml_to_message
from yuxi.channel.extensions.wechat_mp.dedupe import MessageDeduplicator
from yuxi.channel.extensions.wechat_mp.format import remove_markdown
from yuxi.channel.extensions.wechat_mp.outbound import WeChatMPOutbound
from yuxi.channel.extensions.wechat_mp.pairing import WeChatPairing
from yuxi.channel.extensions.wechat_mp.security import WeChatMPSecurity
from yuxi.channel.extensions.wechat_mp.user import fetch_user_info
from yuxi.channel.runtime.manager import gateway
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/webhook/wechat-mp", tags=["wechat-mp"])
_config = WeChatMPConfig()
_deduplicator = MessageDeduplicator()
_pairing = WeChatPairing()
def _build_crypto() -> WeChatCrypto | None:
account = _config.resolve_account()
if not account.is_configured():
return None
return WeChatCrypto(
token=account.token,
encoding_aes_key=account.encoding_aes_key,
app_id=account.app_id,
)
def _wrap_encrypted_reply(encrypted: str, crypto: WeChatCrypto) -> str:
ts = str(int(time.time()))
nonce = "".join(random.choices(string.digits, k=10))
sig = crypto.compute_signature(ts, nonce, encrypted)
return (
"<xml>"
f"<Encrypt><![CDATA[{encrypted}]]></Encrypt>"
f"<MsgSignature><![CDATA[{sig}]]></MsgSignature>"
f"<TimeStamp>{ts}</TimeStamp>"
f"<Nonce><![CDATA[{nonce}]]></Nonce>"
"</xml>"
)
@router.get("/callback")
async def wechat_url_verify(
signature: str = Query(...),
timestamp: str = Query(...),
nonce: str = Query(...),
echostr: str = Query(...),
):
crypto = _build_crypto()
if crypto is None:
return PlainTextResponse("", status_code=503)
if not crypto.verify_signature(signature, timestamp, nonce):
logger.warning("WeChat MP URL verification failed: invalid signature")
return PlainTextResponse("", status_code=403)
return PlainTextResponse(echostr)
@router.post("/callback")
async def wechat_message_callback(
request: Request,
msg_signature: str = Query(default=""),
timestamp: str = Query(default=""),
nonce: str = Query(default=""),
):
crypto = _build_crypto()
if crypto is None:
return PlainTextResponse("", status_code=503)
xml_body = await request.body()
xml_text = xml_body.decode("utf-8")
root = ET.fromstring(xml_text)
encrypt_el = root.find("Encrypt")
encrypt = encrypt_el.text or "" if encrypt_el is not None else ""
if not crypto.verify_signature(msg_signature, timestamp, nonce, encrypt):
logger.warning("WeChat MP message callback: invalid msg_signature")
return PlainTextResponse("", status_code=403)
try:
decrypted_xml, app_id = crypto.decrypt(encrypt)
except Exception:
logger.exception("WeChat MP message decryption failed")
return PlainTextResponse("", status_code=403)
try:
raw_msg = parse_xml_to_message(decrypted_xml)
except Exception:
logger.exception("WeChat MP XML parsing failed")
return PlainTextResponse("", status_code=400)
if raw_msg.from_user == raw_msg.to_user:
return PlainTextResponse("success")
account = _config.resolve_account()
if raw_msg.msg_type == "event":
return await _handle_event(raw_msg, account)
if not raw_msg.msg_id or _deduplicator.is_duplicate(raw_msg.msg_id):
return PlainTextResponse("success")
content = extract_content(raw_msg)
if not content:
return PlainTextResponse("success")
if account.remove_markdown:
content = remove_markdown(content)
security = WeChatMPSecurity(account)
policy = security.resolve_dm_policy()
if policy == "disabled":
return PlainTextResponse("success")
if policy == "pairing":
if not security.check_allowlist(raw_msg.from_user):
if content.strip().startswith("配对 "):
code_input = content.strip()[3:].strip()
if _pairing.verify(raw_msg.from_user, code_input):
security.add_to_allowlist(raw_msg.from_user)
await _send_wechat_text(raw_msg.from_user, "配对成功!现在可以开始对话了。")
else:
await _send_wechat_text(raw_msg.from_user, "配对码无效或已过期,请重新发送消息获取配对码。")
else:
code = _pairing.generate_code(raw_msg.from_user)
if code:
await _send_wechat_text(
raw_msg.from_user,
f"首次对话需要验证身份,请输入以下配对码:\n\n配对 {code}\n\n(配对码有效期 10 分钟)",
)
else:
await _send_wechat_text(raw_msg.from_user, "配对请求过于频繁,请稍后再试。")
return PlainTextResponse("success")
if policy == "allowlist":
if not security.check_allowlist(raw_msg.from_user):
return PlainTextResponse("success")
if account.is_passive_mode():
return await _handle_passive_dispatch(raw_msg, crypto)
return await _handle_active_dispatch(raw_msg, content)
async def _handle_event(raw_msg, account) -> str:
if raw_msg.event in ("subscribe", "subscribe_scan"):
welcome = account.subscribe_msg or ""
if welcome:
await _send_wechat_text(raw_msg.from_user, welcome)
logger.info("Sent welcome message to %s", raw_msg.from_user)
return PlainTextResponse("success")
if raw_msg.event == "unsubscribe":
_pairing.cleanup(raw_msg.from_user)
logger.info("User %s unsubscribed", raw_msg.from_user)
return PlainTextResponse("success")
if raw_msg.event == "CLICK":
return await _handle_active_dispatch(raw_msg, raw_msg.event_key or "")
if raw_msg.event == "VIEW":
logger.info("User %s clicked VIEW menu: %s", raw_msg.from_user, raw_msg.event_key)
return PlainTextResponse("success")
if raw_msg.event == "SCAN":
return await _handle_active_dispatch(
raw_msg, f"[扫码事件] {raw_msg.event_key or ''}"
)
logger.debug("Unhandled event %s from %s", raw_msg.event, raw_msg.from_user)
return PlainTextResponse("success")
async def _handle_passive_dispatch(raw_msg, crypto: WeChatCrypto) -> Response:
from yuxi.channel.extensions.wechat_mp.passive_reply import handle_passive_callback
processor = gateway._processor
if processor is None:
logger.warning("Message processor not available for passive reply")
return PlainTextResponse("success")
xml_reply = await handle_passive_callback(
raw_msg=raw_msg,
decrypted_xml="",
processor=processor,
outbound=None,
subscribe_msg=_config.resolve_account().subscribe_msg,
)
if xml_reply == "success":
return PlainTextResponse("success")
encrypted = crypto.encrypt(xml_reply)
wrapped = _wrap_encrypted_reply(encrypted, crypto)
return Response(content=wrapped, media_type="application/xml")
async def _handle_active_dispatch(raw_msg, content: str) -> str:
processor = gateway._processor
if processor is None:
logger.warning("Message processor not available, cannot dispatch WeChat MP message")
return PlainTextResponse("", status_code=503)
msg_type_map = {
"text": MessageType.TEXT,
"image": MessageType.IMAGE,
"voice": MessageType.VOICE,
"video": MessageType.VIDEO,
"shortvideo": MessageType.VIDEO,
"location": MessageType.TEXT,
}
msg_type = msg_type_map.get(raw_msg.msg_type, MessageType.TEXT)
display_name = raw_msg.from_user
gw = gateway
if gw:
try:
user_info = await fetch_user_info(raw_msg.from_user, gw)
if user_info and user_info.get("nickname"):
display_name = user_info["nickname"]
except Exception:
logger.debug("Failed to fetch user info for %s", raw_msg.from_user)
unified = UnifiedMessage(
msg_id=raw_msg.msg_id,
channel_type="wechat-mp",
account_id="default",
content=content,
message_type=msg_type,
sender=PeerInfo(
id=raw_msg.from_user,
kind=PeerKind.DIRECT,
display_name=display_name,
),
timestamp=datetime.fromtimestamp(raw_msg.create_time, tz=UTC) if raw_msg.create_time else None,
raw_payload=raw_msg.raw_xml,
body_for_agent=content,
metadata={
"FromUserName": raw_msg.from_user,
"ToUserName": raw_msg.to_user,
"MsgType": raw_msg.msg_type,
"Event": raw_msg.event,
"EventKey": raw_msg.event_key,
"MediaId": raw_msg.media_id,
"PicUrl": raw_msg.pic_url,
},
)
asyncio.create_task(
_dispatch_to_agent(processor, unified),
name=f"wechat-mp-dispatch-{raw_msg.from_user}",
)
return PlainTextResponse("success")
async def _dispatch_to_agent(processor, msg: UnifiedMessage) -> None:
try:
await asyncio.wait_for(processor.process(msg), timeout=120.0)
except TimeoutError:
logger.error("Agent response timeout for wechat-mp user %s", msg.sender.id)
except Exception:
logger.exception("Failed to process WeChat MP message for user %s", msg.sender.id)
async def _send_wechat_text(to_user: str, content: str) -> None:
from yuxi.channel.extensions.wechat_mp.gateway import _get_gateway
gw = _get_gateway()
if gw is None:
logger.warning("No WeChat MP gateway available for sending text")
return
outbound = WeChatMPOutbound(gw)
try:
await outbound.send_text(to_user, content)
except Exception:
logger.exception("Failed to send WeChat MP text to %s", to_user)