/**
* 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
? `
📁 变更文件:${data.allFilesChanged.map(f => `- ${f}
`).join('')}
`
: '';
const commitsHtml = (data.gitCommits || []).length > 0
? `📝 提交记录:${data.gitCommits.map(c => `- ${c}
`).join('')}
`
: '';
const stepsHtml = (data.steps || []).length > 0
? `📊 步骤:${data.steps.map(s => {
const icon = s.status === 'completed' ? '✅' : s.status === 'failed' || s.status === 'rolled_back' ? '❌' : '⏳';
return `- ${icon} ${s.description || '步骤' + s.step_id}
`;
}).join('')}
`
: '';
return `
${subject}
${statusIcon}
${data.title || ''}
任务 ${data.taskId} · ${data.now}
${subject}
总步骤: ${(data.steps || []).length} · 完成: ${(data.completedSteps || []).length} · 失败: ${(data.failedSteps || []).length}
${stepsHtml}
${filesHtml}
${data.gitBranch && data.gitBranch !== '未知' ? `
🌿 分支: ${data.gitBranch}
` : ''}
${commitsHtml}
铸渊 · GLADA 自主开发Agent · 自动发送
版权 国作登字-2026-A-00037559 · TCS-0002∞
`;
}
/**
* 发送QQ邮箱通知(主通道)
* @param {Object} notification - 通知内容 { subject, body, html }
* @returns {Promise}
*/
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}
*/
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