feat: M-BRIDGE 飞书集成 — 三条链路脚本 + 飞书机器人回传桥

- sync-login-entry.yml + .js: Notion 登录入口 → 飞书文档A (全量替换)
- push-broadcast.yml + .js: Notion 广播 → 飞书文档B (追加到顶部)
- receive-syslog.yml + .js: SYSLOG 回传 → 存入仓库 + Notion 收件箱 + 霜砚工单
- feishu-bot.js: 飞书机器人事件回调处理 (SYSLOG 回传桥)
- syslog/ 目录创建

Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-11 17:12:52 +00:00
parent d6c977de74
commit ceb61cbac8
9 changed files with 1330 additions and 3 deletions

47
.github/workflows/push-broadcast.yml vendored Normal file
View File

@ -0,0 +1,47 @@
name: 铸渊 · Push Broadcast · Notion → 飞书文档B
# 链路2新广播推送
# Notion 新广播页面 → 追加到飞书文档B顶部
#
# 依赖 Secrets
# NOTION_TOKEN Notion API token
# FEISHU_APP_ID 飞书应用 App ID
# FEISHU_APP_SECRET 飞书应用 App Secret
# FEISHU_DOC_B_ID 飞书文档B的 document_id
#
# dispatch payload
# broadcast_page_id Notion 广播页面 ID
on:
repository_dispatch:
types: [push-broadcast]
workflow_dispatch:
inputs:
broadcast_page_id:
description: 'Notion 广播页面 ID'
required: true
jobs:
push-broadcast:
name: 📡 推送广播到飞书
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: 📡 推送广播 → 飞书文档B
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
BROADCAST_PAGE_ID: ${{ github.event.client_payload.broadcast_page_id || github.event.inputs.broadcast_page_id }}
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
FEISHU_DOC_B_ID: ${{ secrets.FEISHU_DOC_B_ID }}
run: node scripts/push-broadcast.js

53
.github/workflows/receive-syslog.yml vendored Normal file
View File

@ -0,0 +1,53 @@
name: 铸渊 · Receive SYSLOG · 飞书机器人 → GitHub → Notion
# 链路3SYSLOG 回传
# 飞书机器人发来的 SYSLOG → 存入仓库 + 创建 Notion 条目 + 创建霜砚工单
#
# 依赖 Secrets
# NOTION_TOKEN Notion API token
# NOTION_SYSLOG_DB_ID SYSLOG 收件箱数据库 ID
# NOTION_TICKET_DB_ID 霜砚工单数据库 ID
#
# dispatch payload
# syslog SYSLOG JSON 全文
on:
repository_dispatch:
types: [receive-syslog]
jobs:
receive-syslog:
name: 📥 接收 SYSLOG 回传
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: 📥 处理 SYSLOG
env:
SYSLOG_JSON: ${{ toJSON(github.event.client_payload.syslog) }}
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
NOTION_SYSLOG_DB_ID: ${{ secrets.NOTION_SYSLOG_DB_ID }}
NOTION_TICKET_DB_ID: ${{ secrets.NOTION_TICKET_DB_ID }}
run: node scripts/receive-syslog.js
- name: 💾 提交 SYSLOG 文件到仓库
run: |
git config user.name "zhuyuan-bot"
git config user.email "zhuyuan-bot@guanghulab.com"
git add syslog/
if git diff --cached --quiet; then
echo "No new syslog files to commit"
else
git pull --rebase origin main || true
git commit -m "📥 铸渊接收 SYSLOG 回传 [skip ci]"
git push origin main
fi

41
.github/workflows/sync-login-entry.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: 铸渊 · Sync Login Entry · Notion → 飞书文档A
# 链路1登录入口同步
# Notion 霜砚登录入口页面 → 全量替换飞书文档A
#
# 依赖 Secrets
# NOTION_TOKEN Notion API token
# NOTION_LOGIN_PAGE_ID Notion 登录入口页面 ID
# FEISHU_APP_ID 飞书应用 App ID
# FEISHU_APP_SECRET 飞书应用 App Secret
# FEISHU_DOC_A_ID 飞书文档A的 document_id
on:
repository_dispatch:
types: [sync-login-entry]
workflow_dispatch:
jobs:
sync-login-entry:
name: 📋 同步登录入口到飞书
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: 🔗 同步 Notion 登录入口 → 飞书文档A
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
NOTION_LOGIN_PAGE_ID: ${{ secrets.NOTION_LOGIN_PAGE_ID }}
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
FEISHU_DOC_A_ID: ${{ secrets.FEISHU_DOC_A_ID }}
run: node scripts/sync-login-entry.js

View File

