diff --git a/.github/workflows/push-broadcast.yml b/.github/workflows/push-broadcast.yml new file mode 100644 index 00000000..2299c537 --- /dev/null +++ b/.github/workflows/push-broadcast.yml @@ -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" diff --git a/.github/workflows/receive-syslog.yml b/.github/workflows/receive-syslog.yml new file mode 100644 index 00000000..0e4bc779 --- /dev/null +++ b/.github/workflows/receive-syslog.yml @@ -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" diff --git a/.github/workflows/sync-login-entry.yml b/.github/workflows/sync-login-entry.yml new file mode 100644 index 00000000..02db3b42 --- /dev/null +++ b/.github/workflows/sync-login-entry.yml @@ -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" diff --git a/.gitignore b/.gitignore index 97624956..28f589d3 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/backend/feishu-bot/ai-chat.js b/backend/feishu-bot/ai-chat.js new file mode 100644 index 00000000..7061aaa9 --- /dev/null +++ b/backend/feishu-bot/ai-chat.js @@ -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} 模型回复文本 + */ +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, +}; diff --git a/backend/feishu-bot/collaboration-logger.js b/backend/feishu-bot/collaboration-logger.js new file mode 100644 index 00000000..a84eaf35 --- /dev/null +++ b/backend/feishu-bot/collaboration-logger.js @@ -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, +}; diff --git a/backend/feishu-bot/context-manager.js b/backend/feishu-bot/context-manager.js new file mode 100644 index 00000000..53159aa1 --- /dev/null +++ b/backend/feishu-bot/context-manager.js @@ -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 +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, +}; diff --git a/backend/routes/feishu-bot.js b/backend/routes/feishu-bot.js new file mode 100644 index 00000000..5936123a --- /dev/null +++ b/backend/routes/feishu-bot.js @@ -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; 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/collaboration-logs/.gitkeep b/collaboration-logs/.gitkeep new file mode 100644 index 00000000..e69de29b 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..dd136852 --- /dev/null +++ b/scripts/receive-syslog.js @@ -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); +}); diff --git a/scripts/save-collaboration-log.js b/scripts/save-collaboration-log.js new file mode 100644 index 00000000..cde23392 --- /dev/null +++ b/scripts/save-collaboration-log.js @@ -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(); diff --git a/scripts/send-feishu-alert.js b/scripts/send-feishu-alert.js new file mode 100644 index 00000000..e4c97410 --- /dev/null +++ b/scripts/send-feishu-alert.js @@ -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); +}); 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