新增微信客服、微信公众号、微信支付通知三个渠道扩展。 微信客服渠道扩展功能模块: - 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: 回调补偿
299 lines
10 KiB
Python
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)
|