guanghulab/sync/sync-to-notion.js

404 lines
11 KiB
JavaScript
Raw Permalink Normal View History

process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
// ============================================
// Notion 同步模块 · sync-to-notion.js · v2.0
// HoloLake · M-DINGTALK Phase 5
// DEV-004 之之 × 秋秋
//
// Phase 3 = 模拟模式
// Phase 5 = 真实 Notion API 连通!
// ============================================
const axios = require('axios');
const fs = require('fs');
const path = require('path');
// ====== Notion API 配置 ======
const NOTION_API = 'https://api.notion.com/v1';
const NOTION_VERSION = process.env.NOTION_API_VERSION || '2022-06-28';
const NOTION_TOKEN = process.env.NOTION_TOKEN || '';
// 数据库 ID
const DB_IDS = {
syslog: process.env.NOTION_SYSLOG_DB_ID || '',
signalLog: process.env.NOTION_SIGNAL_LOG_DB_ID || '',
changeLog: process.env.NOTION_CHANGE_LOG_DB_ID || '',
ticket: process.env.NOTION_TICKET_DB_ID || ''
};
// 本地降级日志目录
const FALLBACK_DIR = path.join(__dirname, '..', 'data', 'notion-pending');
const WRITEBACK_LOG = path.join(__dirname, '..', 'data', 'notion-writeback-log.json');
// ====== 通用请求头 ======
function getHeaders() {
return {
'Authorization': 'Bearer ' + NOTION_TOKEN,
'Notion-Version': NOTION_VERSION,
'Content-Type': 'application/json'
};
}
// ====== 检查配置是否就绪 ======
function isConfigured() {
return NOTION_TOKEN && NOTION_TOKEN !== '' && NOTION_TOKEN !== 'mock-notion-token';
}
// ====== 辅助:创建 rich_text 属性 ======
function richText(str) {
if (!str) return { rich_text: [] };
const text = String(str);
if (text.length <= 2000) {
return { rich_text: [{ text: { content: text } }] };
}
var blocks = [];
for (var i = 0; i < text.length; i += 2000) {
blocks.push({ text: { content: text.substring(i, i + 2000) } });
}
return { rich_text: blocks };
}
// ====== 辅助:创建 title 属性 ======
function titleProp(str) {
return { title: [{ text: { content: String(str || '未命名') } }] };
}
// ====== 辅助:创建 select 属性 ======
function selectProp(name) {
if (!name) return undefined;
return { select: { name: String(name) } };
}
// ====== 辅助:创建 status 属性 ======
function statusProp(name) {
if (!name) return undefined;
return { status: { name: String(name) } };
}
// ====== 辅助:创建 date 属性 ======
function dateProp(isoString) {
if (!isoString) return undefined;
return { date: { start: isoString } };
}
// ====== 核心:写入 SYSLOG 收件箱 ======
async function writeSyslog(data) {
console.log('[Notion] 准备写入 SYSLOG 收件箱...');
if (!isConfigured()) {
console.log('[Notion] △ Token 未配置,降级到本地存储');
return saveFallback('syslog', data);
}
if (!DB_IDS.syslog) {
console.log('[Notion] △ SYSLOG 数据库 ID 未配置');
return saveFallback('syslog', data);
}
var properties = {
'标题': titleProp(data.title || data.session_id || 'SYSLOG-' + Date.now())
};
if (data.dev_id) {
properties['DEV编号'] = selectProp(data.dev_id);
}
if (data.broadcast_id || data.session_id) {
properties['广播编号'] = richText(data.broadcast_id || data.session_id);
}
if (data.module) {
properties['模块'] = richText(data.module);
}
if (data.phase_status || data.status) {
var statusVal = data.phase_status || data.status;
if (['completed', 'partial', 'blocked'].includes(statusVal)) {
properties['环节状态'] = selectProp(statusVal);
}
}
if (data.content || data.raw_text) {
properties['文件内容'] = richText(data.content || data.raw_text);
}
properties['协议版本'] = selectProp(data.protocol_version || 'SYSLOG-v4.0');
properties['处理状态'] = statusProp('待处理');
properties['接收时间'] = dateProp(data.timestamp || new Date().toISOString());
properties['推送方'] = richText(data.source || 'M-DINGTALK-钉钉工作台');
if (data.commit_sha) {
properties['commit_sha'] = richText(data.commit_sha);
}
if (data.source_path) {
properties['来源路径'] = richText(data.source_path);
}
try {
var response = await axios.post(NOTION_API + '/pages', {
parent: { database_id: DB_IDS.syslog },
properties: properties
}, {
headers: getHeaders(),
timeout: 15000
});
console.log('[Notion] ✅ SYSLOG 写入成功! 页面ID:', response.data.id);
saveWritebackLog({
timestamp: new Date().toISOString(),
target: 'syslog',
page_id: response.data.id,
title: data.title || data.session_id,
status: 'success'
});
return {
success: true,
written_to: 'notion',
page_id: response.data.id,
url: response.data.url
};
} catch (err) {
var errMsg = err.response ? JSON.stringify(err.response.data) : err.message;
console.error('[Notion] ❌ SYSLOG 写入失败:', errMsg);
saveWritebackLog({
timestamp: new Date().toISOString(),
target: 'syslog',
title: data.title || data.session_id,
status: 'failed',
error: errMsg
});
return saveFallback('syslog', data);
}
}
// ====== 写入信号日志 ======
async function writeSignalLog(data) {
console.log('[Notion] 准备写入信号日志...');
if (!isConfigured() || !DB_IDS.signalLog) {
return saveFallback('signal-log', data);
}
var properties = {
'信号编号': titleProp(data.signal_id || 'SIG-' + Date.now()),
'信号类型': selectProp(data.signal_type || 'GL-DATA'),
'关联DEV': selectProp(data.dev_id),
'发送方': selectProp(data.sender || '霜砚'),
'接收方': selectProp(data.receiver),
'摘要': richText(data.summary),
'方向': selectProp(data.direction),
'执行结果': statusProp(data.result || '已发送'),
'时间戳': dateProp(data.timestamp || new Date().toISOString())
};
Object.keys(properties).forEach(function(k) {
if (properties[k] === undefined) delete properties[k];
});
try {
var response = await axios.post(NOTION_API + '/pages', {
parent: { database_id: DB_IDS.signalLog },
properties: properties
}, {
headers: getHeaders(),
timeout: 15000
});
console.log('[Notion] ✅ 信号日志写入成功!');
return { success: true, written_to: 'notion', page_id: response.data.id };
} catch (err) {
console.error('[Notion] ❌ 信号日志写入失败:', err.message);
return saveFallback('signal-log', data);
}
}
// ====== 健康检查:测试 Token 是否有效 ======
async function healthCheck() {
if (!isConfigured()) {
return { online: false, reason: 'token_not_configured' };
}
try {
var response = await axios.get(NOTION_API + '/users/me', {
headers: getHeaders(),
timeout: 5000
});
return {
online: true,
bot_name: response.data.name,
bot_id: response.data.id
};
} catch (err) {
return {
online: false,
reason: err.response ? err.response.status + ': ' + (err.response.data.message || '') : err.message
};
}
}
// ====== 数据库连通测试 ======
async function testDatabase(dbName) {
var dbId = DB_IDS[dbName];
if (!dbId) {
return { accessible: false, reason: 'db_id_not_configured', db: dbName };
}
try {
var response = await axios.post(NOTION_API + '/databases/' + dbId + '/query', {
page_size: 1
}, {
headers: getHeaders(),
timeout: 10000
});
return {
accessible: true,
db: dbName,
db_id: dbId,
total_results: response.data.results.length,
has_more: response.data.has_more
};
} catch (err) {
return {
accessible: false,
db: dbName,
reason: err.response ? err.response.status + ': ' + (err.response.data.message || '') : err.message
};
}
}
// ====== 降级:保存到本地 ======
function saveFallback(type, data) {
if (!fs.existsSync(FALLBACK_DIR)) {
fs.mkdirSync(FALLBACK_DIR, { recursive: true });
}
var filename = type + '_' + Date.now() + '.json';
var filePath = path.join(FALLBACK_DIR, filename);
var record = {
type: type,
data: data,
saved_at: new Date().toISOString(),
reason: 'notion_unavailable'
};
fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf-8');
console.log('[Notion] 已降级存储到本地:', filename);
return {
success: true,
written_to: 'local',
file: filename,
message: 'Notion不可用已存到本地。Token配置后可重新推送。'
};
}
// ====== 重推本地暂存数据到 Notion ======
async function flushPending() {
if (!fs.existsSync(FALLBACK_DIR)) {
console.log('[Notion] 无暂存数据');
return { flushed: 0 };
}
var files = fs.readdirSync(FALLBACK_DIR).filter(function(f) { return f.endsWith('.json'); });
console.log('[Notion] 发现 ' + files.length + ' 条暂存数据,开始重推...');
var success = 0;
var failed = 0;
for (var file of files) {
try {
var content = JSON.parse(fs.readFileSync(path.join(FALLBACK_DIR, file), 'utf-8'));
var result;
if (content.type === 'syslog') {
result = await writeSyslog(content.data);
} else if (content.type === 'signal-log') {
result = await writeSignalLog(content.data);
}
if (result && result.written_to === 'notion') {
fs.unlinkSync(path.join(FALLBACK_DIR, file));
success++;
} else {
failed++;
}
} catch (err) {
console.error('[Notion] 重推失败:', file, err.message);
failed++;
}
}
console.log('[Notion] 重推完成: 成功 ' + success + ' / 失败 ' + failed);
return { flushed: success, failed: failed, remaining: files.length - success };
}
// ====== 回写进度(兼容 Phase 3 sync-engine 调用) ======
async function updateProgress(syslogData) {
console.log('[Notion Sync] 回写进度到 Notion...');
var result = await writeSyslog({
title: (syslogData.session_id || '') + ' ' + (syslogData.dev_name || syslogData.dev_id || '') + ' ' + (syslogData.status || ''),
session_id: syslogData.session_id,
dev_id: syslogData.dev_id,
module: syslogData.module,
phase_status: syslogData.status || syslogData.completion_status,
content: JSON.stringify(syslogData, null, 2),
timestamp: syslogData.timestamp || new Date().toISOString(),
source: 'M-DINGTALK-钉钉工作台'
});
return result;
}
// ====== 全量同步(兼容 Phase 3 sync-engine 调用) ======
async function sync() {
console.log('[Notion Sync] 执行 Notion 同步...');
var health = await healthCheck();
if (health.online) {
var flushResult = await flushPending();
return {
status: 'online',
bot: health.bot_name,
flushed: flushResult.flushed,
remaining: flushResult.remaining || 0
};
} else {
return {
status: 'offline',
reason: health.reason,
message: '等待 Token 配置或网络恢复'
};
}
}
// ====== 保存回写日志 ======
function saveWritebackLog(record) {
var logDir = path.dirname(WRITEBACK_LOG);
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
var log = [];
if (fs.existsSync(WRITEBACK_LOG)) {
try { log = JSON.parse(fs.readFileSync(WRITEBACK_LOG, 'utf-8')); } catch (e) {}
}
log.unshift(record);
if (log.length > 200) log = log.slice(0, 200);
fs.writeFileSync(WRITEBACK_LOG, JSON.stringify(log, null, 2), 'utf-8');
}
module.exports = {
writeSyslog,
writeSignalLog,
healthCheck,
testDatabase,
updateProgress,
sync,
flushPending,
isConfigured
};