""" 空运群 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"}