guanghulab/scripts/esp-email-processor.js
Guanghu Domestic Migration d1e47f4565
Some checks are pending
自动更新代码和重启 / update-and-restart (push) Waiting to run
CI检查 + 自动部署 / check (push) Waiting to run
CI检查 + 自动部署 / deploy (push) Blocked by required conditions
重启聊天服务 / restart (push) Waiting to run
chore: import sanitized domestic snapshot for REPO-002
Source snapshot: ca48d3ddf926d79aa138306164169baf764bb829
2026-07-17 15:54:41 +08:00

361 lines
14 KiB
JavaScript
Raw Permalink 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.

// 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);
});