初始化询价智能体新仓:同步本地工程并排除密钥与 .env。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-13 14:30:44 +08:00
co-authored by Cursor
commit 2a14def882
2621 changed files with 393165 additions and 0 deletions
@@ -0,0 +1,6 @@
"""
企微通道包:验签、解析、`sender_id`、inbox 写入。
禁止:在本包建工单、调 Graph、调 LLM、写六态。
路由请从 `agent.channel.wecom.callback` 导入 `router`,避免与 queue 循环依赖。
"""
@@ -0,0 +1,157 @@
"""
企微 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()
+140
View File
@@ -0,0 +1,140 @@
"""
企微回调加解密(WXBizMsgCrypt 语义)。
本文件职责:URL 验签、密文解密、回包加密;密钥只从 Settings/环境变量读入。
禁止:把 Token/AESKey 写进源码或注释;禁止在本文件发业务消息。
算法依据:企业微信官方回调加解密文档(AES-CBC + SHA1 签名)。
"""
from __future__ import annotations
import base64
import hashlib
import logging
import os
import struct
import time
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
logger = logging.getLogger(__name__)
class WeComCryptoError(Exception):
"""验签或加解密失败;回调应拒绝入队。"""
class WeComCrypto:
"""
企微消息加解密器。
参数:
token:回调 Token(WECOM_CALLBACK_TOKEN)
encoding_aes_key:43 字符 EncodingAESKey(WECOM_ENCODING_AES_KEY)
corp_id:企业 CorpId(WECOM_CORP_ID),解密后 receiveid 须匹配
线程:无共享可变状态,可多线程复用同一实例。
"""
def __init__(self, token: str, encoding_aes_key: str, corp_id: str) -> None:
if not token or not encoding_aes_key or not corp_id:
raise WeComCryptoError("企微加解密三件套未配置完整(token/aes_key/corp_id)")
self._token = token
self._corp_id = corp_id
# EncodingAESKey 官方为 43 字符,补 '=' 后 base64 得到 32 字节 AES Key
try:
self._aes_key = base64.b64decode(encoding_aes_key + "=")
except Exception as exc: # noqa: BLE001
raise WeComCryptoError("EncodingAESKey 非法") from exc
if len(self._aes_key) != 32:
raise WeComCryptoError("EncodingAESKey 解码后长度必须为 32 字节")
def verify_signature(self, msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> None:
"""
校验 msg_signature = sha1(sort(token, timestamp, nonce, encrypt))。
失败抛 WeComCryptoError;成功无返回值。
"""
expected = self._sha1_signature(timestamp, nonce, encrypt)
if expected != msg_signature:
raise WeComCryptoError("msg_signature 校验失败")
def decrypt(self, encrypt_b64: str) -> str:
"""
解密 Encrypt 字段,返回明文 XML/JSON 字符串。
副作用:无外部 I/O。receiveid 必须等于 corp_id,否则视为串包攻击。
"""
try:
cipher_bytes = base64.b64decode(encrypt_b64)
except Exception as exc: # noqa: BLE001
raise WeComCryptoError("Encrypt base64 解码失败") from exc
iv = self._aes_key[:16]
decryptor = Cipher(
algorithms.AES(self._aes_key),
modes.CBC(iv),
backend=default_backend(),
).decryptor()
plain = decryptor.update(cipher_bytes) + decryptor.finalize()
pad = plain[-1]
if isinstance(pad, str): # pragma: no cover
pad = ord(pad)
content = plain[:-pad]
# 16 字节随机 + 4 字节网络序长度 + msg + receiveid
msg_len = struct.unpack("!I", content[16:20])[0]
xml = content[20 : 20 + msg_len].decode("utf-8")
from_receive_id = content[20 + msg_len :].decode("utf-8")
if from_receive_id != self._corp_id:
raise WeComCryptoError("解密 receiveid 与 CorpId 不匹配")
return xml
def encrypt(self, plain: str) -> str:
"""
加密明文,返回 base64 Encrypt,供被动回包使用。
为何仍实现:URL 验证只需解密;部分场景被动回复需加密(本迭代主动出站走 API)。
"""
random16 = os.urandom(16)
text = plain.encode("utf-8")
corp = self._corp_id.encode("utf-8")
body = random16 + struct.pack("!I", len(text)) + text + corp
# PKCS#7 填充到 32 字节块
amount = 32 - (len(body) % 32)
body = body + bytes([amount] * amount)
iv = self._aes_key[:16]
encryptor = Cipher(
algorithms.AES(self._aes_key),
modes.CBC(iv),
backend=default_backend(),
).encryptor()
cipher = encryptor.update(body) + encryptor.finalize()
return base64.b64encode(cipher).decode("utf-8")
def verify_url(self, msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str:
"""
处理企微「保存 URL」验证:验签并解密 echostr,返回明文给 HTTP 响应体。
"""
self.verify_signature(msg_signature, timestamp, nonce, echostr)
return self.decrypt(echostr)
def build_encrypted_response(self, plain: str, nonce: str | None = None) -> str:
"""
构造加密被动回复 XML(当前 echo 主路径走主动发消息,此方法备用)。
"""
nonce = nonce or str(int(time.time()))
timestamp = str(int(time.time()))
encrypt = self.encrypt(plain)
signature = self._sha1_signature(timestamp, nonce, encrypt)
return (
"<xml>"
f"<Encrypt><![CDATA[{encrypt}]]></Encrypt>"
f"<MsgSignature><![CDATA[{signature}]]></MsgSignature>"
f"<TimeStamp>{timestamp}</TimeStamp>"
f"<Nonce><![CDATA[{nonce}]]></Nonce>"
"</xml>"
)
def _sha1_signature(self, timestamp: str, nonce: str, encrypt: str) -> str:
items = sorted([self._token, timestamp, nonce, encrypt])
return hashlib.sha1("".join(items).encode("utf-8")).hexdigest()
@@ -0,0 +1,37 @@
"""
企微入站归一模型。
本文件职责:定义 InboundMessage,作为 channel → handler 的唯一入站结构。
禁止:在本文件调企微换 ID、写六态、调 LLM。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class InboundMessage:
"""
归一后的入站消息(只读)。
sender_id:必须来自入站报文 FromUserName / AIBOT 发送人,禁止事后向企微拉取。
sender_id 为空时不得入业务队列。
message_id:全局去重键;缺省时由调用方用 msg_id/CreateTime 拼。
"""
sender_id: str
message_id: str
content: str
msg_type: str = "text"
chat_id: str = ""
chat_type: str = "single" # single | group
agent_id: str = ""
create_time: int = 0
media: dict[str, Any] = field(default_factory=dict)
raw: dict[str, Any] = field(default_factory=dict)
def has_sender(self) -> bool:
"""是否具备可进入业务的发送人身份。"""
return bool(self.sender_id and self.sender_id.strip())
@@ -0,0 +1,86 @@
"""
企微 XML/字段解析 → InboundMessage。
本文件职责:从解密后的明文抽出 sender_id / message_id / content。
禁止:无 sender_id 时仍组装「可入业务」消息(调用方应丢弃);禁止再请求企微 userid。
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
from typing import Mapping
from agent.channel.wecom.models import InboundMessage
def _text(root: ET.Element, tag: str) -> str:
node = root.find(tag)
if node is None or node.text is None:
return ""
return node.text.strip()
def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMessage:
"""
解析企微回调明文 XML。
调用时机:验签解密成功后、入 inbox 前。
返回:InboundMessage;MsgType 非 text 时 content 可能为空,由 handler 决定是否忽略。
"""
root = ET.fromstring(xml_text)
msg_type = _text(root, "MsgType") or "unknown"
sender_id = _text(root, "FromUserName")
msg_id = _text(root, "MsgId") or _text(root, "MsgID")
create_time_raw = _text(root, "CreateTime")
create_time = int(create_time_raw) if create_time_raw.isdigit() else 0
agent_id = _text(root, "AgentID") or _text(root, "AgentId") or default_agent_id
content = _text(root, "Content") if msg_type == "text" else ""
# 事件类可带 Event Key;骨架阶段仅记 raw,不进 Graph
event = _text(root, "Event")
if not msg_id:
# 无 MsgId(如部分事件)时用发送人+时间拼幂等键,避免空键撞车
msg_id = f"{sender_id}:{create_time}:{msg_type}:{event}"
chat_type = "single"
chat_id = ""
# 企业微信群聊应用消息偶发带 ChatId;无则保持私聊语义
chat_id = _text(root, "ChatId") or _text(root, "ChatID")
if chat_id:
chat_type = "group"
return InboundMessage(
sender_id=sender_id,
message_id=msg_id,
content=content,
msg_type=msg_type,
chat_id=chat_id,
chat_type=chat_type,
agent_id=agent_id,
create_time=create_time,
media={},
raw={"event": event, "xml": xml_text},
)
def inbound_from_inject(payload: Mapping[str, object], *, default_agent_id: str = "") -> InboundMessage:
"""
本机联调注入:不经企微密文,直接构造 InboundMessage。
用途:并存期公网回调仍进现网时,用 POST /internal/dev/inject-text 测出站回复。
约束:必须带 sender_id(企微 userid),否则不得入队。
"""
sender_id = str(payload.get("sender_id") or "").strip()
content = str(payload.get("content") or "").strip()
message_id = str(payload.get("message_id") or "").strip()
if not message_id:
message_id = f"inject:{sender_id}:{int(payload.get('create_time') or 0)}:{hash(content) & 0xFFFFFFFF}"
return InboundMessage(
sender_id=sender_id,
message_id=message_id,
content=content,
msg_type="text",
chat_type="single",
agent_id=str(payload.get("agent_id") or default_agent_id),
create_time=int(payload.get("create_time") or 0),
raw={"source": "dev_inject"},
)
@@ -0,0 +1,25 @@
"""
企微加解密自检(无外网)。
运行:在 agent 目录 .venv 下 python -m agent.channel.wecom.selftest
需已配置 WECOM_CALLBACK_TOKEN / WECOM_ENCODING_AES_KEY / WECOM_CORP_ID。
"""
from __future__ import annotations
from agent.channel.wecom.crypto import WeComCrypto
from agent.config import get_settings
def main() -> None:
s = get_settings()
c = WeComCrypto(s.wecom_callback_token, s.wecom_encoding_aes_key, s.wecom_corp_id)
plain = "<xml><FromUserName><![CDATA[Alice]]></FromUserName><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[hi]]></Content><MsgId>1</MsgId></xml>"
enc = c.encrypt(plain)
dec = c.decrypt(enc)
assert "Alice" in dec and "hi" in dec
print("crypto_ok")
if __name__ == "__main__":
main()