259 lines
9.0 KiB
Python
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)
|