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:
wshy
2026-07-09 16:35:21 +08:00
committed by GitHub
parent c452ebc325
commit e0badf1458
6 changed files with 560 additions and 0 deletions
+66
View File
@@ -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
+13
View File
@@ -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")
+37
View File
@@ -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 取代)。
+278
View File
@@ -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
+27
View File
@@ -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',
+139
View File
@@ -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: '待定' },