/** * 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 ? `
📁 变更文件:
` : ''; const commitsHtml = (data.gitCommits || []).length > 0 ? `
📝 提交记录:
` : ''; const stepsHtml = (data.steps || []).length > 0 ? `
📊 步骤:
` : ''; 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} 发送结果 */ 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 };