feat: Phase 4 — AI对话引擎 + 多轮上下文 + 协作数据采集 + 失败告警 + 幂等性

Phase 4A: backend/feishu-bot/ai-chat.js — DeepSeek/通义千问 AI 模型接入
Phase 4A: backend/feishu-bot/context-manager.js — 多轮对话上下文管理
Phase 4B: push-broadcast.yml — 飞书群新广播通知
Phase 4C: backend/feishu-bot/collaboration-logger.js — 协作数据采集
Phase 4C: scripts/save-collaboration-log.js — persona-brain-db 数据对齐导出
Phase 4D: 三条链路 workflow 失败告警 → 飞书通知
Phase 4D: receive-syslog.js — 幂等性保护(相同 SYSLOG 不重复创建工单)

Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-11 17:22:57 +00:00
parent 6d1c3e02eb
commit d0cc67953b
11 changed files with 1145 additions and 36 deletions

View File

@ -45,3 +45,69 @@ jobs:
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: |
if [ -z "$ALERT_CHAT_ID" ]; then
echo "⚠️ 未配置 FEISHU_ALERT_CHAT_ID跳过群通知"
exit 0
fi
node -e "
const https = require('https');
function post(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, 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 () => {
const tokenRes = JSON.parse(await post('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
{ app_id: process.env.FEISHU_APP_ID, app_secret: process.env.FEISHU_APP_SECRET }));
const token = tokenRes.tenant_access_token;
const payload = JSON.stringify({ text: '📡 新广播已推送到飞书文档B\n\n时间: ' + new Date().toISOString() + '\n请查看飞书广播收件箱。' });
await post('https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id',
JSON.stringify({ receive_id: process.env.ALERT_CHAT_ID, msg_type: 'text', content: payload }));
})().catch(e => console.error(e.message));
"
- 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: |
if [ -z "$ALERT_CHAT_ID" ]; then
echo "⚠️ 未配置 FEISHU_ALERT_CHAT_ID跳过告警"
exit 0
fi
node -e "
const https = require('https');
function post(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, 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 () => {
const tokenRes = JSON.parse(await post('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
{ app_id: process.env.FEISHU_APP_ID, app_secret: process.env.FEISHU_APP_SECRET }));
const token = tokenRes.tenant_access_token;
const payload = JSON.stringify({ text: '🔴 GitHub Action 失败告警\n\n工作流: push-broadcast\n时间: ' + new Date().toISOString() + '\n请检查 GitHub Actions 日志。' });
await post('https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id',
JSON.stringify({ receive_id: process.env.ALERT_CHAT_ID, msg_type: 'text', content: payload }));
})().catch(e => console.error(e.message));
"

View File

@ -51,3 +51,36 @@ jobs:
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: |
if [ -z "$ALERT_CHAT_ID" ]; then
echo "⚠️ 未配置 FEISHU_ALERT_CHAT_ID跳过告警"
exit 0
fi
node -e "
const https = require('https');
function post(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, 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 () => {
const tokenRes = JSON.parse(await post('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
{ app_id: process.env.FEISHU_APP_ID, app_secret: process.env.FEISHU_APP_SECRET }));
const token = tokenRes.tenant_access_token;
const payload = JSON.stringify({ text: '🔴 GitHub Action 失败告警\n\n工作流: receive-syslog\n时间: ' + new Date().toISOString() + '\n请检查 GitHub Actions 日志。' });
await post('https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id',
JSON.stringify({ receive_id: process.env.ALERT_CHAT_ID, msg_type: 'text', content: payload }));
})().catch(e => console.error(e.message));
"

View File

@ -39,3 +39,36 @@ jobs:
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: |
if [ -z "$ALERT_CHAT_ID" ]; then
echo "⚠️ 未配置 FEISHU_ALERT_CHAT_ID跳过告警"
exit 0
fi
node -e "
const https = require('https');
function post(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, 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 () => {
const tokenRes = JSON.parse(await post('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
{ app_id: process.env.FEISHU_APP_ID, app_secret: process.env.FEISHU_APP_SECRET }));
const token = tokenRes.tenant_access_token;
const payload = JSON.stringify({ text: '🔴 GitHub Action 失败告警\n\n工作流: sync-login-entry\n时间: ' + new Date().toISOString() + '\n请检查 GitHub Actions 日志。' });
await post('https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id',
JSON.stringify({ receive_id: process.env.ALERT_CHAT_ID, msg_type: 'text', content: payload }));
})().catch(e => console.error(e.message));
"

3
.gitignore vendored
View File

@ -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/

View File

