158 lines
5.6 KiB
Python
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()
|