初始化询价智能体新仓:同步本地工程并排除密钥与 .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
+9
View File
@@ -0,0 +1,9 @@
"""
智能体根包。
本包职责:企微/H5 通道、意图路由、Handler、LangGraph、LLM 队列、主账客户端、对象存储。
进程入口见 http_app / worker_app;入口只组装依赖,不写业务分支。
禁止:直连 TMS、直连 MySQL 工单表、在回调线程同步跑完整 Graph/LLM/LibreOffice。
"""
__version__ = "0.1.0"
+6
View File
@@ -0,0 +1,6 @@
"""
企微通道包:验签、XML/JSON 解析、提取 sender_id、写入 inbox。
禁止:在本包建工单、调 Graph、调 LLM、写六态。
骨架:仅路由壳,验签与落库后续迭代。
"""
@@ -0,0 +1,18 @@
"""
主账唤醒桥接路由(合并原 bridge_api)。
本文件职责:接收主账鉴权后的 wake 事件,入队由 Worker 结构化 resume。
禁止:在本请求线程跑完整 Graph;禁止本包直连 MySQL / TMS。
"""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(prefix="/internal/bridge", tags=["bridge"])
@router.get("/ping")
def bridge_ping() -> dict[str, str]:
"""主账唤醒路径占位;正式实现需校验 AGENT_CALLBACK_TOKEN。"""
return {"status": "ok", "channel": "bridge", "note": "scaffold-only"}
@@ -0,0 +1,22 @@
"""
H5 补问 / 附件上传通道。
本文件职责:挂载 /inquiry-form 相关壳路由;字段快照与流程状态在 Graph/PG,不在本包。
禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS。
"""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(prefix="/inquiry-form", tags=["h5"])
@router.get("/ping")
def h5_ping() -> dict[str, str]:
"""
H5 路由占位探测。
正式页用一次性 token;骨架不校验 token、不落库。
"""
return {"status": "ok", "channel": "h5", "note": "scaffold-only"}
@@ -0,0 +1,9 @@
"""
企微出站包:经 outbox 调用发送客户端。
禁止:抽字段、建单、在发送成功时冒充业务成功。
"""
from agent.channel.outbox.sender import WeComAppClient, WeComSendResult
__all__ = ["WeComAppClient", "WeComSendResult"]
@@ -0,0 +1,138 @@
"""
企微主动发消息客户端(应用消息)。
本文件职责:取 access_token、发送文本;密钥来自环境变量。
禁止:在发送成功时冒充业务成功;禁止按 userid 伪造业务分支。
出站唯一入口应经 outbox 消费调用本客户端。
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from typing import Any, Optional
import httpx
logger = logging.getLogger(__name__)
@dataclass
class WeComSendResult:
"""企微发送结果(通道层)。"""
ok: bool
errcode: int = 0
errmsg: str = ""
raw: dict[str, Any] | None = None
class WeComAppClient:
"""
询价应用(默认 AgentId 1000010)主动消息客户端。
线程安全:token 缓存带锁;HTTP 带超时,禁止无限阻塞。
"""
def __init__(
self,
*,
corp_id: str,
secret: str,
agent_id: int,
api_base: str = "https://qyapi.weixin.qq.com",
timeout_sec: float = 10.0,
) -> None:
self._corp_id = corp_id
self._secret = secret
self._agent_id = agent_id
self._api_base = api_base.rstrip("/")
self._timeout = timeout_sec
self._token: Optional[str] = None
self._token_expire_at: float = 0.0
self._lock = threading.Lock()
def send_text(self, *, touser: str, content: str) -> WeComSendResult:
"""
向指定企微 userid 发送文本应用消息。
参数:touser 必须是入站 sender_id;content 测试环境应由上层加 [test] 前缀。
副作用:出站 HTTP 调企微;失败返回 ok=False,不抛给回调线程(由 outbox 重试)。
"""
if not self._corp_id or not self._secret:
return WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置")
if not touser or not content:
return WeComSendResult(ok=False, errcode=-1, errmsg="touser/content 为空")
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("获取 access_token 失败")
return WeComSendResult(ok=False, errcode=-1, errmsg=str(exc))
url = f"{self._api_base}/cgi-bin/message/send"
body = {
"touser": touser,
"msgtype": "text",
"agentid": self._agent_id,
"text": {"content": content},
"safe": 0,
}
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(url, params={"access_token": token}, json=body)
data = resp.json()
except Exception as exc: # noqa: BLE001
logger.exception("企微 message/send 网络失败")
return WeComSendResult(ok=False, errcode=-1, errmsg=str(exc))
errcode = int(data.get("errcode") or 0)
errmsg = str(data.get("errmsg") or "")
if errcode != 0:
# token 失效则清空缓存,下次重试
if errcode in (40014, 42001, 42007):
with self._lock:
self._token = None
self._token_expire_at = 0.0
logger.warning("企微发送失败 errcode=%s errmsg=%s", errcode, errmsg)
return WeComSendResult(ok=False, errcode=errcode, errmsg=errmsg, raw=data)
logger.info("企微发送成功 touser=%s", touser)
return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data)
def _get_token(self) -> str:
with self._lock:
now = time.time()
if self._token and now < self._token_expire_at - 60:
return self._token
# 注意:勿把 corpsecret 打进日志;httpx 默认 INFO 会打印完整 URL
url = f"{self._api_base}/cgi-bin/gettoken"
with httpx.Client(timeout=self._timeout) as client:
resp = client.get(
url,
params={"corpid": self._corp_id, "corpsecret": self._secret},
)
data = resp.json()
errcode = int(data.get("errcode") or 0)
if errcode != 0 or not data.get("access_token"):
raise RuntimeError(f"gettoken 失败 errcode={errcode}")
token = str(data["access_token"])
expires_in = int(data.get("expires_in") or 7200)
with self._lock:
self._token = token
self._token_expire_at = time.time() + expires_in
return token
# 企微侧明确不可重试的错误(IP 白名单、非法 userid 等)
PERMANENT_SEND_ERRORS = frozenset(
{
60020, # not allow to access from your ip
60111, # invalid user
81013, # user not in app visible range
40003, # invalid openid/userid
40013, # invalid corpid
40001, # invalid credential(非 token 过期类时也停)
}
)
+232
View File
@@ -0,0 +1,232 @@
"""
进程内 inbox / outbox 队列(骨架过渡实现)。
本文件职责:回调快写入队、按条认领、message_id 去重;后续可替换为 PostgreSQL 表。
禁止:Redis 全局 Worker 单例锁;禁止在回调线程里等 LLM。
为何先用内存:先打通「收消息→回复」联调;落 PG 迁移不挡本迭代验收。
"""
from __future__ import annotations
import logging
import threading
import time
import uuid
from collections import OrderedDict
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Optional
from agent.channel.wecom.models import InboundMessage
logger = logging.getLogger(__name__)
class TaskState(str, Enum):
PENDING = "pending"
CLAIMED = "claimed"
DONE = "done"
FAILED = "failed"
@dataclass
class InboxItem:
"""inbox 一条入站任务。"""
id: str
message: InboundMessage
state: TaskState = TaskState.PENDING
claimed_at: float = 0.0
attempts: int = 0
last_error: str = ""
@dataclass
class OutboxItem:
"""
outbox 一条出站任务。
touser:必须是入站 sender_id,禁止另换 ID。
发送成功 ≠ 业务成功;此处仅通道送达。
"""
id: str
touser: str
content: str
state: TaskState = TaskState.PENDING
claimed_at: float = 0.0
attempts: int = 0
last_error: str = ""
created_at: float = field(default_factory=time.time)
dedupe_key: str = ""
class MemoryMessageStore:
"""
线程安全的内存 inbox/outbox。
认领:同一条任务同一时刻只被一个消费者持有;超时可回收(默认 120s)。
去重:相同 message_id / dedupe_key 不重复入队。
"""
def __init__(self, *, claim_timeout_sec: float = 120.0, dedupe_maxlen: int = 5000) -> None:
self._lock = threading.RLock()
self._inbox: OrderedDict[str, InboxItem] = OrderedDict()
self._outbox: OrderedDict[str, OutboxItem] = OrderedDict()
self._seen_msg: OrderedDict[str, float] = OrderedDict()
self._seen_out: OrderedDict[str, float] = OrderedDict()
self._claim_timeout = claim_timeout_sec
self._dedupe_maxlen = dedupe_maxlen
self._inbox_event = threading.Event()
self._outbox_event = threading.Event()
def enqueue_inbound(self, message: InboundMessage) -> tuple[bool, str]:
"""
写入 inbox。
返回:(是否新入队, 说明)。无 sender_id 拒绝;message_id 重复则跳过。
副作用:唤醒 inbox 等待线程。
"""
if not message.has_sender():
return False, "missing_sender_id"
with self._lock:
if message.message_id in self._seen_msg:
return False, "duplicate_message_id"
self._remember(self._seen_msg, message.message_id)
item = InboxItem(id=str(uuid.uuid4()), message=message)
self._inbox[item.id] = item
self._inbox_event.set()
logger.info(
"inbox 入队 id=%s msg_id=%s sender=%s type=%s",
item.id,
message.message_id,
message.sender_id,
message.msg_type,
)
return True, item.id
def claim_inbound(self) -> Optional[InboxItem]:
"""认领一条 pending/超时 inbox;无任务返回 None。"""
now = time.time()
with self._lock:
for item in self._inbox.values():
if item.state == TaskState.PENDING or (
item.state == TaskState.CLAIMED
and item.claimed_at
and now - item.claimed_at > self._claim_timeout
):
item.state = TaskState.CLAIMED
item.claimed_at = now
item.attempts += 1
return item
self._inbox_event.clear()
return None
def complete_inbound(self, item_id: str, *, error: str = "") -> None:
"""标记 inbox 完成或失败。"""
with self._lock:
item = self._inbox.get(item_id)
if not item:
return
if error:
item.state = TaskState.FAILED
item.last_error = error
else:
item.state = TaskState.DONE
def enqueue_outbound(
self,
*,
touser: str,
content: str,
dedupe_key: str = "",
) -> tuple[bool, str]:
"""写入 outbox;dedupe_key 重复则跳过。"""
if not touser or not content:
return False, "invalid_outbox"
with self._lock:
if dedupe_key and dedupe_key in self._seen_out:
return False, "duplicate_outbox"
if dedupe_key:
self._remember(self._seen_out, dedupe_key)
item = OutboxItem(
id=str(uuid.uuid4()),
touser=touser,
content=content,
dedupe_key=dedupe_key,
)
self._outbox[item.id] = item
self._outbox_event.set()
logger.info("outbox 入队 id=%s touser=%s", item.id, touser)
return True, item.id
def claim_outbound(self) -> Optional[OutboxItem]:
"""认领一条 pending/超时 outbox。"""
now = time.time()
with self._lock:
for item in self._outbox.values():
if item.state == TaskState.PENDING or (
item.state == TaskState.CLAIMED
and item.claimed_at
and now - item.claimed_at > self._claim_timeout
):
item.state = TaskState.CLAIMED
item.claimed_at = now
item.attempts += 1
return item
self._outbox_event.clear()
return None
def complete_outbound(self, item_id: str, *, error: str = "", permanent: bool = False) -> None:
"""
标记 outbox 完成或失败。
permanent=True:不再重试(如 IP 未加白 60020)。
"""
with self._lock:
item = self._outbox.get(item_id)
if not item:
return
if error:
if permanent or item.attempts >= 5:
item.state = TaskState.FAILED
else:
item.state = TaskState.PENDING
item.claimed_at = 0.0
self._outbox_event.set()
item.last_error = error
else:
item.state = TaskState.DONE
def wait_inbox(self, timeout: float = 1.0) -> None:
self._inbox_event.wait(timeout=timeout)
def wait_outbox(self, timeout: float = 1.0) -> None:
self._outbox_event.wait(timeout=timeout)
def stats(self) -> dict[str, Any]:
with self._lock:
return {
"inbox": len(self._inbox),
"outbox": len(self._outbox),
"seen_msg": len(self._seen_msg),
}
def _remember(self, store: OrderedDict[str, float], key: str) -> None:
store[key] = time.time()
while len(store) > self._dedupe_maxlen:
store.popitem(last=False)
# 进程内单例:HTTP 回调与消费线程共享(同进程)。多进程部署时必须换 PG。
_STORE: Optional[MemoryMessageStore] = None
_STORE_LOCK = threading.Lock()
def get_message_store() -> MemoryMessageStore:
"""获取进程内消息账本单例。"""
global _STORE
with _STORE_LOCK:
if _STORE is None:
_STORE = MemoryMessageStore()
return _STORE
+127
View File
@@ -0,0 +1,127 @@
"""
通道消费循环:inbox → echo handler;outbox → 企微发送。
本文件职责:在 HTTP 进程内独立线程跑快路径,不与 LibreOffice 串队。
禁止:在回调请求线程调用本循环的阻塞逻辑;禁止 Redis 全局单例锁。
"""
from __future__ import annotations
import logging
import threading
from typing import Optional
from agent.channel.outbox.sender import PERMANENT_SEND_ERRORS, WeComAppClient
from agent.channel.queue import MemoryMessageStore, get_message_store
from agent.config import Settings
from agent.handlers.echo_text import handle_echo_text
logger = logging.getLogger(__name__)
# 避免 httpx 把 access_token / secret 打进 INFO
logging.getLogger("httpx").setLevel(logging.WARNING)
class ChannelRuntime:
"""
企微通道运行时:inbox/outbox 各一条守护线程。
启动时机:FastAPI lifespan;停止:进程退出。
"""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._store = get_message_store()
self._stop = threading.Event()
self._threads: list[threading.Thread] = []
agent_id = int(settings.wecom_agent_id or "0") or 0
self._client = WeComAppClient(
corp_id=settings.wecom_corp_id,
secret=settings.wecom_secret,
agent_id=agent_id,
)
@property
def store(self) -> MemoryMessageStore:
return self._store
def start(self) -> None:
"""启动 inbox/outbox 消费线程。"""
self._stop.clear()
self._threads = [
threading.Thread(target=self._inbox_loop, name="wecom-inbox", daemon=True),
threading.Thread(target=self._outbox_loop, name="wecom-outbox", daemon=True),
]
for t in self._threads:
t.start()
logger.info("ChannelRuntime 已启动(inbox/outbox 独立线程)")
def stop(self) -> None:
self._stop.set()
def _inbox_loop(self) -> None:
while not self._stop.is_set():
item = self._store.claim_inbound()
if item is None:
self._store.wait_inbox(1.0)
continue
try:
handle_echo_text(
item.message,
self._store,
ytd_env=self._settings.ytd_env,
reply_template=self._settings.wecom_callback_reply_text,
)
self._store.complete_inbound(item.id)
except Exception as exc: # noqa: BLE001
logger.exception("inbox 处理失败 id=%s", item.id)
self._store.complete_inbound(item.id, error=str(exc))
def _outbox_loop(self) -> None:
while not self._stop.is_set():
item = self._store.claim_outbound()
if item is None:
self._store.wait_outbox(1.0)
continue
# 过期丢弃:超过 30 分钟的出站不再发(对齐「过期不计业务成功」)
import time
if time.time() - item.created_at > 1800:
logger.warning("outbox 过期丢弃 id=%s", item.id)
self._store.complete_outbound(item.id)
continue
result = self._client.send_text(touser=item.touser, content=item.content)
if result.ok:
self._store.complete_outbound(item.id)
elif result.errcode in PERMANENT_SEND_ERRORS:
logger.error(
"outbox 永久失败不再重试 id=%s err=%s:%s",
item.id,
result.errcode,
result.errmsg,
)
self._store.complete_outbound(
item.id,
error=f"permanent:{result.errcode}:{result.errmsg}",
permanent=True,
)
else:
self._store.complete_outbound(
item.id,
error=f"{result.errcode}:{result.errmsg}",
)
_RUNTIME: Optional[ChannelRuntime] = None
def get_channel_runtime() -> Optional[ChannelRuntime]:
return _RUNTIME
def start_channel_runtime(settings: Settings) -> ChannelRuntime:
"""幂等启动通道运行时。"""
global _RUNTIME
if _RUNTIME is None:
_RUNTIME = ChannelRuntime(settings)
_RUNTIME.start()
return _RUNTIME
@@ -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()
+75
View File
@@ -0,0 +1,75 @@
"""
配置包:只读环境变量,组装 Settings。
本文件职责:集中声明 YTD_ENV、端口、企微与库变量名(对齐 prompt/13、09)。
禁止:在本包写业务 if/else;禁止把密钥默认值写进源码。
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
def _find_dotenv() -> str:
"""自 agent 目录向上找仓库根 .env;找不到则返回空,由环境变量本身提供。"""
here = Path(__file__).resolve()
for parent in here.parents:
candidate = parent / ".env"
if candidate.is_file():
return str(candidate)
return ""
class Settings(BaseSettings):
"""
进程级只读配置。
调用时机:HTTP / Worker 启动时通过 get_settings() 加载一次。
企微密钥必须来自环境/.env,源码中不得出现明文 Secret。
"""
model_config = SettingsConfigDict(
env_file=_find_dotenv() or None,
env_file_encoding="utf-8",
extra="ignore",
)
ytd_env: str = Field(default="test", alias="YTD_ENV")
agent_http_port: int = Field(default=8910, alias="AGENT_HTTP_PORT")
public_base_url: str = Field(default="", alias="PUBLIC_BASE_URL")
biz_api_base_url: str = Field(default="http://127.0.0.1:8180", alias="BIZ_API_BASE_URL")
agent_callback_token: str = Field(default="", alias="AGENT_CALLBACK_TOKEN")
# 企微询价应用(与正式同一套应用 ID;密钥只从环境读)
wecom_corp_id: str = Field(default="", alias="WECOM_CORP_ID")
wecom_agent_id: str = Field(default="1000010", alias="WECOM_AGENT_ID")
wecom_secret: str = Field(default="", alias="WECOM_SECRET")
wecom_callback_token: str = Field(default="", alias="WECOM_CALLBACK_TOKEN")
wecom_encoding_aes_key: str = Field(default="", alias="WECOM_ENCODING_AES_KEY")
wecom_callback_reply_text: str = Field(
default="询价智能体接通成功。已收到:{content}",
alias="WECOM_CALLBACK_REPLY_TEXT",
)
# 本机注入开关:test 默认开;prod 默认关
dev_inject_enabled: bool = Field(default=False, alias="DEV_INJECT_ENABLED")
pg_host: str = Field(default="", alias="PG_HOST")
pg_port: int = Field(default=5432, alias="PG_PORT")
pg_database: str = Field(default="", alias="PG_DATABASE")
pg_user: str = Field(default="", alias="PG_USER")
pg_password: str = Field(default="", alias="PG_PASSWORD")
redis_url: str = Field(default="", alias="REDIS_URL")
redis_key_prefix: str = Field(default="inquiry_robot:", alias="REDIS_KEY_PREFIX")
libreoffice_slots: int = Field(default=1, alias="LIBREOFFICE_SLOTS")
http_job_slots: int = Field(default=3, alias="HTTP_JOB_SLOTS")
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""返回进程内单例 Settings。副作用:首次可能读 .env。"""
return Settings()
+9
View File
@@ -0,0 +1,9 @@
"""
settings 子模块:为 `from agent.config.settings import get_settings` 提供稳定导入路径。
实际实现在 config 包根;本文件只做再导出,避免入口散落多套 Settings。
"""
from agent.config import Settings, get_settings
__all__ = ["Settings", "get_settings"]
+6
View File
@@ -0,0 +1,6 @@
"""
LangGraph 编排:图定义与节点分文件;checkpoint 在 PostgreSQL。
禁止:单文件堆全部节点;禁止在本包处理企微 HTTP 或 LibreOffice。
骨架:仅包占位,不注册图。
"""
+6
View File
@@ -0,0 +1,6 @@
"""
Handler 包:一类意图 / 一类卡片一个模块。
禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。
骨架:仅包占位。
"""
+58
View File
@@ -0,0 +1,58 @@
"""
私聊文字 echo 处理器(联调用)。
本文件职责:收到 text 入站后写入 outbox 回复;不加 Graph/TMS。
测试环境正文加 [test] 前缀(对齐运维告警约定)。
禁止:无 sender_id 仍回复;禁止在此改六态。
"""
from __future__ import annotations
import logging
from agent.channel.queue import MemoryMessageStore
from agent.channel.wecom.models import InboundMessage
logger = logging.getLogger(__name__)
def handle_echo_text(
message: InboundMessage,
store: MemoryMessageStore,
*,
ytd_env: str,
reply_template: str,
) -> None:
"""
处理一条私聊文字:按模板生成回复并入 outbox。
参数:
reply_template:可含 {content} 占位,默认来自 WECOM_CALLBACK_REPLY_TEXT
副作用:写 outbox;不直接调企微(由 outbox 线程发送)。
"""
if not message.has_sender():
logger.warning("echo 跳过:无 sender_id msg_id=%s", message.message_id)
return
if message.msg_type != "text":
logger.info("echo 跳过非文字类型 type=%s msg_id=%s", message.msg_type, message.message_id)
return
if not (message.content or "").strip():
logger.info("echo 跳过空正文 msg_id=%s", message.message_id)
return
body = reply_template.replace("{content}", message.content.strip())
if ytd_env == "test" and not body.startswith("[test]"):
body = f"[test] {body}"
dedupe = f"echo:{message.message_id}"
ok, info = store.enqueue_outbound(
touser=message.sender_id,
content=body,
dedupe_key=dedupe,
)
logger.info(
"echo 已排队 outbox ok=%s info=%s sender=%s",
ok,
info,
message.sender_id,
)
+99
View File
@@ -0,0 +1,99 @@
"""
智能体唯一 HTTP 入口(合并原 8810/8809/8811)。
本文件职责:组装 FastAPI、挂载通道、启动 inbox/outbox 线程、暴露 /health。
禁止:在请求处理里同步 LibreOffice / 等完整 LLM / invoke 整图。
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from agent.channel.bridge import router as bridge_router
from agent.channel.h5 import router as h5_router
from agent.channel.runtime import start_channel_runtime
from agent.channel.wecom.callback import router as wecom_router
from agent.config import get_settings
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
进程生命周期:启动企微 inbox/outbox 消费线程。
为何放在 HTTP 进程:回调快回后需同机消费;与 LibreOffice 无关的快路径。
"""
settings = get_settings()
runtime = start_channel_runtime(settings)
app.state.channel_runtime = runtime
logger.info(
"通道运行时就绪 ytd_env=%s wecom_agent_id=%s corp_configured=%s",
settings.ytd_env,
settings.wecom_agent_id,
bool(settings.wecom_corp_id and settings.wecom_secret),
)
yield
runtime.stop()
def create_app() -> FastAPI:
"""构建 FastAPI 应用(可供测试 import)。"""
settings = get_settings()
app = FastAPI(
title="YTD Inquiry Agent HTTP",
version="0.2.0",
description="询价智能体 HTTP:企微回调入队 + echo 出站联调",
lifespan=lifespan,
)
@app.get("/health")
def health() -> dict[str, object]:
"""存活探测:不访问 PG/Redis;附带通道配置是否齐全(不含密钥)。"""
return {
"status": "UP",
"service": "agent-http",
"ytd_env": settings.ytd_env,
"port": settings.agent_http_port,
"wecom_crypto_ready": bool(
settings.wecom_callback_token
and settings.wecom_encoding_aes_key
and settings.wecom_corp_id
),
"wecom_send_ready": bool(settings.wecom_corp_id and settings.wecom_secret),
}
app.include_router(wecom_router)
app.include_router(h5_router)
app.include_router(bridge_router)
return app
def main() -> None:
"""进程入口:默认端口 8910。"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
settings = get_settings()
# test 环境默认允许本机注入,便于并存期联调
if settings.ytd_env == "test" and not settings.dev_inject_enabled:
# pydantic 冻结后不能改;通过环境变量在启动前设置更干净。此处仅打日志提示。
logger.info("提示:test 下可用 POST /internal/dev/inject-text 联调(无需改 Nginx)")
app = create_app()
logger.info(
"启动智能体 HTTP ytd_env=%s port=%s",
settings.ytd_env,
settings.agent_http_port,
)
uvicorn.run(app, host="0.0.0.0", port=settings.agent_http_port, log_level="info")
if __name__ == "__main__":
main()
+122
View File
@@ -0,0 +1,122 @@
"""
任务槽与认领循环:识别、LibreOffice、Archive 等。
本包约束(硬):LibreOffice 仅 1 槽;HTTP 类 2~3 槽;inbox/outbox 不得与 soffice 串同一阻塞线。
禁止:一把大锁串行所有工单;禁止 wecom:worker:singleton。
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Callable
logger = logging.getLogger(__name__)
@dataclass
class SlotPool:
"""
进程内有限槽线程池骨架。
用途:限制同类慢活并发(尤其 LibreOffice=1)。
为何不用进程级 Redis 单例:同机只应起一份 Worker 服务;防重入靠任务认领。
"""
name: str
size: int
_sem: threading.BoundedSemaphore = field(init=False, repr=False)
def __post_init__(self) -> None:
if self.size < 1:
raise ValueError(f"槽位数必须 >=1: {self.name}")
self._sem = threading.BoundedSemaphore(self.size)
def run(self, fn: Callable[[], None], *, label: str = "") -> None:
"""
占用一个槽执行 fn;槽满则阻塞等待(调用方应在 Worker 线程,勿在 HTTP 回调线程调用)。
副作用:执行 fn;不写库(骨架)。
"""
acquired = self._sem.acquire(timeout=30)
if not acquired:
logger.warning("槽超时未获取 name=%s label=%s", self.name, label)
return
try:
fn()
finally:
self._sem.release()
@dataclass
class WorkerRuntime:
"""
Worker 运行时:持有分槽与 inbox/outbox 占位线程。
启动时机:worker_app main;停止:进程退出或 stop()。
禁止:把所有任务丢进 LibreOffice 槽。
"""
libreoffice: SlotPool
http_jobs: SlotPool
_stop: threading.Event = field(default_factory=threading.Event)
_threads: list[threading.Thread] = field(default_factory=list)
@classmethod
def from_settings(cls, libreoffice_slots: int, http_job_slots: int) -> "WorkerRuntime":
"""按配置创建槽;强制 LibreOffice 槽为 1,防止误配双 soffice。"""
lo = 1 if libreoffice_slots != 1 else libreoffice_slots
if libreoffice_slots != 1:
logger.warning(
"LibreOffice 槽必须为 1(收到 %s),已强制为 1,避免本机双 soffice 互抢",
libreoffice_slots,
)
return cls(
libreoffice=SlotPool("libreoffice", lo),
http_jobs=SlotPool("http_jobs", max(2, http_job_slots)),
)
def start(self) -> None:
"""
启动 inbox / outbox / 空转巡检线程(骨架:只心跳日志,不消费真实队列)。
副作用:创建守护线程;不连 Redis(骨架)。
"""
self._stop.clear()
self._threads = [
threading.Thread(target=self._inbox_loop, name="inbox-loop", daemon=True),
threading.Thread(target=self._outbox_loop, name="outbox-loop", daemon=True),
threading.Thread(target=self._http_slot_idle, name="http-slots-idle", daemon=True),
]
for t in self._threads:
t.start()
logger.info(
"WorkerRuntime 已启动 libreoffice_slots=%s http_job_slots=%s",
self.libreoffice.size,
self.http_jobs.size,
)
def stop(self) -> None:
"""请求停止占位循环;不保证立刻打断正在跑的槽任务(骨架无真实任务)。"""
self._stop.set()
def _inbox_loop(self) -> None:
"""inbox 独立线程:后续从 PG/队列认领入站;禁止与 soffice 共用阻塞队列。"""
while not self._stop.is_set():
time.sleep(2.0)
def _outbox_loop(self) -> None:
"""outbox 独立线程:后续发送企微;失败重试;过期丢弃不计业务成功。"""
while not self._stop.is_set():
time.sleep(2.0)
def _http_slot_idle(self) -> None:
"""证明 HTTP 类槽可并行占用;骨架不跑真实 LLM。"""
while not self._stop.is_set():
def _noop() -> None:
time.sleep(0.05)
self.http_jobs.run(_noop, label="idle-ping")
time.sleep(5.0)
+26
View File
@@ -0,0 +1,26 @@
"""
主账 HTTP 客户端:只打主账 API,不直连 TMS / MySQL。
正式写操作必须带幂等键;Graph 成功 ≠ 主账成功。
骨架:仅占位客户端。
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class LedgerClient:
"""
主账客户端骨架。
参数:base_url 来自 BIZ_API_BASE_URL。
禁止:在本类拼 TMS 签名或写工单 SQL。
"""
base_url: str
def health_url(self) -> str:
"""返回主账健康检查 URL,供后续联调;骨架不发请求。"""
return f"{self.base_url.rstrip('/')}/health"
+6
View File
@@ -0,0 +1,6 @@
"""
LLM 网关与队列:DeepSeek / 千问 mode schema、认领、重试。
禁止:在本包维护工单六态;禁止 Redis 全局 Worker 单例锁。
骨架:仅包占位。
"""
+6
View File
@@ -0,0 +1,6 @@
"""
意图路由:DeepSeek A → 唯一 RouteDecision。
禁止:在本包产生业务副作用(建单、发消息、改六态)。
骨架:仅占位。
"""
+6
View File
@@ -0,0 +1,6 @@
"""
对象存储:只存字节;元数据登记走主账 attachment。
禁止:在本包维护工单状态。
骨架:仅占位。
"""
+54
View File
@@ -0,0 +1,54 @@
"""
智能体唯一 Worker 入口(LLM / 识别 / 报价 PDF / 存档)。
本文件职责:组装分槽运行时并阻塞至进程结束;不在此写各业务 Handler。
禁止:Redis wecom:worker:singleton;禁止五进程拆分;禁止在 HTTP 回调里跑本模块逻辑。
"""
from __future__ import annotations
import logging
import signal
import threading
from agent.config import get_settings
from agent.jobs import WorkerRuntime
logger = logging.getLogger(__name__)
def main() -> None:
"""
启动 Worker 槽与 inbox/outbox 占位线程,主线程等待停止信号。
副作用:创建后台线程;骨架不消费真实队列、不连库。
并发:LibreOffice 1 槽;HTTP 类至少 2~3 槽;与 inbox/outbox 隔离。
"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
settings = get_settings()
runtime = WorkerRuntime.from_settings(
libreoffice_slots=settings.libreoffice_slots,
http_job_slots=settings.http_job_slots,
)
runtime.start()
stop_event = threading.Event()
def _handle_signal(signum: int, _frame: object) -> None:
logger.info("收到停止信号 signum=%s", signum)
runtime.stop()
stop_event.set()
signal.signal(signal.SIGINT, _handle_signal)
signal.signal(signal.SIGTERM, _handle_signal)
logger.info("Worker 骨架运行中 ytd_env=%s(Ctrl+C 退出)", settings.ytd_env)
stop_event.wait()
logger.info("Worker 已退出")
if __name__ == "__main__":
main()