锁舱按签名原字节出站,查价要带上 TMS 真实 quoteId/optionNo;收到 2001 换令牌再查,避免工单建了却提示查询失败。 Co-authored-by: Cursor <cursoragent@cursor.com>
196 lines
7.5 KiB
Python
196 lines
7.5 KiB
Python
"""
|
||
空运群 BOT 回话。
|
||
|
||
本文件职责:有即时 `response_url` 就流式回;没有(发文件走存档)就打本机桥 `/send-group`,
|
||
由长连接 `aibot_send_msg` 主动往群里说。
|
||
禁止:走海运 appchat/send;在回调线程等 LLM。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import uuid
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from agent.config import get_settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class AibotReplyClient:
|
||
"""
|
||
空运群出站客户端。
|
||
|
||
@ 带了 response_url:流式只收口这次 @;正文走桥主动发言,和发模板同一条通道。
|
||
存档听到附件时没有 url:直接走桥。该群须先被 @ 过,企微才允许主动说。
|
||
"""
|
||
|
||
def __init__(self, response_url: str = "", timeout_sec: float = 8.0) -> None:
|
||
self._url = (response_url or "").strip()
|
||
self._timeout = timeout_sec
|
||
self.sent: list[dict[str, Any]] = []
|
||
|
||
def send_group(
|
||
self,
|
||
*,
|
||
chat_id: str,
|
||
content: str,
|
||
mention_userids: list[str] | None = None,
|
||
) -> dict[str, Any]:
|
||
_ = mention_userids
|
||
text = str(content or "").strip()
|
||
self.sent.append({"chat_id": chat_id, "content": text, "kind": "text"})
|
||
if not text:
|
||
return {"ok": False, "error": "empty"}
|
||
room = str(chat_id or "").strip()
|
||
# @ 当场流式回话群里经常看不见长摘要,模板却走主动发言,就会只剩一张表。
|
||
# 工单信息必须再走桥,和模板同一条通道;流式只收口这次 @。
|
||
if self._url:
|
||
self._send_stream(room, text.split("\n", 1)[0][:80])
|
||
if room:
|
||
out = self._send_proactive(room, text)
|
||
if out.get("ok") or not self._url:
|
||
return out
|
||
if self._url:
|
||
return self._send_stream(room, text)
|
||
return {"ok": False, "error": "no_chat_id"}
|
||
|
||
def _send_stream(self, chat_id: str, text: str) -> dict[str, Any]:
|
||
"""@ 当场回话:POST 企微给的 response_url。"""
|
||
body = {
|
||
"msgtype": "stream",
|
||
"stream": {
|
||
"id": uuid.uuid4().hex,
|
||
"finish": True,
|
||
"content": text,
|
||
},
|
||
}
|
||
try:
|
||
resp = httpx.post(self._url, json=body, timeout=self._timeout)
|
||
ok = 200 <= resp.status_code < 300
|
||
if not ok:
|
||
logger.warning("aibot 回话 HTTP=%s chat=%s", resp.status_code, chat_id)
|
||
return {"ok": ok, "status": resp.status_code}
|
||
except Exception:
|
||
logger.exception("aibot 回话网络失败 chat=%s", chat_id)
|
||
return {"ok": False, "error": "network"}
|
||
|
||
def _send_proactive(self, chat_id: str, text: str) -> dict[str, Any]:
|
||
"""
|
||
没有 response_url(典型:会话存档听到附件)时,让桥主动往群里说。
|
||
|
||
副作用:回环 HTTP 打 8913,不直连企微。桥未连上或该群从未 @ 过,企微不会投递。
|
||
"""
|
||
if not chat_id:
|
||
logger.warning("aibot 主动发言缺少 chat_id")
|
||
return {"ok": False, "error": "no_chat_id"}
|
||
settings = get_settings()
|
||
base = str(getattr(settings, "wecom_aibot_bridge_url", "") or "").rstrip("/")
|
||
if not base:
|
||
logger.warning("aibot 主动发言未配置 WECOM_AIBOT_BRIDGE_URL chat=%s", chat_id)
|
||
return {"ok": False, "error": "no_bridge"}
|
||
headers = {"Content-Type": "application/json"}
|
||
token = str(getattr(settings, "wecom_aibot_bridge_token", "") or "").strip()
|
||
if token:
|
||
headers["X-Aibot-Bridge-Token"] = token
|
||
headers["X-Ytd-Aibot-Bridge-Token"] = token
|
||
try:
|
||
# 现网官方桥是 POST /send;本仓库骨架桥是 /send-group。两个都试,先打官方口径。
|
||
resp = httpx.post(
|
||
f"{base}/send",
|
||
json={"chatid": chat_id, "operation": "text", "content": text},
|
||
headers=headers,
|
||
timeout=self._timeout,
|
||
)
|
||
if resp.status_code == 404:
|
||
resp = httpx.post(
|
||
f"{base}/send-group",
|
||
json={"chat_id": chat_id, "content": text, "chat_type": 2},
|
||
headers=headers,
|
||
timeout=self._timeout,
|
||
)
|
||
ok = 200 <= resp.status_code < 300
|
||
if not ok:
|
||
logger.warning(
|
||
"aibot 主动发言 HTTP=%s chat=%s body=%s",
|
||
resp.status_code,
|
||
chat_id,
|
||
(resp.text or "")[:200],
|
||
)
|
||
return {"ok": ok, "status": resp.status_code}
|
||
except Exception:
|
||
logger.exception("aibot 主动发言网络失败 chat=%s", chat_id)
|
||
return {"ok": False, "error": "network"}
|
||
|
||
def send_group_file(
|
||
self,
|
||
*,
|
||
chat_id: str,
|
||
filename: str,
|
||
filepath: str = "",
|
||
file_b64: str = "",
|
||
) -> dict[str, Any]:
|
||
"""
|
||
空运群发填好的报价模板。走桥 /send-file,由长连接上传素材再 aibot_send_msg。
|
||
|
||
不走海运 appchat。该群须先 @ 过小助手,企微才投递。
|
||
"""
|
||
import base64
|
||
from pathlib import Path
|
||
|
||
room = str(chat_id or "").strip()
|
||
name = str(filename or "quote.xlsx").strip() or "quote.xlsx"
|
||
raw = b""
|
||
if filepath:
|
||
try:
|
||
raw = Path(filepath).read_bytes()
|
||
except OSError as exc:
|
||
logger.warning("aibot 读模板失败 chat=%s err=%s", room, exc)
|
||
return {"ok": False, "error": f"read_file:{exc}"}
|
||
elif file_b64:
|
||
try:
|
||
raw = base64.b64decode(file_b64)
|
||
except Exception:
|
||
logger.warning("aibot 模板解码失败 chat=%s", room)
|
||
return {"ok": False, "error": "b64"}
|
||
self.sent.append({"chat_id": room, "filename": name, "kind": "file"})
|
||
if not room or not raw:
|
||
return {"ok": False, "error": "file_empty"}
|
||
settings = get_settings()
|
||
base = str(getattr(settings, "wecom_aibot_bridge_url", "") or "").rstrip("/")
|
||
if not base:
|
||
logger.warning("aibot 发文件未配置 WECOM_AIBOT_BRIDGE_URL chat=%s", room)
|
||
return {"ok": False, "error": "no_bridge"}
|
||
headers = {"Content-Type": "application/json"}
|
||
token = str(getattr(settings, "wecom_aibot_bridge_token", "") or "").strip()
|
||
if token:
|
||
headers["X-Aibot-Bridge-Token"] = token
|
||
headers["X-Ytd-Aibot-Bridge-Token"] = token
|
||
payload = {
|
||
"chatid": room,
|
||
"chat_id": room,
|
||
"filename": name,
|
||
"file_b64": base64.b64encode(raw).decode("ascii"),
|
||
}
|
||
try:
|
||
resp = httpx.post(
|
||
f"{base}/send-file",
|
||
json=payload,
|
||
headers=headers,
|
||
timeout=max(self._timeout, 45.0),
|
||
)
|
||
ok = 200 <= resp.status_code < 300
|
||
if not ok:
|
||
logger.warning(
|
||
"aibot 发文件 HTTP=%s chat=%s body=%s",
|
||
resp.status_code,
|
||
room,
|
||
(resp.text or "")[:200],
|
||
)
|
||
return {"ok": ok, "status": resp.status_code}
|
||
except Exception:
|
||
logger.exception("aibot 发文件网络失败 chat=%s", room)
|
||
return {"ok": False, "error": "network"}
|