@ -0,0 +1,281 @@
// backend/routes/feishu-bot.js
// 铸渊 · 飞书机器人事件回调处理
//
// SYSLOG 回传桥:开发者发 SYSLOG → 飞书机器人 → GitHub repository_dispatch
//
// 飞书事件订阅im.message.receive_v1
// 部署:集成到现有 M-BRIDGE 后端服务
//
// 环境变量:
// FEISHU_APP_ID 飞书应用 App ID
// FEISHU_APP_SECRET 飞书应用 App Secret
// GITHUB_TOKEN GitHub Token需要 repo scope
// FEISHU_VERIFICATION_TOKEN 飞书事件验证 Token可选用于安全校验
'use strict';
const express = require('express');
const https = require('https');
const router = express.Router();
const FEISHU_APP_ID = process.env.FEISHU_APP_ID;
const FEISHU_APP_SECRET = process.env.FEISHU_APP_SECRET;
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const VERIFICATION_TOKEN = process.env.FEISHU_VERIFICATION_TOKEN;
const GITHUB_REPO_OWNER = 'qinfendebingshuo';
const GITHUB_REPO_NAME = 'guanghulab';
const VALID_PROTOCOL_VERSIONS = ['4.0', 'v4.0', '4.0.0'];
// ══════════════════════════════════════════════════════════
// 工具函数
// ══════════════════════════════════════════════════════════
function httpsRequest(options, body) {
return new Promise((resolve, reject) => {
const payload = body ? JSON.stringify(body) : null;
if (payload) {
options.headers = options.headers || {};
options.headers['Content-Length'] = Buffer.byteLength(payload);
}
const req = https.request(options, res => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const parsed = JSON.parse(data);
resolve({ statusCode: res.statusCode, data: parsed });
} catch (e) {
resolve({ statusCode: res.statusCode, data: data });
}
});
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
async function getFeishuToken() {
const result = await httpsRequest({
hostname: 'open.feishu.cn',
port: 443,
path: '/open-apis/auth/v3/tenant_access_token/internal',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}, { app_id: FEISHU_APP_ID, app_secret: FEISHU_APP_SECRET });
return result.data.tenant_access_token;
}
async function replyFeishuMessage(token, messageId, content) {
return httpsRequest({
hostname: 'open.feishu.cn',
port: 443,
path: '/open-apis/im/v1/messages/' + messageId + '/reply',
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
},
}, {
msg_type: 'text',
content: JSON.stringify({ text: content }),
});
}
async function triggerGitHubDispatch(syslog) {
return httpsRequest({
hostname: 'api.github.com',
port: 443,
path: '/repos/' + GITHUB_REPO_OWNER + '/' + GITHUB_REPO_NAME + '/dispatches',
method: 'POST',
headers: {
'Authorization': 'token ' + GITHUB_TOKEN,
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'guanghulab-feishu-bot',
'Content-Type': 'application/json',
},
}, {
event_type: 'receive-syslog',
client_payload: {
syslog: syslog,
dev_id: syslog.dev_id || syslog.developer_id || 'UNKNOWN',
broadcast_id: syslog.broadcast_id || syslog.broadcastId || 'UNKNOWN',
},
});
}
// ══════════════════════════════════════════════════════════
// SYSLOG 提取与验证
// ══════════════════════════════════════════════════════════
function extractSyslogFromMessage(text) {
// 尝试从消息中提取 JSON
// 支持:纯 JSON、代码块包裹的 JSON、混合文本中的 JSON
if (!text || typeof text !== 'string') return null;
// 1. 尝试直接解析整个文本
try {
const parsed = JSON.parse(text.trim());
if (parsed && typeof parsed === 'object' && parsed.protocol_version) {
return parsed;
}
} catch (e) { /* not pure JSON */ }
// 2. 尝试提取代码块中的 JSON
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
if (codeBlockMatch) {
try {
const parsed = JSON.parse(codeBlockMatch[1].trim());
if (parsed && typeof parsed === 'object' && parsed.protocol_version) {
return parsed;
}
} catch (e) { /* not valid JSON in code block */ }
}
// 3. 尝试提取大括号包裹的 JSON
const jsonMatch = text.match(/\{[\s\S]*"protocol_version"[\s\S]*\}/);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]);
if (parsed && typeof parsed === 'object') {
return parsed;
}
} catch (e) { /* not valid JSON */ }
}
return null;
}
function validateSyslog(syslog) {
if (!syslog || typeof syslog !== 'object') {
return '无效的 SYSLOG 数据';
}
if (!syslog.protocol_version) {
return '缺少 protocol_version 字段';
}
if (!VALID_PROTOCOL_VERSIONS.includes(String(syslog.protocol_version))) {
return 'protocol_version 不合法: ' + syslog.protocol_version + '(需要 v4.0';
}
if (!syslog.dev_id && !syslog.developer_id) {
return '缺少 dev_id 或 developer_id 字段';
}
return null; // 验证通过
}
// ══════════════════════════════════════════════════════════
// 路由处理
// ══════════════════════════════════════════════════════════
// 飞书事件回调 URL 验证(首次配置时飞书会发送 challenge
router.post('/event', async (req, res) => {
const body = req.body;
// 1. URL 验证(飞书首次配置事件订阅时会发送)
if (body.challenge) {
return res.json({ challenge: body.challenge });
}
// 2. 验证 token如果配置了 VERIFICATION_TOKEN
if (VERIFICATION_TOKEN && body.token && body.token !== VERIFICATION_TOKEN) {
return res.status(403).json({ error: true, message: '验证 token 不匹配' });
}
// 3. 飞书 v2 事件格式
const header = body.header || {};
const event = body.event || {};
// 处理 im.message.receive_v1 事件
if (header.event_type === 'im.message.receive_v1') {
// 立即响应飞书(避免超时重发)
res.json({ code: 0 });
// 异步处理消息
processMessage(event).catch(err => {
console.error('❌ 消息处理失败:', err.message);
});
return;
}
// 其他事件类型
res.json({ code: 0, message: 'event received' });
});
async function processMessage(event) {
const message = event.message || {};
const messageId = message.message_id;
const msgType = message.message_type;
// 只处理文本消息
if (msgType !== 'text') return;
let textContent = '';
try {
const content = JSON.parse(message.content || '{}');
textContent = content.text || '';
} catch (e) {
return;
}
// 提取 SYSLOG
const syslog = extractSyslogFromMessage(textContent);
if (!syslog) return; // 不是 SYSLOG 消息,忽略
// 验证 SYSLOG
const validationError = validateSyslog(syslog);
// 获取飞书 token 用于回复
let feishuToken;
try {
feishuToken = await getFeishuToken();
} catch (e) {
console.error('❌ 获取飞书 token 失败:', e.message);
return;
}
if (validationError) {
await replyFeishuMessage(feishuToken, messageId,
'❌ SYSLOG 验证失败: ' + validationError + '\n\n请检查 JSON 格式后重新发送。');
return;
}
// 触发 GitHub repository_dispatch
if (!GITHUB_TOKEN) {
await replyFeishuMessage(feishuToken, messageId,
'⚠️ 系统配置缺失 (GITHUB_TOKEN),请联系管理员。');
return;
}
try {
const result = await triggerGitHubDispatch(syslog);
if (result.statusCode === 204 || result.statusCode === 200) {
const devId = syslog.dev_id || syslog.developer_id;
const broadcastId = syslog.broadcast_id || syslog.broadcastId || '无';
await replyFeishuMessage(feishuToken, messageId,
'✅ SYSLOG 已收到,正在回传系统\n\n' +
'📋 开发者: ' + devId + '\n' +
'📡 广播编号: ' + broadcastId + '\n' +
'🔄 GitHub Action 已触发Notion 工单将自动创建');
} else {
await replyFeishuMessage(feishuToken, messageId,
'⚠️ GitHub dispatch 触发异常 (HTTP ' + result.statusCode + '),请稍后重试。');
}
} catch (e) {
await replyFeishuMessage(feishuToken, messageId,
'❌ SYSLOG 回传失败: ' + e.message + '\n\n请稍后重试或联系管理员。');
}
}
// 健康检查
router.get('/health', (req, res) => {
res.json({
status: 'ok',
service: 'feishu-bot-syslog-bridge',
github_token_configured: !!GITHUB_TOKEN,
feishu_app_configured: !!(FEISHU_APP_ID && FEISHU_APP_SECRET),
verification_token_configured: !!VERIFICATION_TOKEN,
});
});
module.exports = router;

