guanghulab/backend/routes/feishu-bot.js
2026-05-10 13:12:44 +08:00

513 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 KeyPhase 4A
// MODEL_API_BASE AI 模型 API Base URLPhase 4A
// MODEL_NAME AI 模型名称Phase 4A
// FEISHU_ALERT_CHAT_ID 失败告警推送的飞书群 chat_idPhase 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 });
}
});
// ══════════════════════════════════════════════════════════
// 广播推送Pipeline C 调用)
// ══════════════════════════════════════════════════════════
/**
* 接收 Pipeline C 的广播推送请求,转发到飞书聊天窗口
* POST /push-broadcast (通过 server.js 映射到 /webhook/push-broadcast)
*/
router.post('/push-broadcast', async (req, res) => {
// 1. 验证 token
const token = req.headers['x-push-token'];
if (!process.env.PUSH_BROADCAST_TOKEN || token !== process.env.PUSH_BROADCAST_TOKEN) {
return res.status(401).json({ error: true, code: 'INVALID_TOKEN', message: 'Invalid push token' });
}
// 2. 解析请求体
const { chat_id, sender_open_id, broadcast_title, broadcast_content, broadcast_url } = req.body;
if (!broadcast_content && !broadcast_title) {
return res.status(400).json({ error: true, code: 'MISSING_CONTENT', message: 'Missing broadcast_title or broadcast_content' });
}
const receive_id = chat_id || sender_open_id;
if (!receive_id) {
return res.status(400).json({ error: true, code: 'MISSING_TARGET', message: 'Missing chat_id or sender_open_id' });
}
try {
// 3. 获取飞书 token
const feishuToken = await getFeishuToken();
// 4. 构造消息文本
const urlLine = broadcast_url ? '\n🔗 ' + broadcast_url : '';
const text = '📡 新广播 ' + (broadcast_title || '') + '\n\n' + (broadcast_content || '') + urlLine;
// 5. 发送飞书消息(优先 chat_id备选 open_id
const receive_id_type = chat_id ? 'chat_id' : 'open_id';
const msgResult = await httpsRequest({
hostname: 'open.feishu.cn',
port: 443,
path: '/open-apis/im/v1/messages?receive_id_type=' + receive_id_type,
method: 'POST',
headers: {
'Authorization': 'Bearer ' + feishuToken,
'Content-Type': 'application/json',
},
}, {
receive_id: receive_id,
msg_type: 'text',
content: JSON.stringify({ text }),
});
if (msgResult.data && msgResult.data.code === 0) {
console.log('✅ 广播推送成功: ' + broadcast_title + ' → ' + receive_id);
res.json({ success: true, message_id: msgResult.data.data && msgResult.data.data.message_id });
} else {
console.error('❌ 飞书发送失败:', JSON.stringify(msgResult.data));
res.status(500).json({ error: true, code: 'FEISHU_API_ERROR', message: (msgResult.data && msgResult.data.msg) || 'Feishu API error' });
}
} catch (err) {
console.error('❌ push-broadcast error:', err.message);
res.status(500).json({ error: true, code: 'PUSH_ERROR', message: err.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,
push_broadcast: !!process.env.PUSH_BROADCAST_TOKEN,
},
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;