guanghulab/server/age-os/mcp-server/tools/notion-cos-bridge-ops.js

477 lines
14 KiB
JavaScript
Raw Normal View History

/**
*
* 模块C · Notion接入COS桶 MCP 集成工具
*
*
* 签发: 铸渊 · ICE-GL-ZY001
* 版权: 国作登字-2026-A-00037559
*
* Notion COS双向同步桥接
*
* 读取方向: COS桶中的Notion导出压缩文件 解压 建立目录索引
* 写入方向: Notion层人格体通过API写入COS桶指定路径
*
* COS桶内类Notion数据库结构:
* /notion-mirror/{page_id}/content.json 页面内容
* /notion-mirror/{page_id}/metadata.json 页面属性
* /notion-mirror/index.json
*
* 工具清单:
* notionCosSyncPage 同步Notion页面到COS桶
* notionCosReadMirror 从COS镜像读取页面
* notionCosListMirror 列出COS镜像中的页面
* notionCosBuildIndex 重建COS镜像索引
* notionCosWriteWorkorder 写入工单到COS桶供Notion人格体使用
* notionCosReadWorkorder 读取工单
* notionCosListWorkorders 列出工单
*/
'use strict';
const crypto = require('crypto');
const cos = require('../cos');
// 运行时可选加载 notion-client不影响核心功能
let notionClient = null;
try {
notionClient = require('../notion-client');
} catch {
// Notion模块未安装时降级
}
// ─── 常量 ───
const MIRROR_PREFIX = 'notion-mirror/';
const MIRROR_PREFIX_REGEX = /^notion-mirror\/([^/]+)\//;
const WORKORDER_PREFIX = 'workorders/';
/**
* notionCosSyncPage 同步Notion页面到COS桶
*
* 从Notion API读取页面内容镜像写入COS桶
*
* input:
* page_id: string Notion页面ID
* bucket: string 目标COS桶默认hot
* include_blocks: boolean 是否包含内容块默认true
*/
async function notionCosSyncPage(input) {
const { page_id, bucket, include_blocks } = input;
if (!page_id) throw new Error('缺少 page_id');
if (!notionClient) throw new Error('Notion客户端未加载ZY_NOTION_TOKEN未配置');
const targetBucket = bucket || 'hot';
const shouldIncludeBlocks = include_blocks !== false;
// 从Notion读取页面
const page = await notionClient.readPage(page_id);
// 提取元数据
const metadata = {
id: page.id,
created_time: page.created_time,
last_edited_time: page.last_edited_time,
title: extractTitle(page.properties),
properties: simplifyProperties(page.properties),
synced_at: new Date().toISOString()
};
// 提取内容
const content = {
id: page.id,
title: metadata.title,
blocks: shouldIncludeBlocks ? simplifyBlocks(page.blocks) : [],
text_content: shouldIncludeBlocks ? extractTextFromBlocks(page.blocks) : '',
synced_at: new Date().toISOString()
};
// 写入COS
const metadataKey = `${MIRROR_PREFIX}${page_id}/metadata.json`;
const contentKey = `${MIRROR_PREFIX}${page_id}/content.json`;
await Promise.all([
cos.write(targetBucket, metadataKey, JSON.stringify(metadata, null, 2), 'application/json'),
cos.write(targetBucket, contentKey, JSON.stringify(content, null, 2), 'application/json')
]);
return {
status: 'synced',
page_id,
title: metadata.title,
metadata_key: metadataKey,
content_key: contentKey,
blocks_count: content.blocks.length,
text_length: content.text_content.length
};
}
/**
* notionCosReadMirror 从COS镜像读取页面
*
* input:
* page_id: string 页面ID
* bucket: string COS桶默认hot
* type: string 读取类型: content|metadata|both默认both
*/
async function notionCosReadMirror(input) {
const { page_id, bucket, type } = input;
if (!page_id) throw new Error('缺少 page_id');
const targetBucket = bucket || 'hot';
const readType = type || 'both';
const result = {};
if (readType === 'metadata' || readType === 'both') {
try {
const raw = await cos.read(targetBucket, `${MIRROR_PREFIX}${page_id}/metadata.json`);
result.metadata = JSON.parse(raw.content);
} catch {
result.metadata = null;
result.metadata_error = '元数据不存在';
}
}
if (readType === 'content' || readType === 'both') {
try {
const raw = await cos.read(targetBucket, `${MIRROR_PREFIX}${page_id}/content.json`);
result.content = JSON.parse(raw.content);
} catch {
result.content = null;
result.content_error = '内容不存在';
}
}
return result;
}
/**
* notionCosListMirror 列出COS镜像中的页面
*
* input:
* bucket: string COS桶默认hot
* limit: number 最大数量默认100
*/
async function notionCosListMirror(input) {
const { bucket, limit } = input;
const targetBucket = bucket || 'hot';
const result = await cos.list(targetBucket, MIRROR_PREFIX, limit || 200);
// 提取唯一的page_id
const pageIds = new Set();
for (const file of result.files) {
const match = file.key.match(MIRROR_PREFIX_REGEX);
if (match) pageIds.add(match[1]);
}
// 读取每个page的metadata异步并行
const pages = await Promise.all(
[...pageIds].slice(0, limit || 100).map(async (pageId) => {
try {
const raw = await cos.read(targetBucket, `${MIRROR_PREFIX}${pageId}/metadata.json`);
const meta = JSON.parse(raw.content);
return {
page_id: pageId,
title: meta.title || '未命名',
synced_at: meta.synced_at,
last_edited_time: meta.last_edited_time
};
} catch {
return { page_id: pageId, title: '(元数据缺失)', synced_at: null };
}
})
);
return {
pages,
total: pages.length
};
}
/**
* notionCosBuildIndex 重建COS镜像索引
*
* input:
* bucket: string COS桶默认hot
*/
async function notionCosBuildIndex(input) {
const { bucket } = input;
const targetBucket = bucket || 'hot';
// 列出所有镜像文件
const result = await cos.list(targetBucket, MIRROR_PREFIX, 500);
// 提取唯一的page_id和元数据
const pageMap = {};
for (const file of result.files) {
const match = file.key.match(MIRROR_PREFIX_REGEX);
if (match) {
const pageId = match[1];
if (!pageMap[pageId]) pageMap[pageId] = { files: [] };
pageMap[pageId].files.push(file.key);
}
}
// 读取每个page的metadata
const pages = [];
for (const [pageId, info] of Object.entries(pageMap)) {
try {
const raw = await cos.read(targetBucket, `${MIRROR_PREFIX}${pageId}/metadata.json`);
const meta = JSON.parse(raw.content);
pages.push({
page_id: pageId,
title: meta.title || '未命名',
synced_at: meta.synced_at,
last_edited_time: meta.last_edited_time,
properties: meta.properties || {},
files: info.files
});
} catch {
pages.push({
page_id: pageId,
title: '(元数据缺失)',
files: info.files
});
}
}
// 构建索引
const index = {
version: '1.0',
built_at: new Date().toISOString(),
total_pages: pages.length,
pages,
categories: categorizePages(pages)
};
// 写入索引
await cos.write(targetBucket, `${MIRROR_PREFIX}index.json`, JSON.stringify(index, null, 2), 'application/json');
return {
status: 'index_built',
total_pages: pages.length,
index_key: `${MIRROR_PREFIX}index.json`,
categories: index.categories
};
}
/**
* notionCosWriteWorkorder 写入工单到COS桶
*
* 供Notion层人格体通过COS桶发送工单给铸渊
*
* input:
* bucket: string COS桶默认team
* workorder_id: string 工单ID WO-20260408-001
* title: string 工单标题
* type: string 工单类型: dev|bug|feature|query
* priority: string 优先级: critical|high|normal|low
* description: string 工单描述
* source: string 来源: notion|chat|manual
* assigned_to: string 指派给默认zhuyuan
* attachments: object[] 附件列表可选
*/
async function notionCosWriteWorkorder(input) {
const {
bucket, workorder_id, title, type, priority,
description, source, assigned_to, attachments
} = input;
if (!title) throw new Error('缺少 title');
const targetBucket = bucket || 'team';
const woId = workorder_id || `WO-${formatDate()}-${crypto.randomBytes(4).toString('hex')}`;
// 验证 workorder_id 安全性
if (!/^[a-zA-Z0-9_-]+$/.test(woId)) {
throw new Error('workorder_id 包含非法字符');
}
const now = new Date().toISOString();
const workorder = {
workorder_id: woId,
title,
type: type || 'dev',
priority: priority || 'normal',
status: 'pending',
description: description || '',
source: source || 'manual',
assigned_to: assigned_to || 'zhuyuan',
created_at: now,
updated_at: now,
attachments: attachments || [],
history: [{
action: 'created',
timestamp: now,
by: source || 'manual'
}]
};
const key = `${WORKORDER_PREFIX}pending/${woId}.json`;
await cos.write(targetBucket, key, JSON.stringify(workorder, null, 2), 'application/json');
return {
status: 'created',
workorder_id: woId,
key,
bucket: targetBucket,
priority: workorder.priority
};
}
/**
* notionCosReadWorkorder 读取工单
*
* input:
* bucket: string COS桶默认team
* workorder_id: string 工单ID
* status_folder: string 状态文件夹: pending|processing|completed|rejected默认pending
*/
async function notionCosReadWorkorder(input) {
const { bucket, workorder_id, status_folder } = input;
if (!workorder_id) throw new Error('缺少 workorder_id');
const targetBucket = bucket || 'team';
const folder = status_folder || 'pending';
const key = `${WORKORDER_PREFIX}${folder}/${workorder_id}.json`;
const result = await cos.read(targetBucket, key);
return {
workorder: JSON.parse(result.content),
key,
size_bytes: result.size_bytes
};
}
/**
* notionCosListWorkorders 列出工单
*
* input:
* bucket: string COS桶默认team
* status_folder: string 状态文件夹: pending|processing|completed|rejected默认pending
* limit: number 最大数量
*/
async function notionCosListWorkorders(input) {
const { bucket, status_folder, limit } = input;
const targetBucket = bucket || 'team';
const folder = status_folder || 'pending';
const result = await cos.list(targetBucket, `${WORKORDER_PREFIX}${folder}/`, limit || 100);
const workorders = result.files
.filter(f => f.key.endsWith('.json'))
.map(f => ({
key: f.key,
workorder_id: f.key.split('/').pop().replace('.json', ''),
size_bytes: f.size_bytes,
status: folder
}));
return {
workorders,
count: workorders.length,
status_folder: folder
};
}
// ═══════════════════════════════════════════════════════════
// 辅助函数
// ═══════════════════════════════════════════════════════════
function extractTitle(properties) {
if (!properties) return '未命名';
for (const [, prop] of Object.entries(properties)) {
if (prop.type === 'title' && prop.title) {
return prop.title.map(t => t.plain_text || '').join('') || '未命名';
}
}
return '未命名';
}
function simplifyProperties(properties) {
if (!properties) return {};
const result = {};
for (const [name, prop] of Object.entries(properties)) {
switch (prop.type) {
case 'title':
result[name] = prop.title?.map(t => t.plain_text).join('') || '';
break;
case 'rich_text':
result[name] = prop.rich_text?.map(t => t.plain_text).join('') || '';
break;
case 'select':
result[name] = prop.select?.name || null;
break;
case 'multi_select':
result[name] = prop.multi_select?.map(s => s.name) || [];
break;
case 'date':
result[name] = prop.date?.start || null;
break;
case 'checkbox':
result[name] = prop.checkbox || false;
break;
case 'number':
result[name] = prop.number;
break;
default:
result[name] = `(${prop.type})`;
}
}
return result;
}
function simplifyBlocks(blocks) {
if (!blocks) return [];
return blocks.map(b => {
const type = b.type;
const blockData = b[type];
if (!blockData) return { type, text: '' };
let text = '';
if (blockData.rich_text) {
text = blockData.rich_text.map(t => t.plain_text || '').join('');
}
return {
id: b.id,
type,
text,
has_children: b.has_children || false
};
});
}
function extractTextFromBlocks(blocks) {
if (!blocks) return '';
return blocks.map(b => {
const type = b.type;
const blockData = b[type];
if (!blockData?.rich_text) return '';
return blockData.rich_text.map(t => t.plain_text || '').join('');
}).filter(Boolean).join('\n');
}
function categorizePages(pages) {
const categories = {};
for (const page of pages) {
const cat = page.properties?.category || page.properties?.类型 || 'uncategorized';
const catName = typeof cat === 'string' ? cat : 'uncategorized';
categories[catName] = (categories[catName] || 0) + 1;
}
return categories;
}
function formatDate() {
const d = new Date();
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
}
module.exports = {
notionCosSyncPage,
notionCosReadMirror,
notionCosListMirror,
notionCosBuildIndex,
notionCosWriteWorkorder,
notionCosReadWorkorder,
notionCosListWorkorders
};