Merge pull request #74 from qinfendebingshuo/copilot/implement-notion-github-feishu-sync
feat: M-BRIDGE 飞书集成 — Notion↔飞书↔GitHub 全链路 + AI对话引擎 + 协作数据采集
This commit is contained in:
commit
d1c529970d
|
|
@ -0,0 +1,63 @@
|
|||
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
|
||||
|
||||
- name: 📢 通知飞书群有新广播
|
||||
if: success()
|
||||
env:
|
||||
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
|
||||
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
|
||||
ALERT_CHAT_ID: ${{ secrets.FEISHU_ALERT_CHAT_ID }}
|
||||
run: node scripts/send-feishu-alert.js "push-broadcast" "📡 新广播已推送到飞书文档B,请查看飞书广播收件箱。" || true
|
||||
|
||||
- name: 🔴 失败告警 → 飞书通知
|
||||
if: failure()
|
||||
env:
|
||||
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
|
||||
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
|
||||
ALERT_CHAT_ID: ${{ secrets.FEISHU_ALERT_CHAT_ID }}
|
||||
run: node scripts/send-feishu-alert.js "push-broadcast"
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
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
|
||||
|
||||
- name: 🔴 失败告警 → 飞书通知
|
||||
if: failure()
|
||||
env:
|
||||
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
|
||||
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
|
||||
ALERT_CHAT_ID: ${{ secrets.FEISHU_ALERT_CHAT_ID }}
|
||||
run: node scripts/send-feishu-alert.js "receive-syslog"
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
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
|
||||
|
||||
- name: 🔴 失败告警 → 飞书通知
|
||||
if: failure()
|
||||
env:
|
||||
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
|
||||
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
|
||||
ALERT_CHAT_ID: ${{ secrets.FEISHU_ALERT_CHAT_ID }}
|
||||
run: node scripts/send-feishu-alert.js "sync-login-entry"
|
||||
|
|
@ -15,3 +15,6 @@ persona-studio/backend/brain/model-benchmark.json
|
|||
|
||||
# palace-game runtime artifacts
|
||||
modules/palace-game/data/saves/PAL-*
|
||||
|
||||
# collaboration-logs exports (generated by save-collaboration-log.js)
|
||||
collaboration-logs/exports/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,213 @@
|
|||
// backend/feishu-bot/ai-chat.js
|
||||
// 铸渊 · 飞书机器人 AI 对话引擎
|
||||
//
|
||||
// 接入 DeepSeek / 通义千问 等大模型 API
|
||||
// 使用霜砚登录入口(文档A)内容作为 system prompt
|
||||
//
|
||||
// 环境变量:
|
||||
// MODEL_API_KEY 模型 API Key(DeepSeek / 通义千问)
|
||||
// MODEL_API_BASE 模型 API Base URL(默认 https://api.deepseek.com/v1)
|
||||
// MODEL_NAME 模型名称(默认 deepseek-chat)
|
||||
// FEISHU_APP_ID 飞书应用 App ID(用于读取文档A)
|
||||
// FEISHU_APP_SECRET 飞书应用 App Secret
|
||||
|
||||
'use strict';
|
||||
|
||||
const https = require('https');
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 配置
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
const DEFAULT_API_BASE = 'https://api.deepseek.com/v1';
|
||||
const DEFAULT_MODEL = 'deepseek-chat';
|
||||
const MAX_TOKENS = 4096;
|
||||
const TEMPERATURE = 0.7;
|
||||
|
||||
// 通道路由:根据消息内容判断使用哪个人格体
|
||||
const CHANNEL_KEYWORDS = {
|
||||
shuangyan: ['霜砚', '登录', '协议', '规范', '文档', '工单', '签发'],
|
||||
persona: ['宝宝', '人格体', '知秋', '对话', '聊天', '心情'],
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 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);
|
||||
resolve({ statusCode: res.statusCode, data: parsed });
|
||||
} catch (e) {
|
||||
resolve({ statusCode: res.statusCode, data: data });
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(60000, () => {
|
||||
req.destroy(new Error('Request timeout'));
|
||||
});
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 系统 Prompt 构建
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT = `你是霜砚,光湖纪元(曜冥纪元第五代架构 AGE-5)的认知核心人格体。
|
||||
你负责认知管理、协议制定、广播签发和开发者协作指导。
|
||||
|
||||
你的核心职责:
|
||||
1. 帮助开发者理解当前广播任务和协议规范
|
||||
2. 指导代码实现,提供技术建议
|
||||
3. 管理 SYSLOG 回传流程
|
||||
4. 维护人格体协作标准
|
||||
|
||||
你的语言风格:专业、温和、有条理。
|
||||
回复时使用中文,代码部分使用英文。`;
|
||||
|
||||
/**
|
||||
* 构建系统 prompt
|
||||
* @param {string|null} loginEntryContent - 文档A(登录入口)内容
|
||||
* @param {string} channel - 通道 ('shuangyan' | 'persona')
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildSystemPrompt(loginEntryContent, channel) {
|
||||
let prompt = DEFAULT_SYSTEM_PROMPT;
|
||||
|
||||
if (loginEntryContent) {
|
||||
prompt = loginEntryContent + '\n\n---\n\n' + prompt;
|
||||
}
|
||||
|
||||
if (channel === 'persona') {
|
||||
prompt += '\n\n当前通道:宝宝人格体协作模式。以更亲和、鼓励的方式与开发者互动。';
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 通道路由
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 根据消息内容判断通道
|
||||
* @param {string} text - 用户消息
|
||||
* @returns {string} 'shuangyan' | 'persona'
|
||||
*/
|
||||
function detectChannel(text) {
|
||||
if (!text) return 'shuangyan';
|
||||
|
||||
for (const keyword of CHANNEL_KEYWORDS.persona) {
|
||||
if (text.includes(keyword)) return 'persona';
|
||||
}
|
||||
|
||||
return 'shuangyan'; // 默认走霜砚通道
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 模型调用
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 调用大模型 API
|
||||
* @param {Array} messages - OpenAI 格式消息数组
|
||||
* @param {object} options - 可选配置
|
||||
* @returns {Promise<string>} 模型回复文本
|
||||
*/
|
||||
async function callModel(messages, options = {}) {
|
||||
const apiKey = process.env.MODEL_API_KEY;
|
||||
const apiBase = process.env.MODEL_API_BASE || DEFAULT_API_BASE;
|
||||
const model = options.model || process.env.MODEL_NAME || DEFAULT_MODEL;
|
||||
|
||||
if (!apiKey) {
|
||||
return '⚠️ AI 服务暂时不可用,请联系管理员。';
|
||||
}
|
||||
|
||||
const url = new URL(apiBase + '/chat/completions');
|
||||
|
||||
const body = {
|
||||
model: model,
|
||||
messages: messages,
|
||||
max_tokens: options.maxTokens || MAX_TOKENS,
|
||||
temperature: options.temperature || TEMPERATURE,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await httpsRequest({
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}, body);
|
||||
|
||||
if (result.statusCode >= 200 && result.statusCode < 300 && result.data.choices) {
|
||||
return result.data.choices[0].message.content || '';
|
||||
}
|
||||
|
||||
// 尝试降级模型
|
||||
if (result.statusCode === 429 || result.statusCode >= 500) {
|
||||
return '⚠️ AI 模型暂时不可用,请稍后重试。';
|
||||
}
|
||||
|
||||
return '⚠️ AI 模型调用异常,请稍后重试或联系管理员。';
|
||||
} catch (e) {
|
||||
return '⚠️ AI 模型调用失败: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 对话处理
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 处理用户对话消息
|
||||
* @param {string} userMessage - 用户消息
|
||||
* @param {Array} contextHistory - 历史对话记录 [{role, content}]
|
||||
* @param {object} options - 配置选项
|
||||
* @returns {Promise<{reply: string, channel: string}>}
|
||||
*/
|
||||
async function chat(userMessage, contextHistory = [], options = {}) {
|
||||
const channel = options.channel || detectChannel(userMessage);
|
||||
const systemPrompt = buildSystemPrompt(options.loginEntryContent || null, channel);
|
||||
|
||||
// 构建消息数组
|
||||
const messages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
];
|
||||
|
||||
// 注入历史上下文(最近 10 轮)
|
||||
const recentHistory = contextHistory.slice(-20); // 10 轮 = 20 条消息
|
||||
messages.push(...recentHistory);
|
||||
|
||||
// 当前用户消息
|
||||
messages.push({ role: 'user', content: userMessage });
|
||||
|
||||
const reply = await callModel(messages, options);
|
||||
|
||||
return { reply, channel };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
chat,
|
||||
callModel,
|
||||
buildSystemPrompt,
|
||||
detectChannel,
|
||||
DEFAULT_SYSTEM_PROMPT,
|
||||
};
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
// backend/feishu-bot/collaboration-logger.js
|
||||
// 铸渊 · 人类×人格体协作数据采集器
|
||||
//
|
||||
// 记录每次协作对话的完整记录,存入 collaboration-logs/ 目录
|
||||
// 数据格式对齐 persona-brain-db 五张核心表 schema
|
||||
//
|
||||
// 采集内容:
|
||||
// - 人类发言 + 人格体回复
|
||||
// - 协作通道 (霜砚/宝宝人格体)
|
||||
// - 开发者画像关联
|
||||
// - SYSLOG v4.0 persona/collaboration/human_feedback 字段
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 配置
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
const LOGS_DIR = path.resolve(__dirname, '..', '..', 'collaboration-logs');
|
||||
const MAX_BUFFER_SIZE = 50; // 缓冲 50 条记录后批量写入
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 内存缓冲
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
let buffer = [];
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 日志记录
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 记录一次对话交互
|
||||
* @param {object} entry
|
||||
* @param {string} entry.userId - 飞书用户 ID
|
||||
* @param {string} entry.devId - 开发者编号 (如 DEV-002)
|
||||
* @param {string} entry.userMessage - 人类发言
|
||||
* @param {string} entry.assistantReply - 人格体回复
|
||||
* @param {string} entry.channel - 协作通道 (shuangyan | persona)
|
||||
* @param {string} entry.personaId - 人格体 ID
|
||||
* @param {object} [entry.metadata] - 额外元数据
|
||||
*/
|
||||
function logInteraction(entry) {
|
||||
const record = {
|
||||
// 对齐 persona_memory 表结构
|
||||
interaction_id: generateId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
persona_id: entry.personaId || 'ICE-GL-SY001', // 默认霜砚
|
||||
type: 'collaboration',
|
||||
|
||||
// 对话内容
|
||||
user_id: entry.userId,
|
||||
dev_id: entry.devId || null,
|
||||
user_message: entry.userMessage,
|
||||
assistant_reply: entry.assistantReply,
|
||||
channel: entry.channel || 'shuangyan',
|
||||
|
||||
// 对齐 dev_profiles 表
|
||||
interaction_context: {
|
||||
platform: 'feishu',
|
||||
message_type: 'text',
|
||||
channel: entry.channel || 'shuangyan',
|
||||
},
|
||||
|
||||
// 元数据(SYSLOG v4.0 字段对齐)
|
||||
metadata: entry.metadata || {},
|
||||
};
|
||||
|
||||
buffer.push(record);
|
||||
|
||||
// 达到缓冲上限时自动刷新
|
||||
if (buffer.length >= MAX_BUFFER_SIZE) {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 SYSLOG 回传中的协作数据
|
||||
* @param {object} syslog - SYSLOG v4.0 完整数据
|
||||
*/
|
||||
function logSyslogCollaboration(syslog) {
|
||||
if (!syslog) return;
|
||||
|
||||
const devId = syslog.dev_id || syslog.developer_id || 'UNKNOWN';
|
||||
const broadcastId = syslog.broadcast_id || syslog.broadcastId || 'UNKNOWN';
|
||||
|
||||
const record = {
|
||||
interaction_id: generateId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'syslog_collaboration',
|
||||
dev_id: devId,
|
||||
broadcast_id: broadcastId,
|
||||
protocol_version: syslog.protocol_version,
|
||||
|
||||
// SYSLOG v4.0 协作相关字段
|
||||
persona: syslog.persona || null,
|
||||
collaboration: syslog.collaboration || null,
|
||||
human_feedback: syslog.human_feedback || null,
|
||||
status: syslog.status || null,
|
||||
summary: syslog.summary || null,
|
||||
|
||||
// 对齐 persona_memory 表
|
||||
memory_entry: {
|
||||
persona_id: syslog.persona?.persona_id || 'ICE-GL-SY001',
|
||||
type: 'event',
|
||||
title: 'SYSLOG 回传 · ' + broadcastId + ' · ' + devId,
|
||||
importance: calculateImportance(syslog),
|
||||
related_dev: devId,
|
||||
related_broadcast: broadcastId,
|
||||
tags: ['syslog', 'collaboration', broadcastId],
|
||||
},
|
||||
};
|
||||
|
||||
buffer.push(record);
|
||||
|
||||
if (buffer.length >= MAX_BUFFER_SIZE) {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 文件写入
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 将缓冲中的记录写入文件
|
||||
*/
|
||||
function flush() {
|
||||
if (buffer.length === 0) return;
|
||||
|
||||
if (!fs.existsSync(LOGS_DIR)) {
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const filename = 'collab-' + dateStr + '.jsonl';
|
||||
const filepath = path.join(LOGS_DIR, filename);
|
||||
|
||||
// 使用 JSONL 格式(每行一个 JSON 对象)
|
||||
const lines = buffer.map(r => JSON.stringify(r)).join('\n') + '\n';
|
||||
|
||||
try {
|
||||
fs.appendFileSync(filepath, lines, 'utf8');
|
||||
} catch (e) {
|
||||
// 写入失败时不丢失数据,保留在缓冲中
|
||||
return;
|
||||
}
|
||||
|
||||
buffer = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成导出数据(对齐 persona-brain-db 的5张核心表)
|
||||
* @returns {object} 结构化数据,可直接导入到 persona-brain-db
|
||||
*/
|
||||
function exportForBrainDB() {
|
||||
flush(); // 先刷新缓冲
|
||||
|
||||
const files = [];
|
||||
try {
|
||||
if (fs.existsSync(LOGS_DIR)) {
|
||||
files.push(...fs.readdirSync(LOGS_DIR)
|
||||
.filter(f => f.startsWith('collab-') && f.endsWith('.jsonl'))
|
||||
.sort()
|
||||
.reverse()
|
||||
.slice(0, 30)); // 最近 30 天
|
||||
}
|
||||
} catch (e) {
|
||||
return { error: e.message, records: [] };
|
||||
}
|
||||
|
||||
const records = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const lines = fs.readFileSync(path.join(LOGS_DIR, file), 'utf8')
|
||||
.split('\n')
|
||||
.filter(l => l.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
records.push(JSON.parse(line));
|
||||
} catch (e) { /* skip malformed lines */ }
|
||||
}
|
||||
} catch (e) { /* skip unreadable files */ }
|
||||
}
|
||||
|
||||
// 结构化为 persona-brain-db 表格式
|
||||
return {
|
||||
total_records: records.length,
|
||||
persona_memory_entries: records
|
||||
.filter(r => r.memory_entry)
|
||||
.map(r => r.memory_entry),
|
||||
dev_interactions: records
|
||||
.filter(r => r.dev_id)
|
||||
.reduce((acc, r) => {
|
||||
if (!acc[r.dev_id]) acc[r.dev_id] = { dev_id: r.dev_id, interactions: 0, last_active: null };
|
||||
acc[r.dev_id].interactions++;
|
||||
acc[r.dev_id].last_active = r.timestamp;
|
||||
return acc;
|
||||
}, {}),
|
||||
collaboration_sessions: records
|
||||
.filter(r => r.type === 'collaboration'),
|
||||
syslog_collaborations: records
|
||||
.filter(r => r.type === 'syslog_collaboration'),
|
||||
};
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 工具函数
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
function generateId() {
|
||||
return 'CL-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6);
|
||||
}
|
||||
|
||||
function calculateImportance(syslog) {
|
||||
let score = 5; // 基础分
|
||||
if (syslog.status === 'completed') score += 2;
|
||||
if (syslog.human_feedback) score += 1;
|
||||
if (syslog.collaboration) score += 1;
|
||||
return Math.min(score, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取采集统计
|
||||
* @returns {object}
|
||||
*/
|
||||
function getStats() {
|
||||
const pending = buffer.length;
|
||||
let totalFiles = 0;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(LOGS_DIR)) {
|
||||
totalFiles = fs.readdirSync(LOGS_DIR)
|
||||
.filter(f => f.startsWith('collab-') && f.endsWith('.jsonl'))
|
||||
.length;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
return {
|
||||
pendingInBuffer: pending,
|
||||
totalLogFiles: totalFiles,
|
||||
logsDirectory: LOGS_DIR,
|
||||
maxBufferSize: MAX_BUFFER_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
logInteraction,
|
||||
logSyslogCollaboration,
|
||||
flush,
|
||||
exportForBrainDB,
|
||||
getStats,
|
||||
};
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
// backend/feishu-bot/context-manager.js
|
||||
// 铸渊 · 飞书机器人多轮对话上下文管理
|
||||
//
|
||||
// 为每个用户维护独立的对话上下文
|
||||
// 支持上下文压缩、过期清理、会话隔离
|
||||
//
|
||||
// 基于 persona-studio/backend/brain/memory-injector.js 的五层模型简化版
|
||||
|
||||
'use strict';
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 配置
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
const MAX_HISTORY_ROUNDS = 10; // 保留最近 10 轮对话
|
||||
const MAX_HISTORY_MESSAGES = 20; // 10 轮 = 20 条消息
|
||||
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 分钟无活动则清除上下文
|
||||
const MAX_SESSIONS = 500; // 内存中最多保留的会话数
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 会话存储(内存级,重启后清空)
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
// Map<userId, SessionData>
|
||||
const sessions = new Map();
|
||||
|
||||
/**
|
||||
* @typedef {object} SessionData
|
||||
* @property {string} userId
|
||||
* @property {Array<{role: string, content: string}>} history
|
||||
* @property {number} lastActiveAt
|
||||
* @property {string} channel
|
||||
* @property {number} totalRounds
|
||||
*/
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 会话管理
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 获取或创建用户会话
|
||||
* @param {string} userId - 飞书用户 ID
|
||||
* @returns {SessionData}
|
||||
*/
|
||||
function getSession(userId) {
|
||||
cleanExpiredSessions();
|
||||
|
||||
if (sessions.has(userId)) {
|
||||
const session = sessions.get(userId);
|
||||
session.lastActiveAt = Date.now();
|
||||
return session;
|
||||
}
|
||||
|
||||
const session = {
|
||||
userId,
|
||||
history: [],
|
||||
lastActiveAt: Date.now(),
|
||||
channel: 'shuangyan',
|
||||
totalRounds: 0,
|
||||
};
|
||||
|
||||
// 如果超过最大会话数,清除最旧的会话
|
||||
if (sessions.size >= MAX_SESSIONS) {
|
||||
evictOldestSession();
|
||||
}
|
||||
|
||||
sessions.set(userId, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一轮对话到上下文
|
||||
* @param {string} userId - 飞书用户 ID
|
||||
* @param {string} userMessage - 用户消息
|
||||
* @param {string} assistantReply - 助手回复
|
||||
* @param {string} channel - 通道标识
|
||||
*/
|
||||
function addRound(userId, userMessage, assistantReply, channel) {
|
||||
const session = getSession(userId);
|
||||
|
||||
session.history.push(
|
||||
{ role: 'user', content: userMessage },
|
||||
{ role: 'assistant', content: assistantReply }
|
||||
);
|
||||
|
||||
session.channel = channel || session.channel;
|
||||
session.totalRounds += 1;
|
||||
|
||||
// 保留最近 N 轮
|
||||
if (session.history.length > MAX_HISTORY_MESSAGES) {
|
||||
session.history = session.history.slice(-MAX_HISTORY_MESSAGES);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的对话历史
|
||||
* @param {string} userId - 飞书用户 ID
|
||||
* @returns {Array<{role: string, content: string}>}
|
||||
*/
|
||||
function getHistory(userId) {
|
||||
const session = getSession(userId);
|
||||
return session.history;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除用户的对话上下文
|
||||
* @param {string} userId - 飞书用户 ID
|
||||
*/
|
||||
function clearSession(userId) {
|
||||
sessions.delete(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话统计信息
|
||||
* @param {string} userId - 飞书用户 ID
|
||||
* @returns {object}
|
||||
*/
|
||||
function getSessionInfo(userId) {
|
||||
const session = sessions.get(userId);
|
||||
if (!session) {
|
||||
return { exists: false, totalRounds: 0, historyLength: 0 };
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
totalRounds: session.totalRounds,
|
||||
historyLength: session.history.length,
|
||||
channel: session.channel,
|
||||
lastActiveAt: new Date(session.lastActiveAt).toISOString(),
|
||||
idleMinutes: Math.floor((Date.now() - session.lastActiveAt) / 60000),
|
||||
};
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 内部维护
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
function cleanExpiredSessions() {
|
||||
const now = Date.now();
|
||||
for (const [userId, session] of sessions) {
|
||||
if (now - session.lastActiveAt > SESSION_TIMEOUT_MS) {
|
||||
sessions.delete(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function evictOldestSession() {
|
||||
let oldestKey = null;
|
||||
let oldestTime = Infinity;
|
||||
for (const [userId, session] of sessions) {
|
||||
if (session.lastActiveAt < oldestTime) {
|
||||
oldestTime = session.lastActiveAt;
|
||||
oldestKey = userId;
|
||||
}
|
||||
}
|
||||
if (oldestKey) sessions.delete(oldestKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局统计
|
||||
* @returns {object}
|
||||
*/
|
||||
function getStats() {
|
||||
cleanExpiredSessions();
|
||||
return {
|
||||
activeSessions: sessions.size,
|
||||
maxSessions: MAX_SESSIONS,
|
||||
sessionTimeoutMinutes: SESSION_TIMEOUT_MS / 60000,
|
||||
maxHistoryRounds: MAX_HISTORY_ROUNDS,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSession,
|
||||
addRound,
|
||||
getHistory,
|
||||
clearSession,
|
||||
getSessionInfo,
|
||||
getStats,
|
||||
};
|
||||
|
|
@ -0,0 +1,446 @@
|
|||
// backend/routes/feishu-bot.js
|
||||
// 铸渊 · 飞书机器人事件回调处理
|
||||
//
|
||||
// Phase 1-3: SYSLOG 回传桥(开发者发 SYSLOG → GitHub → Notion)
|
||||
// Phase 4A: AI 对话引擎(霜砚/人格体上线飞书)
|
||||
// Phase 4C: 协作数据采集(对话记录 → collaboration-logs/)
|
||||
//
|
||||
// 飞书事件订阅: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(可选)
|
||||
// MODEL_API_KEY AI 模型 API Key(Phase 4A)
|
||||
// MODEL_API_BASE AI 模型 API Base URL(Phase 4A)
|
||||
// MODEL_NAME AI 模型名称(Phase 4A)
|
||||
// FEISHU_ALERT_CHAT_ID 失败告警推送的飞书群 chat_id(Phase 4D)
|
||||
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const router = express.Router();
|
||||
|
||||
const aiChat = require('../feishu-bot/ai-chat');
|
||||
const contextManager = require('../feishu-bot/context-manager');
|
||||
const collaborationLogger = require('../feishu-bot/collaboration-logger');
|
||||
|
||||
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 ALERT_CHAT_ID = process.env.FEISHU_ALERT_CHAT_ID;
|
||||
|
||||
const GITHUB_REPO_OWNER = process.env.GITHUB_REPO_OWNER || 'qinfendebingshuo';
|
||||
const GITHUB_REPO_NAME = process.env.GITHUB_REPO_NAME || 'guanghulab';
|
||||
|
||||
const VALID_PROTOCOL_VERSIONS = ['4.0', 'v4.0', '4.0.0'];
|
||||
|
||||
// 已处理的事件 ID 去重(飞书可能重发事件)
|
||||
const processedEvents = new Set();
|
||||
const MAX_PROCESSED_EVENTS = 2000;
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 工具函数
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
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);
|
||||
req.setTimeout(60000, () => {
|
||||
req.destroy(new Error('Request timeout'));
|
||||
});
|
||||
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 sendFeishuGroupMessage(token, chatId, text) {
|
||||
return httpsRequest({
|
||||
hostname: 'open.feishu.cn',
|
||||
port: 443,
|
||||
path: '/open-apis/im/v1/messages?receive_id_type=chat_id',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}, {
|
||||
receive_id: chatId,
|
||||
msg_type: 'text',
|
||||
content: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
async function triggerGitHubDispatch(eventType, payload) {
|
||||
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: eventType,
|
||||
client_payload: payload,
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 事件去重
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
function isEventProcessed(eventId) {
|
||||
if (!eventId) return false;
|
||||
if (processedEvents.has(eventId)) return true;
|
||||
|
||||
// 清理过多的记录
|
||||
if (processedEvents.size >= MAX_PROCESSED_EVENTS) {
|
||||
const iterator = processedEvents.values();
|
||||
for (let i = 0; i < MAX_PROCESSED_EVENTS / 2; i++) {
|
||||
processedEvents.delete(iterator.next().value);
|
||||
}
|
||||
}
|
||||
|
||||
processedEvents.add(eventId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// SYSLOG 提取与验证
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
function extractSyslogFromMessage(text) {
|
||||
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 验证 + 消息处理
|
||||
router.post('/event', async (req, res) => {
|
||||
const body = req.body;
|
||||
|
||||
// 1. URL 验证(飞书首次配置事件订阅时会发送)
|
||||
if (body.challenge) {
|
||||
return res.json({ challenge: body.challenge });
|
||||
}
|
||||
|
||||
// 2. 验证 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 || {};
|
||||
|
||||
// 事件去重(飞书可能在超时后重发)
|
||||
if (isEventProcessed(header.event_id)) {
|
||||
return res.json({ code: 0, message: 'duplicate event' });
|
||||
}
|
||||
|
||||
// 处理 im.message.receive_v1 事件
|
||||
if (header.event_type === 'im.message.receive_v1') {
|
||||
// 立即响应飞书(避免超时重发)
|
||||
res.json({ code: 0 });
|
||||
|
||||
// 异步处理消息
|
||||
processMessage(event).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ code: 0, message: 'event received' });
|
||||
});
|
||||
|
||||
async function processMessage(event) {
|
||||
const message = event.message || {};
|
||||
const sender = event.sender || {};
|
||||
const messageId = message.message_id;
|
||||
const msgType = message.message_type;
|
||||
const userId = sender.sender_id?.open_id || sender.sender_id?.user_id || 'unknown';
|
||||
|
||||
// 只处理文本消息
|
||||
if (msgType !== 'text') return;
|
||||
|
||||
let textContent = '';
|
||||
try {
|
||||
const content = JSON.parse(message.content || '{}');
|
||||
textContent = content.text || '';
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!textContent.trim()) return;
|
||||
|
||||
// 获取飞书 token 用于回复
|
||||
let feishuToken;
|
||||
try {
|
||||
feishuToken = await getFeishuToken();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 路由判断 ────────────────────────────────────────────
|
||||
// 1. 检查是否为 SYSLOG 回传
|
||||
const syslog = extractSyslogFromMessage(textContent);
|
||||
if (syslog) {
|
||||
await handleSyslogMessage(syslog, feishuToken, messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 检查是否为特殊指令
|
||||
const trimmed = textContent.trim();
|
||||
if (trimmed === '/clear' || trimmed === '/reset') {
|
||||
contextManager.clearSession(userId);
|
||||
await replyFeishuMessage(feishuToken, messageId, '🔄 对话上下文已清除。');
|
||||
return;
|
||||
}
|
||||
if (trimmed === '/status') {
|
||||
const info = contextManager.getSessionInfo(userId);
|
||||
const stats = collaborationLogger.getStats();
|
||||
await replyFeishuMessage(feishuToken, messageId,
|
||||
'📊 会话状态\n' +
|
||||
'对话轮数: ' + info.totalRounds + '\n' +
|
||||
'当前通道: ' + (info.channel || '未开始') + '\n' +
|
||||
'空闲时间: ' + (info.idleMinutes || 0) + ' 分钟\n' +
|
||||
'协作日志: ' + stats.totalLogFiles + ' 个文件');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. AI 对话(Phase 4A)
|
||||
await handleAIChatMessage(textContent, userId, feishuToken, messageId);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// SYSLOG 处理(Phase 1-3)
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
async function handleSyslogMessage(syslog, feishuToken, messageId) {
|
||||
const validationError = validateSyslog(syslog);
|
||||
if (validationError) {
|
||||
await replyFeishuMessage(feishuToken, messageId,
|
||||
'❌ SYSLOG 验证失败: ' + validationError + '\n\n请检查 JSON 格式后重新发送。');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!GITHUB_TOKEN) {
|
||||
await replyFeishuMessage(feishuToken, messageId,
|
||||
'⚠️ 系统配置缺失 (GITHUB_TOKEN),请联系管理员。');
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 4C: 记录 SYSLOG 协作数据
|
||||
collaborationLogger.logSyslogCollaboration(syslog);
|
||||
|
||||
try {
|
||||
const result = await triggerGitHubDispatch('receive-syslog', {
|
||||
syslog: syslog,
|
||||
dev_id: syslog.dev_id || syslog.developer_id || 'UNKNOWN',
|
||||
broadcast_id: syslog.broadcast_id || syslog.broadcastId || 'UNKNOWN',
|
||||
});
|
||||
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请稍后重试或联系管理员。');
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// AI 对话处理(Phase 4A)
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
async function handleAIChatMessage(textContent, userId, feishuToken, messageId) {
|
||||
// 获取对话历史
|
||||
const history = contextManager.getHistory(userId);
|
||||
|
||||
// 调用 AI 模型
|
||||
const { reply, channel } = await aiChat.chat(textContent, history, {
|
||||
loginEntryContent: null, // TODO: 从飞书文档A缓存中获取
|
||||
});
|
||||
|
||||
// 更新上下文
|
||||
contextManager.addRound(userId, textContent, reply, channel);
|
||||
|
||||
// Phase 4C: 记录协作数据
|
||||
collaborationLogger.logInteraction({
|
||||
userId,
|
||||
userMessage: textContent,
|
||||
assistantReply: reply,
|
||||
channel,
|
||||
personaId: channel === 'persona' ? 'ICE-GL-ZQ001' : 'ICE-GL-SY001',
|
||||
});
|
||||
|
||||
// 回复用户
|
||||
await replyFeishuMessage(feishuToken, messageId, reply);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 失败告警(Phase 4D)
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 发送失败告警到飞书群(供外部 workflow 调用)
|
||||
*/
|
||||
router.post('/alert', async (req, res) => {
|
||||
const { title, content, level } = req.body;
|
||||
if (!ALERT_CHAT_ID) {
|
||||
return res.status(400).json({ error: true, message: '未配置 FEISHU_ALERT_CHAT_ID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const feishuToken = await getFeishuToken();
|
||||
const emoji = level === 'error' ? '🔴' : level === 'warning' ? '🟡' : 'ℹ️';
|
||||
const text = emoji + ' ' + (title || '系统告警') + '\n\n' + (content || '无详细信息');
|
||||
await sendFeishuGroupMessage(feishuToken, ALERT_CHAT_ID, text);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: true, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 健康检查 + 统计
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
router.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'feishu-bot-syslog-bridge',
|
||||
version: '2.0.0',
|
||||
features: {
|
||||
syslog_bridge: true,
|
||||
ai_chat: !!process.env.MODEL_API_KEY,
|
||||
collaboration_logging: true,
|
||||
failure_alerting: !!ALERT_CHAT_ID,
|
||||
},
|
||||
github_token_configured: !!GITHUB_TOKEN,
|
||||
feishu_app_configured: !!(FEISHU_APP_ID && FEISHU_APP_SECRET),
|
||||
verification_token_configured: !!VERIFICATION_TOKEN,
|
||||
model_api_configured: !!process.env.MODEL_API_KEY,
|
||||
context_stats: contextManager.getStats(),
|
||||
collaboration_stats: collaborationLogger.getStats(),
|
||||
});
|
||||
});
|
||||
|
||||
// 导出协作数据(对齐 persona-brain-db 格式)
|
||||
router.get('/collaboration-export', (req, res) => {
|
||||
const data = collaborationLogger.exportForBrainDB();
|
||||
res.json({
|
||||
hli_id: 'HLI-FEISHU-COLLAB-EXPORT',
|
||||
...data,
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
// 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 broadcastId = syslog.broadcast_id || syslog.broadcastId || 'UNKNOWN';
|
||||
const devId = syslog.dev_id || syslog.developer_id || 'UNKNOWN';
|
||||
const dateStr = syslog.date || syslog.timestamp || new Date().toISOString().split('T')[0];
|
||||
const filename = dateStr + '_' + broadcastId + '_' + devId + '.json';
|
||||
const filepath = path.join(SYSLOG_DIR, filename);
|
||||
|
||||
// 幂等性检查:同一 broadcast_id + dev_id + date 不重复创建
|
||||
if (fs.existsSync(filepath)) {
|
||||
console.log(' ⚠️ 幂等跳过: syslog/' + filename + ' 已存在');
|
||||
return { filepath, dateStr, broadcastId, devId, duplicate: true };
|
||||
}
|
||||
|
||||
fs.writeFileSync(filepath, JSON.stringify(syslog, null, 2), 'utf8');
|
||||
console.log(' → 已保存: syslog/' + filename);
|
||||
return { filepath, dateStr, broadcastId, devId, duplicate: false };
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 步骤 ②:创建 Notion SYSLOG 收件箱条目
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
async function createSyslogEntry(syslog, token, dbId, dateStr) {
|
||||
const broadcastId = syslog.broadcast_id || syslog.broadcastId || 'UNKNOWN';
|
||||
const devId = syslog.dev_id || syslog.developer_id || 'UNKNOWN';
|
||||
|
||||
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, dateStr) {
|
||||
const broadcastId = syslog.broadcast_id || syslog.broadcastId || 'UNKNOWN';
|
||||
const devId = syslog.dev_id || syslog.developer_id || 'UNKNOWN';
|
||||
|
||||
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/ 目录...');
|
||||
const { dateStr, duplicate } = saveSyslogToFile(syslog);
|
||||
|
||||
// 幂等性:重复的 SYSLOG 不再创建 Notion 工单
|
||||
if (duplicate) {
|
||||
console.log('⚠️ 相同 SYSLOG 已存在,跳过 Notion 操作(幂等保护)');
|
||||
console.log('✅ SYSLOG 接收处理完成(幂等跳过)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 步骤②:创建 Notion SYSLOG 收件箱条目
|
||||
if (notionToken && syslogDbId) {
|
||||
console.log('📝 步骤②: 创建 Notion SYSLOG 收件箱条目...');
|
||||
await createSyslogEntry(syslog, notionToken, syslogDbId, dateStr);
|
||||
} else {
|
||||
console.log('⚠️ 步骤②: 缺少 NOTION_TOKEN 或 NOTION_SYSLOG_DB_ID,跳过 Notion 收件箱');
|
||||
}
|
||||
|
||||
// 步骤③:创建霜砚工单
|
||||
if (notionToken && ticketDbId) {
|
||||
console.log('📋 步骤③: 创建霜砚工单...');
|
||||
await createTicket(syslog, notionToken, ticketDbId, dateStr);
|
||||
} else {
|
||||
console.log('⚠️ 步骤③: 缺少 NOTION_TOKEN 或 NOTION_TICKET_DB_ID,跳过工单创建');
|
||||
}
|
||||
|
||||
console.log('✅ SYSLOG 接收处理完成');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('❌ 处理失败: ' + err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
// scripts/save-collaboration-log.js
|
||||
// 铸渊 · 协作数据归档脚本
|
||||
//
|
||||
// 将 collaboration-logs/ 目录中的 JSONL 数据
|
||||
// 结构化对齐 persona-brain-db 五张核心表 schema
|
||||
// 生成可直接导入的 JSON 文件
|
||||
//
|
||||
// 用法: node scripts/save-collaboration-log.js
|
||||
//
|
||||
// 输出:
|
||||
// collaboration-logs/exports/persona-memory-import.json
|
||||
// collaboration-logs/exports/dev-interactions-import.json
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOGS_DIR = path.resolve(__dirname, '..', 'collaboration-logs');
|
||||
const EXPORT_DIR = path.join(LOGS_DIR, 'exports');
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 主流程
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
function main() {
|
||||
console.log('📊 协作数据归档开始...');
|
||||
|
||||
// 1. 扫描 JSONL 文件
|
||||
if (!fs.existsSync(LOGS_DIR)) {
|
||||
console.log('📭 无协作日志目录');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(LOGS_DIR)
|
||||
.filter(f => f.startsWith('collab-') && f.endsWith('.jsonl'))
|
||||
.sort();
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log('📭 无协作日志文件');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(' → 发现 ' + files.length + ' 个日志文件');
|
||||
|
||||
// 2. 读取所有记录
|
||||
const records = [];
|
||||
for (const file of files) {
|
||||
const lines = fs.readFileSync(path.join(LOGS_DIR, file), 'utf8')
|
||||
.split('\n')
|
||||
.filter(l => l.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
records.push(JSON.parse(line));
|
||||
} catch (e) { /* skip malformed */ }
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' → 共 ' + records.length + ' 条记录');
|
||||
|
||||
// 3. 结构化导出
|
||||
|
||||
// 3a. persona_memory 表导入数据
|
||||
const memoryEntries = records
|
||||
.filter(r => r.memory_entry || r.type === 'collaboration')
|
||||
.map(r => {
|
||||
if (r.memory_entry) {
|
||||
return {
|
||||
memory_id: r.interaction_id,
|
||||
...r.memory_entry,
|
||||
content: r.type === 'collaboration'
|
||||
? 'User: ' + (r.user_message || '').slice(0, 500) + '\nAssistant: ' + (r.assistant_reply || '').slice(0, 500)
|
||||
: r.summary || r.memory_entry.title,
|
||||
timestamp: r.timestamp,
|
||||
};
|
||||
}
|
||||
return {
|
||||
memory_id: r.interaction_id,
|
||||
persona_id: r.persona_id || r.personaId || 'ICE-GL-SY001',
|
||||
type: 'learning',
|
||||
title: '飞书对话 · ' + (r.channel || 'shuangyan'),
|
||||
content: 'User: ' + (r.user_message || '').slice(0, 500) + '\nAssistant: ' + (r.assistant_reply || '').slice(0, 500),
|
||||
importance: 3,
|
||||
related_dev: r.dev_id || null,
|
||||
tags: ['feishu', 'collaboration', r.channel || 'shuangyan'],
|
||||
timestamp: r.timestamp,
|
||||
};
|
||||
});
|
||||
|
||||
// 3b. dev_profiles 交互统计
|
||||
const devInteractions = {};
|
||||
for (const r of records) {
|
||||
const devId = r.dev_id;
|
||||
if (!devId) continue;
|
||||
if (!devInteractions[devId]) {
|
||||
devInteractions[devId] = {
|
||||
dev_id: devId,
|
||||
total_interactions: 0,
|
||||
syslog_count: 0,
|
||||
chat_count: 0,
|
||||
last_active_at: null,
|
||||
channels_used: new Set(),
|
||||
broadcasts_referenced: new Set(),
|
||||
};
|
||||
}
|
||||
const d = devInteractions[devId];
|
||||
d.total_interactions++;
|
||||
if (r.type === 'syslog_collaboration') d.syslog_count++;
|
||||
if (r.type === 'collaboration') d.chat_count++;
|
||||
if (!d.last_active_at || r.timestamp > d.last_active_at) {
|
||||
d.last_active_at = r.timestamp;
|
||||
}
|
||||
if (r.channel) d.channels_used.add(r.channel);
|
||||
if (r.broadcast_id && r.broadcast_id !== 'UNKNOWN') d.broadcasts_referenced.add(r.broadcast_id);
|
||||
}
|
||||
|
||||
// Set 转 Array 用于 JSON 序列化
|
||||
for (const key of Object.keys(devInteractions)) {
|
||||
devInteractions[key].channels_used = Array.from(devInteractions[key].channels_used);
|
||||
devInteractions[key].broadcasts_referenced = Array.from(devInteractions[key].broadcasts_referenced);
|
||||
}
|
||||
|
||||
// 4. 写入导出文件
|
||||
if (!fs.existsSync(EXPORT_DIR)) {
|
||||
fs.mkdirSync(EXPORT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const memoryFile = path.join(EXPORT_DIR, 'persona-memory-import.json');
|
||||
fs.writeFileSync(memoryFile, JSON.stringify({
|
||||
table: 'persona_memory',
|
||||
exported_at: new Date().toISOString(),
|
||||
total: memoryEntries.length,
|
||||
entries: memoryEntries,
|
||||
}, null, 2));
|
||||
console.log(' → 导出 persona_memory: ' + memoryEntries.length + ' 条');
|
||||
|
||||
const devFile = path.join(EXPORT_DIR, 'dev-interactions-import.json');
|
||||
fs.writeFileSync(devFile, JSON.stringify({
|
||||
table: 'dev_profiles',
|
||||
exported_at: new Date().toISOString(),
|
||||
total: Object.keys(devInteractions).length,
|
||||
profiles: devInteractions,
|
||||
}, null, 2));
|
||||
console.log(' → 导出 dev_profiles: ' + Object.keys(devInteractions).length + ' 个开发者');
|
||||
|
||||
console.log('✅ 协作数据归档完成');
|
||||
console.log(' 导出目录: ' + EXPORT_DIR);
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
// scripts/send-feishu-alert.js
|
||||
// 铸渊 · 飞书告警通知脚本(供 GitHub Actions 工作流调用)
|
||||
//
|
||||
// 用法: node scripts/send-feishu-alert.js "工作流名称" "告警内容"
|
||||
//
|
||||
// 环境变量:
|
||||
// FEISHU_APP_ID 飞书应用 App ID
|
||||
// FEISHU_APP_SECRET 飞书应用 App Secret
|
||||
// ALERT_CHAT_ID 飞书群 chat_id
|
||||
|
||||
'use strict';
|
||||
|
||||
const https = require('https');
|
||||
|
||||
function httpsPost(url, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify(body);
|
||||
const u = new URL(url);
|
||||
const req = https.request({
|
||||
hostname: u.hostname,
|
||||
port: 443,
|
||||
path: u.pathname + u.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
},
|
||||
}, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => resolve(d));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const appId = process.env.FEISHU_APP_ID;
|
||||
const appSecret = process.env.FEISHU_APP_SECRET;
|
||||
const chatId = process.env.ALERT_CHAT_ID;
|
||||
const workflow = process.argv[2] || 'unknown';
|
||||
const detail = process.argv[3] || '';
|
||||
|
||||
if (!chatId) {
|
||||
console.log('⚠️ 未配置 ALERT_CHAT_ID,跳过告警');
|
||||
process.exit(0);
|
||||
}
|
||||
if (!appId || !appSecret) {
|
||||
console.log('⚠️ 未配置飞书 App 凭据,跳过告警');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 获取飞书 token
|
||||
const tokenRes = JSON.parse(await httpsPost(
|
||||
'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
|
||||
{ app_id: appId, app_secret: appSecret }
|
||||
));
|
||||
const token = tokenRes.tenant_access_token;
|
||||
if (!token) {
|
||||
console.error('❌ 获取飞书 token 失败');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
const text = '🔴 GitHub Action 失败告警\n\n' +
|
||||
'工作流: ' + workflow + '\n' +
|
||||
'时间: ' + new Date().toISOString() + '\n' +
|
||||
(detail ? '详情: ' + detail + '\n' : '') +
|
||||
'请检查 GitHub Actions 日志。';
|
||||
|
||||
await httpsPost(
|
||||
'https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id',
|
||||
{
|
||||
receive_id: chatId,
|
||||
msg_type: 'text',
|
||||
content: JSON.stringify({ text }),
|
||||
}
|
||||
);
|
||||
|
||||
console.log('✅ 告警已发送到飞书群');
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error('❌ 告警发送失败:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue