import asyncio import logging import random import string import time from fastapi import APIRouter, Query, Request from fastapi.responses import PlainTextResponse, Response from yuxi.channel.extensions.wechat_miniprogram.crypto import WeChatCrypto from yuxi.channel.extensions.wechat_miniprogram.dedupe import MessageDeduplicator from yuxi.channel.extensions.wechat_miniprogram.message import build_unified_message, extract_content, parse_xml_to_message from yuxi.channel.extensions.wechat_mp.outbound import remove_markdown from yuxi.channel.extensions.wechat_miniprogram.config import MiniProgramConfig from yuxi.channel.extensions.wechat_miniprogram.outbound import MiniProgramOutbound from yuxi.channel.extensions.wechat_miniprogram.pairing import MiniProgramPairing from yuxi.channel.extensions.wechat_miniprogram.security import MiniProgramSecurity from yuxi.channel.runtime.manager import gateway logger = logging.getLogger(__name__) router = APIRouter(prefix="/webhook/wechat-miniprogram", tags=["wechat-miniprogram"]) _config = MiniProgramConfig() _deduplicator = MessageDeduplicator() _pairing = MiniProgramPairing() 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 miniprogram_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("MiniProgram URL verification failed: invalid signature") return PlainTextResponse("", status_code=403) return PlainTextResponse(echostr) @router.post("/callback") async def miniprogram_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") import xml.etree.ElementTree as ET 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("MiniProgram message callback: invalid msg_signature") return PlainTextResponse("", status_code=403) try: decrypted_xml, app_id = crypto.decrypt(encrypt) except Exception: logger.exception("MiniProgram message decryption failed") return PlainTextResponse("", status_code=403) try: raw_msg = parse_xml_to_message(decrypted_xml) except Exception: logger.exception("MiniProgram 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 = MiniProgramSecurity(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_miniprogram_text(raw_msg.from_user, "配对成功!现在可以开始对话了。") else: await _send_miniprogram_text(raw_msg.from_user, "配对码无效或已过期,请重新发送消息获取配对码。") else: code = _pairing.generate_code(raw_msg.from_user) if code: await _send_miniprogram_text( raw_msg.from_user, f"首次对话需要验证身份,请输入以下配对码:\n\n配对 {code}\n\n(配对码有效期 10 分钟)", ) else: await _send_miniprogram_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 == "user_enter_tempsession": welcome = account.subscribe_msg or "" if welcome: await _send_miniprogram_text(raw_msg.from_user, welcome) logger.info("Sent welcome message to %s", raw_msg.from_user) else: logger.info("User %s entered customer service session (no welcome configured)", raw_msg.from_user) return PlainTextResponse("success") if raw_msg.event in ("kf_create_session",): logger.info("Customer service session created: %s", raw_msg.from_user) if raw_msg.event in ("kf_close_session",): logger.info("Customer service session closed: %s", raw_msg.from_user) if raw_msg.event == "kf_switch_session": from_kf = raw_msg.raw_xml.get("FromKfAccount", "") to_kf = raw_msg.raw_xml.get("ToKfAccount", "") logger.info( "kf_switch_session: from=%s to=%s openid=%s", from_kf, to_kf, raw_msg.from_user ) return PlainTextResponse("success") async def _handle_passive_dispatch(raw_msg, crypto: WeChatCrypto) -> Response: from yuxi.channel.extensions.wechat_miniprogram.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 MiniProgram message") return PlainTextResponse("", status_code=503) unified = build_unified_message(raw_msg, content) asyncio.create_task( _dispatch_to_agent(processor, unified), name=f"miniprogram-dispatch-{raw_msg.from_user}", ) return PlainTextResponse("success") async def _dispatch_to_agent(processor, msg) -> None: try: await asyncio.wait_for(processor.process(msg), timeout=120.0) except TimeoutError: logger.error("Agent response timeout for miniprogram user %s", msg.sender.id) except Exception: logger.exception("Failed to process MiniProgram message for user %s", msg.sender.id) async def _send_miniprogram_text(to_user: str, content: str) -> None: from yuxi.channel.extensions.wechat_miniprogram.gateway import _get_gateway gw = _get_gateway() if gw is None: logger.warning("No MiniProgram gateway available for sending text") return outbound = MiniProgramOutbound(gw) try: await outbound.send_text(to_user, content) except Exception: logger.exception("Failed to send MiniProgram text to %s", to_user)