zhizhi/scripts/notion-signal-bridge.js

498 lines
18 KiB
JavaScript
Raw Permalink Normal View History

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