diff --git a/.github/workflows/push-broadcast.yml b/.github/workflows/push-broadcast.yml new file mode 100644 index 00000000..60c15117 --- /dev/null +++ b/.github/workflows/push-broadcast.yml @@ -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 diff --git a/.github/workflows/receive-syslog.yml b/.github/workflows/receive-syslog.yml new file mode 100644 index 00000000..bad68db7 --- /dev/null +++ b/.github/workflows/receive-syslog.yml @@ -0,0 +1,53 @@ +name: 铸渊 · Receive SYSLOG · 飞书机器人 → GitHub → Notion + +# 链路3:SYSLOG 回传 +# 飞书机器人发来的 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 diff --git a/.github/workflows/sync-login-entry.yml b/.github/workflows/sync-login-entry.yml new file mode 100644 index 00000000..86ea60d3 --- /dev/null +++ b/.github/workflows/sync-login-entry.yml @@ -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 diff --git a/backend/routes/feishu-bot.js b/backend/routes/feishu-bot.js new file mode 100644 index 00000000..3c61fdeb --- /dev/null +++ b/backend/routes/feishu-bot.js @@ -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; diff --git a/backend/server.js b/backend/server.js index 251eb6af..3637c1fd 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 }); } diff --git a/scripts/push-broadcast.js b/scripts/push-broadcast.js new file mode 100644 index 00000000..9ef84a84 --- /dev/null +++ b/scripts/push-broadcast.js @@ -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); +}); diff --git a/scripts/receive-syslog.js b/scripts/receive-syslog.js new file mode 100644 index 00000000..8764ba6e --- /dev/null +++ b/scripts/receive-syslog.js @@ -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); +}); diff --git a/scripts/sync-login-entry.js b/scripts/sync-login-entry.js new file mode 100644 index 00000000..9a5ad232 --- /dev/null +++ b/scripts/sync-login-entry.js @@ -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); +}); diff --git a/syslog/.gitkeep b/syslog/.gitkeep new file mode 100644 index 00000000..e69de29b