From e0badf1458c80c83b35b71d8badbd2595cb6d547 Mon Sep 17 00:00:00 2001 From: wshy Date: Thu, 9 Jul 2026 16:35:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(wecom):=20=E6=99=BA=E8=83=BD=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E4=BA=BA=E9=95=BF=E8=BF=9E=E6=8E=A5=E6=8E=A5=E5=85=A5?= =?UTF-8?q?(=E9=85=8D=E7=BD=AE=E9=A1=B9=20+=20=E4=BF=9D=E6=B4=BB)=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 + 原文截断 发消息给机器人即可在后端日志看到接收记录。 --- backend/app/api/settings.py | 66 +++++ backend/app/main.py | 13 + backend/app/services/preferences.py | 37 +++ backend/app/services/wecom_bot_service.py | 278 +++++++++++++++++++++ frontend/src/lib/api.ts | 27 ++ frontend/src/pages/settings/Monitoring.tsx | 139 +++++++++++ 6 files changed, 560 insertions(+) create mode 100644 backend/app/services/wecom_bot_service.py diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index b72dc49..cfe16e8 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -399,6 +399,9 @@ def get_preferences() -> dict: "feishu_webhook_url": preferences.get_feishu_webhook_url(), "feishu_webhook_secret": preferences.get_feishu_webhook_secret(), "wecom_webhook_url": preferences.get_wecom_webhook_url(), + "wecom_bot_id": preferences.get_wecom_bot_id(), + "wecom_bot_secret": preferences.get_wecom_bot_secret(), + "wecom_bot_enabled": preferences.get_wecom_bot_enabled(), "webhook_enabled_default": preferences.get_webhook_enabled_default(), "webhook_default_channels": preferences.get_webhook_default_channels(), "sidebar_index_symbols": preferences.get_sidebar_index_symbols(), @@ -851,6 +854,69 @@ def update_wecom_webhook(req: WecomWebhookPrefsIn) -> dict: return {"wecom_webhook_url": saved_url} +class WecomBotPrefsIn(BaseModel): + bot_id: str + secret: str + enabled: bool = True + + +@router.put("/preferences/wecom-bot") +def update_wecom_bot(req: WecomBotPrefsIn, request: Request) -> dict: + """企业微信智能机器人(BotID + Secret)配置 — 长连接通道。 + + 保存凭证后立即重建连接(stop→start), 因每机器人仅允许 1 条长连接。 + - bot_id/secret 均传空串表示清空配置并断开连接。 + - enabled 控制是否启用长连接(凭证齐全时生效)。 + """ + from app.services import preferences + + bot_id = (req.bot_id or "").strip() + secret = (req.secret or "").strip() + preferences.set_wecom_bot_id(bot_id) + preferences.set_wecom_bot_secret(secret) + # 凭证不齐时强制关闭(避免 enabled=True 但连不上) + enabled = req.enabled and bool(bot_id) and bool(secret) + preferences.set_wecom_bot_enabled(enabled) + + # 立即应用: 重建连接 + bot_svc = getattr(request.app.state, "wecom_bot_service", None) + status: dict = {} + if bot_svc: + bot_svc.apply_credential_change() + status = bot_svc.status() + return { + "wecom_bot_id": preferences.get_wecom_bot_id(), + "wecom_bot_secret": preferences.get_wecom_bot_secret(), + "wecom_bot_enabled": preferences.get_wecom_bot_enabled(), + "wecom_bot_status": status, + } + + +class WecomBotToggleIn(BaseModel): + enabled: bool + + +@router.put("/preferences/wecom-bot-toggle") +def toggle_wecom_bot(req: WecomBotToggleIn, request: Request) -> dict: + """独立开关: 启用/禁用智能机器人长连接(不改动凭证)。 + + 凭证不齐时强制返回未启用(无法连接)。 + """ + from app.services import preferences + + bot_id = preferences.get_wecom_bot_id() + secret = preferences.get_wecom_bot_secret() + enabled = req.enabled and bool(bot_id) and bool(secret) + preferences.set_wecom_bot_enabled(enabled) + + bot_svc = getattr(request.app.state, "wecom_bot_service", None) + status: dict = {} + if bot_svc: + bot_svc.apply_credential_change() + status = bot_svc.status() + return {"wecom_bot_enabled": enabled, "wecom_bot_status": status} + + class WebhookEnabledDefaultIn(BaseModel): enabled: bool diff --git a/backend/app/main.py b/backend/app/main.py index ef5fc99..b768b39 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -104,6 +104,16 @@ async def lifespan(app: FastAPI): except Exception as e: # noqa: BLE001 logger.warning("depth_service init failed: %s", e) + # 企业微信智能机器人长连接(可选通道, 失败不阻断启动) + try: + from app.services.wecom_bot_service import WecomBotService + wecom_bot_service = WecomBotService() + wecom_bot_service.set_app_state(app.state) + app.state.wecom_bot_service = wecom_bot_service + wecom_bot_service.boot_check() + except Exception as e: # noqa: BLE001 + logger.warning("wecom_bot_service init failed: %s", e) + # 扩展数据定时拉取 from app.services.ext_pull import pull_scheduler pull_scheduler.start(store.data_dir) @@ -192,6 +202,9 @@ async def lifespan(app: FastAPI): dsvc = getattr(app.state, "depth_service", None) if dsvc: dsvc.stop_polling() + wbot = getattr(app.state, "wecom_bot_service", None) + if wbot: + wbot.stop() logger.info("shutdown") diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 9b87b7a..a05eb57 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -495,6 +495,43 @@ def set_wecom_webhook_url(url: str) -> str: return get_wecom_webhook_url() +# ===== 企业微信智能机器人 (API 模式 / 长连接) ===== + + +def get_wecom_bot_id() -> str: + """企业微信智能机器人 BotID — 机器人的唯一标识。""" + return load().get("wecom_bot_id", "") + + +def set_wecom_bot_id(bot_id: str) -> str: + """保存智能机器人 BotID。传入空串表示清空。""" + save({"wecom_bot_id": (bot_id or "").strip()}) + return get_wecom_bot_id() + + +def get_wecom_bot_secret() -> str: + """企业微信智能机器人 Secret — 长连接专用密钥。""" + return load().get("wecom_bot_secret", "") + + +def set_wecom_bot_secret(secret: str) -> str: + """保存智能机器人 Secret。传入空串表示清空。""" + save({"wecom_bot_secret": (secret or "").strip()}) + return get_wecom_bot_secret() + + +def get_wecom_bot_enabled() -> bool: + """智能机器人长连接是否启用。默认 False(需用户配置凭证后手动开启)。""" + return load().get("wecom_bot_enabled", False) + + +def set_wecom_bot_enabled(enabled: bool) -> bool: + """保存智能机器人启用状态。""" + save({"wecom_bot_enabled": bool(enabled)}) + return get_wecom_bot_enabled() + + + def get_webhook_enabled_default() -> bool: """新建监控规则时是否默认勾选推送 (老布尔, 已由 webhook_default_channels 取代)。 diff --git a/backend/app/services/wecom_bot_service.py b/backend/app/services/wecom_bot_service.py new file mode 100644 index 0000000..ae71b62 --- /dev/null +++ b/backend/app/services/wecom_bot_service.py @@ -0,0 +1,278 @@ +"""企业微信智能机器人长连接服务 — 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 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 01ee7d0..9c66c60 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -777,6 +777,15 @@ export interface CustomSourceConfig { datasets: Record } +export interface WecomBotStatus { + enabled: boolean + running: boolean + connected: boolean + bot_id_configured: boolean + secret_configured: boolean + last_error: string +} + export interface Preferences { realtime_quotes_enabled: boolean indices_nav_pinned: boolean @@ -813,6 +822,9 @@ export interface Preferences { feishu_webhook_url?: string feishu_webhook_secret?: string wecom_webhook_url?: string + wecom_bot_id?: string + wecom_bot_secret?: string + wecom_bot_enabled?: boolean webhook_enabled_default?: boolean webhook_default_channels?: string[] sidebar_index_symbols: string[] @@ -1021,6 +1033,21 @@ export const api = { method: 'PUT', body: JSON.stringify({ url }), }), + updateWecomBot: (botId: string, secret: string, enabled: boolean = true) => + request<{ + wecom_bot_id: string + wecom_bot_secret: string + wecom_bot_enabled: boolean + wecom_bot_status: WecomBotStatus + }>('/api/settings/preferences/wecom-bot', { + method: 'PUT', + body: JSON.stringify({ bot_id: botId, secret, enabled }), + }), + toggleWecomBot: (enabled: boolean) => + request<{ wecom_bot_enabled: boolean; wecom_bot_status: WecomBotStatus }>('/api/settings/preferences/wecom-bot-toggle', { + method: 'PUT', + body: JSON.stringify({ enabled }), + }), updateWebhookDefault: (enabled: boolean) => request<{ webhook_enabled_default: boolean }>('/api/settings/preferences/webhook-enabled-default', { method: 'PUT', diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index d1d0c16..1fdaf29 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -75,10 +75,20 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = const wecomWebhookUrl = prefs?.wecom_webhook_url ?? '' const [wecomDraft, setWecomDraft] = useState(wecomWebhookUrl) const [wecomError, setWecomError] = useState('') + // 企业微信智能机器人 (BotID + Secret, 长连接通道) + const wecomBotId = prefs?.wecom_bot_id ?? '' + const wecomBotSecret = prefs?.wecom_bot_secret ?? '' + const wecomBotEnabled = prefs?.wecom_bot_enabled ?? false + const [botIdDraft, setBotIdDraft] = useState(wecomBotId) + const [botSecretDraft, setBotSecretDraft] = useState(wecomBotSecret) + const [botError, setBotError] = useState('') + const [botStatus, setBotStatus] = useState<{connected: boolean; last_error: string} | null>(null) // 飞书渠道配置区展开态 (推送通知卡片内) const [channelOpen, setChannelOpen] = useState(false) // 企业微信渠道配置区展开态 const [wecomOpen, setWecomOpen] = useState(false) + // 智能机器人配置区展开态 + const [botOpen, setBotOpen] = useState(false) useEffect(() => { setFeishuDraft(feishuWebhookUrl) setFeishuSecretDraft(feishuWebhookSecret) @@ -86,6 +96,10 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = useEffect(() => { setWecomDraft(wecomWebhookUrl) }, [wecomWebhookUrl]) + useEffect(() => { + setBotIdDraft(wecomBotId) + setBotSecretDraft(wecomBotSecret) + }, [wecomBotId, wecomBotSecret]) const watchlistSymbols = prefs?.realtime_watchlist_symbols ?? [] const watchlist = useQuery({ queryKey: QK.watchlist, @@ -178,6 +192,37 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = saveWecomWebhook.mutate(url) }, [wecomDraft, saveWecomWebhook]) + // 智能机器人 (BotID + Secret) 保存 → 后端立即重建连接 + const saveWecomBot = useMutation({ + mutationFn: ({ botId, secret }: { botId: string; secret: string }) => + api.updateWecomBot(botId, secret, true), + onSuccess: (data) => { + setBotError('') + toast('智能机器人凭证已保存, 正在连接…', 'success') + setBotStatus({ + connected: data.wecom_bot_status?.connected ?? false, + last_error: data.wecom_bot_status?.last_error ?? '', + }) + qc.invalidateQueries({ queryKey: QK.preferences }) + }, + onError: (err: any) => setBotError(String(err?.message ?? '保存失败')), + }) + const submitBot = useCallback(() => { + saveWecomBot.mutate({ botId: botIdDraft.trim(), secret: botSecretDraft.trim() }) + }, [botIdDraft, botSecretDraft, saveWecomBot]) + + // 智能机器人长连接开关(不改动凭证): 开启→连接, 关闭→断开 + const toggleBotConnection = useMutation({ + mutationFn: (enabled: boolean) => api.toggleWecomBot(enabled), + onSuccess: (data) => { + setBotStatus({ + connected: data.wecom_bot_status?.connected ?? false, + last_error: data.wecom_bot_status?.last_error ?? '', + }) + qc.invalidateQueries({ queryKey: QK.preferences }) + }, + }) + const runFix = useMutation({ mutationFn: () => api.runLimitLadderFix(), onSuccess: (data) => { @@ -600,6 +645,100 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = )} + {/* 企业微信智能机器人 (BotID + Secret): 长连接通道, 与群推送 Webhook 并列 */} +
+
setBotOpen(o => !o)} + className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60" + > + { e.stopPropagation(); toggleBotConnection.mutate(e.target.checked) }} + onClick={e => e.stopPropagation()} + disabled={!wecomBotId || toggleBotConnection.isPending} + title="开启后建立长连接保活, 关闭则断开" + className="h-3 w-3 accent-accent cursor-pointer disabled:opacity-40" + /> + 企业微信 + 智能机器人 + + {wecomBotId ? (botStatus?.connected ? '已连接' : (wecomBotEnabled ? '连接中' : '已配置')) : '未配置'} + + +
+ + {botOpen && ( +
+

+ 勾选卡片左侧开关可启用长连接保活(开启后后端持续保持与企业微信的 + WebSocket 连接)。保存凭证后需勾选才会连接, 取消勾选则立即断开。 +

+ + + + + {botError && ( +
{botError}
+ )} + + {botStatus?.last_error && !botError && ( +
连接异常: {botStatus.last_error}
+ )} + +
+ + {wecomBotId && ( + ● 已配置 + )} +
+ +
+ 如何获取 BotID 和 Secret? +
    +
  1. 登录企业微信管理后台 → 应用管理 → 智能机器人 → 创建机器人
  2. +
  3. 填写名称、头像后进入机器人配置页
  4. +
  5. 开启「API 模式」→ 选择「长连接」方式(另一项"回调URL"需公网IP)
  6. +
  7. 页面显示 BotIDSecret, 复制填到上方输入框
  8. +
+

+ 💡 智能机器人支持 @交互和流式回复, 与群推送 Webhook(单向推送)互补。 + 保存后后端会自动建立 WebSocket 长连接保活。 +

+

+ 📖 官方文档: + + 智能机器人长连接 ↗ + +

+
+
+ )} +
+ {/* 占位渠道 — 不可点 */} {[ { name: 'QMT', hint: '量化交易终端', status: '待定' },