新增 Webex、微信 iLink、微信小程序三个渠道扩展。 Webex 渠道扩展功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - media: 媒体资源处理 微信 iLink 渠道扩展功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - context_store: 上下文存储 - aes_ecb: AES-ECB 加解密 - media: 媒体资源处理 - typing: 输入状态 微信小程序渠道扩展功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - crypto: 加解密处理 - dedupe: 消息去重 - message: 消息处理 - passive_reply: 被动回复 - media: 媒体资源处理 - status: 会话状态管理
251 lines
8.8 KiB
Python
251 lines
8.8 KiB
Python
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)
|