落地空运群内协同与锁舱出站。
群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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"}
|
||||
@@ -356,8 +356,9 @@ class WeComAppClient:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
errcode = int(data.get("errcode") or 0)
|
||||
if errcode != 0:
|
||||
logger.warning("企微群消息失败 errcode=%s", errcode)
|
||||
logger.warning("企微群消息失败 errcode=%s chat=%s", errcode, chat_id)
|
||||
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
|
||||
logger.info("企微群文本已发送 chat=%s chars=%s", chat_id, len(content))
|
||||
return {"ok": True, "error": ""}
|
||||
|
||||
def send_group_file(
|
||||
|
||||
@@ -27,7 +27,7 @@ class ChannelRuntime:
|
||||
企微通道运行时:inbox/outbox 各一条守护线程。
|
||||
|
||||
启动时机:FastAPI lifespan;停止:进程退出。
|
||||
inbox:自由文字走 DeepSeek A 认意图(本线程,非回调);点卡不进模型。
|
||||
inbox:私聊自由文字走 DeepSeek A 认意图(本线程,非回调);群消息和点卡不进模型。
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
@@ -70,11 +70,17 @@ class ChannelRuntime:
|
||||
continue
|
||||
try:
|
||||
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 按 BOT/存档分流,不消耗 DeepSeek A,避免空运群先卡 2~3 秒。
|
||||
if (message.chat_type or "") == "group":
|
||||
from agent.routing.decision import RouteDecision
|
||||
|
||||
decision = RouteDecision(intent="ordinary_text_other")
|
||||
else:
|
||||
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(message, decision)
|
||||
self._store.complete_inbound(item.id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
|
||||
@@ -41,6 +41,47 @@ def _create_time(msgtime: Any) -> int:
|
||||
return n
|
||||
|
||||
|
||||
def _unwrap_text(raw: Any) -> str:
|
||||
"""
|
||||
从字符串或嵌套对象取出正文。
|
||||
|
||||
管家 content 经常是摘要;企微官方结构是 text.content。空、null 不当正文。
|
||||
"""
|
||||
if raw is None:
|
||||
return ""
|
||||
if isinstance(raw, str):
|
||||
text = raw.strip()
|
||||
if not text or text.lower() in {"null", "none"}:
|
||||
return ""
|
||||
if text.startswith("{") or text.startswith("["):
|
||||
try:
|
||||
return _unwrap_text(json.loads(text))
|
||||
except json.JSONDecodeError:
|
||||
return text
|
||||
return text
|
||||
if isinstance(raw, Mapping):
|
||||
for key in ("content", "text", "msg"):
|
||||
got = _unwrap_text(raw.get(key))
|
||||
if got:
|
||||
return got
|
||||
return ""
|
||||
return str(raw).strip()
|
||||
|
||||
|
||||
def _best_text(record: Mapping[str, Any], payload: Mapping[str, Any]) -> str:
|
||||
"""多处正文里取最长的一份,避免摘要盖掉带金额的原文。"""
|
||||
candidates = [
|
||||
_unwrap_text(record.get("content")),
|
||||
_unwrap_text(payload.get("content")),
|
||||
_unwrap_text(payload.get("text")),
|
||||
_unwrap_text(payload.get("msg")),
|
||||
]
|
||||
texts = [x for x in candidates if x]
|
||||
if not texts:
|
||||
return ""
|
||||
return max(texts, key=len)
|
||||
|
||||
|
||||
def _filename(msg_type: str, content: str, payload: Mapping[str, Any]) -> str:
|
||||
for key in ("filename", "file_name", "FileName", "title", "name"):
|
||||
val = str(payload.get(key) or "").strip()
|
||||
@@ -75,7 +116,7 @@ def archive_record_to_inbound(
|
||||
return None
|
||||
|
||||
payload = _as_mapping(record.get("msg_content") or record.get("msgcontent"))
|
||||
content = str(record.get("content") or payload.get("content") or "").strip()
|
||||
content = _best_text(record, payload)
|
||||
msgid = str(record.get("msgid") or record.get("msg_id") or "").strip()
|
||||
seq = record.get("seq")
|
||||
if not msgid:
|
||||
|
||||
@@ -123,6 +123,21 @@ def poll_once(
|
||||
page_size = int(getattr(cfg, "wshoto_page_size", 50) or 50)
|
||||
clock = now or time.time
|
||||
rooms = list_watched_rooms(redis=redis)
|
||||
scan_ids = [
|
||||
x.strip()
|
||||
for x in str(getattr(cfg, "wshoto_scan_user_ids", "") or "").replace(";", ",").split(",")
|
||||
if x.strip()
|
||||
]
|
||||
extra: list[dict[str, Any]] = []
|
||||
if scan_ids:
|
||||
extra = _poll_by_scan_users(
|
||||
api,
|
||||
userids=scan_ids,
|
||||
redis=redis,
|
||||
page_size=page_size,
|
||||
lookback=lookback,
|
||||
clock=clock,
|
||||
)
|
||||
if rooms:
|
||||
# 应用群必须带房间号、不要带存档席 userid,否则管家返回空页。
|
||||
records, mode, last = _poll_by_rooms(
|
||||
@@ -132,6 +147,8 @@ def poll_once(
|
||||
lookback=lookback,
|
||||
clock=clock,
|
||||
)
|
||||
if extra:
|
||||
records = list(records) + extra
|
||||
else:
|
||||
seat = seats[0] if seats else ""
|
||||
last = load_last_seq(f"seat:{seat or 'default'}", redis=redis)
|
||||
@@ -160,6 +177,9 @@ def poll_once(
|
||||
lookback=lookback,
|
||||
clock=clock,
|
||||
)
|
||||
if extra:
|
||||
records = list(records) + extra
|
||||
mode = "rooms"
|
||||
# 按房间拉时各群自己的 last_seq 已经过滤过,这里不要再用席游标误丢。
|
||||
ingest_min = 0 if mode == "rooms" else last
|
||||
enqueued, skipped = ingest_records(
|
||||
@@ -194,6 +214,61 @@ def poll_once(
|
||||
}
|
||||
|
||||
|
||||
def _poll_by_scan_users(
|
||||
api: WshotoOpenClient,
|
||||
*,
|
||||
userids: Sequence[str],
|
||||
redis: Optional[RedisClient],
|
||||
page_size: int,
|
||||
lookback: int,
|
||||
clock: Callable[[], float],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
按人扫新群。空运群是人自建的,存档席没进群时房间名单里没有它。
|
||||
|
||||
副作用:看到带 @询价小助手 的新房间会登记监听。
|
||||
"""
|
||||
from agent.channel.wshoto.rooms import watch_collab_room
|
||||
|
||||
merged: list[dict[str, Any]] = []
|
||||
now = int(clock())
|
||||
for uid in userids:
|
||||
scope = f"scan:{uid}"
|
||||
last = load_last_seq(scope, redis=redis)
|
||||
time_start = 0
|
||||
time_end = 0
|
||||
if last <= 0:
|
||||
time_end = now
|
||||
time_start = time_end - min(lookback, 8 * 3600)
|
||||
try:
|
||||
rows = api.list_group_messages(
|
||||
userid=uid,
|
||||
last_seq=last,
|
||||
page_size=page_size,
|
||||
msg_time_start=time_start,
|
||||
msg_time_end=time_end,
|
||||
)
|
||||
except WshotoError as exc:
|
||||
logger.warning("微盛按人扫群失败 userid=%s code=%s", uid, exc.code)
|
||||
continue
|
||||
max_seq = last
|
||||
for rec in rows:
|
||||
seq = _record_seq(rec)
|
||||
if seq > max_seq:
|
||||
max_seq = seq
|
||||
room = str(rec.get("room_id") or rec.get("roomid") or "").strip()
|
||||
blob = str(rec.get("content") or "")
|
||||
if room and "询价小助手" in blob:
|
||||
try:
|
||||
watch_collab_room(room, redis=redis)
|
||||
except Exception:
|
||||
logger.exception("扫到空运群后登记监听失败 chat=%s", room)
|
||||
if max_seq > last:
|
||||
save_last_seq(scope, max_seq, redis=redis)
|
||||
merged.extend(rows)
|
||||
return merged
|
||||
|
||||
|
||||
def _poll_by_rooms(
|
||||
api: WshotoOpenClient,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user