""" 企微 HTTP 路由:回调验签入队 + 本机注入联调。 本文件职责:GET URL 验证、POST 收消息快回、dev 注入。 禁止:在本路由同步调 LLM / 等企微发送完成 / 跑 Graph。 路径:/wecom/callback(与现网对外路径语义对齐,本机测 8910)。 """ from __future__ import annotations import logging import re import xml.etree.ElementTree as ET from fastapi import APIRouter, HTTPException, Query, Request, Response from pydantic import BaseModel, Field from agent.channel.queue import get_message_store from agent.channel.wecom.crypto import WeComCrypto, WeComCryptoError from agent.channel.wecom.parse import inbound_from_inject, parse_plain_xml from agent.config import get_settings logger = logging.getLogger(__name__) router = APIRouter(tags=["wecom"]) def _crypto() -> WeComCrypto: s = get_settings() return WeComCrypto( token=s.wecom_callback_token, encoding_aes_key=s.wecom_encoding_aes_key, corp_id=s.wecom_corp_id, ) @router.get("/wecom/callback") def wecom_callback_verify( msg_signature: str = Query(...), timestamp: str = Query(...), nonce: str = Query(...), echostr: str = Query(...), ) -> Response: """ 企微配置回调 URL 时的 GET 验证。 成功:响应体为解密后的明文 echostr(纯文本,非 JSON)。 失败:403。不入业务队列。 """ try: plain = _crypto().verify_url(msg_signature, timestamp, nonce, echostr) except WeComCryptoError as exc: logger.warning("企微 URL 验证失败: %s", exc) raise HTTPException(status_code=403, detail="verify failed") from exc return Response(content=plain, media_type="text/plain") @router.post("/wecom/callback") async def wecom_callback_receive( request: Request, msg_signature: str = Query(...), timestamp: str = Query(...), nonce: str = Query(...), ) -> Response: """ 企微消息回调:验签解密 → 归一 → 入 inbox → 立即返回 success。 为何不在此发回复:企微回调有超时;发送走 outbox 独立线程。 无 sender_id:不入队,仍返回 success,避免企微狂重试。 """ body = (await request.body()).decode("utf-8", errors="replace") encrypt = _extract_encrypt(body) if not encrypt: logger.warning("回调体缺少 Encrypt") raise HTTPException(status_code=400, detail="missing encrypt") settings = get_settings() try: crypto = _crypto() crypto.verify_signature(msg_signature, timestamp, nonce, encrypt) plain = crypto.decrypt(encrypt) except WeComCryptoError as exc: logger.warning("回调验签/解密失败: %s", exc) raise HTTPException(status_code=403, detail="crypto failed") from exc inbound = parse_plain_xml(plain, default_agent_id=str(settings.wecom_agent_id)) if not inbound.has_sender(): logger.warning("回调无 FromUserName/sender_id,丢弃 msg_id=%s", inbound.message_id) return Response(content="success", media_type="text/plain") store = get_message_store() ok, info = store.enqueue_inbound(inbound) logger.info("回调入队 ok=%s info=%s type=%s", ok, info, inbound.msg_type) return Response(content="success", media_type="text/plain") def _extract_encrypt(xml_or_json: str) -> str: """从回调体取出 Encrypt 字段(XML 为主)。""" text = xml_or_json.strip() if not text: return "" if text.startswith("<"): try: root = ET.fromstring(text) node = root.find("Encrypt") return (node.text or "").strip() if node is not None else "" except ET.ParseError: m = re.search(r"", text, re.S) return m.group(1).strip() if m else "" m = re.search(r'"Encrypt"\s*:\s*"([^"]+)"', text) return m.group(1) if m else "" class InjectTextBody(BaseModel): """本机注入私聊文字:模拟企微入站,走同一套 inbox→echo→outbox。""" sender_id: str = Field(..., description="企微 userid,必须真实,否则发不出") content: str = Field(..., description="模拟用户发送的文字") message_id: str = Field(default="", description="可选幂等键;空则自动生成") @router.post("/internal/dev/inject-text") def inject_text(body: InjectTextBody) -> dict[str, object]: """ 并存期联调入口:不经公网回调,直接入 inbox,并由 outbox 向 sender_id 真发企微消息。 安全:YTD_ENV=prod 时默认拒绝。 副作用:入队;发送在后台线程异步完成。 """ settings = get_settings() if settings.ytd_env == "prod" and not settings.dev_inject_enabled: raise HTTPException(status_code=403, detail="prod 禁止注入,除非 DEV_INJECT_ENABLED=true") inbound = inbound_from_inject( { "sender_id": body.sender_id, "content": body.content, "message_id": body.message_id, }, default_agent_id=str(settings.wecom_agent_id), ) if not inbound.has_sender(): raise HTTPException(status_code=400, detail="sender_id 必填") ok, info = get_message_store().enqueue_inbound(inbound) return { "accepted": ok, "info": info, "queue": get_message_store().stats(), "hint": "请查企微是否收到 [test] 回复;并存期公网回调仍进现网,真收消息需切流", } @router.get("/internal/dev/queue-stats") def queue_stats() -> dict[str, object]: """查看内存队列积压(联调)。""" return get_message_store().stats()