mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat(wecom): 智能机器人长连接接入(配置项 + 保活) (#89)
* 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 + 原文截断 发消息给机器人即可在后端日志看到接收记录。
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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 取代)。
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -777,6 +777,15 @@ export interface CustomSourceConfig {
|
||||
datasets: Record<string, DatasetConfig>
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
@@ -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 } =
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 企业微信智能机器人 (BotID + Secret): 长连接通道, 与群推送 Webhook 并列 */}
|
||||
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
|
||||
<div
|
||||
onClick={() => setBotOpen(o => !o)}
|
||||
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wecomBotEnabled}
|
||||
onChange={e => { 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"
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-foreground">企业微信</span>
|
||||
<span className="text-[9px] text-muted">智能机器人</span>
|
||||
<span className={`ml-auto text-[9px] ${wecomBotId ? (botStatus?.connected ? 'text-emerald-500' : 'text-warning') : 'text-muted'}`}>
|
||||
{wecomBotId ? (botStatus?.connected ? '已连接' : (wecomBotEnabled ? '连接中' : '已配置')) : '未配置'}
|
||||
</span>
|
||||
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${botOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
{botOpen && (
|
||||
<div className="border-t border-border/60 bg-base/30 p-3">
|
||||
<p className="mb-2.5 text-[10px] text-muted leading-relaxed">
|
||||
勾选卡片左侧开关可启用长连接保活(开启后后端持续保持与企业微信的
|
||||
WebSocket 连接)。保存凭证后需勾选才会连接, 取消勾选则立即断开。
|
||||
</p>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[11px] text-muted">BotID</span>
|
||||
<input
|
||||
value={botIdDraft}
|
||||
onChange={e => setBotIdDraft(e.target.value)}
|
||||
placeholder="智能机器人的唯一标识"
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block mt-2 space-y-1.5">
|
||||
<span className="text-[11px] text-muted">Secret (长连接专用密钥)</span>
|
||||
<input
|
||||
type="password"
|
||||
value={botSecretDraft}
|
||||
onChange={e => setBotSecretDraft(e.target.value)}
|
||||
placeholder="开启长连接 API 模式后获取的密钥"
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{botError && (
|
||||
<div className="mt-2 text-[11px] text-danger">{botError}</div>
|
||||
)}
|
||||
|
||||
{botStatus?.last_error && !botError && (
|
||||
<div className="mt-2 text-[11px] text-warning">连接异常: {botStatus.last_error}</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={submitBot}
|
||||
disabled={saveWecomBot.isPending || (botIdDraft.trim() === wecomBotId && botSecretDraft.trim() === wecomBotSecret)}
|
||||
className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
{saveWecomBot.isPending ? '保存中…' : '保存并连接'}
|
||||
</button>
|
||||
{wecomBotId && (
|
||||
<span className="text-[10px] text-emerald-500">● 已配置</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details className="mt-3 text-[10px] text-muted">
|
||||
<summary className="cursor-pointer hover:text-secondary">如何获取 BotID 和 Secret?</summary>
|
||||
<ol className="mt-1.5 space-y-1 pl-4 list-decimal leading-relaxed">
|
||||
<li>登录<b>企业微信管理后台</b> → 应用管理 → <b>智能机器人</b> → 创建机器人</li>
|
||||
<li>填写名称、头像后进入机器人配置页</li>
|
||||
<li>开启「<b>API 模式</b>」→ 选择「<b>长连接</b>」方式(另一项"回调URL"需公网IP)</li>
|
||||
<li>页面显示 <b>BotID</b> 和 <b>Secret</b>, 复制填到上方输入框</li>
|
||||
</ol>
|
||||
<p className="mt-1.5 pl-4 text-muted/70">
|
||||
💡 智能机器人支持 @交互和流式回复, 与群推送 Webhook(单向推送)互补。
|
||||
保存后后端会自动建立 WebSocket 长连接保活。
|
||||
</p>
|
||||
<p className="mt-1.5 pl-4 text-muted/70">
|
||||
📖 官方文档:
|
||||
<a href="https://developer.work.weixin.qq.com/document/path/101463" target="_blank" rel="noreferrer" className="text-accent hover:text-accent/80">
|
||||
智能机器人长连接 ↗
|
||||
</a>
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 占位渠道 — 不可点 */}
|
||||
{[
|
||||
{ name: 'QMT', hint: '量化交易终端', status: '待定' },
|
||||
|
||||
Reference in New Issue
Block a user