初始化询价智能体新仓:同步本地工程并排除密钥与 .env。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user