guanghulab/sync/sync-to-notion.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

404 lines
11 KiB
JavaScript
Raw 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.

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