落地空运群内协同与锁舱出站。

群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 17:25:42 +08:00
co-authored by Cursor
parent 12219cacb1
commit d9029dec89
62 changed files with 6772 additions and 159 deletions
+52 -14
View File
@@ -26,8 +26,8 @@ router = APIRouter(prefix="/internal/aibot", tags=["aibot"])
class AibotInboundBody(BaseModel):
"""桥转发的归一字段(桥侧已解析,智能体不再调企微换 ID)。"""
sender_id: str = Field(description="企微 userid,必须来自 AIBOT 报文")
message_id: str
sender_id: str = Field(default="", description="企微 userid,必须来自 AIBOT 报文")
message_id: str = ""
content: str = ""
msg_type: str = "text"
chat_id: str = ""
@@ -38,6 +38,38 @@ class AibotInboundBody(BaseModel):
raw: dict[str, Any] = Field(default_factory=dict)
def normalize_aibot_payload(body: dict[str, Any]) -> AibotInboundBody:
"""
同时吃两套入站:智能体骨架字段,以及现网官方桥转发的企微 body
(msgid/chatid/from.userid/text.content)。
"""
data = dict(body or {})
from_user = data.get("from") if isinstance(data.get("from"), dict) else {}
text_obj = data.get("text") if isinstance(data.get("text"), dict) else {}
sender = str(data.get("sender_id") or from_user.get("userid") or "").strip()
mid = str(data.get("message_id") or data.get("msgid") or "").strip()
content = str(data.get("content") or text_obj.get("content") or "")
chat_id = str(data.get("chat_id") or data.get("chatid") or "").strip()
chattype = str(data.get("chat_type") or data.get("chattype") or "group").strip().lower()
chat_type = "c2c" if chattype in {"single", "c2c", "private"} else "group"
raw = dict(data.get("raw") or {})
raw.setdefault("source", "aibot")
if data.get("response_url"):
raw.setdefault("response_url", data.get("response_url"))
return AibotInboundBody(
sender_id=sender,
message_id=mid,
content=content,
msg_type=str(data.get("msg_type") or data.get("msgtype") or "text"),
chat_id=chat_id,
chat_type=chat_type,
agent_id=str(data.get("agent_id") or data.get("aibotid") or ""),
create_time=int(data.get("create_time") or 0),
media=dict(data.get("media") or {}),
raw=raw,
)
def _check_bridge_token(x_token: Optional[str]) -> None:
s = get_settings()
expected = (getattr(s, "wecom_aibot_bridge_token", None) or "").strip()
@@ -57,26 +89,32 @@ def aibot_ping() -> dict[str, str]:
@router.post("/inbound")
def aibot_inbound(
body: AibotInboundBody,
body: dict[str, Any],
x_aibot_bridge_token: Optional[str] = Header(
default=None, alias="X-Aibot-Bridge-Token"
),
x_ytd_aibot_bridge_token: Optional[str] = Header(
default=None, alias="X-Ytd-Aibot-Bridge-Token"
),
) -> dict[str, Any]:
"""
接收桥转发入站:无 sender_id 拒绝;其余入 inbox。
"""
_check_bridge_token(x_aibot_bridge_token)
_check_bridge_token(x_aibot_bridge_token or x_ytd_aibot_bridge_token)
parsed = normalize_aibot_payload(body)
raw = dict(parsed.raw or {})
raw.setdefault("source", "aibot")
msg = InboundMessage(
sender_id=body.sender_id.strip(),
message_id=body.message_id.strip(),
content=body.content or "",
msg_type=body.msg_type or "text",
chat_id=body.chat_id or "",
chat_type=body.chat_type or "group",
agent_id=body.agent_id or "",
create_time=int(body.create_time or 0),
media=body.media or {},
raw=body.raw or {},
sender_id=parsed.sender_id.strip(),
message_id=parsed.message_id.strip(),
content=parsed.content or "",
msg_type=parsed.msg_type or "text",
chat_id=parsed.chat_id or "",
chat_type=parsed.chat_type or "group",
agent_id=parsed.agent_id or "",
create_time=int(parsed.create_time or 0),
media=parsed.media or {},
raw=raw,
)
if not msg.has_sender():
raise HTTPException(status_code=400, detail="missing_sender_id")
+186
View File
@@ -0,0 +1,186 @@
"""
空运群 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"}