ForcePilot/backend/package/yuxi/channel/extensions/alipay/webhook.py
Kris 71364ea579 feat(alipay): 新增支付宝渠道插件完整实现
实现了支付宝生活号渠道的完整功能,包括消息接收回调、发送、安全验证、去重、配对绑定、流式回复支持等完整能力。
2026-05-21 10:39:23 +08:00

259 lines
9.0 KiB
Python

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)