397 lines
14 KiB
JavaScript
397 lines
14 KiB
JavaScript
#!/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<font color="comment">${ts}</font>`,
|
||
},
|
||
};
|
||
|
||
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);
|
||
});
|
||
}
|