feat: 新增机器人上/下线按钮,识别「账号已在别处登录」与「等待验证码」

下线按钮
- 顶栏新增「下线 / 上线」切换:下线会断开连接、停止自动重连,并停掉定时消息与
  扩地任务;需要在游戏客户端登同一账号时必须先下线,否则会被服务端以
  「您已经连接到此代理服务器了」反复踢掉
- 接口 POST /api/bots/:id/offline 与 /online;上线时会清掉「已在别处登录」标记
  并把重连计数归零,否则重连会一直退避到最大间隔

识别两类此前「看起来像启动不了」的情况
- 「您已经连接到此代理服务器了」:服务端踢人时发的是聊天组件对象,
  原来 JSON.stringify 之后没法看,关键信息被淹没。新增 extractKickReason()
  提取纯文本,命中后给出「请先在客户端退出」的可操作提示,
  并直接把重连退避到最大间隔,不再频繁硬撞服务器
- 「请直接输入验证码进行验证」:账号绑定过之后服务器仍可能每次登录都要求验证码,
  而原来的正则只认「完成验证 / 身份验证」,漏掉了这句话,用户只看到机器人卡在大厅。
  现在补上匹配,并在控制台弹出醒目提示 + 把输入框 placeholder 换成「输入验证码后回车发送」

测试 343 -> 355,全部通过。
这个提交包含在:
lyizai
2026-09-28 14:21:14 +08:00
父节点 71e78f06dc
当前提交 42a9dd6cf0
共修改 9 个文件,包含 390 行新增和 13 行删除
+80 -6
查看文件
@@ -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 <UID> <验证码>`
* 或扫码完成绑定,否则会被反复传送、并在倒计时结束后踢出。
* 账号绑定过之后,服务器仍可能**每次登录都要求输入验证码**
* (「请直接输入验证码进行验证」),这时验证码是当聊天内容发出去的,不是指令。
* 这种流程无法靠密码自动完成,因此只做「明确告知 + 可选模板」处理。
*/
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 <UID> <验证码>);'
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
};
+39
查看文件
@@ -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;
}
/**
* 向某机器人发送游戏指令。
*