#!/usr/bin/env node /** * Webhook 告警外发 (v5.3) * * 支持 Slack / Discord / 企业微信 / 自定义 URL。 * 集成到 health-check 和 auto-cleanup 的告警管道。 * * 用法: * node webhook-notifier.js --test 测试 webhook 连通性 * node webhook-notifier.js --alert "消息内容" 发送告警 * node webhook-notifier.js --level warning "消息" 指定级别 (info/warning/critical) * node webhook-notifier.js --health-report 读取 health-check 并发送摘要 * * 配置: * 环境变量 WEBHOOK_URL 主 webhook 地址 * 环境变量 WEBHOOK_URL_BACKUP 备用 webhook 地址 * 或 debug/webhook-config.json 本地配置文件 * * 支持的 webhook 类型 (自动检测): * - Slack Incoming Webhook * - Discord Webhook * - 企业微信机器人 * - 通用 JSON POST (自定义) */ const fs = require('fs'); const path = require('path'); const https = require('https'); const http = require('http'); const { URL } = require('url'); // ─── 路径 ─────────────────────────────────────────── let PATHS; try { PATHS = require('./paths.config.js').PATHS; } catch { const ROOT = __dirname.replace(/[/\\]scripts$/, ''); PATHS = { root: ROOT, debugDir: path.join(ROOT, 'debug'), }; } const CONFIG_FILE = path.join(PATHS.debugDir, 'webhook-config.json'); const LOG_FILE = path.join(PATHS.debugDir, 'webhook-log.jsonl'); // ─── 配置加载 ─────────────────────────────────────── function loadConfig() { const config = { urls: [], enabled: true, minLevel: 'warning', // 最低告警级别: info < warning < critical rateLimitMs: 60000, // 同一消息最小间隔 (1分钟) }; // 环境变量优先 if (process.env.WEBHOOK_URL) { config.urls.push(process.env.WEBHOOK_URL); } if (process.env.WEBHOOK_URL_BACKUP) { config.urls.push(process.env.WEBHOOK_URL_BACKUP); } // 本地配置文件补充 if (fs.existsSync(CONFIG_FILE)) { try { const local = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); if (local.urls) config.urls.push(...local.urls); if (local.enabled !== undefined) config.enabled = local.enabled; if (local.minLevel) config.minLevel = local.minLevel; if (local.rateLimitMs) config.rateLimitMs = local.rateLimitMs; } catch {} } // 去重 config.urls = [...new Set(config.urls)]; return config; } // ─── Webhook 类型检测 ─────────────────────────────── function detectWebhookType(url) { if (url.includes('hooks.slack.com')) return 'slack'; if (url.includes('discord.com/api/webhooks') || url.includes('discordapp.com/api/webhooks')) return 'discord'; if (url.includes('qyapi.weixin.qq.com')) return 'wechat'; return 'generic'; } // ─── Payload 格式化 ───────────────────────────────── const LEVEL_EMOJI = { info: 'ℹ️', warning: '⚠️', critical: '🚨' }; const LEVEL_COLOR = { info: '#36a64f', warning: '#ffa500', critical: '#ff0000' }; const LEVEL_PRIORITY = { info: 0, warning: 1, critical: 2 }; function formatPayload(type, message, level = 'warning', title = 'Bookworm Alert') { const emoji = LEVEL_EMOJI[level] || '📢'; const color = LEVEL_COLOR[level] || '#888'; const ts = new Date().toISOString().slice(0, 19).replace('T', ' '); const text = `${emoji} **${title}** [${level.toUpperCase()}]\n${message}\n_${ts}_`; switch (type) { case 'slack': return { attachments: [{ color, title: `${emoji} ${title}`, text: message, footer: `Bookworm v5.3 | ${ts}`, mrkdwn_in: ['text'], }], }; case 'discord': return { embeds: [{ title: `${emoji} ${title}`, description: message, color: parseInt(color.replace('#', ''), 16), footer: { text: `Bookworm v5.3 | ${ts}` }, }], }; case 'wechat': return { msgtype: 'markdown', markdown: { content: `### ${emoji} ${title}\n> 级别: **${level.toUpperCase()}**\n\n${message}\n\n${ts}`, }, }; default: // generic return { source: 'bookworm-smart-assistant', version: 'v5.3', level, title, message, timestamp: new Date().toISOString(), }; } } // ─── HTTP POST ────────────────────────────────────── function httpPost(url, payload) { return new Promise((resolve, reject) => { const parsed = new URL(url); const data = JSON.stringify(payload); const mod = parsed.protocol === 'https:' ? https : http; const req = mod.request({ hostname: parsed.hostname, port: parsed.port, path: parsed.pathname + parsed.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), 'User-Agent': 'Bookworm-Webhook/5.3', }, timeout: 10000, }, (res) => { let body = ''; res.on('data', (c) => body += c); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve({ ok: true, status: res.statusCode, body }); } else { resolve({ ok: false, status: res.statusCode, body }); } }); }); req.on('error', (e) => reject(e)); req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); }); req.write(data); req.end(); }); } // ─── 速率限制 ──────────────────────────────────────── function checkRateLimit(message, rateLimitMs) { if (!fs.existsSync(LOG_FILE)) return true; try { const lines = fs.readFileSync(LOG_FILE, 'utf8').trim().split('\n').slice(-20); const now = Date.now(); for (const line of lines.reverse()) { const entry = JSON.parse(line); if (entry.message === message && (now - new Date(entry.ts).getTime()) < rateLimitMs) { return false; // 重复消息,限流 } } } catch {} return true; } function logSend(message, level, results) { try { if (!fs.existsSync(PATHS.debugDir)) fs.mkdirSync(PATHS.debugDir, { recursive: true }); const entry = { ts: new Date().toISOString(), level, message: message.slice(0, 200), results: results.map(r => ({ url: r.url.replace(/\/[^/]{20,}/, '/***'), ok: r.ok, status: r.status })), }; fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n'); } catch {} } // ─── 核心发送函数 ──────────────────────────────────── /** * 发送告警到所有已配置的 webhook * @param {string} message - 告警消息 * @param {object} [options] - 选项 * @param {string} [options.level='warning'] - 告警级别 * @param {string} [options.title='Bookworm Alert'] - 标题 * @param {boolean} [options.force=false] - 跳过级别过滤和速率限制 * @returns {Promise<{sent: number, failed: number, skipped: string|null}>} */ async function sendAlert(message, options = {}) { const { level = 'warning', title = 'Bookworm Alert', force = false } = options; const config = loadConfig(); // 未配置 if (config.urls.length === 0) { return { sent: 0, failed: 0, skipped: 'no webhook URLs configured' }; } // 禁用 if (!config.enabled && !force) { return { sent: 0, failed: 0, skipped: 'webhooks disabled' }; } // 级别过滤 if (!force && LEVEL_PRIORITY[level] < LEVEL_PRIORITY[config.minLevel]) { return { sent: 0, failed: 0, skipped: `level ${level} below minLevel ${config.minLevel}` }; } // 速率限制 if (!force && !checkRateLimit(message, config.rateLimitMs)) { return { sent: 0, failed: 0, skipped: 'rate limited (duplicate message)' }; } let sent = 0, failed = 0; const results = []; for (const url of config.urls) { const type = detectWebhookType(url); const payload = formatPayload(type, message, level, title); try { const result = await httpPost(url, payload); results.push({ url, ...result }); if (result.ok) sent++; else failed++; } catch (e) { results.push({ url, ok: false, status: 0, body: e.message }); failed++; } } logSend(message, level, results); return { sent, failed, skipped: null, results }; } /** * 从 health-check JSON 输出生成告警消息 * @returns {Promise<{sent: number, message: string}>} */ async function sendHealthReport() { const { execSync } = require('child_process'); try { const output = execSync( `node "${path.join(PATHS.root, 'scripts', 'health-check.js')}" --json`, { encoding: 'utf8', timeout: 30000, cwd: PATHS.root } ); const report = JSON.parse(output); // 仅在非 HEALTHY 时发送 if (report.overallStatus === 'HEALTHY' && report.overallScore >= 90) { return { sent: 0, message: 'System healthy, no alert needed' }; } const failDims = (report.dimensions || []) .filter(d => d.status === 'FAIL' || d.status === 'WARN') .map(d => `${d.id} ${d.name}: ${d.score}/100 (${d.detail})`) .join('\n'); const message = `健康评分: ${report.overallScore}/100 (${report.overallStatus})\n\n问题维度:\n${failDims || '无'}`; const level = report.overallScore < 60 ? 'critical' : 'warning'; const result = await sendAlert(message, { level, title: 'Bookworm 健康检查告警', }); return { ...result, message }; } catch (e) { return { sent: 0, message: `健康检查执行失败: ${e.message}` }; } } // ─── CLI ───────────────────────────────────────────── async function main() { const args = process.argv.slice(2); if (args.includes('--test')) { console.log('=== Webhook 连通性测试 ==='); const config = loadConfig(); if (config.urls.length === 0) { console.log('未配置 webhook URL。'); console.log('设置方式:'); console.log(' 1. 环境变量: export WEBHOOK_URL="https://hooks.slack.com/..."'); console.log(` 2. 配置文件: ${CONFIG_FILE}`); console.log(' {"urls": ["https://hooks.slack.com/..."], "enabled": true}'); return; } console.log(`已配置 ${config.urls.length} 个 webhook:`); for (const url of config.urls) { const type = detectWebhookType(url); const masked = url.replace(/\/[^/]{20,}/, '/***'); console.log(` [${type}] ${masked}`); } const result = await sendAlert('Bookworm webhook 测试消息 - 请忽略', { level: 'info', title: 'Webhook 连通性测试', force: true, }); console.log(`\n结果: ${result.sent} 成功, ${result.failed} 失败`); if (result.results) { for (const r of result.results) { console.log(` ${r.ok ? '✓' : '✗'} ${r.url.replace(/\/[^/]{20,}/, '/***')} (HTTP ${r.status})`); } } return; } if (args.includes('--health-report')) { const result = await sendHealthReport(); console.log(result.message); if (result.sent > 0) console.log(`已发送 ${result.sent} 条告警`); return; } if (args.includes('--alert')) { const alertIdx = args.indexOf('--alert'); const levelIdx = args.indexOf('--level'); const level = levelIdx >= 0 ? args[levelIdx + 1] : 'warning'; const message = args.filter((a, i) => !a.startsWith('--') && !(levelIdx >= 0 && i === levelIdx + 1) ).join(' ') || args[alertIdx + 1]; if (!message) { console.error('Usage: --alert "消息内容" [--level warning]'); process.exit(1); } const result = await sendAlert(message, { level }); if (result.skipped) { console.log(`跳过: ${result.skipped}`); } else { console.log(`已发送: ${result.sent} 成功, ${result.failed} 失败`); } return; } // 默认: 显示帮助 console.log('Bookworm Webhook Notifier v5.3'); console.log(''); console.log('用法:'); console.log(' --test 测试 webhook 连通性'); console.log(' --alert "消息" 发送告警'); console.log(' --level info|warning|critical 指定级别 (默认 warning)'); console.log(' --health-report 发送健康检查报告'); console.log(''); console.log('配置:'); console.log(' WEBHOOK_URL=... 环境变量'); console.log(` ${CONFIG_FILE} 本地配置`); } // ─── 导出 ───────────────────────────────────────────── if (typeof module !== 'undefined') { module.exports = { loadConfig, detectWebhookType, formatPayload, sendAlert, sendHealthReport, checkRateLimit, httpPost, LEVEL_PRIORITY, }; } if (require.main === module) { main().catch(e => { console.error(e.message); process.exit(1); }); }