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

397 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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