初始化询价智能体新仓:同步本地工程并排除密钥与 .env。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
智能体根包。
|
||||
|
||||
本包职责:企微/H5 通道、意图路由、Handler、LangGraph、LLM 队列、主账客户端、对象存储。
|
||||
进程入口见 http_app / worker_app;入口只组装依赖,不写业务分支。
|
||||
禁止:直连 TMS、直连 MySQL 工单表、在回调线程同步跑完整 Graph/LLM/LibreOffice。
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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 过期类时也停)
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
LangGraph 编排:图定义与节点分文件;checkpoint 在 PostgreSQL。
|
||||
|
||||
禁止:单文件堆全部节点;禁止在本包处理企微 HTTP 或 LibreOffice。
|
||||
骨架:仅包占位,不注册图。
|
||||
"""
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Handler 包:一类意图 / 一类卡片一个模块。
|
||||
|
||||
禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。
|
||||
骨架:仅包占位。
|
||||
"""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
LLM 网关与队列:DeepSeek / 千问 mode schema、认领、重试。
|
||||
|
||||
禁止:在本包维护工单六态;禁止 Redis 全局 Worker 单例锁。
|
||||
骨架:仅包占位。
|
||||
"""
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
意图路由:DeepSeek A → 唯一 RouteDecision。
|
||||
|
||||
禁止:在本包产生业务副作用(建单、发消息、改六态)。
|
||||
骨架:仅占位。
|
||||
"""
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
对象存储:只存字节;元数据登记走主账 attachment。
|
||||
|
||||
禁止:在本包维护工单状态。
|
||||
骨架:仅占位。
|
||||
"""
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user