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