'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} 去重 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} 是否成功发出 */ 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 };