feat: 1.7.0 落地 TODO 全部待办(地面修正 / 手动执行器 / 自救 / 回城 / 租约 / 通知)
按 TODO.md 的优先级把待办全部落地为代码,详见 CHANGELOG 的 [1.7.0]。 - 「走不动」攻关:地面状态修正(syncGroundToPhysics / trustSetbackOnGround)、 出站包类型分布、备选执行器 manualMover(只借 A* 规划、自己按键执行) - 原点自救分两轮并统计成败;回城前评估「原地是否更差」并记录前后坐标 - 新增目标区块租约表(targetLease)避免多机器人撞同一块 - 定时消息支持 cron 与「期望回复」确认;新增人工介入 Webhook 通知(notifier) - 大地图后台预热与视野裁剪;控制台日志按帧批量写入、配置面板支持搜索 顺带修掉两个影响部署的细节: - 部署脚本(deploy.js / ssh-helper.js)改为也能从 .env 读 SSH 凭据 —— 在命令行传密码会留在 shell 历史与进程列表里,写进已被忽略的 .env 更安全; 显式环境变量仍然优先。.env.example 补上占位符并说明这些只在本机用。 - package.json / package-lock.json 版本对齐到 1.7.0(此前停留在 1.1.0, 与 CHANGELOG 的语义化版本声明脱节)。 测试:368 → 436 个用例,`npm test` 与 `npm run check` 全绿。
这个提交包含在:
+140
@@ -0,0 +1,140 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 通知提醒(Webhook):需要人工介入时把消息推到飞书机器人等渠道。
|
||||
*
|
||||
* 服务器只给 120 秒输入登录验证码,错过就要等下一次重登 —— 这是整个机器人流程里
|
||||
* 几乎唯一必须人工介入的环节(另一个是「原点自救失败」说明可能被切到了登录服),
|
||||
* 因此值得在手机上也收到提醒,而不是只写在日志里等人翻。
|
||||
*
|
||||
* 实现刻意做到最小:POST 一个 JSON,默认按飞书 / Lark 自定义机器人的文本消息格式
|
||||
* (`{"msgtype":"text","content":{"text":"…"}}`)—— 在群聊里加一个自定义机器人
|
||||
* 就能拿到 Webhook 地址,零依赖。
|
||||
*/
|
||||
|
||||
/** 同一 key 的最小提醒间隔(毫秒):重连循环里同一个问题只提醒一次 */
|
||||
const DEFAULT_MIN_INTERVAL_MS = 60000;
|
||||
/** 单次请求超时(毫秒) */
|
||||
const REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
class Notifier {
|
||||
/**
|
||||
* @param {object} config 完整配置对象
|
||||
* @param {import('../util/logger').Logger} [logger]
|
||||
* @param {{fetchImpl?: Function, minIntervalMs?: number}} [options]
|
||||
*/
|
||||
constructor(config, logger, options) {
|
||||
const opts = options || {};
|
||||
this.config = config;
|
||||
this.logger = logger || null;
|
||||
this.fetchImpl = opts.fetchImpl || globalThis.fetch;
|
||||
this.minIntervalMs = Number.isFinite(opts.minIntervalMs) ? opts.minIntervalMs : DEFAULT_MIN_INTERVAL_MS;
|
||||
/** @type {Map<string, number>} 去重 key → 上次发送时间 */
|
||||
this._lastSentAt = new Map();
|
||||
this.sent = 0;
|
||||
this.failed = 0;
|
||||
this.lastError = '';
|
||||
this.lastSentAt = 0;
|
||||
}
|
||||
|
||||
/** @returns {string} Webhook 地址(配置优先,其次环境变量 NOTIFY_WEBHOOK_URL) */
|
||||
get webhookUrl() {
|
||||
const cfg = (this.config && this.config.notify) || {};
|
||||
const configured = typeof cfg.webhookUrl === 'string' ? cfg.webhookUrl.trim() : '';
|
||||
if (configured) return configured;
|
||||
return String(process.env.NOTIFY_WEBHOOK_URL || '').trim();
|
||||
}
|
||||
|
||||
/** @returns {boolean} 是否已开启且配了地址 */
|
||||
isEnabled() {
|
||||
const cfg = (this.config && this.config.notify) || {};
|
||||
return cfg.enabled === true && this.webhookUrl !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送一条提醒。
|
||||
*
|
||||
* @param {string} text 正文
|
||||
* @param {{key?: string, force?: boolean}} [options]
|
||||
* key 用于去重:同一个 key 在 `minIntervalMs` 内只会发一次(force 可跳过去重)
|
||||
* @returns {Promise<boolean>} 是否成功发出
|
||||
*/
|
||||
async notify(text, options) {
|
||||
const opts = options || {};
|
||||
if (!this.isEnabled()) return false;
|
||||
const key = opts.key || 'default';
|
||||
const now = Date.now();
|
||||
const last = this._lastSentAt.get(key);
|
||||
if (opts.force !== true && Number.isFinite(last) && now - last < this.minIntervalMs) {
|
||||
return false; // 刚提醒过同一个问题,不重复打扰
|
||||
}
|
||||
this._lastSentAt.set(key, now);
|
||||
const payload = {
|
||||
msgtype: 'text',
|
||||
content: { text: String(text) }
|
||||
};
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
const timer = controller ? setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) : null;
|
||||
try {
|
||||
const response = await this.fetchImpl(this.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller ? controller.signal : undefined
|
||||
});
|
||||
if (!response || response.ok !== true) {
|
||||
this.failed++;
|
||||
this.lastError = `HTTP ${response ? response.status : '???'}`;
|
||||
this._log('通知发送失败:' + this.lastError, 'warn');
|
||||
return false;
|
||||
}
|
||||
this.sent++;
|
||||
this.lastSentAt = Date.now();
|
||||
this.lastError = '';
|
||||
this._log('已发送通知提醒:' + firstLine(text));
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.failed++;
|
||||
this.lastError = err && err.message ? err.message : String(err);
|
||||
this._log('通知发送异常:' + this.lastError, 'warn');
|
||||
return false;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {object} 供控制台展示的状态(不暴露完整 Webhook 地址) */
|
||||
snapshot() {
|
||||
const url = this.webhookUrl;
|
||||
return {
|
||||
enabled: this.isEnabled(),
|
||||
configured: url !== '',
|
||||
/** 只显示域名部分,避免把带凭证的完整 Webhook 打进接口响应 */
|
||||
host: url ? safeHost(url) : '',
|
||||
sent: this.sent,
|
||||
failed: this.failed,
|
||||
lastError: this.lastError,
|
||||
lastSentAt: this.lastSentAt
|
||||
};
|
||||
}
|
||||
|
||||
_log(message, level) {
|
||||
if (this.logger) this.logger.log(message, level);
|
||||
}
|
||||
}
|
||||
|
||||
/** 取 URL 的域名(拿不到就返回空串) */
|
||||
function safeHost(url) {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 取多行文本的第一行(日志里只记一行摘要) */
|
||||
function firstLine(text) {
|
||||
return String(text).split('\n')[0].slice(0, 120);
|
||||
}
|
||||
|
||||
module.exports = { Notifier, DEFAULT_MIN_INTERVAL_MS };
|
||||
在新工单中引用
屏蔽一个用户