bookworm-smart-assistant/scripts/archive/webhook-notifier.js

397 lines
14 KiB
JavaScript
Raw Permalink Normal View History

#!/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);
});
}