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 ( "" f"" f"" f"{ts}" f"" "" ) @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)