498 lines
18 KiB
JavaScript
498 lines
18 KiB
JavaScript
// scripts/notion-signal-bridge.js
|
||
// 铸渊 ↔ Notion 信号桥(替代 ESP 邮件协议)
|
||
//
|
||
// 功能:
|
||
// 1. 轮询 Notion 工单簿,读取状态为「已发送」的新工单
|
||
// 2. 解析工单中的 OP 类型,执行对应操作
|
||
// 3. 回写工单状态 + 写入 Notion 信号日志
|
||
// 4. 同时写入本地 signal-log/ 文件(双写)
|
||
// 5. 读取广播归档,推送到聊天室子频道数据
|
||
//
|
||
// 用法:
|
||
// node scripts/notion-signal-bridge.js poll — 轮询工单簿
|
||
// node scripts/notion-signal-bridge.js health — 健康检查
|
||
//
|
||
// 必需环境变量:
|
||
// NOTION_API_TOKEN 铸渊信号桥 Integration Token(GitHub Secret: NOTION_API_TOKEN)
|
||
//
|
||
// 可选环境变量:
|
||
// WORKORDER_DB_ID 人格协作工单簿 database_id
|
||
// SIGNAL_LOG_DB_ID 跨平台信号日志 database_id
|
||
|
||
'use strict';
|
||
|
||
const https = require('https');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 常量
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
const NOTION_VERSION = '2022-06-28';
|
||
const NOTION_API_HOSTNAME = 'api.notion.com';
|
||
const NOTION_RICH_TEXT_MAX = 2000;
|
||
|
||
const SIGNAL_LOG_DIR = path.join(__dirname, '../signal-log');
|
||
const SIGNAL_INDEX_PATH = path.join(SIGNAL_LOG_DIR, 'index.json');
|
||
|
||
// Notion 数据库 ID(从环境变量或默认值)
|
||
// 注意:铸渊需从 Notion URL 中提取实际 ID,这里用占位符
|
||
const WORKORDER_DB_ID = process.env.WORKORDER_DB_ID || '';
|
||
const SIGNAL_LOG_DB_ID = process.env.SIGNAL_LOG_DB_ID || '';
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// Notion API 基础调用
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function notionRequest(method, endpoint, body, token) {
|
||
return new Promise((resolve, reject) => {
|
||
const payload = body ? JSON.stringify(body) : '';
|
||
const opts = {
|
||
hostname: NOTION_API_HOSTNAME,
|
||
port: 443,
|
||
path: endpoint,
|
||
method: method,
|
||
headers: {
|
||
'Authorization': 'Bearer ' + token,
|
||
'Content-Type': 'application/json',
|
||
'Notion-Version': NOTION_VERSION,
|
||
},
|
||
};
|
||
if (payload) opts.headers['Content-Length'] = Buffer.byteLength(payload);
|
||
|
||
const req = https.request(opts, res => {
|
||
let data = '';
|
||
res.on('data', chunk => data += chunk);
|
||
res.on('end', () => {
|
||
try {
|
||
const parsed = JSON.parse(data);
|
||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||
resolve(parsed);
|
||
} else {
|
||
reject(new Error(`Notion API ${method} ${endpoint} → ${res.statusCode}: ${parsed.message || data}`));
|
||
}
|
||
} catch (e) {
|
||
reject(new Error(`Notion API parse error: ${data.slice(0, 200)}`));
|
||
}
|
||
});
|
||
});
|
||
|
||
req.on('error', reject);
|
||
req.setTimeout(30000, () => { req.destroy(new Error('Notion API 请求超时')); });
|
||
if (payload) req.write(payload);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
function notionPost(endpoint, body, token) {
|
||
return notionRequest('POST', endpoint, body, token);
|
||
}
|
||
|
||
function notionPatch(endpoint, body, token) {
|
||
return notionRequest('PATCH', endpoint, body, token);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// Notion 属性构建辅助
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function richText(content) {
|
||
const str = String(content || '');
|
||
const chunks = [];
|
||
for (let i = 0; i < str.length; i += NOTION_RICH_TEXT_MAX) {
|
||
chunks.push({ type: 'text', text: { content: str.slice(i, i + NOTION_RICH_TEXT_MAX) } });
|
||
}
|
||
return chunks.length ? chunks : [{ type: 'text', text: { content: '' } }];
|
||
}
|
||
|
||
function titleProp(content) {
|
||
return [{ type: 'text', text: { content: String(content || '').slice(0, 120) } }];
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 本地信号日志读写(与 esp-email-processor.js 相同逻辑)
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
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 {
|
||
description: '铸渊信号日志目录索引 · AGE OS 信号协议(Notion API 直连)',
|
||
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 writeLocalSignalLog(signal) {
|
||
const dateStr = signal.timestamp.slice(0, 7);
|
||
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 信号日志写入(双写:Notion + 本地)
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
async function writeNotionSignalLog(signal, token) {
|
||
// 写入本地文件
|
||
writeLocalSignalLog(signal);
|
||
|
||
// 写入 Notion 信号日志数据库(如果配置了 ID)
|
||
if (!SIGNAL_LOG_DB_ID) {
|
||
console.log('⚠️ SIGNAL_LOG_DB_ID 未配置,跳过 Notion 信号日志写入');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await notionPost('/v1/pages', {
|
||
parent: { database_id: SIGNAL_LOG_DB_ID },
|
||
properties: {
|
||
'信号编号': { title: titleProp(signal.signal_id) },
|
||
'信号类型': { select: { name: signal.signal_type } },
|
||
'方向': { select: { name: signal.direction || 'GitHub→Notion' } },
|
||
'发送方': { select: { name: signal.sender || '铸渊' } },
|
||
'接收方': { select: { name: signal.receiver || '霜砚' } },
|
||
'trace_id': { rich_text: richText(signal.trace_id) },
|
||
'摘要': { rich_text: richText(signal.summary) },
|
||
'执行结果': { status: { name: signal.result || '成功' } }
|
||
}
|
||
}, token);
|
||
console.log(`📡 Notion 信号日志已写入: ${signal.signal_id}`);
|
||
} catch (err) {
|
||
console.error(`⚠️ Notion 信号日志写入失败: ${err.message}`);
|
||
// 本地已写入,Notion 写入失败不影响流程
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 工单簿轮询
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* 从工单簿中获取「已发送」状态的工单
|
||
*/
|
||
async function queryPendingWorkOrders(token) {
|
||
if (!WORKORDER_DB_ID) {
|
||
console.log('⚠️ WORKORDER_DB_ID 未配置,跳过工单轮询');
|
||
console.log(' 请设置环境变量 WORKORDER_DB_ID(从 Notion 工单簿 URL 提取)');
|
||
return [];
|
||
}
|
||
|
||
const result = await notionPost(`/v1/databases/${WORKORDER_DB_ID}/query`, {
|
||
filter: {
|
||
property: '执行结果',
|
||
status: { equals: '已发送' }
|
||
},
|
||
sorts: [{ timestamp: 'created_time', direction: 'ascending' }]
|
||
}, token);
|
||
|
||
return result.results || [];
|
||
}
|
||
|
||
/**
|
||
* 解析工单页面属性
|
||
*/
|
||
function parseWorkOrder(page) {
|
||
const props = page.properties || {};
|
||
|
||
// 安全提取各类属性值
|
||
const getTitle = (p) => p?.title?.[0]?.text?.content || '';
|
||
const getRichText = (p) => p?.rich_text?.map(r => r.text?.content || '').join('') || '';
|
||
const getSelect = (p) => p?.select?.name || '';
|
||
const getStatus = (p) => p?.status?.name || '';
|
||
|
||
return {
|
||
page_id: page.id,
|
||
title: getTitle(props['工单名称'] || props['Name'] || props['名称']),
|
||
op_type: getSelect(props['OP类型'] || props['操作类型']),
|
||
priority: getSelect(props['优先级']),
|
||
status: getStatus(props['执行结果']),
|
||
payload: getRichText(props['payload'] || props['指令内容']),
|
||
trace_id: getRichText(props['trace_id']),
|
||
sender: getSelect(props['发送方']),
|
||
receiver: getSelect(props['接收方']),
|
||
created: page.created_time,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 更新工单状态
|
||
*/
|
||
async function updateWorkOrderStatus(pageId, status, message, token) {
|
||
const props = {
|
||
'执行结果': { status: { name: status } }
|
||
};
|
||
// 如果有回执信息字段,也更新
|
||
if (message) {
|
||
props['回执信息'] = { rich_text: richText(message) };
|
||
}
|
||
|
||
await notionPatch(`/v1/pages/${pageId}`, { properties: props }, token);
|
||
console.log(`✅ 工单 ${pageId.slice(0, 8)}... 状态更新为: ${status}`);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 工单执行引擎
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* 根据工单 OP 类型执行对应操作
|
||
*/
|
||
async function executeWorkOrder(wo, token) {
|
||
const signalId = generateSignalId();
|
||
const traceId = wo.trace_id || `TRC-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${signalId.slice(-3)}`;
|
||
|
||
console.log(`\n🔧 处理工单: ${wo.title}`);
|
||
console.log(` OP: ${wo.op_type} | 优先级: ${wo.priority} | trace: ${traceId}`);
|
||
|
||
let result = '成功';
|
||
let message = '';
|
||
|
||
try {
|
||
switch (wo.op_type) {
|
||
case 'BROADCAST':
|
||
case '广播推送':
|
||
message = await handleBroadcast(wo);
|
||
break;
|
||
|
||
case 'STATUS_QUERY':
|
||
case '状态查询':
|
||
message = await handleStatusQuery(wo);
|
||
break;
|
||
|
||
case 'DEPLOY_CHECK':
|
||
case '部署检查':
|
||
message = await handleDeployCheck(wo);
|
||
break;
|
||
|
||
case 'DATA_SYNC':
|
||
case '数据同步':
|
||
message = await handleDataSync(wo);
|
||
break;
|
||
|
||
default:
|
||
message = `未知操作类型: ${wo.op_type},已记录,等待人工处理`;
|
||
console.log(`⚠️ ${message}`);
|
||
break;
|
||
}
|
||
} catch (err) {
|
||
result = '失败';
|
||
message = `执行失败: ${err.message}`;
|
||
console.error(`❌ ${message}`);
|
||
}
|
||
|
||
// 写回执到工单簿
|
||
await updateWorkOrderStatus(wo.page_id, result, message, token);
|
||
|
||
// 双写信号日志(本地 + Notion)
|
||
const ackSignal = {
|
||
signal_id: signalId,
|
||
signal_type: 'GL-ACK',
|
||
trace_id: traceId,
|
||
timestamp: new Date().toISOString(),
|
||
sender: '铸渊',
|
||
receiver: '霜砚',
|
||
direction: 'GitHub→Notion',
|
||
summary: `${wo.op_type}: ${message.slice(0, 100)}`,
|
||
result: result,
|
||
related_dev: null,
|
||
original_wo: wo.title,
|
||
esp_version: '2.0-notion'
|
||
};
|
||
|
||
await writeNotionSignalLog(ackSignal, token);
|
||
|
||
return { result, message };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// OP 处理器
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
async function handleBroadcast(wo) {
|
||
// 读取广播内容,写入聊天室可读的文件
|
||
const broadcastDir = path.join(__dirname, '../broadcasts-outbox');
|
||
if (!fs.existsSync(broadcastDir)) {
|
||
return '广播目录不存在,跳过';
|
||
}
|
||
|
||
console.log('📢 处理广播推送工单');
|
||
// 将广播内容标记为待推送
|
||
const pendingFile = path.join(broadcastDir, 'pending-push.json');
|
||
const pending = fs.existsSync(pendingFile)
|
||
? JSON.parse(fs.readFileSync(pendingFile, 'utf8'))
|
||
: { broadcasts: [] };
|
||
|
||
pending.broadcasts.push({
|
||
title: wo.title,
|
||
content: wo.payload,
|
||
timestamp: new Date().toISOString(),
|
||
trace_id: wo.trace_id
|
||
});
|
||
|
||
fs.writeFileSync(pendingFile, JSON.stringify(pending, null, 2));
|
||
return `广播已加入推送队列 (${pending.broadcasts.length} 条待推)`;
|
||
}
|
||
|
||
async function handleStatusQuery(wo) {
|
||
// 收集系统状态信息
|
||
console.log('📊 处理状态查询工单');
|
||
const devStatusPath = path.join(__dirname, '../.github/persona-brain/dev-status.json');
|
||
if (fs.existsSync(devStatusPath)) {
|
||
const status = JSON.parse(fs.readFileSync(devStatusPath, 'utf8'));
|
||
const teamCount = status.team_status?.length || 0;
|
||
const active = status.team_status?.filter(d => d.status?.includes('🟢')).length || 0;
|
||
return `团队状态: ${teamCount} 名开发者, ${active} 人推进中`;
|
||
}
|
||
return '状态文件不存在';
|
||
}
|
||
|
||
async function handleDeployCheck(wo) {
|
||
console.log('🚀 处理部署检查工单');
|
||
// 检查关键文件是否存在
|
||
const checks = [
|
||
{ name: 'docs/index.html', label: '铸渊聊天室' },
|
||
{ name: 'backend-integration/api-proxy.js', label: 'API 代理' },
|
||
{ name: '.github/workflows/deploy-pages.yml', label: '部署工作流' },
|
||
];
|
||
const results = checks.map(c => {
|
||
const exists = fs.existsSync(path.join(__dirname, '..', c.name));
|
||
return `${exists ? '✅' : '❌'} ${c.label}`;
|
||
});
|
||
return results.join(' | ');
|
||
}
|
||
|
||
async function handleDataSync(wo) {
|
||
console.log('🔄 处理数据同步工单');
|
||
return '数据同步指令已接收,等待下次 CI 流程执行';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 健康检查
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
async function healthCheck(token) {
|
||
console.log('🏥 Notion 信号桥健康检查\n');
|
||
console.log(` Notion API Token: ${token ? '✅ 已配置' : '❌ 未配置'}`);
|
||
console.log(` 工单簿 DB ID: ${WORKORDER_DB_ID ? '✅ ' + WORKORDER_DB_ID.slice(0, 8) + '...' : '⚠️ 未配置'}`);
|
||
console.log(` 信号日志 DB ID: ${SIGNAL_LOG_DB_ID ? '✅ ' + SIGNAL_LOG_DB_ID.slice(0, 8) + '...' : '⚠️ 未配置'}`);
|
||
console.log(` 本地信号日志: ${fs.existsSync(SIGNAL_INDEX_PATH) ? '✅ 存在' : '⚠️ 不存在'}`);
|
||
|
||
if (token) {
|
||
try {
|
||
// 测试 API 连通性 — 获取 bot user
|
||
const me = await notionRequest('GET', '/v1/users/me', null, token);
|
||
console.log(` API 连通性: ✅ Bot: ${me.name || me.id}`);
|
||
} catch (err) {
|
||
console.log(` API 连通性: ❌ ${err.message}`);
|
||
}
|
||
}
|
||
|
||
// 本地信号统计
|
||
const index = loadSignalIndex();
|
||
console.log(`\n 📊 本地信号总数: ${index.total_count}`);
|
||
if (index.signals.length > 0) {
|
||
console.log(` 📝 最近信号: ${index.signals[0].signal_id} (${index.signals[0].type})`);
|
||
}
|
||
|
||
console.log('\n✅ 健康检查完成');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 主入口
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
async function main() {
|
||
const cmd = process.argv[2] || 'poll';
|
||
const token = process.env.NOTION_API_TOKEN;
|
||
|
||
if (!token) {
|
||
console.error('❌ 缺少环境变量 NOTION_API_TOKEN');
|
||
console.error(' 请在 GitHub Secrets 中配置 NOTION_API_TOKEN');
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log(`🔌 铸渊 Notion 信号桥 v1.0`);
|
||
console.log(` 时间: ${new Date().toISOString()}`);
|
||
console.log(` 命令: ${cmd}\n`);
|
||
|
||
switch (cmd) {
|
||
case 'poll': {
|
||
console.log('📡 开始轮询 Notion 工单簿...\n');
|
||
|
||
const workOrders = await queryPendingWorkOrders(token);
|
||
|
||
if (workOrders.length === 0) {
|
||
console.log('✅ 无待处理工单');
|
||
return;
|
||
}
|
||
|
||
console.log(`📋 发现 ${workOrders.length} 条待处理工单\n`);
|
||
|
||
let success = 0, failed = 0;
|
||
|
||
for (const page of workOrders) {
|
||
const wo = parseWorkOrder(page);
|
||
try {
|
||
const { result } = await executeWorkOrder(wo, token);
|
||
if (result === '成功') success++;
|
||
else failed++;
|
||
} catch (err) {
|
||
console.error(`❌ 工单处理异常: ${err.message}`);
|
||
failed++;
|
||
}
|
||
}
|
||
|
||
console.log(`\n📊 处理结果: ${success} 成功, ${failed} 失败 (共 ${workOrders.length} 条)`);
|
||
break;
|
||
}
|
||
|
||
case 'health':
|
||
await healthCheck(token);
|
||
break;
|
||
|
||
default:
|
||
console.error(`❌ 未知命令: ${cmd}`);
|
||
console.error(' 可用命令: poll, health');
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
main().catch(err => {
|
||
console.error(`\n💥 致命错误: ${err.message}`);
|
||
process.exit(1);
|
||
});
|