Files
inquiry_robot/inquiry-agent/agent/channel/aibot/reply.py
T
jillion886andCursor d9029dec89 落地空运群内协同与锁舱出站。
群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 17:25:42 +08:00

187 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
空运群 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"}
if self._url:
return self._send_stream(chat_id, text)
return self._send_proactive(str(chat_id or "").strip(), text)
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"}