文件
BotStore/test/botManager.test.js
T
lyizai 2671da95d5 fix: 打通登录门禁死锁,失败信息区分未连接/登录被拒/未通过验证
继续修复部署时暴露的门禁设计问题:

- 登录门禁死锁:/login、/register、简幻通的 /auth 本身就是登录流程的一部分,
  原先门禁把它们一并拦下,形成「要先登录才能发登录指令」,账号永远卡在未验证状态。
  新增 server.preLoginCommands 白名单(默认 login,l,register,reg,auth),
  这些前缀在未验证时也放行,其余指令仍要求已完成验证。

- 离线时指令被静默丢弃却回报「已发送」:bot.chat() 在未连接时无处可发。
  canSendCommand() 现在要求已连接并进入世界,失败信息区分
  「未连接」「登录被服务器拒绝」「未通过登录验证」三种情况并给出对应建议。

- 前端配置表单支持 list 类型字段(逗号分隔的字符串数组)。

测试从 252 增至 263,全部通过。
2026-09-27 22:11:52 +08:00

352 行
15 KiB
JavaScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { BotManager, kindOfPosition, computeReconnectDelay } = require('../src/bot/botManager');
const { ChatLog, ChatKind } = require('../src/core/chatLog');
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger');
/** 建一个不连接的机器人管理器(含公屏日志) */
function setup(options) {
const opts = options || {};
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-botmgr-'));
const chatLog = new ChatLog({ dir });
const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {}));
const logger = new Logger({ echo: false });
const manager = new BotManager({
id: 'BotA',
username: 'BotA',
password: 'pw',
host: 'mc.example.com',
port: 25565,
version: '1.21.4',
label: '主号'
}, config, logger, { chatLog });
const sent = [];
manager.bot = { chat: (text) => sent.push(text) };
return { manager, chatLog, config, sent, logger };
}
/** 等一轮宏任务,让指令节流定时器真正把内容写出 */
function drainQueue() {
return new Promise((resolve) => setTimeout(resolve, 5));
}
test('kindOfPosition 把消息通道映射为公屏日志类型', () => {
assert.strictEqual(kindOfPosition('chat'), ChatKind.CHAT);
assert.strictEqual(kindOfPosition('system'), ChatKind.SYSTEM);
assert.strictEqual(kindOfPosition('game_info'), ChatKind.ACTION);
assert.strictEqual(kindOfPosition(undefined), ChatKind.CHAT);
});
test('isReady 要求已连接、已进入世界、寻路器就绪且完成登录验证', () => {
const { manager } = setup();
manager.state = 'online';
manager.bot = { entity: {}, pathfinder: {} };
manager.loggedIn = false;
assert.strictEqual(manager.isReady(), false, '未完成登录验证不算就绪');
manager.loggedIn = true;
assert.strictEqual(manager.isReady(), true);
manager.state = 'connecting';
assert.strictEqual(manager.isReady(), false);
});
test('未完成登录验证时拒绝发送指令与聊天', () => {
const { manager, sent } = setup();
manager.state = 'online';
manager.loggedIn = false;
assert.strictEqual(manager.canOperate(), false);
assert.strictEqual(manager.sendCommand('u claimmode'), false);
assert.strictEqual(manager.sendChat('大家好'), false);
assert.deepStrictEqual(sent, [], '不应真的写出任何内容');
});
test('登录验证通过后指令与聊天都会被写出并留档', () => {
const { manager, chatLog, sent } = setup({ config: { server: { commandCooldownMs: 0 } } });
manager.state = 'online';
manager.loggedIn = true;
manager.sendCommand('u claimmode', { immediate: true });
manager.sendChat('大家好', { internal: true });
manager._chatQueue.length = 0;
manager._writeChat('大家好');
assert.deepStrictEqual(sent, ['/u claimmode', '大家好']);
const rows = chatLog.list('BotA');
assert.strictEqual(rows.length, 2);
assert.strictEqual(rows[0].kind, ChatKind.COMMAND);
assert.strictEqual(rows[0].message, '/u claimmode');
assert.strictEqual(rows[1].kind, ChatKind.OUT);
assert.strictEqual(rows[1].message, '大家好');
});
test('以斜杠开头的聊天内容被当作指令发送', () => {
const { manager, sent } = setup();
manager.state = 'online';
manager.loggedIn = true;
manager.sendChat('/spawn', { internal: true });
manager._chatQueue.length = 0;
// 指令走队列,直接驱动写入
manager._writeCommand(manager._commandQueue.pop() || 'spawn');
assert.deepStrictEqual(sent, ['/spawn']);
});
test('internal 选项绕过登录门禁(登录 / 注册流程自身使用)', () => {
const { manager, sent } = setup();
manager.state = 'online';
manager.loggedIn = false;
assert.strictEqual(manager.sendCommand('login pw', { internal: true, immediate: true }), true);
assert.deepStrictEqual(sent, ['/login pw']);
});
test('收到「登录成功」提示后置为已登录', () => {
const { manager } = setup();
manager.config.server.loginCommand = 'login {password}';
manager.loggedIn = false;
manager._handleAuthPrompt('登录成功,欢迎回来');
assert.strictEqual(manager.loggedIn, true);
assert.strictEqual(manager.loginFailed, false);
});
test('收到「已登录」提示同样视为通过验证', () => {
const { manager } = setup();
manager.loggedIn = false;
manager._handleAuthPrompt('你已登录,无需重复登录');
assert.strictEqual(manager.loggedIn, true);
});
test('收到密码错误提示时标记登录失败且不放行操作', () => {
const { manager } = setup();
manager.loggedIn = false;
manager._handleAuthPrompt('密码错误,请重新输入');
assert.strictEqual(manager.loginFailed, true);
assert.strictEqual(manager.loggedIn, false);
assert.strictEqual(manager.canOperate(), false);
});
test('服务器提示注册时自动发送注册指令(绕过门禁)', async () => {
const { manager, sent } = setup({ config: { server: { commandCooldownMs: 0 } } });
manager.config.server.registerCommand = 'register {password}';
manager.config.server.loginCommand = 'login {password}';
manager.loggedIn = false;
manager._handleAuthPrompt('请先注册');
await drainQueue();
assert.deepStrictEqual(sent, ['/register pw']);
});
test('服务器提示登录时自动发送登录指令(绕过门禁)', async () => {
const { manager, sent } = setup({ config: { server: { commandCooldownMs: 0 } } });
manager.config.server.loginCommand = 'login {password}';
manager.loggedIn = false;
manager._handleAuthPrompt('请先输入登录密码');
await drainQueue();
assert.deepStrictEqual(sent, ['/login pw']);
});
test('登录确认兜底:超时未收到任何提示时按已登录放行', async () => {
const { manager } = setup({ config: { server: { loginConfirmTimeoutMs: 20 } } });
manager.loggedIn = false;
manager.loginFailed = false;
manager._armLoginConfirmFallback();
await new Promise((resolve) => setTimeout(resolve, 60));
assert.strictEqual(manager.loggedIn, true);
});
test('登录确认兜底不会覆盖已明确失败的登录', async () => {
const { manager } = setup({ config: { server: { loginConfirmTimeoutMs: 20 } } });
manager.loggedIn = false;
manager._armLoginConfirmFallback();
manager.loginFailed = true;
await new Promise((resolve) => setTimeout(resolve, 60));
assert.strictEqual(manager.loggedIn, false);
});
test('收到消息时写入公屏日志(含系统消息与发言者)', () => {
const { manager, chatLog } = setup();
manager._recordChat('服务器将在 5 分钟后重启', 'system', null);
manager._recordChat('你好', 'chat', 'Steve');
const rows = chatLog.list('BotA');
assert.strictEqual(rows.length, 2);
assert.strictEqual(rows[0].kind, ChatKind.SYSTEM);
assert.strictEqual(rows[1].kind, ChatKind.CHAT);
assert.strictEqual(rows[1].username, 'Steve');
});
test('snapshot 暴露多机器人需要的 id / 备注 / 登录与操作许可', () => {
const { manager } = setup();
const snap = manager.snapshot();
assert.strictEqual(snap.id, 'BotA');
assert.strictEqual(snap.label, '主号');
assert.strictEqual(snap.username, 'BotA');
assert.strictEqual(snap.loggedIn, false);
assert.strictEqual(snap.canOperate, false);
assert.strictEqual(snap.loginFailed, false);
});
// ==================== 被服务器反复传送时的日志聚合 ====================
test('被服务器传送:首次立即记录,其余聚合为一条汇总', () => {
const { manager, logger } = setup();
manager._countForcedMove();
const afterFirst = logger.list().filter((e) => e.message.includes('被服务器传送'));
assert.strictEqual(afterFirst.length, 1, '首次传送应立即记一条');
// 模拟账号未验证时被每 tick 强推位置:再触发 200 次不应继续刷日志
for (let i = 0; i < 200; i++) manager._countForcedMove();
assert.strictEqual(logger.list().filter((e) => e.message.includes('被服务器传送')).length, 1,
'同一窗口内的重复传送不得逐条记录');
manager._flushForcedMove();
const lines = logger.list().filter((e) => e.message.includes('被服务器传送'));
assert.strictEqual(lines.length, 2, '窗口结束时汇总成一条');
assert.ok(lines[1].message.includes('另有 200 次'), '汇总应包含次数');
assert.strictEqual(lines[1].level, 'warn');
});
test('被服务器传送:窗口内只有一次时不输出多余汇总', () => {
const { manager, logger } = setup();
manager._countForcedMove();
manager._flushForcedMove();
assert.strictEqual(logger.list().filter((e) => e.message.includes('被服务器传送')).length, 1);
});
test('被服务器传送:断开连接时把未汇总的计数刷出去并清掉定时器', () => {
const { manager, logger } = setup();
manager._countForcedMove();
for (let i = 0; i < 9; i++) manager._countForcedMove();
manager._clearForcedMoveTimer();
assert.strictEqual(manager._forcedMoveTimer, null);
assert.strictEqual(manager._forcedMoveCount, 0);
const lines = logger.list().filter((e) => e.message.includes('被服务器传送'));
assert.ok(lines.some((e) => e.message.includes('另有 9 次')));
});
// ==================== 账号绑定 / 二次验证 ====================
test('服务器要求账号绑定时给出可操作的中文告警(且只提示一次)', () => {
const { manager, logger } = setup();
manager.config.server.loginCommand = 'login {password}';
manager.loggedIn = false;
manager._handleAuthPrompt('为了保障服务器安全,您需要绑定您的简幻通账号。');
manager._handleAuthPrompt('请使用命令: /auth <简幻通UID> <验证码>');
manager._handleAuthPrompt('请在 120 秒内完成验证,否则将被踢出服务器。');
const errors = logger.list().filter((e) => e.level === 'error' && e.message.includes('账号绑定'));
assert.strictEqual(errors.length, 1, '同一连接内只提示一次,避免刷屏');
assert.ok(logger.list().some((e) => e.message.includes('需要人工完成一次')));
assert.strictEqual(manager.loggedIn, false, '未完成验证时不得放行操作');
});
test('配置了账号绑定指令模板时自动发送(绕过门禁)', () => {
const { manager, sent } = setup();
manager.config.server.authCommand = 'auth {password}';
manager.loggedIn = false;
manager._handleAuthPrompt('请使用命令: /auth <简幻通UID> <验证码>');
assert.deepStrictEqual(sent, ['/auth pw']);
});
test('未配置账号绑定模板时不乱发指令', () => {
const { manager, sent } = setup();
manager.config.server.authCommand = '';
manager._handleAuthPrompt('请使用命令: /auth <简幻通UID> <验证码>');
assert.deepStrictEqual(sent, []);
});
test('重新登录后绑定告警标记被复位', () => {
const { manager } = setup();
manager._handleAuthPrompt('您需要绑定您的简幻通账号');
assert.strictEqual(manager._needAuthWarned, true);
// 模拟 login 事件的处理
manager._needAuthWarned = false;
assert.strictEqual(manager._needAuthWarned, false);
});
// ==================== 重连退避 ====================
test('computeReconnectDelay 按指数退避并在上限处钳制', () => {
const server = { reconnectDelayMs: 10000, reconnectMaxDelayMs: 120000 };
assert.strictEqual(computeReconnectDelay(server, 1), 10000);
assert.strictEqual(computeReconnectDelay(server, 2), 20000);
assert.strictEqual(computeReconnectDelay(server, 3), 40000);
assert.strictEqual(computeReconnectDelay(server, 4), 80000);
assert.strictEqual(computeReconnectDelay(server, 5), 120000, '超过上限后钳制');
assert.strictEqual(computeReconnectDelay(server, 30), 120000);
});
test('computeReconnectDelay 缺省上限时退化为固定间隔', () => {
assert.strictEqual(computeReconnectDelay({ reconnectDelayMs: 5000 }, 1), 5000);
// 未配置上限时 max 取基准值,等价于固定间隔(保持旧行为,不突然拉长重连)
assert.strictEqual(computeReconnectDelay({ reconnectDelayMs: 5000 }, 4), 5000);
assert.strictEqual(computeReconnectDelay({ reconnectDelayMs: 5000, reconnectMaxDelayMs: 5000 }, 4), 5000);
});
test('config 校验补全 authCommand 与 reconnectMaxDelayMs', () => {
const { config } = setup();
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']);
});