// scripts/esp-email-processor.js // ESP 邮件信号协议 · GitHub 端处理器 // 功能:读取 Gmail IMAP → 解析 [GL-CMD] 信号 → 执行指令 → 写 signal-log → 发 [GL-ACK] 'use strict'; const fs = require('fs'); const path = require('path'); const { ImapFlow } = require('imapflow'); const nodemailer = require('nodemailer'); // ── 配置 ──────────────────────────────────────────────────────────────────── const EMAIL_ADDRESS = process.env.EMAIL_ADDRESS; const EMAIL_PASSWORD = process.env.EMAIL_APP_PASSWORD; if (!EMAIL_ADDRESS || !EMAIL_PASSWORD) { console.error('❌ 缺少环境变量 EMAIL_ADDRESS 或 EMAIL_APP_PASSWORD'); process.exit(1); } const SIGNAL_LOG_DIR = path.join(__dirname, '../signal-log'); const SIGNAL_INDEX_PATH = path.join(SIGNAL_LOG_DIR, 'index.json'); const NOTION_PUSH_DIR = path.join(__dirname, '../notion-push/pending'); const DEV_NODES_DIR = path.join(__dirname, '../dev-nodes'); // ── 信号 ID 生成 ────────────────────────────────────────────────────────── function generateSignalId() { const now = new Date(); const ymd = now.toISOString().slice(0, 10).replace(/-/g, ''); const index = loadSignalIndex(); const seq = String(index.total_count + 1).padStart(3, '0'); return `SIG-${ymd}-${seq}`; } // ── 信号索引读写 ────────────────────────────────────────────────────────── function loadSignalIndex() { if (!fs.existsSync(SIGNAL_INDEX_PATH)) { return { last_updated: new Date().toISOString(), total_count: 0, signals: [] }; } return JSON.parse(fs.readFileSync(SIGNAL_INDEX_PATH, 'utf8')); } function saveSignalIndex(index) { index.last_updated = new Date().toISOString(); fs.writeFileSync(SIGNAL_INDEX_PATH, JSON.stringify(index, null, 2)); } // ── 写入信号日志文件 ────────────────────────────────────────────────────── function writeSignalLog(signal) { const dateStr = signal.timestamp.slice(0, 7); // "YYYY-MM" const monthDir = path.join(SIGNAL_LOG_DIR, dateStr); fs.mkdirSync(monthDir, { recursive: true }); const filePath = path.join(monthDir, `${signal.signal_id}.json`); fs.writeFileSync(filePath, JSON.stringify(signal, null, 2)); const index = loadSignalIndex(); index.total_count += 1; index.signals.unshift({ signal_id: signal.signal_id, trace_id: signal.trace_id, type: signal.signal_type, timestamp: signal.timestamp, summary: signal.summary, related_dev: signal.related_dev || null, file: `${dateStr}/${signal.signal_id}.json` }); saveSignalIndex(index); console.log(`📝 信号已写入: ${signal.signal_id}`); } // ── 写入 notion-push/pending ────────────────────────────────────────────── function pushToNotion(signal) { fs.mkdirSync(NOTION_PUSH_DIR, { recursive: true }); const filePath = path.join(NOTION_PUSH_DIR, `${signal.signal_id}.json`); fs.writeFileSync(filePath, JSON.stringify(signal, null, 2)); console.log(`📤 已写入 notion-push/pending: ${signal.signal_id}`); } // ── SMTP 发送 ACK 邮件 ──────────────────────────────────────────────────── async function sendAckEmail(originalSignal, ackSignalId, result) { const transporter = nodemailer.createTransport({ host: 'smtp.gmail.com', port: 587, secure: false, auth: { user: EMAIL_ADDRESS, pass: EMAIL_PASSWORD } }); const ackPayload = { signal_type: 'GL-ACK', trace_id: originalSignal.trace_id, ack_signal_id: ackSignalId, original_cmd_id: originalSignal.signal_id, timestamp: new Date().toISOString(), sender: '铸渊', receiver: '霜砚', original_command: originalSignal.command, result, message: `指令 ${originalSignal.command} 执行${result === '成功' ? '完成' : '失败'}` }; await transporter.sendMail({ from: EMAIL_ADDRESS, to: EMAIL_ADDRESS, subject: `[GL-ACK] ${originalSignal.trace_id} | ${originalSignal.command} ${result}`, text: JSON.stringify(ackPayload, null, 2) }); console.log(`📧 [GL-ACK] 已发送: ${ackSignalId}`); return ackPayload; } // ── 指令执行器 ──────────────────────────────────────────────────────────── async function executeCommand(signal) { const { command, payload } = signal; console.log(`⚙️ 执行指令: ${command}`); switch (command) { case 'sync_broadcast': { // 将 broadcasts-outbox/ 中的广播同步到对应 dev-nodes/ const outboxDir = path.join(__dirname, '../broadcasts-outbox'); if (!fs.existsSync(outboxDir)) { return { result: '失败', detail: 'broadcasts-outbox 目录不存在' }; } const devDirs = fs.readdirSync(outboxDir).filter(f => f.startsWith('DEV-')); let synced = 0; for (const devId of devDirs) { const srcDir = path.join(outboxDir, devId); const destDir = path.join(DEV_NODES_DIR, devId); if (!fs.existsSync(destDir)) continue; const files = fs.readdirSync(srcDir).filter(f => f.endsWith('.json')); for (const file of files) { const src = path.join(srcDir, file); const dest = path.join(destDir, file); if (!fs.existsSync(dest)) { fs.copyFileSync(src, dest); synced++; } } // 更新 status.json 中的 pending_broadcasts const statusPath = path.join(destDir, 'status.json'); if (fs.existsSync(statusPath)) { const status = JSON.parse(fs.readFileSync(statusPath, 'utf8')); status.pending_broadcasts = files.length; status.updated_at = new Date().toISOString(); fs.writeFileSync(statusPath, JSON.stringify(status, null, 2)); } } return { result: '成功', detail: `已同步 ${synced} 个广播文件` }; } case 'check_syslog': { const syslogInbox = path.join(__dirname, '../syslog-inbox'); const files = fs.existsSync(syslogInbox) ? fs.readdirSync(syslogInbox).filter(f => f.endsWith('.json')) : []; const summary = `syslog-inbox 待处理: ${files.length} 条`; pushToNotion({ signal_id: generateSignalId(), trace_id: signal.trace_id, timestamp: new Date().toISOString(), signal_type: 'GL-DATA', direction: 'GitHub→Notion', sender: '铸渊', receiver: '霜砚', related_dev: null, related_module: null, summary, payload: { syslog_pending: files.length, files } }); return { result: '成功', detail: summary }; } case 'update_status': { const { dev_id, updates } = payload || {}; if (!dev_id) return { result: '失败', detail: '缺少 dev_id' }; const statusPath = path.join(DEV_NODES_DIR, dev_id, 'status.json'); if (!fs.existsSync(statusPath)) { return { result: '失败', detail: `${dev_id} status.json 不存在` }; } let status; try { status = JSON.parse(fs.readFileSync(statusPath, 'utf8')); } catch (err) { return { result: '失败', detail: `${dev_id} status.json 解析失败: ${err.message}` }; } Object.assign(status, updates, { updated_at: new Date().toISOString() }); fs.writeFileSync(statusPath, JSON.stringify(status, null, 2)); return { result: '成功', detail: `${dev_id} 状态已更新` }; } default: return { result: '失败', detail: `未知指令: ${command}` }; } } // ── 解析邮件正文中的 JSON 信号 ──────────────────────────────────────────── function parseSignalFromEmail(subject, body) { if (!subject.includes('[GL-CMD]')) return null; try { // 提取第一个完整 JSON 对象(跳过邮件头中的任何前缀内容) const jsonMatch = body.match(/\{[\s\S]*\}/); if (!jsonMatch) return null; const parsed = JSON.parse(jsonMatch[0]); if (!parsed.signal_type || !parsed.trace_id || !parsed.command) return null; return parsed; } catch (err) { console.log(`⚠️ 邮件 JSON 解析失败: ${err.message}`); return null; } } // ── 主流程 ──────────────────────────────────────────────────────────────── async function main() { console.log('═══════════════════════════════════════════════'); console.log('📡 ESP 邮件信号处理器启动'); console.log(` 时间: ${new Date().toISOString()}`); console.log('═══════════════════════════════════════════════\n'); const client = new ImapFlow({ host: 'imap.gmail.com', port: 993, secure: true, auth: { user: EMAIL_ADDRESS, pass: EMAIL_PASSWORD }, logger: false }); await client.connect(); let processed = 0; let errors = 0; try { const lock = await client.getMailboxLock('INBOX'); try { // 搜索未读的 [GL-CMD] 邮件 const messages = await client.search({ seen: false, subject: '[GL-CMD]' }); if (!messages || messages.length === 0) { console.log('📭 无待处理 [GL-CMD] 邮件'); } else { console.log(`📬 发现 ${messages.length} 封 [GL-CMD] 邮件`); for await (const msg of client.fetch(messages, { envelope: true, source: true })) { const subject = msg.envelope?.subject || ''; const rawBody = msg.source?.toString('utf8') || ''; // 提取 JSON 正文(跳过邮件头) const bodyStart = rawBody.indexOf('{'); const body = bodyStart >= 0 ? rawBody.slice(bodyStart) : ''; const signal = parseSignalFromEmail(subject, body); if (!signal) { console.log(`⚠️ 跳过无效邮件: ${subject}`); continue; } console.log(`\n📩 处理信号: ${signal.trace_id} · ${signal.command}`); // 生成信号 ID(如邮件中未提供) if (!signal.signal_id) signal.signal_id = generateSignalId(); // 写入 signal-log const cmdSignal = { signal_id: signal.signal_id, trace_id: signal.trace_id, timestamp: signal.timestamp || new Date().toISOString(), signal_type: 'GL-CMD', direction: 'Notion→GitHub', sender: signal.sender || '霜砚', receiver: '铸渊', related_dev: signal.payload?.dev_id || null, related_module: signal.payload?.module || null, summary: `执行指令 ${signal.command}`, payload: signal.payload || {}, result: null, ack_signal_id: null }; writeSignalLog(cmdSignal); // 执行指令 let execResult; try { execResult = await executeCommand(signal); } catch (err) { execResult = { result: '失败', detail: err.message }; } cmdSignal.result = execResult.result; // 发送 ACK 邮件并写入 ACK 信号日志 const ackSignalId = generateSignalId(); cmdSignal.ack_signal_id = ackSignalId; try { const ackPayload = await sendAckEmail( { ...signal, signal_id: cmdSignal.signal_id }, ackSignalId, execResult.result ); writeSignalLog({ signal_id: ackSignalId, trace_id: signal.trace_id, timestamp: new Date().toISOString(), signal_type: 'GL-ACK', direction: 'GitHub→Notion', sender: '铸渊', receiver: '霜砚', related_dev: cmdSignal.related_dev, related_module: cmdSignal.related_module, summary: `ACK: ${signal.command} ${execResult.result}`, payload: ackPayload, result: execResult.result, ack_signal_id: null }); pushToNotion({ signal_id: ackSignalId, trace_id: signal.trace_id, timestamp: new Date().toISOString(), signal_type: 'GL-ACK', direction: 'GitHub→Notion', sender: '铸渊', receiver: '霜砚', related_dev: cmdSignal.related_dev, related_module: cmdSignal.related_module, summary: `ACK: ${signal.command} ${execResult.result}`, payload: ackPayload }); } catch (err) { console.error(`❌ 发送 ACK 邮件失败: ${err.message}`); errors++; } // 标记邮件为已读 await client.messageFlagsAdd(msg.seq, ['\\Seen']); processed++; console.log(`✅ 信号处理完成: ${signal.trace_id} → ${execResult.result}`); if (execResult.detail) console.log(` 详情: ${execResult.detail}`); } } } finally { lock.release(); } } finally { await client.logout(); } console.log(`\n═══════════════════════════════════════════════`); console.log(`📡 ESP 处理完毕 · 处理: ${processed} · 错误: ${errors}`); console.log(`═══════════════════════════════════════════════`); if (errors > 0) process.exit(1); } main().catch(err => { console.error('❌ ESP 处理器致命错误:', err.message); process.exit(1); });