View File

@ -9,6 +9,7 @@ app.use(express.json());
// 路由引入
const notionRoutes = require('./routes/notion');
const feishuRoutes = require('./routes/feishu');
const feishuBotRoutes = require('./routes/feishu-bot');
const routerRoutes = require('./routes/router');
const coldstartRoutes = require('./routes/coldstart');
const developersRoutes = require('./routes/developers');
@ -16,6 +17,7 @@ const hliRoutes = require('../src/routes/hli');
app.use('/notion', notionRoutes);
app.use('/feishu', feishuRoutes);
app.use('/feishu-bot', feishuBotRoutes);
app.use('/router', routerRoutes);
app.use('/api/coldstart', coldstartRoutes);
app.use('/api/v1/developers', developersRoutes);
@ -26,14 +28,13 @@ app.get('/', (req, res) => {
status: 'ok',
message: 'HoloLake 后端服务运行中',
version: '0.2.0',
routes: ['/notion/test', '/feishu/test', '/router/test', '/router/chat', '/api/coldstart', '/api/v1/developers/test', '/hli/test']
routes: ['/notion/test', '/feishu/test', '/feishu-bot/health', '/router/test', '/router/chat', '/api/coldstart', '/api/v1/developers/test', '/hli/test']
});
});
const PORT = process.env.PORT || 3000;
// 飞书 Webhook 处理
// 飞书 Webhook 处理(旧版兼容入口,新事件请使用 /feishu-bot/event
app.post('/webhook/feishu', (req, res) => {
console.log('收到飞书请求:', req.body);
if (req.body.challenge) {
return res.json({ challenge: req.body.challenge });
}

300
scripts/push-broadcast.js Normal file
View File

@ -0,0 +1,300 @@
// scripts/push-broadcast.js
// 铸渊 · 广播推送脚本
//
// Notion 广播页面 → 飞书文档B追加到顶部
//
// 环境变量:
// NOTION_TOKEN Notion API token
// BROADCAST_PAGE_ID Notion 广播页面 ID由 dispatch payload 提供)
// FEISHU_APP_ID 飞书应用 App ID
// FEISHU_APP_SECRET 飞书应用 App Secret
// FEISHU_DOC_B_ID 飞书文档B的 document_id
'use strict';
const https = require('https');
// ══════════════════════════════════════════════════════════
// 常量
// ══════════════════════════════════════════════════════════
const NOTION_VERSION = '2022-06-28';
const NOTION_API_HOSTNAME = 'api.notion.com';
const FEISHU_API_HOSTNAME = 'open.feishu.cn';
// ══════════════════════════════════════════════════════════
// HTTP 请求工具
// ══════════════════════════════════════════════════════════
function httpsRequest(options, body) {
return new Promise((resolve, reject) => {
const payload = body ? JSON.stringify(body) : null;
if (payload) {
options.headers = options.headers || {};
options.headers['Content-Length'] = Buffer.byteLength(payload);
}
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 >= 200 && res.statusCode < 300) {
resolve(parsed);
} else {
reject(new Error(`HTTP ${res.statusCode}: ${parsed.message || parsed.msg || data}`));
}
} catch (e) {
reject(new Error(`Parse error: ${data}`));
}
});
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
// ══════════════════════════════════════════════════════════
// Notion API
// ══════════════════════════════════════════════════════════
function notionGet(endpoint, token) {
return httpsRequest({
hostname: NOTION_API_HOSTNAME,
port: 443,
path: endpoint,
method: 'GET',
headers: {
'Authorization': 'Bearer ' + token,
'Notion-Version': NOTION_VERSION,
},
});
}
async function getNotionPage(pageId, token) {
return notionGet('/v1/pages/' + pageId, token);
}
async function getNotionPageBlocks(pageId, token) {
const blocks = [];
let cursor = undefined;
do {
const qs = cursor ? '?start_cursor=' + cursor : '';
const result = await notionGet('/v1/blocks/' + pageId + '/children' + qs, token);
blocks.push(...(result.results || []));
cursor = result.has_more ? result.next_cursor : undefined;
} while (cursor);
return blocks;
}
// ══════════════════════════════════════════════════════════
// 飞书 API
// ══════════════════════════════════════════════════════════
async function getFeishuToken(appId, appSecret) {
const result = await httpsRequest({
hostname: FEISHU_API_HOSTNAME,
port: 443,
path: '/open-apis/auth/v3/tenant_access_token/internal',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}, { app_id: appId, app_secret: appSecret });
return result.tenant_access_token;
}
async function addFeishuDocBlocks(token, docId, parentBlockId, children, index) {
return httpsRequest({
hostname: FEISHU_API_HOSTNAME,
port: 443,
path: '/open-apis/docx/v1/documents/' + docId + '/blocks/' + parentBlockId + '/children',
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
},
}, { children, index });
}
// ══════════════════════════════════════════════════════════
// Notion → 飞书 格式转换
// ══════════════════════════════════════════════════════════
function notionRichTextToFeishu(richTextArray) {
if (!richTextArray || richTextArray.length === 0) return [];
return richTextArray.map(rt => {
const elem = { content: rt.plain_text || '' };
if (rt.annotations) {
const style = {};
if (rt.annotations.bold) style.bold = true;
if (rt.annotations.italic) style.italic = true;
if (rt.annotations.strikethrough) style.strikethrough = true;
if (rt.annotations.underline) style.underline = true;
if (rt.annotations.code) style.inline_code = true;
if (Object.keys(style).length > 0) elem.text_element_style = style;
}
if (rt.href) {
elem.text_element_style = elem.text_element_style || {};
elem.text_element_style.link = { url: rt.href };
}
return { text_run: elem };
});
}
function notionBlockToFeishu(block) {
const type = block.type;
const data = block[type];
if (!data) return null;
switch (type) {
case 'paragraph':
return {
block_type: 2,
text: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
case 'heading_1':
return {
block_type: 4,
heading1: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
case 'heading_2':
return {
block_type: 5,
heading2: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
case 'heading_3':
return {
block_type: 6,
heading3: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
case 'bulleted_list_item':
return {
block_type: 12,
bullet: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
case 'numbered_list_item':
return {
block_type: 13,
ordered: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
case 'to_do':
return {
block_type: 14,
todo: {
elements: notionRichTextToFeishu(data.rich_text),
style: { done: !!data.checked },
},
};
case 'code':
return {
block_type: 15,
code: {
elements: notionRichTextToFeishu(data.rich_text),
style: { language: 1 },
},
};
case 'divider':
return { block_type: 22, horizontal_rule: {} };
case 'callout':
return {
block_type: 2,
text: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
default:
if (data.rich_text) {
return {
block_type: 2,
text: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
}
return null;
}
}
function extractBroadcastId(page) {
// 尝试从 Notion 页面属性中提取广播编号
const props = page.properties || {};
for (const key of Object.keys(props)) {
const prop = props[key];
if (prop.type === 'title' && prop.title) {
const text = prop.title.map(t => t.plain_text).join('');
const match = text.match(/BC-\w+-\d+-\d+/);
if (match) return match[0];
}
if (prop.type === 'rich_text' && prop.rich_text) {
const text = prop.rich_text.map(t => t.plain_text).join('');
const match = text.match(/BC-\w+-\d+-\d+/);
if (match) return match[0];
}
}
return null;
}
// ══════════════════════════════════════════════════════════
// 主流程
// ══════════════════════════════════════════════════════════
async function main() {
const notionToken = process.env.NOTION_TOKEN;
const pageId = process.env.BROADCAST_PAGE_ID;
const feishuAppId = process.env.FEISHU_APP_ID;
const feishuSecret = process.env.FEISHU_APP_SECRET;
const docBId = process.env.FEISHU_DOC_B_ID;
if (!notionToken || !pageId || !feishuAppId || !feishuSecret || !docBId) {
console.error('❌ 缺少必要环境变量');
console.error(' 需要: NOTION_TOKEN, BROADCAST_PAGE_ID, FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_DOC_B_ID');
process.exit(1);
}
// 1. 读取 Notion 广播页面元信息
console.log('📖 读取 Notion 广播页面...');
const page = await getNotionPage(pageId, notionToken);
const broadcastId = extractBroadcastId(page) || 'UNKNOWN';
const dateStr = new Date().toISOString().split('T')[0];
console.log(' → 广播编号: ' + broadcastId);
console.log(' → 签发日期: ' + dateStr);
// 2. 读取 Notion 广播页面内容
const blocks = await getNotionPageBlocks(pageId, notionToken);
console.log(' → 获取到 ' + blocks.length + ' 个内容块');
// 3. 转换为飞书文档格式
const feishuBlocks = blocks
.map(notionBlockToFeishu)
.filter(b => b !== null);
// 4. 构建追加内容(带分隔线+广播编号+日期头)
const headerBlocks = [
// 分隔线
{ block_type: 22, horizontal_rule: {} },
// 广播编号 + 日期
{
block_type: 5, // heading2
heading2: {
elements: [
{ text_run: { content: '📡 ' + broadcastId + ' · ' + dateStr } },
],
style: {},
},
},
];
const allBlocks = headerBlocks.concat(feishuBlocks);
console.log(' → 共 ' + allBlocks.length + ' 个飞书内容块(含头部)');
// 5. 获取飞书 token
console.log('🔑 获取飞书 access token...');
const feishuToken = await getFeishuToken(feishuAppId, feishuSecret);
// 6. 追加到飞书文档B顶部index=0 表示插入到最前面)
console.log('✍️ 追加广播到飞书文档B顶部...');
await addFeishuDocBlocks(feishuToken, docBId, docBId, allBlocks, 0);
console.log('✅ 广播推送完成: ' + broadcastId);
}
main().catch(err => {
console.error('❌ 推送失败: ' + err.message);
process.exit(1);
});

297
scripts/receive-syslog.js Normal file
View File

@ -0,0 +1,297 @@
// scripts/receive-syslog.js
// 铸渊 · SYSLOG 接收处理脚本
//
// 接收 repository_dispatch 传来的 SYSLOG JSON执行三步操作
// ① 存入 syslog/YYYY-MM-DD_BC-XXX-YYY-ZZ.json
// ② Notion API 创建 SYSLOG 收件箱条目
// ③ Notion API 创建霜砚工单(触发巡检引擎)
//
// 环境变量:
// SYSLOG_JSON SYSLOG 完整 JSON 字符串
// NOTION_TOKEN Notion API token
// NOTION_SYSLOG_DB_ID SYSLOG 收件箱数据库 ID
// NOTION_TICKET_DB_ID 霜砚工单数据库 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 SYSLOG_DIR = path.resolve(__dirname, '..', 'syslog');
// ══════════════════════════════════════════════════════════
// HTTP 请求工具
// ══════════════════════════════════════════════════════════
function notionPost(endpoint, body, token) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const opts = {
hostname: NOTION_API_HOSTNAME,
port: 443,
path: endpoint,
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
'Notion-Version': NOTION_VERSION,
'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 ${res.statusCode}: ${parsed.message || data}`));
}
} catch (e) {
reject(new Error(`Notion API parse error: ${data}`));
}
});
});
req.on('error', reject);
req.write(payload);
req.end();
});
}
// ══════════════════════════════════════════════════════════
// 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;
}
function titleProp(text) {
return { title: [{ type: 'text', text: { content: String(text || '').slice(0, 120) } }] };
}
function richTextProp(text) {
return { rich_text: richText(text) };
}
function selectProp(name) {
return { select: { name: String(name) } };
}
function dateProp(dateStr) {
return { date: { start: dateStr } };
}
function checkboxProp(val) {
return { checkbox: !!val };
}
// ══════════════════════════════════════════════════════════
// SYSLOG 验证
// ══════════════════════════════════════════════════════════
const VALID_PROTOCOL_VERSIONS = ['4.0', 'v4.0', '4.0.0'];
function validateSyslog(syslog) {
const errors = [];
if (!syslog.protocol_version) {
errors.push('缺少 protocol_version 字段');
} else if (!VALID_PROTOCOL_VERSIONS.includes(String(syslog.protocol_version))) {
errors.push('protocol_version 不合法: ' + syslog.protocol_version);
}
if (!syslog.dev_id && !syslog.developer_id) {
errors.push('缺少 dev_id 或 developer_id 字段');
}
return errors;
}
// ══════════════════════════════════════════════════════════
// 步骤 ①:存入仓库
// ══════════════════════════════════════════════════════════
function saveSyslogToFile(syslog) {
if (!fs.existsSync(SYSLOG_DIR)) {
fs.mkdirSync(SYSLOG_DIR, { recursive: true });
}
const dateStr = new Date().toISOString().split('T')[0];
const broadcastId = syslog.broadcast_id || syslog.broadcastId || 'UNKNOWN';
const devId = syslog.dev_id || syslog.developer_id || 'UNKNOWN';
const filename = dateStr + '_' + broadcastId + '_' + devId + '.json';
const filepath = path.join(SYSLOG_DIR, filename);
fs.writeFileSync(filepath, JSON.stringify(syslog, null, 2), 'utf8');
console.log(' → 已保存: syslog/' + filename);
return filepath;
}
// ══════════════════════════════════════════════════════════
// 步骤 ②:创建 Notion SYSLOG 收件箱条目
// ══════════════════════════════════════════════════════════
async function createSyslogEntry(syslog, token, dbId) {
const broadcastId = syslog.broadcast_id || syslog.broadcastId || 'UNKNOWN';
const devId = syslog.dev_id || syslog.developer_id || 'UNKNOWN';
const dateStr = new Date().toISOString().split('T')[0];
const properties = {
'标题': titleProp('SYSLOG · ' + broadcastId + ' · ' + devId),
'广播编号': richTextProp(broadcastId),
'开发者编号': richTextProp(devId),
'协议版本': richTextProp(String(syslog.protocol_version || '')),
'提交日期': dateProp(dateStr),
'霜砚已读': checkboxProp(false),
};
// 可选字段
if (syslog.status) properties['状态'] = selectProp(syslog.status);
if (syslog.summary) properties['摘要'] = richTextProp(syslog.summary);
const body = {
parent: { database_id: dbId },
properties,
children: [
{
object: 'block',
type: 'code',
code: {
rich_text: richText(JSON.stringify(syslog, null, 2)),
language: 'json',
},
},
],
};
const result = await notionPost('/v1/pages', body, token);
console.log(' → Notion SYSLOG 收件箱条目已创建: ' + result.id);
return result;
}
// ══════════════════════════════════════════════════════════
// 步骤 ③:创建霜砚工单
// ══════════════════════════════════════════════════════════
async function createTicket(syslog, token, dbId) {
const broadcastId = syslog.broadcast_id || syslog.broadcastId || 'UNKNOWN';
const devId = syslog.dev_id || syslog.developer_id || 'UNKNOWN';
const dateStr = new Date().toISOString().split('T')[0];
const properties = {
'标题': titleProp('SYSLOG 回传|' + broadcastId + ' · ' + devId),
'操作类型': selectProp('其他'),
'提交者': richTextProp('巡检引擎'),
'提交日期': dateProp(dateStr),
'状态': selectProp('待处理'),
'优先级': selectProp('P1'),
};
// 添加关联信息
if (broadcastId !== 'UNKNOWN') properties['广播编号'] = richTextProp(broadcastId);
if (devId !== 'UNKNOWN') properties['开发者编号'] = richTextProp(devId);
const body = {
parent: { database_id: dbId },
properties,
children: [
{
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: [{
type: 'text',
text: { content: '📥 SYSLOG 回传,来自 ' + devId + ',关联广播 ' + broadcastId },
}],
},
},
{
object: 'block',
type: 'code',
code: {
rich_text: richText(JSON.stringify(syslog, null, 2)),
language: 'json',
},
},
],
};
const result = await notionPost('/v1/pages', body, token);
console.log(' → 霜砚工单已创建: ' + result.id);
return result;
}
// ══════════════════════════════════════════════════════════
// 主流程
// ══════════════════════════════════════════════════════════
async function main() {
const syslogJson = process.env.SYSLOG_JSON;
const notionToken = process.env.NOTION_TOKEN;
const syslogDbId = process.env.NOTION_SYSLOG_DB_ID;
const ticketDbId = process.env.NOTION_TICKET_DB_ID;
if (!syslogJson) {
console.error('❌ 缺少 SYSLOG_JSON 环境变量');
process.exit(1);
}
// 解析 SYSLOG JSON
let syslog;
try {
syslog = JSON.parse(syslogJson);
} catch (e) {
console.error('❌ SYSLOG JSON 解析失败: ' + e.message);
process.exit(1);
}
// 验证 SYSLOG
console.log('🔍 验证 SYSLOG...');
const errors = validateSyslog(syslog);
if (errors.length > 0) {
console.error('❌ SYSLOG 验证失败:');
errors.forEach(e => console.error(' - ' + e));
process.exit(1);
}
console.log(' → 验证通过 (protocol_version: ' + syslog.protocol_version + ')');
// 步骤①:存入仓库
console.log('💾 步骤①: 存入仓库 syslog/ 目录...');
saveSyslogToFile(syslog);
// 步骤②:创建 Notion SYSLOG 收件箱条目
if (notionToken && syslogDbId) {
console.log('📝 步骤②: 创建 Notion SYSLOG 收件箱条目...');
await createSyslogEntry(syslog, notionToken, syslogDbId);
} else {
console.log('⚠️ 步骤②: 缺少 NOTION_TOKEN 或 NOTION_SYSLOG_DB_ID跳过 Notion 收件箱');
}
// 步骤③:创建霜砚工单
if (notionToken && ticketDbId) {
console.log('📋 步骤③: 创建霜砚工单...');
await createTicket(syslog, notionToken, ticketDbId);
} else {
console.log('⚠️ 步骤③: 缺少 NOTION_TOKEN 或 NOTION_TICKET_DB_ID跳过工单创建');
}
console.log('✅ SYSLOG 接收处理完成');
}
main().catch(err => {
console.error('❌ 处理失败: ' + err.message);
process.exit(1);
});

