265 lines
7.5 KiB
JavaScript
265 lines
7.5 KiB
JavaScript
|
|
/**
|
|||
|
|
* connectors/notion-sync — Notion 双向同步模块
|
|||
|
|
*
|
|||
|
|
* 功能:
|
|||
|
|
* - 读取 Notion 广播
|
|||
|
|
* - 写回执行日志
|
|||
|
|
* - 同步执行层状态
|
|||
|
|
*
|
|||
|
|
* 同步结构:
|
|||
|
|
* Notion → 仓库(下行:读取广播/工单)
|
|||
|
|
* 仓库 → Notion(上行:写回日志/状态)
|
|||
|
|
*
|
|||
|
|
* 环境变量:
|
|||
|
|
* NOTION_TOKEN — Notion API Token
|
|||
|
|
* BROADCAST_DB_ID — 广播数据库 ID(需包含 status 属性,值: 待执行/已完成)
|
|||
|
|
* EXECUTION_LOG_DB_ID — 执行日志数据库 ID
|
|||
|
|
*
|
|||
|
|
* 调用方式:
|
|||
|
|
* node connectors/notion-sync [pull|push|status]
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const https = require('https');
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
const ROOT = path.resolve(__dirname, '../..');
|
|||
|
|
const NOTION_VERSION = '2022-06-28';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 发送 Notion API 请求
|
|||
|
|
*/
|
|||
|
|
function notionRequest(method, endpoint, body = null) {
|
|||
|
|
const token = process.env.NOTION_TOKEN;
|
|||
|
|
if (!token) {
|
|||
|
|
return Promise.reject(new Error('NOTION_TOKEN 环境变量未设置'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
const options = {
|
|||
|
|
hostname: 'api.notion.com',
|
|||
|
|
path: `/v1/${endpoint}`,
|
|||
|
|
method,
|
|||
|
|
headers: {
|
|||
|
|
'Authorization': `Bearer ${token}`,
|
|||
|
|
'Notion-Version': NOTION_VERSION,
|
|||
|
|
'Content-Type': 'application/json'
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const req = https.request(options, res => {
|
|||
|
|
let data = '';
|
|||
|
|
res.on('data', chunk => { data += chunk; });
|
|||
|
|
res.on('end', () => {
|
|||
|
|
try {
|
|||
|
|
const parsed = JSON.parse(data);
|
|||
|
|
if (res.statusCode >= 400) {
|
|||
|
|
reject(new Error(`Notion API ${res.statusCode}: ${parsed.message || data}`));
|
|||
|
|
} else {
|
|||
|
|
resolve(parsed);
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
reject(new Error(`Notion 响应解析失败: ${data}`));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
req.on('error', reject);
|
|||
|
|
if (body) req.write(JSON.stringify(body));
|
|||
|
|
req.end();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 从 Notion 拉取广播
|
|||
|
|
*/
|
|||
|
|
async function pullBroadcasts() {
|
|||
|
|
const dbId = process.env.BROADCAST_DB_ID;
|
|||
|
|
if (!dbId) {
|
|||
|
|
console.log('⚠️ BROADCAST_DB_ID 未设置,跳过广播拉取');
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('📡 拉取 Notion 广播...');
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const result = await notionRequest('POST', `databases/${dbId}/query`, {
|
|||
|
|
filter: {
|
|||
|
|
property: 'status',
|
|||
|
|
select: { equals: '待执行' }
|
|||
|
|
},
|
|||
|
|
sorts: [{ property: 'created_time', direction: 'descending' }]
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const broadcasts = (result.results || []).map(page => ({
|
|||
|
|
id: page.id,
|
|||
|
|
title: page.properties?.Name?.title?.[0]?.plain_text || '未命名',
|
|||
|
|
status: page.properties?.status?.select?.name || 'unknown',
|
|||
|
|
created: page.created_time
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
console.log(`✅ 拉取到 ${broadcasts.length} 条广播`);
|
|||
|
|
return broadcasts;
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(`❌ 广播拉取失败: ${err.message}`);
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 写回执行日志到 Notion
|
|||
|
|
*/
|
|||
|
|
async function pushExecutionLog(logEntry) {
|
|||
|
|
const dbId = process.env.EXECUTION_LOG_DB_ID;
|
|||
|
|
if (!dbId) {
|
|||
|
|
console.log('⚠️ EXECUTION_LOG_DB_ID 未设置,执行日志仅写入本地');
|
|||
|
|
return writeLocalLog(logEntry);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`📝 写回执行日志: ${logEntry.task_id || 'unknown'}`);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await notionRequest('POST', 'pages', {
|
|||
|
|
parent: { database_id: dbId },
|
|||
|
|
properties: {
|
|||
|
|
Name: { title: [{ text: { content: logEntry.task_id || 'execution-log' } }] },
|
|||
|
|
Status: { select: { name: logEntry.status || 'completed' } },
|
|||
|
|
Executor: { rich_text: [{ text: { content: 'zhuyuan' } }] },
|
|||
|
|
Timestamp: { rich_text: [{ text: { content: new Date().toISOString() } }] }
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
console.log('✅ 日志已同步到 Notion');
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(`⚠️ Notion 写入失败,回退本地: ${err.message}`);
|
|||
|
|
writeLocalLog(logEntry);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 本地日志写入(回退方案)
|
|||
|
|
*/
|
|||
|
|
function writeLocalLog(logEntry) {
|
|||
|
|
const logDir = path.join(ROOT, 'core/task-queue');
|
|||
|
|
const logFile = path.join(logDir, 'execution-log.json');
|
|||
|
|
|
|||
|
|
let logs = [];
|
|||
|
|
if (fs.existsSync(logFile)) {
|
|||
|
|
try {
|
|||
|
|
logs = JSON.parse(fs.readFileSync(logFile, 'utf-8'));
|
|||
|
|
} catch {
|
|||
|
|
logs = [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logs.push({
|
|||
|
|
...logEntry,
|
|||
|
|
logged_at: new Date().toISOString()
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 保留最近 100 条
|
|||
|
|
if (logs.length > 100) {
|
|||
|
|
logs = logs.slice(-100);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fs.writeFileSync(logFile, JSON.stringify(logs, null, 2), 'utf-8');
|
|||
|
|
console.log('📝 日志已写入本地');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 同步执行层状态到 Notion
|
|||
|
|
*/
|
|||
|
|
async function syncExecutionStatus(statusData) {
|
|||
|
|
const dbId = process.env.EXECUTION_LOG_DB_ID;
|
|||
|
|
if (!dbId) {
|
|||
|
|
console.log('⚠️ EXECUTION_LOG_DB_ID 未设置,执行状态仅写入本地');
|
|||
|
|
writeLocalLog({
|
|||
|
|
task_id: `execution-status-${new Date().toISOString().slice(0, 10)}`,
|
|||
|
|
status: statusData.execution_layer_status || 'stable',
|
|||
|
|
type: 'execution_status',
|
|||
|
|
data: statusData
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('📡 同步执行层状态到 Notion...');
|
|||
|
|
|
|||
|
|
const summary = [
|
|||
|
|
`version: v${statusData.version || 'unknown'}`,
|
|||
|
|
`modules: ${(statusData.core_modules || []).filter(m => m.status === 'enabled').length}/${(statusData.core_modules || []).length}`,
|
|||
|
|
`connectors: ${(statusData.connectors || []).filter(c => c.status === 'enabled').length}/${(statusData.connectors || []).length}`,
|
|||
|
|
`workflows: ${statusData.workflows?.count || 0}`,
|
|||
|
|
`queue: ${statusData.task_queue?.total || 0} total / ${statusData.task_queue?.pending || 0} pending`
|
|||
|
|
].join(' | ');
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await notionRequest('POST', 'pages', {
|
|||
|
|
parent: { database_id: dbId },
|
|||
|
|
properties: {
|
|||
|
|
Name: { title: [{ text: { content: `Execution Status · ${new Date().toISOString().slice(0, 10)}` } }] },
|
|||
|
|
Status: { select: { name: statusData.execution_layer_status || 'stable' } },
|
|||
|
|
Executor: { rich_text: [{ text: { content: 'zhuyuan' } }] },
|
|||
|
|
Timestamp: { rich_text: [{ text: { content: statusData.timestamp || new Date().toISOString() } }] }
|
|||
|
|
},
|
|||
|
|
children: [{
|
|||
|
|
object: 'block',
|
|||
|
|
type: 'paragraph',
|
|||
|
|
paragraph: {
|
|||
|
|
rich_text: [{ text: { content: summary } }]
|
|||
|
|
}
|
|||
|
|
}]
|
|||
|
|
});
|
|||
|
|
console.log('✅ 执行层状态已同步到 Notion');
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(`⚠️ Notion 同步失败,回退本地: ${err.message}`);
|
|||
|
|
writeLocalLog({
|
|||
|
|
task_id: `execution-status-${new Date().toISOString().slice(0, 10)}`,
|
|||
|
|
status: statusData.execution_layer_status || 'stable',
|
|||
|
|
type: 'execution_status',
|
|||
|
|
data: statusData
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 检查 Notion 连接状态
|
|||
|
|
*/
|
|||
|
|
async function checkStatus() {
|
|||
|
|
console.log('🔍 检查 Notion 连接状态...');
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await notionRequest('GET', 'users/me');
|
|||
|
|
console.log('✅ Notion API 连接正常');
|
|||
|
|
return { connected: true };
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(`❌ Notion 连接失败: ${err.message}`);
|
|||
|
|
return { connected: false, error: err.message };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CLI 入口
|
|||
|
|
if (require.main === module) {
|
|||
|
|
const cmd = process.argv[2] || 'status';
|
|||
|
|
|
|||
|
|
(async () => {
|
|||
|
|
switch (cmd) {
|
|||
|
|
case 'pull':
|
|||
|
|
await pullBroadcasts();
|
|||
|
|
break;
|
|||
|
|
case 'push':
|
|||
|
|
await pushExecutionLog({
|
|||
|
|
task_id: 'manual-test',
|
|||
|
|
status: 'completed',
|
|||
|
|
message: 'Manual push test'
|
|||
|
|
});
|
|||
|
|
break;
|
|||
|
|
case 'status':
|
|||
|
|
await checkStatus();
|
|||
|
|
break;
|
|||
|
|
default:
|
|||
|
|
console.log('用法: node connectors/notion-sync [pull|push|status]');
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = { pullBroadcasts, pushExecutionLog, syncExecutionStatus, writeLocalLog, checkStatus, notionRequest };
|