mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
* feat(wecom): 智能机器人长连接接入(配置项 + 保活) 新增企业微信智能机器人(API模式/长连接)通道, 与群推送 Webhook 并存。 后端: - wecom_bot_service: WebSocket 长连接管理器, daemon 线程内跑 asyncio 连接 wss://openws.work.weixin.qq.com → aibot_subscribe 鉴权 → 30s ping 心跳 → 断开指数退避重连(min(base*2^(n-1),60s)) 结构对齐 depth_service(start/stop/boot_check/apply_credential_change) - preferences: 新增 wecom_bot_id/secret/enabled getter/setter - settings: PUT /preferences/wecom-bot 保存即重建连接 + 聚合 GET 补字段 - main.py: lifespan 挂载启停(失败不阻断应用启动) 前端: - api.ts: Preferences 加 3 字段 + updateWecomBot 函数 - Monitoring.tsx: 智能机器人配置卡片(BotID + Secret 密码框 + 连接状态) 本阶段只做连接保活, 暂不处理消息收发(@交互/流式回复后续扩展)。 * fix(wecom-bot): 卡片标签改为「企业微信 智能机器人」与上方一致 * feat(wecom-bot): 智能机器人长连接独立开关 卡片头部加勾选框, 用户可明确控制长连接开启/关闭: - 勾选 → 建立 WebSocket 连接保活 - 取消勾选 → 立即断开 后端: 新增 PUT /preferences/wecom-bot-toggle(只切 enabled 不改凭证) 前端: toggleWecomBot API + 勾选框 + 说明文字 凭证不齐时勾选框禁用(无法连接) * feat(wecom-bot): 接收消息解析并记 INFO 日志(验证接收能力) 收到消息从 DEBUG 提升到 INFO, 并解析关键帧: - aibot_msg_callback: 用户消息(会话类型/消息类型/用户ID/内容) - aibot_event_callback: 事件回调(进入会话/卡片点击/被踢等) - 其他帧: cmd + 原文截断 发消息给机器人即可在后端日志看到接收记录。
279 lines
12 KiB
Python
279 lines
12 KiB
Python
"""企业微信智能机器人长连接服务 — WebSocket 保活。
|
|
|
|
与「群推送 Webhook」(webhook_adapter, 单向 POST) 并存的第二条企业微信通道:
|
|
智能机器人开「API 模式 / 长连接」后, 通过 WebSocket 双向通信, 支持 @机器人
|
|
交互、流式回复、模板卡片。本服务只负责连接保活(连接/鉴权/心跳/重连),
|
|
暂不处理消息收发 — 后续可在此基础上扩展。
|
|
|
|
架构(对齐 depth_service):
|
|
- daemon 线程内跑 asyncio 事件循环, 用已安装的 websockets(v16) 库
|
|
- _running 线程存活标志 / _enabled 功能开关(持久化)
|
|
- 指数退避重连 min(base * 2^(n-1), 60s), 与 useQuoteStream / _post_feishu 一致
|
|
- 失败静默降级: 连接失败/凭证错误只记 WARNING, 不阻断应用启动
|
|
|
|
凭证来源: preferences.wecom_bot_id / wecom_bot_secret
|
|
连接地址: wss://openws.work.weixin.qq.com (官方固定)
|
|
协议帧(官方文档 path/101463):
|
|
订阅 aibot_subscribe → 收 errcode=0 → 每 30s ping → 收消息/事件回调
|
|
限制: 每机器人仅 1 条长连接(新连接踢旧连接), 故配置变更需 stop→start
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import threading
|
|
import time
|
|
import uuid
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 企业微信智能机器人 WebSocket 固定连接地址
|
|
_WECOM_WS_URL = "wss://openws.work.weixin.qq.com"
|
|
# 心跳间隔(官方要求 ≤30s, 否则服务端断开)
|
|
_HEARTBEAT_INTERVAL = 30.0
|
|
# 重连退避: base * 2^(n-1), 上限 60s (与 useQuoteStream.ts 一致)
|
|
_RECONNECT_BASE_DELAY = 5.0
|
|
_RECONNECT_CAP = 60.0
|
|
_RECONNECT_MAX_ATTEMPTS = 10 # 连续失败到此次数后仍继续重连, 仅放慢节奏
|
|
|
|
|
|
class WecomBotService:
|
|
"""企业微信智能机器人 WebSocket 长连接管理器 — 单例。"""
|
|
|
|
def __init__(self) -> None:
|
|
self._lock = threading.Lock()
|
|
self._running = False # 连接线程存活标志
|
|
self._thread: threading.Thread | None = None
|
|
self._ws_loop: asyncio.AbstractEventLoop | None = None
|
|
self._connected = False # WebSocket 是否已连接并通过鉴权
|
|
self._last_error: str = ""
|
|
self._app_state = None # 延迟注入, 避免循环导入
|
|
|
|
# ================================================================
|
|
# 生命周期
|
|
# ================================================================
|
|
|
|
def set_app_state(self, app_state) -> None:
|
|
"""注入 FastAPI app.state (目前未使用, 预留消息处理时访问 monitor/repo)。"""
|
|
self._app_state = app_state
|
|
|
|
def start(self) -> bool:
|
|
"""启动长连接线程。凭证不齐或已运行则跳过。返回是否真正启动。"""
|
|
from app.services import preferences
|
|
|
|
bot_id = preferences.get_wecom_bot_id()
|
|
secret = preferences.get_wecom_bot_secret()
|
|
if not bot_id or not secret:
|
|
logger.info("智能机器人未启动: 缺少 BotID 或 Secret")
|
|
return False
|
|
with self._lock:
|
|
if self._running:
|
|
return False
|
|
self._running = True
|
|
self._last_error = ""
|
|
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
|
self._thread.start()
|
|
logger.info("智能机器人长连接服务已启动 (bot_id=%s)", bot_id)
|
|
return True
|
|
|
|
def stop(self) -> None:
|
|
"""停止长连接线程。"""
|
|
with self._lock:
|
|
self._running = False
|
|
loop = self._ws_loop
|
|
# 唤醒可能在 recv/退避 sleep 中的事件循环, 促使其退出
|
|
if loop and loop.is_running():
|
|
loop.call_soon_threadsafe(loop.stop)
|
|
if self._thread:
|
|
self._thread.join(timeout=10)
|
|
self._thread = None
|
|
self._ws_loop = None
|
|
self._connected = False
|
|
logger.info("智能机器人长连接服务已停止")
|
|
|
|
def boot_check(self) -> None:
|
|
"""启动时检查 preferences, 凭证齐全且 enabled 则自动连接。
|
|
|
|
失败静默降级(记 WARNING), 不阻断应用启动。
|
|
"""
|
|
from app.services import preferences
|
|
|
|
try:
|
|
if preferences.get_wecom_bot_enabled():
|
|
self.start()
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("智能机器人 boot_check 失败: %s", e)
|
|
|
|
def apply_credential_change(self) -> None:
|
|
"""配置变更后重建连接。单连接限制: 必须 stop 再 start。
|
|
|
|
保存新凭证 → stop 旧连接 → 若 enabled 且凭证齐全则 start 新连接。
|
|
"""
|
|
was_running = self._running
|
|
if was_running:
|
|
self.stop()
|
|
# 重新读取最新凭证判断是否应启动
|
|
from app.services import preferences
|
|
if preferences.get_wecom_bot_enabled() and self.start():
|
|
logger.info("智能机器人凭证已更新, 重新连接")
|
|
elif was_running:
|
|
logger.info("智能机器人凭证已更新, 但当前未启用或凭证不齐, 停止连接")
|
|
|
|
def status(self) -> dict:
|
|
"""返回连接状态(供 UI 展示)。"""
|
|
from app.services import preferences
|
|
return {
|
|
"enabled": preferences.get_wecom_bot_enabled(),
|
|
"running": self._running,
|
|
"connected": self._connected,
|
|
"bot_id_configured": bool(preferences.get_wecom_bot_id()),
|
|
"secret_configured": bool(preferences.get_wecom_bot_secret()),
|
|
"last_error": self._last_error,
|
|
}
|
|
|
|
# ================================================================
|
|
# 连接线程
|
|
# ================================================================
|
|
|
|
def _run_loop(self) -> None:
|
|
"""daemon 线程入口: 创建 asyncio 事件循环并运行连接主循环。"""
|
|
try:
|
|
loop = asyncio.new_event_loop()
|
|
with self._lock:
|
|
self._ws_loop = loop
|
|
asyncio.set_event_loop(loop)
|
|
loop.run_until_complete(self._connect_loop())
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("智能机器人连接线程异常: %s", e)
|
|
self._last_error = str(e)
|
|
finally:
|
|
with self._lock:
|
|
self._connected = False
|
|
self._ws_loop = None
|
|
try:
|
|
loop.close()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
async def _connect_loop(self) -> None:
|
|
"""主连接循环: 连接 → 鉴权 → 心跳保活 → 断开 → 退避重连。"""
|
|
import websockets
|
|
from app.services import preferences
|
|
|
|
attempt = 0
|
|
while self._running:
|
|
bot_id = preferences.get_wecom_bot_id()
|
|
secret = preferences.get_wecom_bot_secret()
|
|
if not bot_id or not secret:
|
|
# 凭证被清空, 等待重新配置
|
|
self._last_error = "缺少 BotID 或 Secret"
|
|
await self._sleep_interruptible(5.0)
|
|
continue
|
|
|
|
try:
|
|
async with websockets.connect(
|
|
_WECOM_WS_URL,
|
|
ping_interval=None, # 用业务层 ping, 不用协议层
|
|
close_timeout=5,
|
|
) as ws:
|
|
# 1. 发送订阅鉴权帧
|
|
req_id = str(uuid.uuid4())
|
|
subscribe_frame = {
|
|
"cmd": "aibot_subscribe",
|
|
"headers": {"req_id": req_id},
|
|
"body": {"bot_id": bot_id, "secret": secret},
|
|
}
|
|
await ws.send(json.dumps(subscribe_frame))
|
|
|
|
# 2. 等待鉴权响应(errcode=0 表示成功)
|
|
resp_raw = await asyncio.wait_for(ws.recv(), timeout=15)
|
|
resp = json.loads(resp_raw)
|
|
errcode = resp.get("errcode", resp.get("body", {}).get("errcode", -1))
|
|
if errcode != 0:
|
|
errmsg = resp.get("errmsg", resp.get("body", {}).get("errmsg", "未知错误"))
|
|
self._last_error = f"鉴权失败(errcode={errcode}): {errmsg}"
|
|
logger.warning("智能机器人鉴权失败: %s", self._last_error)
|
|
# 鉴权失败是凭证问题, 重连无益, 等待用户修正
|
|
await self._sleep_interruptible(30)
|
|
continue
|
|
|
|
# 连接成功
|
|
with self._lock:
|
|
self._connected = True
|
|
self._last_error = ""
|
|
attempt = 0
|
|
logger.info("智能机器人已连接 (bot_id=%s)", bot_id)
|
|
|
|
# 3. 心跳保活 + 接收循环
|
|
await self._maintain_connection(ws)
|
|
|
|
except asyncio.TimeoutError:
|
|
self._last_error = "鉴权响应超时"
|
|
logger.warning("智能机器人鉴权超时")
|
|
except Exception as e: # noqa: BLE001 — 网络/断开, 可重连
|
|
self._last_error = str(e)
|
|
logger.warning("智能机器人连接异常: %s", e)
|
|
finally:
|
|
with self._lock:
|
|
self._connected = False
|
|
|
|
# 4. 指数退避重连
|
|
if not self._running:
|
|
break
|
|
attempt += 1
|
|
delay = min(_RECONNECT_BASE_DELAY * (2 ** (attempt - 1)), _RECONNECT_CAP)
|
|
logger.info("智能机器人 %ds 后重连(第 %d 次)", delay, attempt)
|
|
await self._sleep_interruptible(delay)
|
|
|
|
async def _maintain_connection(self, ws) -> None:
|
|
"""连接保持阶段: 每 30s 发 ping, 同时接收服务端推送。
|
|
|
|
本阶段收到消息解析并记 INFO 日志(验证接收能力), 后续消息处理在此扩展。
|
|
"""
|
|
while self._running:
|
|
try:
|
|
# 用 wait_for 同时实现"心跳定时"和"接收消息", 哪个先到都行
|
|
try:
|
|
raw = await asyncio.wait_for(ws.recv(), timeout=_HEARTBEAT_INTERVAL)
|
|
self._log_incoming(raw)
|
|
continue
|
|
except asyncio.TimeoutError:
|
|
pass # 接收超时 → 到了心跳时间
|
|
# 发送业务层 ping 保活
|
|
await ws.send(json.dumps({"cmd": "ping"}))
|
|
except Exception as e: # noqa: BLE001 — 连接断开, 抛给上层重连
|
|
raise
|
|
|
|
def _log_incoming(self, raw) -> None:
|
|
"""解析并记录收到的消息帧(供测试接收能力)。"""
|
|
try:
|
|
frame = json.loads(raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.info("智能机器人收到非 JSON 消息: %s", str(raw)[:200])
|
|
return
|
|
cmd = frame.get("cmd", "?")
|
|
body = frame.get("body", {})
|
|
# 用户消息: aibot_msg_callback (用户 @机器人 / 单聊发消息)
|
|
if cmd == "aibot_msg_callback":
|
|
userid = body.get("from", {}).get("userid", "?")
|
|
chattype = body.get("chattype", "?")
|
|
msgtype = body.get("msgtype", "?")
|
|
# 文本消息内容在 body.text.content 或 body.content
|
|
content = body.get("text", {}).get("content") or body.get("content", "")
|
|
logger.info("智能机器人收到用户消息 [%s/%s] %s: %s",
|
|
chattype, msgtype, userid, str(content)[:100])
|
|
# 事件回调: 进入会话 / 卡片点击 / 连接被踢等
|
|
elif cmd == "aibot_event_callback":
|
|
eventtype = body.get("event", {}).get("eventtype", "?")
|
|
logger.info("智能机器人收到事件回调: %s", eventtype)
|
|
else:
|
|
logger.info("智能机器人收到帧 cmd=%s: %s", cmd, str(raw)[:200])
|
|
|
|
async def _sleep_interruptible(self, seconds: float) -> None:
|
|
"""可被 stop() 中断的 sleep(通过检查 _running)。"""
|
|
waited = 0.0
|
|
while self._running and waited < seconds:
|
|
await asyncio.sleep(min(0.5, seconds - waited))
|
|
waited += 0.5
|