From 2671da95d5679ea5f9024041e726a26ceb12d103 Mon Sep 17 00:00:00 2001 From: lyizai <3945107889@qq.com> Date: Sun, 27 Sep 2026 22:11:52 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=89=93=E9=80=9A=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E6=AD=BB=E9=94=81=EF=BC=8C=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=8C=BA=E5=88=86=E6=9C=AA=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?/=E7=99=BB=E5=BD=95=E8=A2=AB=E6=8B=92/=E6=9C=AA=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 继续修复部署时暴露的门禁设计问题: - 登录门禁死锁:/login、/register、简幻通的 /auth 本身就是登录流程的一部分, 原先门禁把它们一并拦下,形成「要先登录才能发登录指令」,账号永远卡在未验证状态。 新增 server.preLoginCommands 白名单(默认 login,l,register,reg,auth), 这些前缀在未验证时也放行,其余指令仍要求已完成验证。 - 离线时指令被静默丢弃却回报「已发送」:bot.chat() 在未连接时无处可发。 canSendCommand() 现在要求已连接并进入世界,失败信息区分 「未连接」「登录被服务器拒绝」「未通过登录验证」三种情况并给出对应建议。 - 前端配置表单支持 list 类型字段(逗号分隔的字符串数组)。 测试从 252 增至 263,全部通过。 --- CHANGELOG.md | 8 +++++ README.md | 5 +++ src/bot/botManager.js | 38 ++++++++++++++++++++-- src/bot/botPool.js | 45 ++++++++++++++++++++++---- src/config/expansionConfig.js | 16 +++++++++ src/web/public/app.js | 12 +++++-- src/web/server.js | 13 ++++++-- test/botManager.test.js | 61 +++++++++++++++++++++++++++++++++-- test/botPool.test.js | 48 ++++++++++++++++++++++++++- test/multiBotWeb.test.js | 51 ++++++++++++++++++++++++++++- 10 files changed, 279 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f35eb..5609b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,14 @@ 并新增 `server.authCommand` 模板供配置固定验证码的服务器自动过验证 - **重连无退避**:连续被踢时每 10 秒硬撞一次服务器。现按指数退避拉长间隔 (`server.reconnectMaxDelayMs`,默认上限 120 秒),成功进入世界后计数清零 +- **登录门禁死锁**:有些服务器的登录流程本身就是指令(`/login`、`/register`, + 简幻通体系还要先 `/auth`),原先的门禁把这些指令也一并拦下,导致 + 「要先登录才能发登录指令」,账号永远卡在未验证状态。 + 现新增 `server.preLoginCommands`(默认 `login,l,register,reg,auth`)白名单, + 这些前缀在未验证时也放行;其余指令仍要求已完成验证 +- **离线时指令被静默丢弃却回报「已发送」**:`bot.chat()` 在未连接时无处可发, + 原先调用方只会收到「已发送」。现在 `canSendCommand()` 要求已连接并进入世界, + 且失败信息会区分「未连接」「登录被拒」「未通过验证」三种情况并给出对应处理建议 ### 已知限制 diff --git a/README.md b/README.md index 5a7619a..a17841f 100644 --- a/README.md +++ b/README.md @@ -166,9 +166,14 @@ squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为 - **登录确认兜底**:部分离线服登录成功后不回明确提示,`server.loginConfirmTimeoutMs` (默认 10 秒)内既没收到成功也没收到失败提示时按「已登录」放行,避免机器人永远卡死; 收到「密码错误 / 登录失败」则标记 `loginFailed` 且不放行。设为 `0` 可禁用兜底 +- **免登录指令白名单**:有些服务器的登录流程本身就是指令(`/login`、`/register`, + 简幻通体系还要先 `/auth`),若把门禁一刀切会形成「要先登录才能发登录指令」的死锁。 + `server.preLoginCommands`(默认 `login,l,register,reg,auth`)里列出的前缀在未验证时也放行 - **外站账号绑定(如简幻通)**:服务器要求 `/auth <验证码>` 或扫码绑定时, 密码无法自动完成,程序会输出明确的中文告警(每个连接只提示一次)而不是静默死循环。 若该服务器提供固定验证码,可在配置的「账号绑定 / 二次验证模板」(`server.authCommand`)里填写 +- **失败信息可操作**:发送失败会区分「未连接」「登录被服务器拒绝」「未通过登录验证」三种情况, + 而不是笼统报一句「发送失败」 **控制台侧**(`src/web/server.js`) diff --git a/src/bot/botManager.js b/src/bot/botManager.js index 5bc5259..00f0536 100644 --- a/src/bot/botManager.js +++ b/src/bot/botManager.js @@ -442,11 +442,45 @@ class BotManager extends EventEmitter { return this.state === 'online' && !!this.bot && this.loggedIn; } + /** + * 判断一条指令是否允许在「登录验证通过之前」发送。 + * + * 登录 / 注册 / 账号绑定这类指令本身就是验证流程的一部分,必须放行, + * 否则会出现「要先登录才能发登录指令」的死锁。 + * + * @param {string} command 不带斜杠的指令 + * @returns {boolean} + */ + isPreLoginCommand(command) { + const prefixes = (this.config.server && this.config.server.preLoginCommands) || []; + if (!Array.isArray(prefixes) || prefixes.length === 0) return false; + const head = String(command || '').replace(/^\//, '').trim().toLowerCase().split(/\s+/)[0]; + if (!head) return false; + return prefixes.some((prefix) => head === prefix || head.startsWith(prefix + ':')); + } + + /** + * 是否允许发送该指令。 + * + * 两个条件: + * 1. 必须已连接并进入世界——否则 `bot.chat()` 无处可发,只会被静默丢弃, + * 而调用方却会收到「已发送」,这是最误导人的失败方式; + * 2. 已完成登录验证,或它是验证流程自身的指令(见 {@link isPreLoginCommand})。 + * + * @param {string} command + * @returns {boolean} + */ + canSendCommand(command) { + if (this.state !== 'online' || !this.bot) return false; + return this.canOperate() || this.isPreLoginCommand(command); + } + /** * 发送一条聊天指令(带最小间隔节流,防刷屏被服务器或 Grim 判定异常)。 * * 未完成登录验证时拒绝发送:离线服的 /login 是硬门槛, * 未登录就发 /u claimmode 只会被服务器静默丢弃。 + * 例外是 `server.preLoginCommands` 里列出的验证类指令(login / register / auth 等)。 * * @param {string} command 不带斜杠的指令,例如 `u claimmode` * @param {{immediate?: boolean, internal?: boolean}} [options] internal 为登录 / 注册流程自身使用,绕过门禁 @@ -455,8 +489,8 @@ class BotManager extends EventEmitter { sendCommand(command, options) { if (!command) return false; const opts = options || {}; - if (!opts.internal && !this.canOperate()) { - this._log('未完成登录验证,已拒绝发送指令:/' + command, 'warn'); + if (!opts.internal && !this.canSendCommand(command)) { + this._log('未连接或未完成登录验证,已拒绝发送指令:/' + command, 'warn'); return false; } if (opts.immediate) { diff --git a/src/bot/botPool.js b/src/bot/botPool.js index 49a48ae..3f4fb2e 100644 --- a/src/bot/botPool.js +++ b/src/bot/botPool.js @@ -301,16 +301,22 @@ class BotPool extends EventEmitter { /** * 向某机器人发送游戏指令。 + * + * 登录门禁由 BotManager 判定:验证类指令(login / register / auth 等)即使在 + * 未通过验证时也放行,否则会出现「要先登录才能发登录指令」的死锁。 + * * @param {string} id * @param {string} command 不带斜杠的指令 * @returns {{ok: boolean, message: string}} */ sendCommand(id, command) { const entry = this.require(id); - if (!entry.manager.canOperate()) { - return { ok: false, message: `${entry.connection.username} 未完成登录验证,已拒绝发送指令` }; + const text = String(command || '').replace(/^\//, '').trim(); + if (!text) return { ok: false, message: '指令为空' }; + if (!entry.manager.canSendCommand(text)) { + return { ok: false, message: describeSendFailure(entry.manager, text) }; } - const ok = entry.manager.sendCommand(command.replace(/^\//, '')); + const ok = entry.manager.sendCommand(text); return { ok, message: ok ? '已发送:' + command : '发送失败' }; } @@ -322,11 +328,14 @@ class BotPool extends EventEmitter { */ sendChat(id, message) { const entry = this.require(id); - if (!entry.manager.canOperate()) { - return { ok: false, message: `${entry.connection.username} 未完成登录验证,已拒绝发送消息` }; - } const text = String(message || '').trim(); if (!text) return { ok: false, message: '消息为空' }; + if (!entry.manager.canOperate()) { + if (entry.manager.state !== 'online' || !entry.manager.bot) { + return { ok: false, message: `${entry.connection.username} 当前未连接(状态:${entry.manager.state}),消息未发送` }; + } + return { ok: false, message: `${entry.connection.username} 未完成登录验证,已拒绝发送公屏消息` }; + } const ok = entry.manager.sendChat(text); return { ok, message: ok ? '已发送' : '发送失败' }; } @@ -377,4 +386,26 @@ class BotPool extends EventEmitter { } } -module.exports = { BotPool }; +/** + * 生成「为什么这条指令发不出去」的可操作说明。 + * + * 区分「没连上」与「连上了但没过登录验证」——两者的处理方式完全不同, + * 笼统报一句「发送失败」会让用户无从下手。 + * + * @param {import('./botManager').BotManager} manager + * @param {string} command 不带斜杠的指令 + * @returns {string} + */ +function describeSendFailure(manager, command) { + const name = manager.connection.username; + if (manager.state !== 'online' || !manager.bot) { + return `${name} 当前未连接(状态:${manager.state}),指令未发送;请先重连或等待自动重连`; + } + if (manager.loginFailed) { + return `${name} 登录被服务器拒绝,请检查 .env 中的账号密码`; + } + return `${name} 未完成登录验证,已拒绝发送 /${command};` + + '若是验证类指令(login / register / auth),可在配置的「允许免登录发送的指令前缀」中放行'; +} + +module.exports = { BotPool, describeSendFailure }; diff --git a/src/config/expansionConfig.js b/src/config/expansionConfig.js index b300e52..e81cdf9 100644 --- a/src/config/expansionConfig.js +++ b/src/config/expansionConfig.js @@ -207,6 +207,15 @@ function defaultConfig() { * 若该服务器提供固定验证码,把 `auth {password}` 之类填进来就能自动过。 */ authCommand: '', + /** + * 允许在「登录验证通过之前」发送的指令前缀。 + * + * 存在先有鸡还是先有蛋的问题:有些服务器的登录流程本身就是一条指令 + * (`/login`、`/register`,简幻通体系还要先 `/auth`), + * 若把登录门禁一刀切,这些指令就永远发不出去,账号会卡在未验证状态被反复踢出。 + * 因此这里列出的前缀可以绕过门禁,其余指令仍要求已完成验证。 + */ + preLoginCommands: ['login', 'l', 'register', 'reg', 'auth'], /** * 登录结果确认超时(毫秒)。 * 部分离线服登录成功后不回明确提示,超时后按「已登录」放行, @@ -329,6 +338,13 @@ function validateConfig(config) { config.server.reconnectMaxDelayMs = clampInt(config.server.reconnectMaxDelayMs, config.server.reconnectDelayMs, 3600000, 120000); config.server.loginConfirmTimeoutMs = clampInt(config.server.loginConfirmTimeoutMs, 0, 300000, 10000); if (typeof config.server.authCommand !== 'string') config.server.authCommand = ''; + if (!Array.isArray(config.server.preLoginCommands)) { + config.server.preLoginCommands = defaultConfig().server.preLoginCommands; + } else { + config.server.preLoginCommands = config.server.preLoginCommands + .filter((item) => typeof item === 'string' && item.trim()) + .map((item) => item.trim().toLowerCase().replace(/^\//, '')); + } if (config.waterMode !== WATER_MODE.SWIM && config.waterMode !== WATER_MODE.BOAT) { config.waterMode = WATER_MODE.AVOID; diff --git a/src/web/public/app.js b/src/web/public/app.js index 9b5f727..7197d4f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -271,6 +271,7 @@ const CONFIG_SCHEMA = { { path: 'server.loginCommand', label: '登录指令模板', type: 'text', hint: '用 {password} 占位,密码取自 .env 的 MC_BOTS / MC_PASSWORD' }, { path: 'server.registerCommand', label: '注册指令模板', type: 'text', hint: '留空则不自动注册' }, { path: 'server.authCommand', label: '账号绑定 / 二次验证模板', type: 'text', hint: '如简幻通的 auth {password};扫码类验证无法自动完成,留空即可(日志会提示人工验证一次)' }, + { path: 'server.preLoginCommands', label: '允许免登录发送的指令前缀', type: 'list', hint: '逗号分隔,默认 login,l,register,reg,auth;这些指令在登录验证前也放行,避免死锁' }, { path: 'server.loginConfirmTimeoutMs', label: '登录结果确认超时(毫秒)', type: 'number', min: 0, hint: '超时未收到登录提示则按已登录放行;0 表示禁用兜底' }, { path: 'server.commandCooldownMs', label: '指令最小间隔(毫秒)', type: 'number', min: 0 }, { path: 'server.autoReconnect', label: '断线自动重连', type: 'checkbox' }, @@ -438,7 +439,12 @@ function buildField(field) { if (field.min !== undefined) input.min = field.min; if (field.max !== undefined) input.max = field.max; if (field.step !== undefined) input.step = field.step; - input.value = value === undefined || value === null ? '' : String(value); + if (field.type === 'list') { + // 字符串数组用逗号分隔编辑,避免为几个短列表单独做一套增删 UI + input.value = Array.isArray(value) ? value.join(', ') : ''; + } else { + input.value = value === undefined || value === null ? '' : String(value); + } input.dataset.path = field.path; input.dataset.type = field.type; wrap.appendChild(input); @@ -464,7 +470,9 @@ function collectConfig() { const raw = input.value; value = /^-?\d+$/.test(raw) ? Number(raw) : raw; } else if (type === 'number') value = input.value === '' ? 0 : Number(input.value); - else value = input.value; + else if (type === 'list') { + value = input.value.split(',').map((item) => item.trim()).filter(Boolean); + } else value = input.value; setByPath(result, path, value); } return result; diff --git a/src/web/server.js b/src/web/server.js index a43465f..7a31d72 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -812,8 +812,17 @@ class SingleBotPool { /** @param {string} id @param {string} command */ sendCommand(id, command) { const entry = this.require(id); - const ok = entry.manager.sendCommand(String(command).replace(/^\//, '')); - return { ok, message: ok ? '已发送:' + command : '发送失败(未完成登录验证)' }; + const text = String(command || '').replace(/^\//, '').trim(); + if (!text) return { ok: false, message: '指令为空' }; + if (!entry.manager.canSendCommand(text)) { + const manager = entry.manager; + if (manager.state !== 'online' || !manager.bot) { + return { ok: false, message: `当前未连接(状态:${manager.state}),指令未发送` }; + } + return { ok: false, message: `未完成登录验证,已拒绝发送 /${text}` }; + } + const ok = entry.manager.sendCommand(text); + return { ok, message: ok ? '已发送:' + command : '发送失败' }; } /** @param {string} id @param {string} message */ diff --git a/test/botManager.test.js b/test/botManager.test.js index dd69ecf..dc8a26c 100644 --- a/test/botManager.test.js +++ b/test/botManager.test.js @@ -17,9 +17,6 @@ function setup(options) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-botmgr-')); const chatLog = new ChatLog({ dir }); const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {})); - if (opts.config && opts.config.server) { - Object.assign(config.server, opts.config.server); - } const logger = new Logger({ echo: false }); const manager = new BotManager({ id: 'BotA', @@ -294,3 +291,61 @@ test('config 校验补全 authCommand 与 reconnectMaxDelayMs', () => { assert.strictEqual(config.server.authCommand, ''); assert.strictEqual(config.server.reconnectMaxDelayMs, 120000); }); + +// ==================== 免登录指令白名单(避免「先登录才能发登录指令」死锁)==================== + +test('验证类指令在未登录时也放行(login / register / auth)', () => { + const { manager, sent } = setup({ config: { server: { commandCooldownMs: 0 } } }); + manager.state = 'online'; + manager.loggedIn = false; + + assert.strictEqual(manager.canSendCommand('login mypass'), true); + assert.strictEqual(manager.canSendCommand('/auth 12345 6789'), true); + assert.strictEqual(manager.canSendCommand('register mypass'), true); + assert.strictEqual(manager.canSendCommand('l mypass'), true); + + assert.strictEqual(manager.sendCommand('auth 12345 6789', { immediate: true }), true); + assert.deepStrictEqual(sent, ['/auth 12345 6789']); +}); + +test('任务类指令在未登录时仍被拒绝', () => { + const { manager, sent } = setup(); + manager.state = 'online'; + manager.loggedIn = false; + + assert.strictEqual(manager.canSendCommand('u claimmode'), false); + assert.strictEqual(manager.canSendCommand('u return'), false); + assert.strictEqual(manager.canSendCommand('spawn'), false); + assert.strictEqual(manager.sendCommand('u claimmode'), false); + assert.deepStrictEqual(sent, []); +}); + +test('前缀匹配不会误放行同前缀的其它指令', () => { + const { manager } = setup(); + manager.state = 'online'; + manager.loggedIn = false; + // auth 放行,但 authx / authenticate 不属于白名单里的精确前缀 + assert.strictEqual(manager.canSendCommand('auth 1 2'), true); + assert.strictEqual(manager.canSendCommand('authx 1 2'), false); + assert.strictEqual(manager.canSendCommand('login'), true); + assert.strictEqual(manager.canSendCommand('loginx'), false); +}); + +test('白名单可通过配置清空(全部指令都要求登录验证)', () => { + const { manager } = setup({ config: { server: { preLoginCommands: [] } } }); + manager.state = 'online'; + manager.loggedIn = false; + assert.strictEqual(manager.canSendCommand('login mypass'), false); +}); + +test('登录验证通过后白名单不再是唯一通道', () => { + const { manager } = setup(); + manager.state = 'online'; + manager.loggedIn = true; + assert.strictEqual(manager.canSendCommand('u claimmode'), true); +}); + +test('preLoginCommands 配置被归一化(去斜杠、去空项、转小写)', () => { + const { config } = setup({ config: { server: { preLoginCommands: ['/Login', ' AUTH ', '', ' ', 'reg'] } } }); + assert.deepStrictEqual(config.server.preLoginCommands, ['login', 'auth', 'reg']); +}); diff --git a/test/botPool.test.js b/test/botPool.test.js index fcccb06..fae0b76 100644 --- a/test/botPool.test.js +++ b/test/botPool.test.js @@ -146,8 +146,24 @@ test('positions 输出各机器人坐标供大地图打点', () => { assert.strictEqual(positions[0].stateName, ''); }); -test('未完成登录验证时拒绝发送指令与聊天', () => { +test('未连接时拒绝发送指令与聊天,并说明是「未连接」', () => { const { pool } = setup(); + const command = pool.sendCommand('BotA', 'u claimmode'); + assert.strictEqual(command.ok, false); + assert.ok(command.message.includes('未连接'), '离线时应明确说明未连接,而不是含糊的失败'); + + const chat = pool.sendChat('BotA', '大家好'); + assert.strictEqual(chat.ok, false); + assert.ok(chat.message.includes('未连接')); +}); + +test('已连接但未完成登录验证时拒绝发送指令与聊天', () => { + const { pool } = setup(); + const entry = pool.get('BotA'); + entry.manager.bot = { chat: () => {} }; + entry.manager.state = 'online'; + entry.manager.loggedIn = false; + const command = pool.sendCommand('BotA', 'u claimmode'); assert.strictEqual(command.ok, false); assert.ok(command.message.includes('未完成登录验证')); @@ -157,6 +173,36 @@ test('未完成登录验证时拒绝发送指令与聊天', () => { assert.ok(chat.message.includes('未完成登录验证')); }); +test('未登录时验证类指令仍可下发(否则无法完成登录)', () => { + const { pool } = setup(); + const entry = pool.get('BotA'); + const sent = []; + entry.manager.bot = { chat: (text) => sent.push(text) }; + entry.manager.state = 'online'; + entry.manager.loggedIn = false; + + const result = pool.sendCommand('BotA', '/auth 12345 6789'); + assert.strictEqual(result.ok, true); + // 指令有节流,这里直接驱动写入验证内容 + entry.manager._commandQueue.length = 0; + entry.manager._writeCommand('auth 12345 6789'); + assert.deepStrictEqual(sent, ['/auth 12345 6789']); +}); + +test('空指令被拒绝', () => { + const { pool } = setup(); + const result = pool.sendCommand('BotA', ' '); + assert.strictEqual(result.ok, false); + assert.ok(result.message.includes('指令为空')); +}); + +test('空聊天消息被拒绝', () => { + const { pool } = setup(); + const result = pool.sendChat('BotA', ' '); + assert.strictEqual(result.ok, false); + assert.ok(result.message.includes('消息为空')); +}); + test('完成登录验证后允许发送指令与聊天', () => { const { pool } = setup(); const entry = pool.get('BotA'); diff --git a/test/multiBotWeb.test.js b/test/multiBotWeb.test.js index 9610228..afee360 100644 --- a/test/multiBotWeb.test.js +++ b/test/multiBotWeb.test.js @@ -172,7 +172,7 @@ test('HTTP:机器人未就绪时启动任务返回 ok=false 并给出原因', }); test('HTTP:聊天接口在未完成登录验证时返回 409,缺参数返回 400', async () => { - const { webConsole } = setup(); + const { webConsole, pool } = setup(); await webConsole.start(); const base = webConsole.baseUrl(); try { @@ -183,6 +183,21 @@ test('HTTP:聊天接口在未完成登录验证时返回 409,缺参数返回 }); assert.strictEqual(missing.status, 400); + // 未连接:提示应指向「没连上」而不是含糊的失败 + const offline = await fetch(base + '/api/bots/BotA/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: '大家好' }) + }); + assert.strictEqual(offline.status, 409); + const offlineBody = await offline.json(); + assert.ok(offlineBody.message.includes('未连接'), '离线时应说明未连接'); + + // 已连接但未通过登录验证:提示应指向登录门禁 + const manager = pool.get('BotA').manager; + manager.state = 'online'; + manager.bot = { chat: () => {} }; + manager.loggedIn = false; const denied = await fetch(base + '/api/bots/BotA/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -196,6 +211,40 @@ test('HTTP:聊天接口在未完成登录验证时返回 409,缺参数返回 } }); +test('HTTP:指令接口区分「未连接」与「未通过登录验证」,并放行验证类指令', async () => { + const { webConsole, pool } = setup(); + await webConsole.start(); + const base = webConsole.baseUrl(); + const post = (command) => fetch(base + '/api/bots/BotA/command', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command }) + }); + try { + const offline = await post('u claimmode'); + assert.strictEqual(offline.status, 409); + assert.ok((await offline.json()).message.includes('未连接')); + + const manager = pool.get('BotA').manager; + const sent = []; + manager.bot = { chat: (text) => sent.push(text) }; + manager.state = 'online'; + manager.loggedIn = false; + + // 任务类指令仍被拦下 + const blocked = await post('u claimmode'); + assert.strictEqual(blocked.status, 409); + assert.ok((await blocked.json()).message.includes('未完成登录验证')); + + // 验证类指令放行(否则无法完成登录,形成死锁) + const allowed = await post('/auth 12345 6789'); + assert.strictEqual(allowed.status, 200); + assert.strictEqual((await allowed.json()).ok, true); + } finally { + await webConsole.stop(); + } +}); + test('HTTP:公屏日志可按机器人查询与导出', async () => { const { webConsole, chatLog } = setup(); chatLog.append({ botId: 'BotA', bot: 'BotA', message: '你好', kind: ChatKind.CHAT, username: 'Steve' });