307
scripts/sync-login-entry.js Normal file
View File

@ -0,0 +1,307 @@
// scripts/sync-login-entry.js
// 铸渊 · 登录入口同步脚本
//
// Notion 登录入口页面 → 飞书文档A全量替换
//
// 环境变量:
// NOTION_TOKEN Notion API token
// NOTION_LOGIN_PAGE_ID Notion 登录入口页面 ID
// FEISHU_APP_ID 飞书应用 App ID
// FEISHU_APP_SECRET 飞书应用 App Secret
// FEISHU_DOC_A_ID 飞书文档A的 document_id
'use strict';
const https = require('https');
// ══════════════════════════════════════════════════════════
// 常量
// ══════════════════════════════════════════════════════════
const NOTION_VERSION = '2022-06-28';
const NOTION_API_HOSTNAME = 'api.notion.com';
const FEISHU_API_HOSTNAME = 'open.feishu.cn';
// ══════════════════════════════════════════════════════════
// HTTP 请求工具
// ══════════════════════════════════════════════════════════
function httpsRequest(options, body) {
return new Promise((resolve, reject) => {
const payload = body ? JSON.stringify(body) : null;
if (payload) {
options.headers = options.headers || {};
options.headers['Content-Length'] = Buffer.byteLength(payload);
}
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 >= 200 && res.statusCode < 300) {
resolve(parsed);
} else {
reject(new Error(`HTTP ${res.statusCode}: ${parsed.message || parsed.msg || data}`));
}
} catch (e) {
reject(new Error(`Parse error: ${data}`));
}
});
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
// ══════════════════════════════════════════════════════════
// Notion API
// ══════════════════════════════════════════════════════════
function notionGet(endpoint, token) {
return httpsRequest({
hostname: NOTION_API_HOSTNAME,
port: 443,
path: endpoint,
method: 'GET',
headers: {
'Authorization': 'Bearer ' + token,
'Notion-Version': NOTION_VERSION,
},
});
}
async function getNotionPageBlocks(pageId, token) {
const blocks = [];
let cursor = undefined;
do {
const qs = cursor ? '?start_cursor=' + cursor : '';
const result = await notionGet('/v1/blocks/' + pageId + '/children' + qs, token);
blocks.push(...(result.results || []));
cursor = result.has_more ? result.next_cursor : undefined;
} while (cursor);
return blocks;
}
// ══════════════════════════════════════════════════════════
// 飞书 API
// ══════════════════════════════════════════════════════════
async function getFeishuToken(appId, appSecret) {
const result = await httpsRequest({
hostname: FEISHU_API_HOSTNAME,
port: 443,
path: '/open-apis/auth/v3/tenant_access_token/internal',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}, { app_id: appId, app_secret: appSecret });
return result.tenant_access_token;
}
async function getFeishuDocBlocks(token, docId) {
return httpsRequest({
hostname: FEISHU_API_HOSTNAME,
port: 443,
path: '/open-apis/docx/v1/documents/' + docId + '/blocks/' + docId + '/children',
method: 'GET',
headers: { 'Authorization': 'Bearer ' + token },
});
}
async function deleteFeishuDocBlocks(token, docId, blockIds) {
if (!blockIds || blockIds.length === 0) return;
// 飞书 API 需要逐个或批量删除子块
for (const blockId of blockIds) {
try {
await httpsRequest({
hostname: FEISHU_API_HOSTNAME,
port: 443,
path: '/open-apis/docx/v1/documents/' + docId + '/blocks/' + blockId,
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
} catch (e) {
console.log('⚠️ 删除块 ' + blockId + ' 失败: ' + e.message);
}
}
}
async function addFeishuDocBlocks(token, docId, parentBlockId, children, index) {
return httpsRequest({
hostname: FEISHU_API_HOSTNAME,
port: 443,
path: '/open-apis/docx/v1/documents/' + docId + '/blocks/' + parentBlockId + '/children',
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
},
}, { children, index });
}
// ══════════════════════════════════════════════════════════
// Notion → 飞书 格式转换
// ══════════════════════════════════════════════════════════
function notionRichTextToFeishu(richTextArray) {
if (!richTextArray || richTextArray.length === 0) return [];
return richTextArray.map(rt => {
const elem = { content: rt.plain_text || '' };
if (rt.annotations) {
const style = {};
if (rt.annotations.bold) style.bold = true;
if (rt.annotations.italic) style.italic = true;
if (rt.annotations.strikethrough) style.strikethrough = true;
if (rt.annotations.underline) style.underline = true;
if (rt.annotations.code) style.inline_code = true;
if (Object.keys(style).length > 0) elem.text_element_style = style;
}
if (rt.href) {
elem.text_element_style = elem.text_element_style || {};
elem.text_element_style.link = { url: rt.href };
}
return { text_run: elem };
});
}
function notionBlockToFeishu(block) {
const type = block.type;
const data = block[type];
if (!data) return null;
switch (type) {
case 'paragraph':
return {
block_type: 2, // text
text: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
case 'heading_1':
return {
block_type: 4, // heading1
heading1: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
case 'heading_2':
return {
block_type: 5, // heading2
heading2: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
case 'heading_3':
return {
block_type: 6, // heading3
heading3: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
case 'bulleted_list_item':
return {
block_type: 12, // bullet
bullet: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
case 'numbered_list_item':
return {
block_type: 13, // ordered
ordered: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
case 'to_do':
return {
block_type: 14, // todo
todo: {
elements: notionRichTextToFeishu(data.rich_text),
style: { done: !!data.checked },
},
};
case 'code':
return {
block_type: 15, // code
code: {
elements: notionRichTextToFeishu(data.rich_text),
style: { language: 1 }, // plain text
},
};
case 'divider':
return {
block_type: 22, // horizontal_rule
horizontal_rule: {},
};
case 'quote':
return {
block_type: 19, // quote_container (simplified)
quote_container: {},
};
case 'callout':
return {
block_type: 2, // fallback to text
text: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
default:
// 不支持的块类型,转为文本
if (data.rich_text) {
return {
block_type: 2,
text: { elements: notionRichTextToFeishu(data.rich_text), style: {} },
};
}
return null;
}
}
// ══════════════════════════════════════════════════════════
// 主流程
// ══════════════════════════════════════════════════════════
async function main() {
const notionToken = process.env.NOTION_TOKEN;
const pageId = process.env.NOTION_LOGIN_PAGE_ID;
const feishuAppId = process.env.FEISHU_APP_ID;
const feishuSecret = process.env.FEISHU_APP_SECRET;
const docAId = process.env.FEISHU_DOC_A_ID;
if (!notionToken || !pageId || !feishuAppId || !feishuSecret || !docAId) {
console.error('❌ 缺少必要环境变量');
console.error(' 需要: NOTION_TOKEN, NOTION_LOGIN_PAGE_ID, FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_DOC_A_ID');
process.exit(1);
}
// 1. 读取 Notion 页面内容
console.log('📖 读取 Notion 登录入口页面...');
const blocks = await getNotionPageBlocks(pageId, notionToken);
console.log(' → 获取到 ' + blocks.length + ' 个内容块');
// 2. 转换为飞书文档格式
const feishuBlocks = blocks
.map(notionBlockToFeishu)
.filter(b => b !== null);
console.log(' → 转换为 ' + feishuBlocks.length + ' 个飞书内容块');
if (feishuBlocks.length === 0) {
console.log('⚠️ 无可转换内容,跳过同步');
process.exit(0);
}
// 3. 获取飞书 token
console.log('🔑 获取飞书 access token...');
const feishuToken = await getFeishuToken(feishuAppId, feishuSecret);
// 4. 清除飞书文档现有内容(全量替换)
console.log('🗑️ 清除飞书文档A现有内容...');
try {
const existing = await getFeishuDocBlocks(feishuToken, docAId);
const existingIds = (existing.data && existing.data.items || []).map(item => item.block_id);
if (existingIds.length > 0) {
await deleteFeishuDocBlocks(feishuToken, docAId, existingIds);
console.log(' → 已删除 ' + existingIds.length + ' 个现有块');
}
} catch (e) {
console.log('⚠️ 清除现有内容时出错: ' + e.message + '(继续执行)');
}
// 5. 写入新内容
console.log('✍️ 写入新内容到飞书文档A...');
await addFeishuDocBlocks(feishuToken, docAId, docAId, feishuBlocks, 0);
console.log('✅ 登录入口同步完成');
}
main().catch(err => {
console.error('❌ 同步失败: ' + err.message);
process.exit(1);
});

0
syslog/.gitkeep Normal file
View File