361 lines
14 KiB
JavaScript
361 lines
14 KiB
JavaScript
// 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);
|
||
});
|