feat: 新增机器人上/下线按钮,识别「账号已在别处登录」与「等待验证码」
下线按钮 - 顶栏新增「下线 / 上线」切换:下线会断开连接、停止自动重连,并停掉定时消息与 扩地任务;需要在游戏客户端登同一账号时必须先下线,否则会被服务端以 「您已经连接到此代理服务器了」反复踢掉 - 接口 POST /api/bots/:id/offline 与 /online;上线时会清掉「已在别处登录」标记 并把重连计数归零,否则重连会一直退避到最大间隔 识别两类此前「看起来像启动不了」的情况 - 「您已经连接到此代理服务器了」:服务端踢人时发的是聊天组件对象, 原来 JSON.stringify 之后没法看,关键信息被淹没。新增 extractKickReason() 提取纯文本,命中后给出「请先在客户端退出」的可操作提示, 并直接把重连退避到最大间隔,不再频繁硬撞服务器 - 「请直接输入验证码进行验证」:账号绑定过之后服务器仍可能每次登录都要求验证码, 而原来的正则只认「完成验证 / 身份验证」,漏掉了这句话,用户只看到机器人卡在大厅。 现在补上匹配,并在控制台弹出醒目提示 + 把输入框 placeholder 换成「输入验证码后回车发送」 测试 343 -> 355,全部通过。
这个提交包含在:
+90
-1
@@ -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');
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
在新工单中引用
屏蔽一个用户