fix: 打通登录门禁死锁,失败信息区分未连接/登录被拒/未通过验证

继续修复部署时暴露的门禁设计问题:

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

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

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

测试从 252 增至 263,全部通过。
这个提交包含在:
lyizai
2026-09-27 22:11:52 +08:00
父节点 d6b1339867
当前提交 2671da95d5
共修改 10 个文件,包含 279 行新增和 18 行删除
+36 -2
查看文件
@@ -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) {
+38 -7
查看文件
@@ -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 };
+16
查看文件
@@ -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;
+10 -2
查看文件
@@ -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;
+11 -2
查看文件
@@ -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 */