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