ForcePilot/backend/package/yuxi/channel/extensions/wechat_miniprogram/webhook.py

251 lines
8.8 KiB
Python
Raw Normal View History

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 (
"<xml>"
f"<Encrypt><![CDATA[{encrypted}]]></Encrypt>"
f"<MsgSignature><![CDATA[{sig}]]></MsgSignature>"
f"<TimeStamp>{ts}</TimeStamp>"
f"<Nonce><![CDATA[{nonce}]]></Nonce>"
"</xml>"
)
@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)