import asyncio import json import logging from fastapi import APIRouter, Request, Response from yuxi.channel.extensions.alipay.config import AlipayConfig from yuxi.channel.extensions.alipay.crypto import AlipayAESCrypto, AlipayCrypto from yuxi.channel.extensions.alipay.dedup import AlipayMessageDeduplicator from yuxi.channel.extensions.alipay.format import remove_markdown from yuxi.channel.extensions.alipay.monitor import convert_alipay_event from yuxi.channel.extensions.alipay.outbound import AlipayOutbound from yuxi.channel.extensions.alipay.pairing import AlipayPairing from yuxi.channel.extensions.alipay.security import AlipaySecurity from yuxi.channel.extensions.alipay.types import AlipayInboundEvent from yuxi.channel.runtime.manager import gateway from yuxi.channel.message.models import MessageType, PeerInfo, PeerKind, UnifiedMessage logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/channel/alipay", tags=["alipay"]) _webhook_state: dict | None = None def init_webhook( config: AlipayConfig, deduplicator: AlipayMessageDeduplicator, security: AlipaySecurity, pairing: AlipayPairing, outbound: AlipayOutbound | None = None, ) -> None: global _webhook_state _webhook_state = { "config": config, "deduplicator": deduplicator, "security": security, "pairing": pairing, "outbound": outbound, } def _get_state() -> dict: if _webhook_state is None: raise RuntimeError("Webhook not initialized") return _webhook_state def _build_crypto() -> AlipayCrypto | None: state = _get_state() account = state["config"].resolve_account() if not account.is_configured(): return None return AlipayCrypto( app_private_key_pem=account.app_private_key, alipay_public_key_pem=account.alipay_public_key, ) @router.get("/callback") async def alipay_url_verify(request: Request): params = dict(request.query_params) sign = params.pop("sign", None) params.pop("sign_type", None) crypto = _build_crypto() if crypto is None: return Response(content="fail", status_code=503) if sign and crypto.verify(params, sign): echostr = params.get("echostr", "") return Response(content=echostr) return Response(content="fail", status_code=400) @router.post("/callback") async def alipay_message_callback(request: Request): try: data = await request.json() except (json.JSONDecodeError, UnicodeDecodeError): return Response(content="fail", status_code=400) sign = data.pop("sign", None) data.pop("sign_type", None) state = _get_state() account = state["config"].resolve_account() if not account.is_configured(): return Response(content="fail", status_code=500) crypto = _build_crypto() if crypto is None: return Response(content="fail", status_code=500) if sign and not crypto.verify(data, sign): logger.warning("支付宝回调签名验证失败") return Response(content="fail", status_code=400) encrypt_type = data.get("encrypt_type", "") if encrypt_type == "aes" and account.aes_key: aes = AlipayAESCrypto(account.aes_key) encrypted = data.get("biz_content", "") if isinstance(encrypted, str) and encrypted: try: decrypted = aes.decrypt(encrypted) data = json.loads(decrypted) except Exception: logger.exception("AES decrypt failed") return Response(content="fail", status_code=400) msg_type = data.get("msg_type", data.get("MsgType", "")) from_user_id = data.get("from_user_id", data.get("FromAlipayUserId", "")) msg_id = data.get("msg_id", data.get("MsgId", "")) biz_content = data.get("biz_content", {}) if isinstance(biz_content, str): try: biz_content = json.loads(biz_content) except json.JSONDecodeError: biz_content = {} if not msg_id or state["deduplicator"].is_duplicate(msg_id): return Response(content="success") event = AlipayInboundEvent( msg_type=msg_type, event_type=data.get("event_type", data.get("EventType")), from_user_id=from_user_id, from_user_name=data.get("from_user_name", data.get("FromAlipayUserName")), create_time=int(data.get("create_time", data.get("CreateTime", 0))), msg_id=msg_id, app_id=data.get("app_id", data.get("AppId", "")), biz_content=biz_content, raw=data, ) security = state["security"] if not security.check_allowlist(from_user_id, "direct", account): if account.dm_policy.value == "pairing": content = biz_content.get("content", "") if isinstance(biz_content, dict) else "" if content.strip().startswith("配对 "): code_input = content.strip()[3:].strip() if state["pairing"].verify_code(from_user_id, code_input): return await _handle_pairing_success(from_user_id, security) else: await _send_alipay_text(from_user_id, "配对码无效或已过期,请重新发送消息获取配对码。") else: code = state["pairing"].generate_code(from_user_id) if code: await _send_alipay_text( from_user_id, f"首次对话需要验证身份,请输入以下配对码:\n\n配对 {code}\n\n(配对码有效期 5 分钟)", ) else: await _send_alipay_text(from_user_id, "配对请求过于频繁,请稍后再试。") return Response(content="success") return Response(content="success") outbound = state.get("outbound") if outbound: outbound.record_interaction(from_user_id, account.account_id) if event.msg_type == "event": return await _handle_event(event, account) content_str = (event.biz_content or {}).get("content", "") if not content_str: return Response(content="success") if account.remove_markdown: content_str = remove_markdown(content_str) return await _dispatch_to_agent(event, content_str) async def _handle_event(event: AlipayInboundEvent, account) -> Response: if event.event_type in ("follow", "enter"): unified = convert_alipay_event(event, account) if unified: await _dispatch_unified(unified) welcome = account.subscribe_msg or "" if welcome: await _send_alipay_text(event.from_user_id, welcome) logger.info("Sent welcome message to %s", event.from_user_id) return Response(content="success") async def _dispatch_to_agent(event: AlipayInboundEvent, content_str: str) -> Response: state = _get_state() account = state["config"].resolve_account() unified = UnifiedMessage( msg_id=event.msg_id, channel_type="alipay", account_id=account.account_id, content=content_str, message_type=MessageType.TEXT if event.msg_type == "text" else MessageType.IMAGE, sender=PeerInfo( id=event.from_user_id, kind=PeerKind.DIRECT, display_name=event.from_user_name or event.from_user_id, ), raw_payload=event.raw, body_for_agent=content_str, metadata={ "FromAlipayUserId": event.from_user_id, "AppId": event.app_id, "MsgType": event.msg_type, "EventType": event.event_type or "", }, ) await _dispatch_unified(unified) return Response(content="success") async def _dispatch_unified(msg: UnifiedMessage) -> None: processor = gateway._processor if processor is None: logger.warning("Message processor not available, cannot dispatch Alipay message") return asyncio.create_task( _run_processor(processor, msg), name=f"alipay-dispatch-{msg.sender.id}", ) async def _run_processor(processor, msg: UnifiedMessage) -> None: try: await asyncio.wait_for(processor.process(msg), timeout=120.0) except TimeoutError: logger.error("Agent response timeout for alipay user %s", msg.sender.id) except Exception: logger.exception("Failed to process Alipay message for user %s", msg.sender.id) async def _handle_pairing_success(from_user_id: str, security: AlipaySecurity) -> Response: security.add_to_allowlist(from_user_id) await _send_alipay_text(from_user_id, "配对成功!现在可以开始对话了。") return Response(content="success") async def _send_alipay_text(to_user: str, content: str) -> None: state = _get_state() account = state["config"].resolve_account() if not account.is_configured(): logger.warning("Alipay account not configured, cannot send text") return outbound: AlipayOutbound | None = state.get("outbound") if outbound is None: return try: await outbound.send_text(to_user, content, account=account) except Exception: logger.exception("Failed to send Alipay text to %s", to_user)