@ -0,0 +1,213 @@
// backend/feishu-bot/ai-chat.js
// 铸渊 · 飞书机器人 AI 对话引擎
//
// 接入 DeepSeek / 通义千问 等大模型 API
// 使用霜砚登录入口文档A内容作为 system prompt
//
// 环境变量:
// MODEL_API_KEY 模型 API KeyDeepSeek / 通义千问)
// 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 模型未配置(缺少 MODEL_API_KEY请联系管理员。';
}
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 模型暂时不可用HTTP ' + result.statusCode + '),请稍后重试。';
}
return '⚠️ AI 模型调用异常: ' + (result.data.error?.message || JSON.stringify(result.data));
} 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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -1,16 +1,22 @@
// backend/routes/feishu-bot.js
// 铸渊 · 飞书机器人事件回调处理
//
// SYSLOG 回传桥:开发者发 SYSLOG → 飞书机器人 → GitHub repository_dispatch
// 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可选用于安全校验
// 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 KeyPhase 4A
// MODEL_API_BASE AI 模型 API Base URLPhase 4A
// MODEL_NAME AI 模型名称Phase 4A
// FEISHU_ALERT_CHAT_ID 失败告警推送的飞书群 chat_idPhase 4D
'use strict';
@ -18,16 +24,25 @@ const express = require('express');
const https = require('https');
const router = express.Router();
const FEISHU_APP_ID = process.env.FEISHU_APP_ID;
const FEISHU_APP_SECRET = process.env.FEISHU_APP_SECRET;
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const 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;
// ══════════════════════════════════════════════════════════
// 工具函数
// ══════════════════════════════════════════════════════════
@ -52,6 +67,9 @@ function httpsRequest(options, body) {
});
});
req.on('error', reject);
req.setTimeout(60000, () => {
req.destroy(new Error('Request timeout'));
});
if (payload) req.write(payload);
req.end();
});
@ -84,7 +102,24 @@ async function replyFeishuMessage(token, messageId, content) {
});
}
async function triggerGitHubDispatch(syslog) {
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,
@ -97,22 +132,36 @@ async function triggerGitHubDispatch(syslog) {
'Content-Type': 'application/json',
},
}, {
event_type: 'receive-syslog',
client_payload: {
syslog: syslog,
dev_id: syslog.dev_id || syslog.developer_id || 'UNKNOWN',
broadcast_id: syslog.broadcast_id || syslog.broadcastId || 'UNKNOWN',
},
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) {
// 尝试从消息中提取 JSON
// 支持:纯 JSON、代码块包裹的 JSON、混合文本中的 JSON
if (!text || typeof text !== 'string') return null;
// 1. 尝试直接解析整个文本
@ -161,14 +210,14 @@ function validateSyslog(syslog) {
if (!syslog.dev_id && !syslog.developer_id) {
return '缺少 dev_id 或 developer_id 字段';
}
return null; // 验证通过
return null;
}
// ══════════════════════════════════════════════════════════
// 路由处理
// ══════════════════════════════════════════════════════════
// 飞书事件回调 URL 验证(首次配置时飞书会发送 challenge
// 飞书事件回调 URL 验证 + 消息处理
router.post('/event', async (req, res) => {
const body = req.body;
@ -177,7 +226,7 @@ router.post('/event', async (req, res) => {
return res.json({ challenge: body.challenge });
}
// 2. 验证 token(如果配置了 VERIFICATION_TOKEN
// 2. 验证 token
if (VERIFICATION_TOKEN && body.token && body.token !== VERIFICATION_TOKEN) {
return res.status(403).json({ error: true, message: '验证 token 不匹配' });
}
@ -186,26 +235,30 @@ router.post('/event', async (req, res) => {
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(err => {
console.error('❌ 消息处理失败:', err.message);
});
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;
@ -218,37 +271,74 @@ async function processMessage(event) {
return;
}
// 提取 SYSLOG
const syslog = extractSyslogFromMessage(textContent);
if (!syslog) return; // 不是 SYSLOG 消息,忽略
// 验证 SYSLOG
const validationError = validateSyslog(syslog);
if (!textContent.trim()) return;
// 获取飞书 token 用于回复
let feishuToken;
try {
feishuToken = await getFeishuToken();
} catch (e) {
console.error('❌ 获取飞书 token 失败:', e.message);
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;
}
// 触发 GitHub repository_dispatch
if (!GITHUB_TOKEN) {
await replyFeishuMessage(feishuToken, messageId,
'⚠️ 系统配置缺失 (GITHUB_TOKEN),请联系管理员。');
return;
}
// Phase 4C: 记录 SYSLOG 协作数据
collaborationLogger.logSyslogCollaboration(syslog);
try {
const result = await triggerGitHubDispatch(syslog);
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 || '无';
@ -267,14 +357,89 @@ async function processMessage(event) {
}
}
// 健康检查
// ══════════════════════════════════════════════════════════
// 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,
});
});

View File

View File

@ -135,9 +135,15 @@ function saveSyslogToFile(syslog) {
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 };
return { filepath, dateStr, broadcastId, devId, duplicate: false };
}
// ══════════════════════════════════════════════════════════
@ -268,7 +274,14 @@ async function main() {
// 步骤①:存入仓库
console.log('💾 步骤①: 存入仓库 syslog/ 目录...');
const { dateStr } = saveSyslogToFile(syslog);
const { dateStr, duplicate } = saveSyslogToFile(syslog);
// 幂等性:重复的 SYSLOG 不再创建 Notion 工单
if (duplicate) {
console.log('⚠️ 相同 SYSLOG 已存在,跳过 Notion 操作(幂等保护)');
console.log('✅ SYSLOG 接收处理完成(幂等跳过)');
return;
}
// 步骤②:创建 Notion SYSLOG 收件箱条目
if (notionToken && syslogDbId) {

View File

@ -0,0 +1,148 @@
// 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++;
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();