diff --git a/src/bot/botManager.js b/src/bot/botManager.js index 63e6bbe..78b881e 100644 --- a/src/bot/botManager.js +++ b/src/bot/botManager.js @@ -19,14 +19,24 @@ const LOGIN_OK_PATTERN = /登录成功|登陆成功|注册成功|欢迎回来| const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/; /** 登录 / 注册明确失败的关键词(命中后不再自动重试,也不放行操作) */ const LOGIN_FAIL_PATTERN = /密码(错误|不正确|有误)|登录失败|注册失败|wrong password|invalid password/i; +/** + * 「该账号已在别处登录」的踢出提示。 + * + * 同一账号同时只能有一个连接:玩家在游戏客户端登录后忘了退出,机器人每次连上去 + * 都会被立刻踢掉。这种失败重试多少次都没用,必须明确告诉用户去关掉另一个客户端, + * 否则只会看到「连上→被踢→重连」的循环而不知道为什么。 + */ +const ALREADY_CONNECTED_PATTERN = /已经连接|已在别处登录|already (logged|connected)|duplicate login/i; /** * 服务器要求完成账号绑定 / 二次验证时的提示关键词。 * * 典型场景:服务器用「简幻通」等外站账号体系,新账号必须先用 `/auth <验证码>` * 或扫码完成绑定,否则会被反复传送、并在倒计时结束后踢出。 + * 账号绑定过之后,服务器仍可能**每次登录都要求输入验证码** + * (「请直接输入验证码进行验证」),这时验证码是当聊天内容发出去的,不是指令。 * 这种流程无法靠密码自动完成,因此只做「明确告知 + 可选模板」处理。 */ -const NEED_AUTH_PATTERN = /需要绑定|绑定您的|绑定你的|请使用命令:\s*\/?auth|扫描.{0,6}二维码|扫码|完成验证|身份验证/i; +const NEED_AUTH_PATTERN = /需要绑定|绑定您的|绑定你的|请使用命令:\s*\/?auth|扫描.{0,8}二维码|扫码|完成验证|身份验证|输入验证码|验证码进行验证|输入验证|verify|verification code/i; /** * 「被服务器传送」事件的日志聚合窗口(毫秒)。 @@ -116,6 +126,8 @@ class BotManager extends EventEmitter { this._needAuthWarned = false; /** 本次连接内是否已提示过「服务器要登录但没配密码」 */ this._noPasswordWarned = false; + /** 上次断开是否因为「该账号已在别处登录」(决定重连退避策略) */ + this.alreadyConnected = false; /** * 位置同步统计。 * @@ -221,7 +233,10 @@ class BotManager extends EventEmitter { if (this._manualDisconnect || !this.config.server.autoReconnect) return; if (this._reconnectTimer) return; this.reconnectAttempts++; - const delay = computeReconnectDelay(this.config.server, this.reconnectAttempts); + // 「账号已在别处登录」时重试多少次都没用,直接退避到最大间隔,别频繁硬撞服务器 + const delay = this.alreadyConnected + ? (this.config.server.reconnectMaxDelayMs || 120000) + : computeReconnectDelay(this.config.server, this.reconnectAttempts); this._log(`${Math.round(delay / 1000)} 秒后尝试第 ${this.reconnectAttempts} 次重连`); this._reconnectTimer = setTimeout(() => { this._reconnectTimer = null; @@ -240,6 +255,7 @@ class BotManager extends EventEmitter { this.state = 'online'; this._needAuthWarned = false; this._noPasswordWarned = false; + this.alreadyConnected = false; this._log('已连接到服务器,正在进入世界'); this._handleLogin(); }); @@ -262,8 +278,16 @@ class BotManager extends EventEmitter { }); bot.on('kicked', (reason) => { - const text = typeof reason === 'string' ? reason : JSON.stringify(reason); - this._log('被服务器踢出:' + text, 'warn'); + const text = extractKickReason(reason); + if (ALREADY_CONNECTED_PATTERN.test(text)) { + this.alreadyConnected = true; + this._log(`被服务器踢出:${text}`, 'error'); + this._log('该账号已在别处登录(通常是你在游戏客户端里登着没退)。' + + '同一账号只能有一个连接,请先在客户端退出,或点控制台的「上线」重新连接;' + + '重连间隔已退避到最大,不会频繁硬撞服务器', 'warn'); + } else { + this._log('被服务器踢出:' + text, 'warn'); + } this.emit('kicked', text); }); @@ -541,7 +565,7 @@ class BotManager extends EventEmitter { if (!this._needAuthWarned) { this._needAuthWarned = true; this._log('服务器要求先完成账号绑定 / 二次验证(本服为「简幻通」体系):' + message, 'error'); - this._log('该验证需要人工完成一次(游戏内扫码或 /auth <验证码>);' + this._log('该验证需要人工完成一次(游戏内扫码,或把验证码当聊天内容直接发出去);' + '绑定成功后同一账号即可自动登录。若服务器提供固定验证码,可在配置的「账号绑定指令模板」里填写。', 'warn'); } if (server.authCommand) { @@ -794,6 +818,15 @@ class BotManager extends EventEmitter { loggedIn: this.loggedIn, loginFailed: this.loginFailed, canOperate: this.canOperate(), + /** 是否因「该账号已在别处登录」被踢(控制台据此提示用户去关掉另一个客户端) */ + alreadyConnected: this.alreadyConnected, + /** + * 服务器是否正在要求输入验证码 / 完成二次验证。 + * + * 这种验证只能人工完成(验证码当聊天内容发出去,或扫码), + * 控制台据此弹醒目提示并引导用户在输入框里填验证码。 + */ + needsVerification: this._needAuthWarned === true, username: this.connection.username, host: `${this.connection.host}:${this.connection.port}`, version: this.connection.version, @@ -1009,6 +1042,40 @@ function kindOfPosition(position) { return ChatKind.CHAT; } +/** + * 把踢出原因(可能是字符串,也可能是 Minecraft 聊天组件对象)提取成纯文本。 + * + * 服务端踢人时发的是组件对象,直接 `JSON.stringify` 会得到 + * `{"color":"red","text":"您已经连接到此代理服务器了!"}` 这种没法看的东西, + * 而里面那句话恰恰是判断问题的关键。 + * + * @param {*} reason + * @returns {string} + */ +function extractKickReason(reason) { + if (reason === undefined || reason === null) return '未知原因'; + if (typeof reason === 'string') return reason; + const walk = (value) => { + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value.map(walk).join(''); + if (value && typeof value === 'object') { + const parts = []; + if (typeof value.text === 'string') parts.push(value.text); + if (value.translate && typeof value.translate === 'string') parts.push(value.translate); + if (value.extra !== undefined) parts.push(walk(value.extra)); + return parts.join(''); + } + return ''; + }; + const text = walk(reason); + if (text) return text; + try { + return JSON.stringify(reason); + } catch { + return String(reason); + } +} + /** * 把指令模板里的 `{password}` 占位符替换为真实密码。 * @param {string} template @@ -1036,4 +1103,11 @@ function computeReconnectDelay(serverConfig, attempts) { return Math.min(max, base * Math.pow(2, Math.min(6, step - 1))); } -module.exports = { BotManager, fillTemplate, kindOfPosition, computeReconnectDelay, FORCED_MOVE_WINDOW_MS }; +module.exports = { + BotManager, + fillTemplate, + kindOfPosition, + computeReconnectDelay, + extractKickReason, + FORCED_MOVE_WINDOW_MS +}; diff --git a/src/bot/botPool.js b/src/bot/botPool.js index 27f4ecc..3cb6e7c 100644 --- a/src/bot/botPool.js +++ b/src/bot/botPool.js @@ -450,6 +450,45 @@ class BotPool extends EventEmitter { return entry; } + /** + * 让机器人下线:断开连接、停止自动重连、停掉定时消息与扩地任务。 + * + * 与「重连」的区别在于**下线后不会自己再连回来** —— + * 需要在游戏客户端里登同一个账号(同一账号只能有一个连接)、 + * 或者手动进游戏处理点事情时,必须先让机器人下线。 + * + * @param {string} id + * @returns {object} 机器人条目 + */ + disconnectBot(id) { + const entry = this.require(id); + try { + entry.controller.stop(); + } catch { + // 忽略 + } + entry.messenger.stop(); + entry.manager.disconnect(); + this._log(`${entry.connection.username} 已下线(不会自动重连,点「上线」可恢复)`); + return entry; + } + + /** + * 让机器人上线:恢复定时消息并重新连接。 + * @param {string} id + * @returns {object} 机器人条目 + */ + connectBot(id) { + const entry = this.require(id); + // 清掉「账号已在别处登录」的标记,否则重连会一直退避到最大间隔 + entry.manager.alreadyConnected = false; + entry.manager.reconnectAttempts = 0; + entry.messenger.start(); + entry.manager.connect(); + this._log(`${entry.connection.username} 正在上线`); + return entry; + } + /** * 向某机器人发送游戏指令。 * diff --git a/src/web/public/app.js b/src/web/public/app.js index 57dbbb2..3be42ac 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -703,17 +703,44 @@ function renderStatus(status) { : `${bot.health.toFixed(1)} / ${bot.food === null || bot.food === undefined ? '-' : bot.food}`; $('kBotState').textContent = (bot.riding ? '乘船中 · ' : '') + (bot.inWater ? '水中' : '陆地'); const loginEl = $('kLogin'); - loginEl.textContent = bot.loginFailed ? '登录被拒绝' : (bot.loggedIn ? '已通过' : '未通过'); - loginEl.style.color = bot.loginFailed ? 'var(--red)' : (bot.loggedIn ? 'var(--green)' : 'var(--yellow)'); + if (bot.alreadyConnected) { + // 同一账号只能有一个连接:玩家在客户端登着没退时,机器人每次连上都会被踢 + loginEl.textContent = '账号已在别处登录'; + loginEl.style.color = 'var(--red)'; + loginEl.title = '该账号正在别处登录(通常是你的游戏客户端没退)。请先在客户端退出,或点「上线」重试。'; + } else { + loginEl.textContent = bot.loginFailed ? '登录被拒绝' : (bot.loggedIn ? '已通过' : '未通过'); + loginEl.style.color = bot.loginFailed ? 'var(--red)' : (bot.loggedIn ? 'var(--green)' : 'var(--yellow)'); + loginEl.title = ''; + } $('kReconnect').textContent = String(bot.reconnectAttempts || 0); + + // 上线 / 下线按钮:在线时是「下线」,离线时是「上线」 + const offlineBtn = $('btnOffline'); + const isOnline = bot.state === 'online' || bot.state === 'connecting'; + offlineBtn.textContent = isOnline ? '下线' : '上线'; + offlineBtn.className = 'btn' + (isOnline ? '' : ' btn-primary'); + offlineBtn.title = isOnline + ? '断开且不再自动重连(需要在游戏客户端登同一账号时用)' + : '重新连接并恢复定时消息'; if (document.activeElement !== $('chkEnabled')) $('chkEnabled').checked = bot.enabled !== false; if (document.activeElement !== $('chkAutoStart')) $('chkAutoStart').checked = bot.autoStart === true; const canOperate = bot.canOperate === true; - $('consoleHint').textContent = canOperate - ? '以 / 开头按游戏指令发送,否则按公屏消息发送。' - : '该机器人尚未通过游戏内登录验证,发送会被拒绝(服务器要求验证时会自动放行,用于发验证码)。'; - $('consoleHint').style.color = canOperate ? 'var(--muted)' : 'var(--yellow)'; + if (bot.needsVerification) { + $('consoleHint').textContent = '服务器正在等待验证码:把验证码填进输入框发送即可。'; + $('consoleHint').style.color = 'var(--yellow)'; + $('cmdInput').placeholder = '输入验证码后回车发送'; + } else if (canOperate) { + $('consoleHint').textContent = '以 / 开头按游戏指令发送,否则按公屏消息发送。'; + $('consoleHint').style.color = 'var(--muted)'; + $('cmdInput').placeholder = '输入内容或 /指令,回车发送'; + } else { + $('consoleHint').textContent = '该机器人尚未通过游戏内登录验证,发送会被拒绝(服务器要求验证时会自动放行,用于发验证码)。'; + $('consoleHint').style.color = 'var(--yellow)'; + $('cmdInput').placeholder = '输入内容或 /指令,回车发送'; + } + $('verifyBanner').classList.toggle('hidden', bot.needsVerification !== true); // ===== 任务卡片 ===== $('kTaskState').textContent = task.state || 'IDLE'; @@ -2367,6 +2394,7 @@ function bindEvents() { $('btnStart').addEventListener('click', () => startTask(selectedBotId)); $('btnStop').addEventListener('click', () => stopTask(selectedBotId)); $('btnReconnect').addEventListener('click', () => reconnectBot(selectedBotId)); + $('btnOffline').addEventListener('click', () => toggleBotOnline(selectedBotId)); $('btnStartAll').addEventListener('click', async () => { const bots = (lastStatus && lastStatus.bots) || []; @@ -2744,6 +2772,30 @@ async function reconnectBot(botId) { } } +/** + * 上线 / 下线切换。 + * + * 下线会断开连接并**停止自动重连**、同时停掉定时消息与扩地任务 —— + * 需要在游戏客户端登同一个账号时必须先下线,因为同一账号只能有一个连接, + * 否则会一直被服务端以「您已经连接到此代理服务器了」踢掉。 + */ +async function toggleBotOnline(botId) { + if (!botId) return; + const bot = selectedBot(lastStatus || {}); + const isOnline = bot && (bot.state === 'online' || bot.state === 'connecting'); + const action = isOnline ? 'offline' : 'online'; + if (isOnline && !window.confirm('让机器人下线?\n\n会断开连接并停止自动重连,同时停掉定时消息与扩地任务。\n之后点「上线」即可恢复。')) { + return; + } + try { + const res = await api('/api/bots/' + encodeURIComponent(botId) + '/' + action, { method: 'POST' }); + toast(res.message || '已切换'); + if (res.data) renderStatus(res.data); + } catch (err) { + toast('操作失败:' + err.message, true); + } +} + /** 控制台新增一个玩家(机器人) */ async function addBot() { const username = $('newBotName').value.trim(); diff --git a/src/web/public/index.html b/src/web/public/index.html index 576baae..132ed96 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -30,6 +30,7 @@ + @@ -272,6 +273,10 @@ -

以 / 开头按游戏指令发送,否则按公屏消息发送。登录验证通过后才允许发送。

+
diff --git a/src/web/public/style.css b/src/web/public/style.css index 2cf7dfb..7f175b6 100644 --- a/src/web/public/style.css +++ b/src/web/public/style.css @@ -338,6 +338,19 @@ html, body { /* ==================== 表单 ==================== */ .card-config { display: flex; flex-direction: column; } .config-scope { margin-bottom: 8px; } + +/* 需要验证码时的醒目提示 */ +.verify-banner { + margin: 8px 0; + padding: 10px 12px; + border: 1px solid #7a5a18; + border-left: 3px solid var(--yellow); + border-radius: var(--radius); + background: rgba(214, 160, 31, .12); + font-size: 12.5px; +} +.verify-banner strong { color: var(--yellow); display: block; margin-bottom: 3px; } +.verify-banner p { margin: 0; color: var(--text-dim); line-height: 1.5; } .tabs { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 9px; } .tabs-inline { margin-bottom: 0; } .tab { diff --git a/src/web/server.js b/src/web/server.js index 00fee93..bac1c09 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -368,6 +368,40 @@ class WebConsole { }); }); + // 下线:断开且不再自动重连(需要在游戏客户端登同一账号时用) + app.post('/api/bots/:id/offline', (req, res) => { + if (typeof this.pool.disconnectBot !== 'function') { + res.status(400).json({ ok: false, message: '当前运行模式不支持下线操作' }); + return; + } + this._withEntry(req, res, (entry) => { + this.pool.disconnectBot(entry.id); + res.json({ + ok: true, + message: entry.connection.username + ' 已下线(不会自动重连)', + data: this.statusPayload() + }); + return null; + }); + }); + + // 上线:恢复连接与定时消息 + app.post('/api/bots/:id/online', (req, res) => { + if (typeof this.pool.connectBot !== 'function') { + res.status(400).json({ ok: false, message: '当前运行模式不支持上线操作' }); + return; + } + this._withEntry(req, res, (entry) => { + this.pool.connectBot(entry.id); + res.json({ + ok: true, + message: entry.connection.username + ' 正在上线', + data: this.statusPayload() + }); + return null; + }); + }); + // 手动移动测试:绕开寻路器直接按方向键,用于判断「算不出路径」还是「根本走不动」 app.post('/api/bots/:id/move', async (req, res) => { const body = req.body || {}; diff --git a/test/botManager.test.js b/test/botManager.test.js index f4f7f84..f5dbd8f 100644 --- a/test/botManager.test.js +++ b/test/botManager.test.js @@ -6,7 +6,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const { BotManager, kindOfPosition, computeReconnectDelay } = require('../src/bot/botManager'); +const { BotManager, kindOfPosition, computeReconnectDelay, extractKickReason } = require('../src/bot/botManager'); const { FakeBot } = require('./helpers/fakeBot'); const { ChatLog, ChatKind } = require('../src/core/chatLog'); const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig'); @@ -529,6 +529,95 @@ test('walkTest 拒绝非法方向与未连接状态', async () => { assert.ok(offline.message.includes('未连接')); }); +// ==================== 踢出原因解析 ==================== + +test('extractKickReason 把组件对象提取成纯文本', () => { + // 服务端踢人时发的是组件对象,直接 JSON.stringify 会得到没法看的一串 + const component = { color: 'red', text: '您已经连接到此代理服务器了!' }; + assert.strictEqual(extractKickReason(component), '您已经连接到此代理服务器了!'); + assert.strictEqual(extractKickReason('普通字符串'), '普通字符串'); + assert.strictEqual( + extractKickReason({ text: 'You were kicked: ', extra: [{ text: '验证超时' }] }), + 'You were kicked: 验证超时' + ); + assert.strictEqual(extractKickReason(null), '未知原因'); + assert.strictEqual(extractKickReason(undefined), '未知原因'); +}); + +test('「账号已在别处登录」被识别并给出可操作提示', () => { + const { manager, logger } = setup(); + const bot = new FakeBot(); + manager.bot = bot; + manager._wireEvents(bot); + + bot.emit('kicked', { color: 'red', text: '您已经连接到此代理服务器了!' }); + + assert.strictEqual(manager.alreadyConnected, true, '应标记为「已在别处登录」'); + const lines = logger.list(); + assert.ok(lines.some((e) => e.level === 'error' && e.message.includes('您已经连接到此代理服务器了')), + '应把组件里的纯文本记进日志'); + assert.ok(lines.some((e) => e.message.includes('该账号已在别处登录') && e.message.includes('客户端')), + '应提示用户去关掉另一个客户端'); +}); + +test('其它踢出原因不触发「已在别处登录」提示', () => { + const { manager, logger } = setup(); + const bot = new FakeBot(); + manager.bot = bot; + manager._wireEvents(bot); + + bot.emit('kicked', '服务器重启中'); + assert.strictEqual(manager.alreadyConnected, false); + assert.ok(logger.list().some((e) => e.message.includes('服务器重启中'))); + assert.ok(!logger.list().some((e) => e.message.includes('该账号已在别处登录'))); +}); + +test('账号已在别处登录时重连退避到最大间隔,不频繁硬撞服务器', () => { + const { manager } = setup(); + manager.config.server.reconnectDelayMs = 10000; + manager.config.server.reconnectMaxDelayMs = 120000; + manager.alreadyConnected = true; + manager._scheduleReconnect(); + assert.ok(manager._reconnectTimer, '应安排重连'); + // 正常退避第 1 次是 10 秒,被踢时应直接跳到上限 120 秒 + const delay = manager._reconnectTimer._idleTimeout; + assert.strictEqual(delay, 120000, `实际 ${delay}`); + manager._manualDisconnect = true; + clearTimeout(manager._reconnectTimer); +}); + +test('重新登录成功后清掉「已在别处登录」标记', () => { + const { manager } = setup(); + manager.alreadyConnected = true; + // 模拟 login 事件的处理 + manager.alreadyConnected = false; + assert.strictEqual(manager.alreadyConnected, false); +}); + +test('识别「请直接输入验证码进行验证」(绑定过的账号每次登录仍可能要验证码)', () => { + const { manager, logger } = setup(); + manager.loggedIn = false; + manager._handleAuthPrompt('欢迎回来!您绑定的简幻通UID是: 102017'); + manager._handleAuthPrompt('请直接输入验证码进行验证'); + + assert.strictEqual(manager._needAuthWarned, true, '必须识别这句话,否则用户只看到机器人卡在大厅'); + assert.ok(logger.list().some((e) => e.level === 'error' && e.message.includes('二次验证'))); + assert.ok(manager.snapshot().needsVerification === true, 'snapshot 应暴露给控制台弹提示'); +}); + +test('识别「请使用微信扫描手中的二维码进行身份验证」', () => { + const { manager } = setup(); + manager._handleAuthPrompt('请使用微信扫描手中的二维码进行身份验证'); + assert.strictEqual(manager._needAuthWarned, true); +}); + +test('snapshot 暴露 alreadyConnected 供控制台提示用户', () => { + const { manager } = setup(); + assert.strictEqual(manager.snapshot().alreadyConnected, false); + manager.alreadyConnected = true; + assert.strictEqual(manager.snapshot().alreadyConnected, true); +}); + // ==================== 插件加载 ==================== test('connect 时显式加载 mineflayer-pathfinder 插件', () => { const mineflayer = require('mineflayer'); diff --git a/test/botPool.test.js b/test/botPool.test.js index fcaec6a..e821f2e 100644 --- a/test/botPool.test.js +++ b/test/botPool.test.js @@ -290,6 +290,49 @@ test('tick 驱动全部机器人且单个异常不影响其它', () => { assert.deepStrictEqual(ticks, ['B']); }); +// ==================== 上线 / 下线 ==================== + +test('disconnectBot 断线并停止自动重连、定时消息与扩地任务', () => { + const { pool } = setup(); + const entry = pool.get('BotA'); + let disconnected = false; + let taskStopped = false; + let messengerStopped = false; + entry.manager.disconnect = () => { disconnected = true; }; + entry.controller.stop = () => { taskStopped = true; }; + entry.messenger.stop = () => { messengerStopped = true; }; + + pool.disconnectBot('BotA'); + + assert.strictEqual(disconnected, true, '应断开连接'); + assert.strictEqual(taskStopped, true, '应停掉扩地任务'); + assert.strictEqual(messengerStopped, true, '应停掉定时消息'); +}); + +test('connectBot 清掉「已在别处登录」标记并恢复连接', () => { + const { pool } = setup(); + const entry = pool.get('BotA'); + entry.manager.alreadyConnected = true; + entry.manager.reconnectAttempts = 5; + const connected = []; + entry.manager.connect = () => connected.push('BotA'); + let messengerStarted = false; + entry.messenger.start = () => { messengerStarted = true; }; + + pool.connectBot('BotA'); + + assert.strictEqual(entry.manager.alreadyConnected, false, '不清掉标记会一直退避到最大间隔'); + assert.strictEqual(entry.manager.reconnectAttempts, 0); + assert.strictEqual(messengerStarted, true); + assert.deepStrictEqual(connected, ['BotA']); +}); + +test('上线 / 下线对不存在的机器人抛 404', () => { + const { pool } = setup(); + assert.throws(() => pool.disconnectBot('不存在'), (err) => err.statusCode === 404); + assert.throws(() => pool.connectBot('不存在'), (err) => err.statusCode === 404); +}); + test('未启用(enabled=false)的机器人不参与连接', () => { const { pool } = setup({ settings: { BotB: { enabled: false } } }); const connected = []; diff --git a/test/multiBotWeb.test.js b/test/multiBotWeb.test.js index 8466aa5..a75b708 100644 --- a/test/multiBotWeb.test.js +++ b/test/multiBotWeb.test.js @@ -511,6 +511,34 @@ test('HTTP:大地图图幅可按机器人位置与半径计算瓦片范围', a } }); +test('HTTP:可让机器人下线(停止自动重连)与重新上线', async () => { + const { webConsole, pool } = setup(); + const entry = pool.get('BotA'); + let disconnected = false; + entry.manager.disconnect = () => { disconnected = true; }; + const connected = []; + entry.manager.connect = () => connected.push('BotA'); + await webConsole.start(); + const base = webConsole.baseUrl(); + try { + const offline = await fetch(base + '/api/bots/BotA/offline', { method: 'POST' }).then((r) => r.json()); + assert.strictEqual(offline.ok, true); + assert.ok(offline.message.includes('已下线')); + assert.strictEqual(disconnected, true); + assert.ok(offline.data.bots, '应返回最新状态负载供前端直接渲染'); + + const online = await fetch(base + '/api/bots/BotA/online', { method: 'POST' }).then((r) => r.json()); + assert.strictEqual(online.ok, true); + assert.ok(online.message.includes('正在上线')); + assert.deepStrictEqual(connected, ['BotA']); + + const missing = await fetch(base + '/api/bots/没有这个/offline', { method: 'POST' }); + assert.strictEqual(missing.status, 404); + } finally { + await webConsole.stop(); + } +}); + test('HTTP:未配置密码时免登录(本机开发模式)', async () => { const { webConsole } = setup(); await webConsole.start();