Files
inquiry_robot/inquiry-agent/agent/channel/wecom/callback.py
T

158 lines
5.6 KiB
Python

"""
企微 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"<Encrypt><!\[CDATA\[(.*?)\]\]></Encrypt>", 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()