guanghulab/glada/notifier.js
2026-05-10 13:12:44 +08:00

436 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

/**
* GLADA · 通知器 · notifier.js
*
* 多通道通知系统:
* 1. QQ邮箱 SMTP 通知(主通道·已打通)
* 2. 企业微信推送(预留通道·冰朔开通后接入)
* 3. 文件日志通知(本地记录·始终启用)
*
* 通知内容包含:
* - 任务编号 + 标题
* - 完成步骤列表
* - 变更文件清单
* - 测试结果
* - 开发回执git branch + commits
*
* 环境变量:
* ZY_SMTP_USER - QQ邮箱账号同零点原核的SMTP
* ZY_SMTP_PASS - QQ邮箱授权码
* GLADA_NOTIFY_TO - 通知接收邮箱(默认 = ZY_SMTP_USER
* WECOM_WEBHOOK - 企业微信机器人 Webhook可选·预留
*
* 版权:国作登字-2026-A-00037559
* 签发:铸渊 · ICE-GL-ZY001
*/
'use strict';
const https = require('https');
const http = require('http');
const fs = require('fs');
const path = require('path');
const nodemailer = require('nodemailer');
const ROOT = path.resolve(__dirname, '..');
const NOTIFICATION_LOG_DIR = path.join(ROOT, 'glada', 'logs', 'notifications');
// ── SMTP 传输器(懒初始化,复用零点原核频道的 QQ 邮箱配置) ──
let _transporter = null;
function getSmtpTransporter() {
if (_transporter) return _transporter;
const smtpUser = process.env.ZY_SMTP_USER;
const smtpPass = process.env.ZY_SMTP_PASS;
if (!smtpUser || !smtpPass) {
return null;
}
_transporter = nodemailer.createTransport({
host: 'smtp.qq.com',
port: 465,
secure: true,
auth: {
user: smtpUser,
pass: smtpPass
},
tls: {
rejectUnauthorized: true
}
});
return _transporter;
}
/**
* HTTP 请求工具(用于企业微信等 Webhook 通道)
*/
function httpRequest(url, options, body) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const isHttps = parsed.protocol === 'https:';
const mod = isHttps ? https : http;
const opts = {
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
method: options.method || 'POST',
headers: options.headers || {},
timeout: options.timeout || 30000,
};
const req = mod.request(opts, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body: data }));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
if (body) req.write(body);
req.end();
});
}
/**
* 构建通知内容
* @param {Object} gladaTask - GLADA 任务
* @param {string} eventType - 事件类型: completed | failed | step_completed | started
* @returns {Object} { subject, body, html }
*/
function buildNotification(gladaTask, eventType) {
const taskId = gladaTask.glada_task_id;
const title = gladaTask.plan.title;
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
const steps = gladaTask.plan.steps || [];
const completedSteps = steps.filter(s => s.status === 'completed');
const failedSteps = steps.filter(s => s.status === 'failed' || s.status === 'rolled_back');
const allFilesChanged = gladaTask.completion?.total_files_changed || [];
const gitBranch = gladaTask.completion?.git_branch || '未知';
const gitCommits = gladaTask.completion?.git_commits || [];
let subject = '';
let body = '';
switch (eventType) {
case 'completed':
subject = `✅ GLADA任务完成: ${taskId} · ${title}`;
body = [
`🎉 GLADA 任务已完成`,
``,
`任务编号: ${taskId}`,
`任务标题: ${title}`,
`完成时间: ${now}`,
``,
`📊 执行概况:`,
` - 总步骤: ${steps.length}`,
` - 已完成: ${completedSteps.length}`,
` - 失败: ${failedSteps.length}`,
``,
`📁 变更文件 (${allFilesChanged.length}):`,
...allFilesChanged.map(f => ` - ${f}`),
``,
`🌿 Git 分支: ${gitBranch}`,
`📝 提交记录:`,
...gitCommits.map(c => ` - ${c}`),
``,
`请打开副驾驶验收: "GLADA任务 ${taskId} 已完成,请验收"`,
].join('\n');
break;
case 'failed':
subject = `❌ GLADA任务失败: ${taskId} · ${title}`;
body = [
`⚠️ GLADA 任务执行失败`,
``,
`任务编号: ${taskId}`,
`任务标题: ${title}`,
`失败时间: ${now}`,
``,
`📊 执行概况:`,
` - 总步骤: ${steps.length}`,
` - 已完成: ${completedSteps.length}`,
` - 失败: ${failedSteps.length}`,
``,
`❌ 失败步骤:`,
...failedSteps.map(s => ` - 步骤${s.step_id}: ${s.description} (${s.error || '未知错误'})`),
``,
`请打开副驾驶排查: "GLADA任务 ${taskId} 失败了,请帮我排查"`,
].join('\n');
break;
case 'step_completed':
subject = `📋 GLADA进度: ${taskId} · 步骤${completedSteps.length}/${steps.length}`;
body = [
`GLADA 步骤完成通知`,
``,
`任务: ${title}`,
`进度: ${completedSteps.length}/${steps.length}`,
`时间: ${now}`,
].join('\n');
break;
case 'started':
subject = `🚀 GLADA任务开始: ${taskId} · ${title}`;
body = [
`GLADA 任务已开始执行`,
``,
`任务编号: ${taskId}`,
`任务标题: ${title}`,
`总步骤: ${steps.length}`,
`开始时间: ${now}`,
].join('\n');
break;
default:
subject = `GLADA: ${taskId}`;
body = `任务 ${taskId} 状态更新: ${eventType}`;
}
// 生成 HTML 邮件格式
const html = buildEmailHtml(subject, body, eventType, {
taskId, title, now, steps, completedSteps, failedSteps,
allFilesChanged, gitBranch, gitCommits
});
return { subject, body, html };
}
/**
* 构建 GLADA 通知邮件 HTML光湖视觉风格
*/
function buildEmailHtml(subject, plainBody, eventType, data) {
const statusColor = eventType === 'completed' ? '#22d3ee'
: eventType === 'failed' ? '#f87171'
: eventType === 'started' ? '#a78bfa'
: '#60a5fa';
const statusIcon = eventType === 'completed' ? '✅'
: eventType === 'failed' ? '❌'
: eventType === 'started' ? '🚀'
: '📋';
const filesHtml = (data.allFilesChanged || []).length > 0
? `<div style="margin:16px 0"><strong style="color:#94a3b8">📁 变更文件:</strong><ul style="margin:8px 0;padding-left:20px;color:#cbd5e1">${data.allFilesChanged.map(f => `<li style="margin:2px 0;font-family:monospace;font-size:12px">${f}</li>`).join('')}</ul></div>`
: '';
const commitsHtml = (data.gitCommits || []).length > 0
? `<div style="margin:16px 0"><strong style="color:#94a3b8">📝 提交记录:</strong><ul style="margin:8px 0;padding-left:20px;color:#cbd5e1">${data.gitCommits.map(c => `<li style="margin:2px 0;font-family:monospace;font-size:12px">${c}</li>`).join('')}</ul></div>`
: '';
const stepsHtml = (data.steps || []).length > 0
? `<div style="margin:16px 0"><strong style="color:#94a3b8">📊 步骤:</strong><ul style="margin:8px 0;padding-left:20px;color:#cbd5e1">${data.steps.map(s => {
const icon = s.status === 'completed' ? '✅' : s.status === 'failed' || s.status === 'rolled_back' ? '❌' : '⏳';
return `<li style="margin:2px 0">${icon} ${s.description || '步骤' + s.step_id}</li>`;
}).join('')}</ul></div>`
: '';
return `<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>${subject}</title></head>
<body style="margin:0;padding:0;background:#050810;font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Helvetica Neue',sans-serif">
<main style="max-width:560px;margin:0 auto;padding:40px 24px">
<div style="text-align:center;margin-bottom:24px">
<div style="display:inline-block;width:48px;height:48px;line-height:48px;border-radius:12px;background:linear-gradient(135deg,rgba(34,211,238,0.15),rgba(167,139,250,0.12));border:1px solid rgba(96,165,250,0.2);font-size:20px;font-weight:900;color:#22d3ee;font-family:serif">渊</div>
</div>
<div style="background:rgba(10,16,36,0.95);border-radius:16px;border:1px solid rgba(96,165,250,0.1);padding:28px 24px">
<div style="text-align:center;margin-bottom:20px">
<span style="font-size:28px">${statusIcon}</span>
<h2 style="color:#e2eaf8;font-size:16px;font-weight:700;margin:8px 0 4px">${data.title || ''}</h2>
<p style="color:#7a8db8;font-size:12px;margin:0">任务 ${data.taskId} · ${data.now}</p>
</div>
<div style="background:rgba(${statusColor === '#22d3ee' ? '34,211,238' : statusColor === '#f87171' ? '248,113,113' : '167,139,250'},0.06);border:1px solid rgba(${statusColor === '#22d3ee' ? '34,211,238' : statusColor === '#f87171' ? '248,113,113' : '167,139,250'},0.15);border-radius:10px;padding:16px;margin-bottom:16px">
<p style="color:${statusColor};font-weight:600;margin:0 0 8px;font-size:14px">${subject}</p>
<p style="color:#94a3b8;font-size:13px;margin:0">总步骤: ${(data.steps || []).length} · 完成: ${(data.completedSteps || []).length} · 失败: ${(data.failedSteps || []).length}</p>
</div>
${stepsHtml}
${filesHtml}
${data.gitBranch && data.gitBranch !== '未知' ? `<p style="color:#94a3b8;font-size:12px;margin:8px 0">🌿 分支: <code style="background:rgba(96,165,250,0.1);padding:2px 6px;border-radius:4px;color:#60a5fa">${data.gitBranch}</code></p>` : ''}
${commitsHtml}
</div>
<div style="text-align:center;margin-top:20px">
<p style="color:rgba(100,130,180,0.4);font-size:10px;margin:0">铸渊 · GLADA 自主开发Agent · 自动发送</p>
<p style="color:rgba(100,130,180,0.3);font-size:9px;margin:4px 0 0">版权 国作登字-2026-A-00037559 · TCS-0002∞</p>
</div>
</main>
</body>
</html>`;
}
/**
* 发送QQ邮箱通知主通道
* @param {Object} notification - 通知内容 { subject, body, html }
* @returns {Promise<boolean>}
*/
async function sendEmail(notification) {
// ⚠️ D72 (2026-05-03) 冰朔决策:默认禁用所有邮件告警。
// 与 ops-agent/notifier.js 行为一致:必须显式 OPS_EMAIL_ENABLED=true 才发。
const v = process.env.OPS_EMAIL_ENABLED;
const norm = v === undefined || v === null ? '' : String(v).trim().toLowerCase();
const enabled = norm === 'true' || norm === '1' || norm === 'yes' || norm === 'on';
if (!enabled) {
console.log('[GLADA-Notify] OPS_EMAIL_ENABLED!=true (D72 默认禁用),跳过邮件通知');
return false;
}
const transporter = getSmtpTransporter();
if (!transporter) {
console.log('[GLADA-Notify] ⚠️ QQ邮箱 SMTP 未配置(需要 ZY_SMTP_USER + ZY_SMTP_PASS跳过邮件通知');
return false;
}
const smtpUser = process.env.ZY_SMTP_USER;
const notifyTo = process.env.GLADA_NOTIFY_TO || smtpUser;
try {
const info = await transporter.sendMail({
from: `"铸渊 · GLADA Agent" <${smtpUser}>`,
to: notifyTo,
subject: notification.subject,
text: notification.body,
html: notification.html
});
console.log(`[GLADA-Notify] 📧 邮件通知已发送: ${info.messageId}`);
return true;
} catch (err) {
console.error(`[GLADA-Notify] 📧 邮件通知失败: ${err.message}`);
return false;
}
}
/**
* 发送企业微信通知(预留通道·冰朔开通企业微信后接入)
*
* 企业微信机器人 Webhook 格式:
* https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
*
* @param {string} webhook - 企业微信 Webhook URL
* @param {Object} notification - 通知内容
* @returns {Promise<boolean>}
*/
async function sendWeCom(webhook, notification) {
if (!webhook) {
// 未配置时静默跳过(预留通道,不打印警告)
return false;
}
try {
const payload = JSON.stringify({
msgtype: 'markdown',
markdown: {
content: [
`### ${notification.subject}`,
``,
notification.body.split('\n').map(line => `> ${line}`).join('\n')
].join('\n')
}
});
const response = await httpRequest(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}, payload);
const result = JSON.parse(response.body || '{}');
const success = result.errcode === 0;
console.log(`[GLADA-Notify] 💬 企业微信通知: ${success ? '✅' : '❌'} ${result.errmsg || ''}`);
return success;
} catch (err) {
console.error(`[GLADA-Notify] 💬 企业微信通知失败: ${err.message}`);
return false;
}
}
/**
* 写入本地通知日志
* @param {Object} notification - 通知内容
* @param {string} taskId - 任务 ID
*/
function writeNotificationLog(notification, taskId) {
fs.mkdirSync(NOTIFICATION_LOG_DIR, { recursive: true });
const logEntry = {
timestamp: new Date().toISOString(),
task_id: taskId,
subject: notification.subject,
body: notification.body,
channels: []
};
const today = new Date().toISOString().split('T')[0];
const logFile = path.join(NOTIFICATION_LOG_DIR, `notify-${today}.jsonl`);
fs.appendFileSync(logFile, JSON.stringify(logEntry) + '\n', 'utf-8');
}
/**
* 发送任务通知(所有通道)
* @param {Object} gladaTask - GLADA 任务
* @param {string} eventType - 事件类型
* @returns {Promise<Object>} 发送结果
*/
async function notify(gladaTask, eventType) {
const notification = buildNotification(gladaTask, eventType);
const results = { channels: {} };
console.log(`[GLADA-Notify] 📣 ${notification.subject}`);
// 1. 本地日志(始终记录)
writeNotificationLog(notification, gladaTask.glada_task_id);
results.channels.log = true;
// 2. QQ邮箱 SMTP主通道
const smtpConfigured = !!(process.env.ZY_SMTP_USER && process.env.ZY_SMTP_PASS);
if (smtpConfigured) {
results.channels.email = await sendEmail(notification);
}
// 3. 企业微信(预留通道)
const wecomWebhook = process.env.WECOM_WEBHOOK || process.env.GLADA_WECOM_WEBHOOK || '';
if (wecomWebhook) {
results.channels.wecom = await sendWeCom(wecomWebhook, notification);
}
// 4. 生成开发回执文件
if (eventType === 'completed' || eventType === 'failed') {
const receiptPath = path.join(ROOT, 'glada', 'receipts', `${gladaTask.glada_task_id}.json`);
fs.mkdirSync(path.dirname(receiptPath), { recursive: true });
fs.writeFileSync(receiptPath, JSON.stringify({
task_id: gladaTask.glada_task_id,
source_task_id: gladaTask.source_task_id,
status: eventType,
title: gladaTask.plan.title,
completed_at: new Date().toISOString(),
steps_summary: gladaTask.plan.steps.map(s => ({
step_id: s.step_id,
description: s.description,
status: s.status
})),
files_changed: gladaTask.completion?.total_files_changed || [],
git_branch: gladaTask.completion?.git_branch || null,
git_commits: gladaTask.completion?.git_commits || [],
notification: notification.subject
}, null, 2), 'utf-8');
results.channels.receipt = true;
console.log(`[GLADA-Notify] 📄 开发回执: ${receiptPath}`);
}
return results;
}
module.exports = {
buildNotification,
sendEmail,
sendWeCom,
writeNotificationLog,
notify
};