202 lines
7.7 KiB
Python
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"}
|