Files
inquiry_robot/inquiry-agent/agent/channel/aibot/reply.py
T

202 lines
7.7 KiB
Python

"""
空运群 BOT 回话。
本文件职责:打本机桥 `/send`(骨架 `/send-group`)。
有这次 @ 的 `aibot_req_id` 时,桥走 `aibot_respond_msg`,群里才会引用原消息;
没有 req_id(典型:会话存档听到附件)才走 `aibot_send_msg` 主动发言。
禁止:走海运 appchat/send;用企微 stream 回复(会带「内容由AI生成」和赞踩);在回调线程等 LLM。
"""
from __future__ import annotations
import logging
import re
from typing import Any
import httpx
from agent.config import get_settings
logger = logging.getLogger(__name__)
# 含星号的尺寸或箱量,如 1.1*2.1*2.1、40HQ*1。已在行内代码里的不再套。
_CODE_OR_STAR = re.compile(r"`[^`]*`|([A-Za-z0-9.]+(?:\s*\*\s*[A-Za-z0-9.]+)+)")
def protect_markdown_asterisks(text: str) -> str:
"""
协同群正文走企微 markdown。星号会被当成强调,1.1*2.1*2.1 会显示成 1.12.12.1。
把含星号的一段放进行内代码,群里看到的仍是星号,不会变成灰底以外的别的字。
已经套过代码的不再套第二层。不改账上的体积原文,只改这一次出站文字。
"""
def _wrap(match: re.Match[str]) -> str:
token = match.group(1)
if not token:
return match.group(0)
return f"`{token}`"
return _CODE_OR_STAR.sub(_wrap, text or "")
class AibotReplyClient:
"""
空运群出站客户端。
空运群出站只走桥。带上这次 @ 的 req_id,桥才能引用原消息。
禁止 POST 企微 stream(群里会显示「内容由AI生成」和赞踩)。
"""
def __init__(self, response_url: str = "", timeout_sec: float = 8.0, callback_req_id: str = "") -> None:
self._url = (response_url or "").strip()
self._timeout = timeout_sec
self._callback_req_id = str(callback_req_id or "").strip()
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 = protect_markdown_asterisks(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()
# 企微 stream 会自带「内容由AI生成」和赞踩,空运群业务话术不要这条通道。
# 工单正文只走桥主动发言(markdown),和发模板同一条通道。
if room:
return self._send_proactive(room, text)
return {"ok": False, "error": "no_chat_id"}
def _send_proactive(self, chat_id: str, text: str) -> dict[str, Any]:
"""
把正文交给桥。有 callback_req_id 时桥引用这次 @;没有则主动发言。
副作用:回环 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,
"aibot_req_id": self._callback_req_id,
},
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,
"aibot_req_id": self._callback_req_id,
},
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"}