落地私聊空运询价与TMS报价回填,按后台模板出单。

查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 20:25:35 +08:00
co-authored by Cursor
parent cea3a6e51e
commit 99da1faf30
78 changed files with 8838 additions and 321 deletions
+180 -12
View File
@@ -54,6 +54,170 @@ class WeComAppClient:
self._token_expire_at: float = 0.0
self._lock = threading.Lock()
def send_outbound(
self,
*,
touser: str,
content: str,
payload: dict[str, Any] | None = None,
) -> WeComSendResult:
"""
按 outbox 内容发应用消息:有模板卡先发卡,失败再回退文本。
副作用:出站 HTTP 调企微;失败返回 ok=False,由 outbox 重试。
"""
extra = dict(payload or {})
if extra.get("msgtype") == "file":
return self.send_file(
touser=touser,
filename=str(extra.get("filename") or "quote.xlsx"),
filepath=str(extra.get("filepath") or ""),
file_b64=str(extra.get("file_b64") or extra.get("fileBase64") or ""),
)
if extra.get("msgtype") == "update_template_card":
return self.update_template_card(
touser=touser,
response_code=str(extra.get("response_code") or ""),
replace_name=str((extra.get("button") or {}).get("replace_name") or ""),
)
if extra.get("msgtype") == "template_card" and extra.get("template_card"):
card_result = self._post_message(
{
"touser": touser,
"msgtype": "template_card",
"agentid": self._agent_id,
"template_card": extra["template_card"],
"enable_id_trans": 0,
},
preview="[template_card]",
)
if card_result.ok:
return card_result
logger.warning(
"模板卡发送失败,回退文本 err=%s:%s",
card_result.errcode,
card_result.errmsg,
)
return self.send_text(touser=touser, content=content)
def update_template_card(
self,
*,
touser: str,
response_code: str,
replace_name: str,
) -> WeComSendResult:
"""
按回调 ResponseCode 更新原模板卡按钮文案,保持置灰不可再点。
失败不回落成新文本气泡,避免销售看到重复「已提交」。
"""
if not touser or not response_code or not replace_name:
return WeComSendResult(ok=True, errcode=0, errmsg="skip_card_update")
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("更新模板卡取 token 失败")
return WeComSendResult(ok=True, errcode=-1, errmsg=str(exc))
url = f"{self._api_base}/cgi-bin/message/update_template_card"
body = {
"userids": [touser],
"agentid": self._agent_id,
"response_code": response_code,
"enable_id_trans": 0,
"button": {"replace_name": replace_name},
}
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("企微 update_template_card 网络失败")
return WeComSendResult(ok=True, errcode=-1, errmsg=str(exc))
errcode = int(data.get("errcode") or 0)
errmsg = str(data.get("errmsg") or "")
if errcode != 0:
logger.warning("更新模板卡失败 errcode=%s errmsg=%s", errcode, errmsg)
return WeComSendResult(ok=True, errcode=errcode, errmsg=errmsg, raw=data)
logger.info("企微模板卡已置灰 touser=%s", touser)
return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data)
def send_file(
self,
*,
touser: str,
filename: str,
filepath: str = "",
file_b64: str = "",
) -> WeComSendResult:
"""
先上传临时素材再发文件消息。
字节来自本机路径或 Base64;失败回 ok=False 由 outbox 重试。
禁止在回调线程调用;本方法只给出站消费线程用。
"""
if not touser:
return WeComSendResult(ok=False, errcode=-1, errmsg="touser 为空")
raw = b""
if filepath:
try:
from pathlib import Path
raw = Path(filepath).read_bytes()
except OSError as exc:
return WeComSendResult(ok=False, errcode=-1, errmsg=f"read_file:{exc}")
elif file_b64:
import base64
try:
raw = base64.b64decode(file_b64)
except Exception as exc: # noqa: BLE001
return WeComSendResult(ok=False, errcode=-1, errmsg=f"b64:{exc}")
if not raw:
return WeComSendResult(ok=False, errcode=-1, errmsg="file_empty")
media_id, up = self._upload_media(filename=filename or "quote.xlsx", content=raw)
if not media_id:
return up
return self._post_message(
{
"touser": touser,
"msgtype": "file",
"agentid": self._agent_id,
"file": {"media_id": media_id},
},
preview=f"[file]{filename}",
)
def _upload_media(self, *, filename: str, content: bytes) -> tuple[str, WeComSendResult]:
"""cgi-bin/media/upload;密钥不写日志。"""
if not self._corp_id or not self._secret:
return "", WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置")
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("上传素材取 token 失败")
return "", WeComSendResult(ok=False, errcode=-1, errmsg=str(exc))
url = f"{self._api_base}/cgi-bin/media/upload"
try:
with httpx.Client(timeout=max(self._timeout, 30.0)) as client:
resp = client.post(
url,
params={"access_token": token, "type": "file"},
files={"media": (filename, content, "application/octet-stream")},
)
data = resp.json()
except Exception as exc: # noqa: BLE001
logger.exception("企微 media/upload 网络失败")
return "", WeComSendResult(ok=False, errcode=-1, errmsg=str(exc))
errcode = int(data.get("errcode") or 0)
media_id = str(data.get("media_id") or "")
if errcode != 0 or not media_id:
logger.warning("企微上传文件失败 errcode=%s", errcode)
return "", WeComSendResult(
ok=False, errcode=errcode, errmsg=str(data.get("errmsg") or "upload_fail"), raw=data
)
return media_id, WeComSendResult(ok=True, errcode=0, raw=data)
def send_text(self, *, touser: str, content: str) -> WeComSendResult:
"""
向指定企微 userid 发送文本应用消息。
@@ -61,11 +225,23 @@ class WeComAppClient:
参数: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 为空")
return self._post_message(
{
"touser": touser,
"msgtype": "text",
"agentid": self._agent_id,
"text": {"content": content},
"safe": 0,
},
preview=content,
)
def _post_message(self, body: dict[str, Any], *, preview: str) -> WeComSendResult:
"""真正打企微 message/send;密钥不写日志。"""
if not self._corp_id or not self._secret:
return WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置")
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
@@ -73,13 +249,6 @@ class WeComAppClient:
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)
@@ -91,15 +260,14 @@ class WeComAppClient:
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)
preview = content if len(content) <= 200 else (content[:200] + "…")
logger.info("企微发送成功 touser=%s reply=%s", touser, preview)
shown = preview if len(preview) <= 200 else (preview[:200] + "…")
logger.info("企微发送成功 touser=%s reply=%s", body.get("touser"), shown)
return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data)
def _get_token(self) -> str:
+17 -3
View File
@@ -157,6 +157,7 @@ class PostgresMessageStore:
touser: str,
content: str,
dedupe_key: str = "",
payload: Optional[dict[str, Any]] = None,
) -> tuple[bool, str]:
if not touser or not content:
return False, "invalid_outbox"
@@ -167,9 +168,15 @@ class PostgresMessageStore:
"""
INSERT INTO agent_outbox (
id, dedupe_key, touser, content, payload, state
) VALUES (%s, %s, %s, %s, '{}'::jsonb, 'pending')
) VALUES (%s, %s, %s, %s, %s::jsonb, 'pending')
""",
(item_id, dedupe_key or "", touser, content),
(
item_id,
dedupe_key or "",
touser,
content,
json.dumps(dict(payload or {}), ensure_ascii=False),
),
)
conn.commit()
self._outbox_event.set()
@@ -209,7 +216,7 @@ class PostgresMessageStore:
FROM cte
WHERE o.id = cte.id
RETURNING o.id, o.touser, o.content, o.attempts, o.last_error,
o.created_at, o.dedupe_key, o.claimed_at
o.created_at, o.dedupe_key, o.claimed_at, o.payload
""",
(str(timeout), self._worker_id),
).fetchone()
@@ -221,6 +228,12 @@ class PostgresMessageStore:
created_ts = created.timestamp() if hasattr(created, "timestamp") else time.time()
claimed = row[7]
claimed_ts = claimed.timestamp() if hasattr(claimed, "timestamp") else time.time()
raw_payload = row[8] if len(row) > 8 else {}
if isinstance(raw_payload, str):
try:
raw_payload = json.loads(raw_payload)
except json.JSONDecodeError:
raw_payload = {}
return OutboxItem(
id=str(row[0]),
touser=str(row[1]),
@@ -231,6 +244,7 @@ class PostgresMessageStore:
last_error=str(row[4] or ""),
created_at=created_ts,
dedupe_key=str(row[6] or ""),
payload=dict(raw_payload or {}),
)
def complete_outbound(
+4 -1
View File
@@ -59,6 +59,7 @@ class OutboxItem:
last_error: str = ""
created_at: float = field(default_factory=time.time)
dedupe_key: str = ""
payload: dict[str, Any] = field(default_factory=dict)
class MemoryMessageStore:
@@ -140,8 +141,9 @@ class MemoryMessageStore:
touser: str,
content: str,
dedupe_key: str = "",
payload: Optional[dict[str, Any]] = None,
) -> tuple[bool, str]:
"""写入 outbox;dedupe_key 重复则跳过。"""
"""写入 outbox;dedupe_key 重复则跳过。payload 可带企微模板卡。"""
if not touser or not content:
return False, "invalid_outbox"
with self._lock:
@@ -154,6 +156,7 @@ class MemoryMessageStore:
touser=touser,
content=content,
dedupe_key=dedupe_key,
payload=dict(payload or {}),
)
self._outbox[item.id] = item
self._outbox_event.set()
+14 -7
View File
@@ -27,7 +27,7 @@ class ChannelRuntime:
企微通道运行时:inbox/outbox 各一条守护线程。
启动时机:FastAPI lifespan;停止:进程退出。
inbox:IntentRouter.route(默认 stub)→ dispatch 查表;无业务分支森林。
inbox:自由文字走 DeepSeek A 认意图(本线程,非回调);点卡不进模型。
"""
def __init__(self, settings: Settings) -> None:
@@ -35,7 +35,8 @@ class ChannelRuntime:
self._store = get_message_store()
self._stop = threading.Event()
self._threads: list[threading.Thread] = []
self._router = IntentRouter(allow_network=False)
# 开网后销售原话交给模型认「续办/新询价」;未开网才走 stub。
self._router = IntentRouter(allow_network=bool(settings.llm_allow_network))
agent_id = int(settings.wecom_agent_id or "0") or 0
self._client = WeComAppClient(
corp_id=settings.wecom_corp_id,
@@ -68,11 +69,13 @@ class ChannelRuntime:
self._store.wait_inbox(1.0)
continue
try:
decision = self._router.route(
text=item.message.content or "",
sender_id=item.message.sender_id or "",
message = item.message
decision = self._router.route_inbound(
text=message.content or "",
sender_id=message.sender_id or "",
event=str((message.raw or {}).get("event") or ""),
)
dispatch_inbound(item.message, decision)
dispatch_inbound(message, decision)
self._store.complete_inbound(item.id)
except Exception as exc: # noqa: BLE001
logger.exception("inbox 处理失败 id=%s", item.id)
@@ -91,7 +94,11 @@ class ChannelRuntime:
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)
result = self._client.send_outbound(
touser=item.touser,
content=item.content,
payload=getattr(item, "payload", None) or {},
)
if result.ok:
self._store.complete_outbound(item.id)
elif result.errcode in PERMANENT_SEND_ERRORS:
@@ -27,6 +27,7 @@ class MessageStore(Protocol):
touser: str,
content: str,
dedupe_key: str = "",
payload: Optional[dict[str, Any]] = None,
) -> tuple[bool, str]: ...
def claim_outbound(self) -> Optional[OutboxItem]: ...
+13 -2
View File
@@ -35,8 +35,13 @@ def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMess
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 Key;模板卡点击要把按钮键收成可入询价流程的正文
event = _text(root, "Event")
event_key = _text(root, "EventKey")
response_code = _text(root, "ResponseCode")
task_id = _text(root, "TaskId") or _text(root, "TaskID")
if not content and event.lower() in {"template_card_event", "click", "templatecardevent"}:
content = event_key
if not msg_id:
# 无 MsgId(如部分事件)时用发送人+时间拼幂等键,避免空键撞车
msg_id = f"{sender_id}:{create_time}:{msg_type}:{event}"
@@ -58,7 +63,13 @@ def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMess
agent_id=agent_id,
create_time=create_time,
media={},
raw={"event": event, "xml": xml_text},
raw={
"event": event,
"event_key": event_key,
"response_code": response_code,
"task_id": task_id,
"xml": xml_text,
},
)