v2 版本 - 飞书文件上传功能修复完成

主要修复:
1. 使用飞书 SDK im.messageResource.get API 下载文件(和 OpenClaw 一致)
2. 修复 sendMessage 方法,自动判断 receive_id_type(oc_=chat_id, ou_=open_id)
3. 修复 sendCard 方法,传递正确的 receive_id_type 参数
4. 修复事件类型识别,支持飞书 v2 schema(header.event_type)
5. 添加临时文件清理机制(每小时清理 1 小时前的文件)
6. 完善卡片交互(确认上传/取消按钮)
7. 完善错误处理和日志记录

功能:
-  飞书文件接收和卡片回复
-  卡片交互(确认/取消)
-  七牛云上传(支持多存储桶)
-  CDN 自动刷新
-  临时文件自动清理
-  配置管理命令(/config)

配置文件:
- config/qiniu-config.json - 七牛云配置
- .env - 飞书应用配置
This commit is contained in:
饭团
2026-03-06 08:38:52 +08:00
parent 3769d164b1
commit f7776aaf69
3 changed files with 369 additions and 305 deletions

18
pm2.config.cjs Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
apps: [{
name: 'qiniu-bot',
script: './src/index.js',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '500M',
env: {
NODE_ENV: 'production',
PORT: 3030
},
error_file: './logs/error.log',
out_file: './logs/out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
merge_logs: true
}]
};

View File

@@ -1,11 +1,18 @@
/**
* 飞书 API 封装
* 飞书 API 封装 v2 - 使用飞书 SDK
*/
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const https = require('https');
const lark = require('@larksuiteoapi/node-sdk');
// 日志函数
function log(...args) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [FeishuAPI]`, ...args);
}
class FeishuAPI {
constructor() {
@@ -14,6 +21,12 @@ class FeishuAPI {
this.baseURL = 'https://open.feishu.cn/open-apis';
this.tokenCache = null;
this.tokenExpiry = 0;
// 创建飞书 SDK 客户端
this.client = new lark.Client({
appId: this.appId,
appSecret: this.appSecret
});
}
// 获取访问令牌
@@ -50,16 +63,26 @@ class FeishuAPI {
}
// 发送文本消息
async sendMessage(chatId, payload) {
async sendMessage(chatId, payload, receiveIdType = 'chat_id') {
const token = await this.getAccessToken();
try {
// 判断 receive_id_typeoc_开头的是 chat_idou_开头的是 open_id
let idType = receiveIdType;
if (chatId.startsWith('oc_')) {
idType = 'chat_id';
} else if (chatId.startsWith('ou_')) {
idType = 'open_id';
}
log('发送消息到 chatId:', chatId, 'receive_id_type:', idType, 'msg_type:', payload.msg_type);
const response = await axios.post(
`${this.baseURL}/im/v1/messages`,
`${this.baseURL}/im/v1/messages?receive_id_type=${idType}`,
{
receive_id: chatId,
msg_type: payload.msg_type,
content: payload.content
content: typeof payload.content === 'string' ? payload.content : JSON.stringify(payload.content)
},
{
headers: {
@@ -69,56 +92,63 @@ class FeishuAPI {
}
);
log('消息发送响应:', response.data.code, response.data.msg || 'ok');
if (response.data.code !== 0) {
throw new Error(`发送消息失败:${response.data.msg}`);
}
return response.data;
} catch (error) {
log('消息发送错误:', error.message);
throw new Error(`飞书消息发送失败:${error.message}`);
}
}
// 发送卡片消息
async sendCard(chatId, card) {
// 判断 receive_id_type
const receiveIdType = chatId.startsWith('oc_') ? 'chat_id' : 'open_id';
return this.sendMessage(chatId, {
msg_type: 'interactive',
content: JSON.stringify(card)
});
}, receiveIdType);
}
// 下载文件
async downloadFile(fileKey) {
const token = await this.getAccessToken();
// 下载文件 - 使用飞书 SDK 的 messageResource APIOpenClaw 使用的方式)
async downloadFile(fileKey, messageId, chatId) {
const tempDir = path.join(process.cwd(), 'temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const tempFile = path.join(tempDir, `feishu_${Date.now()}_${fileKey}`);
const tempFile = path.join(tempDir, `feishu_${Date.now()}_${path.basename(fileKey)}`);
return new Promise((resolve, reject) => {
const url = `${this.baseURL}/im/v1/files/${fileKey}/download`;
log('下载文件:', fileKey, 'messageId:', messageId);
try {
// 使用 SDK 的 im.messageResource.get 方法
const result = await this.client.im.messageResource.get({
path: {
message_id: messageId,
file_key: fileKey
},
params: {
type: 'file'
}
});
https.get(url, {
headers: {
'Authorization': `Bearer ${token}`
}
}, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`下载失败:${res.statusCode}`));
return;
}
// SDK 返回一个带有 writeFile 方法的对象
await result.writeFile(tempFile);
log('文件下载完成:', tempFile, fs.statSync(tempFile).size, 'bytes');
return tempFile;
const file = fs.createWriteStream(tempFile);
res.pipe(file);
file.on('finish', () => {
file.close();
resolve(tempFile);
});
}).on('error', reject);
});
} catch (error) {
log('下载错误:', error.message);
throw new Error(`飞书文件下载失败:${error.message}`);
}
}
// 回复消息

View File

@@ -1,14 +1,7 @@
#!/usr/bin/env node
/**
* 七牛云上传 - 飞书独立应用
*
* 功能:
* 1. 监听飞书消息事件
* 2. 支持交互式卡片上传
* 3. 支持命令触发上传
* 4. 配置管理
* 5. 支持 HTTP 回调和 WebSocket 长连接两种模式
* 七牛云上传 - 飞书独立应用 v2
*/
require('dotenv').config();
@@ -19,223 +12,260 @@ const fs = require('fs');
const { FeishuAPI } = require('./feishu-api');
const { QiniuUploader } = require('./qiniu-uploader');
const { UploadCard } = require('./cards/upload-card');
const { ConfigCard } = require('./cards/config-card');
// 飞书 SDKWebSocket 模式)
const { Api, eventSubscription } = require('@larksuiteoapi/node-sdk');
const app = express();
const PORT = process.env.PORT || 3030;
// 运行模式:'http' 或 'websocket'
const MODE = (process.env.FEISHU_MODE || 'http').toLowerCase();
// 中间件
app.use(express.json());
// 日志
function log(...args) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}]`, ...args);
}
// ============ 飞书事件处理 ============
async function handleFeishuEvent(req, res) {
const event = req.body;
const headers = req.headers;
log('📩 收到飞书请求');
log('收到飞书事件:', event.type);
let decryptedEvent = event;
if (event.encrypt) {
try {
const { decrypt } = require('@larksuiteoapi/node-sdk');
decryptedEvent = decrypt(event.encrypt, process.env.FEISHU_ENCRYPT_KEY);
} catch (e) {
log('❌ 解密失败:', e.message);
res.status(500).send('Decrypt error');
return;
}
}
// URL 验证
if (event.type === 'url_verification') {
log('✅ URL 验证请求');
res.json({ challenge: event.challenge });
// 飞书 v2 schema: event_type 在 header 中
const eventType = decryptedEvent.event_type ||
decryptedEvent.header?.event_type ||
decryptedEvent.type;
log('收到飞书事件:', eventType);
log('事件结构:', JSON.stringify({
has_event_type: !!decryptedEvent.event_type,
has_header: !!decryptedEvent.header,
header_event_type: decryptedEvent.header?.event_type,
has_type: !!decryptedEvent.type
}).substring(0, 300));
if (eventType === 'url_verification') {
res.json({ challenge: decryptedEvent.challenge || event.challenge });
return;
}
// 验证签名
const timestamp = headers['x-feishu-request-timestamp'];
const nonce = headers['x-feishu-request-nonce'];
const signature = headers['x-feishu-request-signature'];
if (!verifySignature(timestamp, nonce, signature)) {
log('❌ 签名验证失败');
res.status(401).send('Invalid signature');
return;
if (eventType === 'im.message.receive_v1') {
await handleMessage(decryptedEvent);
}
// 处理消息事件
if (event.type === 'im.message.receive_v1') {
await handleMessage(event);
if (eventType === 'card.action.trigger') {
await handleCardInteraction(decryptedEvent);
res.status(200).json({});
return;
}
res.status(200).send('OK');
}
function verifySignature(timestamp, nonce, signature) {
const encryptKey = process.env.FEISHU_ENCRYPT_KEY;
if (!encryptKey) return true;
const arr = [encryptKey, timestamp, nonce];
arr.sort();
const str = arr.join('');
const hash = crypto.createHash('sha1').update(str).digest('hex');
return hash === signature;
}
// ============ 消息处理 ============
async function handleMessage(event) {
try {
const message = event.message;
const content = JSON.parse(message.content);
const text = content.text || '';
const chatId = message.chat_id;
const senderId = message.sender?.sender_id?.user_id || message.sender?.sender_id?.open_id;
log(`处理消息:${chatId} - ${text.substring(0, 50)}`);
// 初始化 API
const feishu = new FeishuAPI();
const uploader = new QiniuUploader();
// 卡片交互回调
if (event.type === 'im.message.receive_v1' && content.interaction?.type) {
await handleCardInteraction(event, feishu, uploader);
const messageData = event.event?.message || event.message;
if (!messageData) {
log('❌ 未找到消息数据');
return;
}
// 命令处理
const messageContent = JSON.parse(messageData.content);
const text = messageContent.text || '';
const chatId = messageData.chat_id;
const messageType = messageData.message_type || 'text';
log(`处理消息:${chatId} - 类型:${messageType}`);
const feishu = new FeishuAPI();
const uploader = new QiniuUploader();
if (text.startsWith('/upload') || text.startsWith('/u ')) {
await handleUploadCommand(message, content, feishu, uploader);
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: '📎 请发送要上传的文件(直接发送文件即可)' }
});
} else if (text.startsWith('/config') || text.startsWith('/qc ')) {
await handleConfigCommand(message, content, feishu, uploader);
await handleConfigCommandV2(messageData, messageContent, feishu, uploader);
} else if (text.startsWith('/help') || text.startsWith('/qh')) {
await handleHelpCommand(message, feishu);
await handleHelpCommandV2(chatId, feishu);
} else if (messageType === 'file' || messageContent.file_key) {
log('🔍 收到文件消息 - 发送确认卡片');
await handleFileReceivedWithCard(messageData, feishu, uploader);
} else {
// 默认回复交互卡片
await sendWelcomeCard(chatId, feishu);
}
} catch (error) {
log('❌ 消息处理失败:', error.message);
}
}
async function handleCardInteraction(event, feishu, uploader) {
const interaction = event.message.content.interaction;
const chatId = event.message.chat_id;
const action = interaction.value?.action;
log('卡片交互:', action);
switch (action) {
case 'upload_file':
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: '📎 请发送要上传的文件,我会自动处理~' }
});
break;
case 'config':
const configData = await uploader.listConfig();
const configCard = ConfigCard.create(configData);
await feishu.sendCard(chatId, configCard);
break;
case 'help':
await handleHelpCommand(event.message, feishu);
break;
}
}
async function handleUploadCommand(message, content, feishu, uploader) {
const chatId = message.chat_id;
const attachments = message.attachments || [];
// 解析命令参数
const text = content.text || '';
const args = text.replace(/^\/(upload|u)\s*/i, '').trim().split(/\s+/);
let targetPath = null;
let useOriginal = false;
let bucket = 'default';
for (const arg of args) {
if (arg === '--original') useOriginal = true;
else if (arg.startsWith('/') || arg.includes('.')) targetPath = arg;
else bucket = arg;
}
if (attachments.length === 0) {
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: {
text: '❌ 请附上要上传的文件\n\n💡 使用示例:\n/upload /config/test/file.txt default\n[附上文件]'
}
});
return;
}
const attachment = attachments[0];
const fileKey = attachment.file_key;
const fileName = attachment.file_name;
async function handleCardInteraction(event) {
try {
// 下载文件
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `📥 正在下载:${fileName}` }
});
const eventData = event.event;
const operator = eventData?.operator;
const actionData = eventData?.action;
const message = eventData?.message;
const tempFile = await feishu.downloadFile(fileKey);
// 确定目标路径
let key = targetPath;
if (!key || useOriginal) {
key = fileName;
} else if (key.startsWith('/')) {
key = key.substring(1);
if (!actionData) {
log('❌ 卡片交互:缺少 action 数据');
return;
}
// 上传到七牛云
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `📤 上传中:${key}${bucket}` }
});
const action = actionData.value?.action;
let chatId = message?.chat_id || actionData.value?.chat_id;
const result = await uploader.upload(tempFile, key, bucket);
if (!chatId && operator?.open_id) {
chatId = operator.open_id;
log('使用 operator open_id 作为 chatId:', chatId);
}
// 刷新 CDN
await uploader.refreshCDN(bucket, key);
log('卡片交互:', action, 'chatId:', chatId);
// 回复结果
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: {
text: `✅ 上传成功!\n\n` +
`📦 文件:${key}\n` +
`🔗 链接:${result.url}\n` +
`💾 原文件:${fileName}\n` +
`🪣 存储桶:${bucket}`
const feishu = new FeishuAPI();
const uploader = new QiniuUploader();
switch (action) {
case 'confirm_upload': {
const { file_key, file_name, message_id } = actionData.value;
log('📤 开始上传文件:', file_name, 'file_key:', file_key);
if (!chatId) {
log('❌ 缺少 chatId');
return;
}
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `📥 正在下载:${file_name}` }
});
try {
const tempFile = await feishu.downloadFile(file_key, message_id, chatId);
log('✅ 文件下载完成:', tempFile);
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `📤 上传中:${file_name} → default` }
});
const result = await uploader.upload(tempFile, file_name, 'default');
await uploader.refreshCDN('default', file_name);
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: {
text: `✅ 上传成功!\n\n` +
`📦 文件:${file_name}\n` +
`🔗 链接:${result.url}\n` +
`🪣 存储桶default`
}
});
fs.unlinkSync(tempFile);
log('🗑️ 临时文件已清理:', tempFile);
} catch (error) {
log('上传失败:', error.message);
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `❌ 上传失败:${error.message}` }
});
}
break;
}
});
// 清理临时文件
fs.unlinkSync(tempFile);
case 'cancel_upload':
log('取消上传chatId:', chatId);
if (chatId) {
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: '❌ 已取消上传' }
});
log('✅ 取消回复已发送');
}
break;
case 'config': {
const configData = await uploader.listConfig();
const configCard = createConfigCard(configData);
await feishu.sendCard(chatId, configCard);
break;
}
case 'help':
await handleHelpCommandV2(chatId, feishu);
break;
}
} catch (error) {
log('上传失败:', error.message);
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `❌ 上传失败:${error.message}` }
});
log('❌ 卡片交互处理失败:', error.message);
}
}
async function handleConfigCommand(message, content, feishu, uploader) {
async function handleFileReceivedWithCard(messageData, feishu, uploader) {
const chatId = messageData.chat_id;
const messageId = messageData.message_id;
const messageContent = JSON.parse(messageData.content);
const fileKey = messageContent.file_key;
const fileName = messageContent.file_name;
if (!fileKey) return;
log('📎 收到文件,发送确认卡片:', fileName);
const card = {
config: { wide_screen_mode: true },
header: {
template: 'blue',
title: { content: '📎 文件上传确认', tag: 'plain_text' }
},
elements: [
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**文件名:** ${fileName}\n**存储桶:** default\n\n点击"确认上传"将文件上传到七牛云`
}
},
{
tag: 'action',
actions: [
{
tag: 'button',
text: { tag: 'plain_text', content: '✅ 确认上传' },
type: 'primary',
value: {
action: 'confirm_upload',
file_key: fileKey,
file_name: fileName,
message_id: messageId,
chat_id: chatId
}
},
{
tag: 'button',
text: { tag: 'plain_text', content: '❌ 取消' },
type: 'default',
value: { action: 'cancel_upload' }
}
]
}
]
};
log('发送卡片到 chatId:', chatId);
await feishu.sendCard(chatId, card);
}
async function handleConfigCommandV2(message, content, feishu, uploader) {
const chatId = message.chat_id;
const text = content.text || '';
const args = text.replace(/^\/(config|qc)\s*/i, '').trim().split(/\s+/);
@@ -244,13 +274,11 @@ async function handleConfigCommand(message, content, feishu, uploader) {
try {
if (subCommand === 'list' || !subCommand) {
const configData = await uploader.listConfig();
const configCard = ConfigCard.create(configData);
const configCard = createConfigCard(configData);
await feishu.sendCard(chatId, configCard);
} else if (subCommand === 'set') {
const [keyPath, value] = args.slice(1);
if (!keyPath || !value) {
throw new Error('用法:/config set <key> <value>');
}
if (!keyPath || !value) throw new Error('用法:/config set <key> <value>');
await uploader.setConfigValue(keyPath, value);
await feishu.sendMessage(chatId, {
msg_type: 'text',
@@ -267,128 +295,116 @@ async function handleConfigCommand(message, content, feishu, uploader) {
}
}
async function handleHelpCommand(message, feishu) {
const helpText = `
async function handleHelpCommandV2(chatId, feishu) {
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `
🍙 七牛云上传 - 使用帮助
📤 上传文件:
/upload [目标路径] [存储桶名]
/upload --original [存储桶名]
示例:
/upload /config/test/file.txt default
/upload --original default
1. 直接发送文件给我
2. 点击确认卡片上的"✅ 确认上传"
3. 等待上传完成,获取下载链接
⚙️ 配置管理:
/config list # 查看配置
/config set <key> <value> # 修改配置
/config list - 查看配置
/config set <key> <value> - 修改配置
💡 提示
- 直接发送文件给我也会收到上传卡片
💡 提示:
- 支持多存储桶配置
- 上传同名文件会自动覆盖
`;
await feishu.sendMessage(message.chat_id, {
msg_type: 'text',
content: { text: helpText }
` }
});
}
async function sendWelcomeCard(chatId, feishu) {
const card = UploadCard.create();
const card = {
config: { wide_screen_mode: true },
header: {
template: 'blue',
title: { content: '🍙 七牛云上传机器人', tag: 'plain_text' }
},
elements: [
{
tag: 'div',
text: {
tag: 'lark_md',
content: '你好!我是七牛云上传机器人。\n\n**使用方式:**\n• 直接发送文件给我\n• 点击确认卡片上传\n\n**命令:**\n• /config - 查看配置\n• /help - 查看帮助'
}
},
{
tag: 'action',
actions: [
{
tag: 'button',
text: { tag: 'plain_text', content: '📎 上传文件' },
type: 'primary',
value: { action: 'upload_file' }
},
{
tag: 'button',
text: { tag: 'plain_text', content: '⚙️ 配置' },
type: 'default',
value: { action: 'config' }
},
{
tag: 'button',
text: { tag: 'plain_text', content: '❓ 帮助' },
type: 'default',
value: { action: 'help' }
}
]
}
]
};
await feishu.sendCard(chatId, card);
}
// ============ 路由 ============
function createConfigCard(configData) {
let configText = '';
for (const [name, bucket] of Object.entries(configData.buckets)) {
configText += `**[${name}]**\nBucket: ${bucket.bucket}\nRegion: ${bucket.region}\nDomain: ${bucket.domain}\n\n`;
}
return {
config: { wide_screen_mode: true },
header: {
template: 'green',
title: { content: '⚙️ 七牛云配置', tag: 'plain_text' }
},
elements: [{ tag: 'div', text: { tag: 'lark_md', content: configText || '暂无配置' } }]
};
}
app.post('/feishu/event', handleFeishuEvent);
// 健康检查
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
mode: MODE,
port: PORT
});
res.json({ status: 'ok', timestamp: new Date().toISOString(), port: PORT });
});
// ============ 启动服务 ============
function startHTTPMode() {
app.listen(PORT, () => {
log(`🚀 七牛云上传机器人启动 (HTTP 回调模式)`);
log(`📍 端口:${PORT}`);
log(`🔗 事件地址http://your-domain.com:${PORT}/feishu/event`);
log(`💡 提示:在飞书开放平台配置事件订阅地址为上述地址`);
});
}
function startWebSocketMode() {
log(`🚀 七牛云上传机器人启动 (WebSocket 长连接模式)`);
log(`💡 提示:在飞书开放平台选择 "WebSocket 长连接" 方式`);
// 创建飞书客户端
const client = new Api({
appId: process.env.FEISHU_APP_ID,
appSecret: process.env.FEISHU_APP_SECRET,
});
// 创建 WebSocket 长连接
const ws = eventSubscription({
appId: process.env.FEISHU_APP_ID,
appSecret: process.env.FEISHU_APP_SECRET,
encryptKey: process.env.FEISHU_ENCRYPT_KEY,
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN,
logLevel: 'info',
});
// 监听消息事件
ws.on('im.message.receive_v1', async (data) => {
log('收到消息事件');
await handleMessage(data);
});
// 监听连接状态
ws.on('open', () => {
log('✅ WebSocket 连接成功');
});
ws.on('close', () => {
log('❌ WebSocket 连接关闭5 秒后重连...');
setTimeout(() => {
try {
ws.start();
} catch (e) {
log('重连失败:', e.message);
function cleanupTempFiles() {
const tempDir = path.join(process.cwd(), 'temp');
const maxAgeMs = 60 * 60 * 1000;
if (!fs.existsSync(tempDir)) return;
const now = Date.now();
let cleaned = 0;
fs.readdirSync(tempDir).forEach(file => {
const filePath = path.join(tempDir, file);
try {
const stats = fs.statSync(filePath);
if (now - stats.mtimeMs > maxAgeMs) {
fs.unlinkSync(filePath);
log('🗑️ 清理过期临时文件:', file);
cleaned++;
}
}, 5000);
});
ws.on('error', (error) => {
log('❌ WebSocket 错误:', error.message);
});
// 启动 WebSocket 连接
try {
ws.start();
log('📡 WebSocket 已启动');
} catch (error) {
log('❌ WebSocket 启动失败:', error.message);
log('💡 请检查飞书配置是否正确');
}
// HTTP 服务器仍然运行(用于健康检查)
app.listen(PORT, () => {
log(`📍 健康检查端口:${PORT}`);
log(`🔗 健康检查地址http://localhost:${PORT}/health`);
} catch (e) {}
});
if (cleaned > 0) log(`✅ 清理完成:${cleaned} 个文件`);
}
// 根据配置启动对应模式
if (MODE === 'websocket') {
startWebSocketMode();
} else {
startHTTPMode();
}
setInterval(cleanupTempFiles, 60 * 60 * 1000);
log('⏰ 临时文件清理任务已启动(每小时执行一次)');
app.listen(PORT, () => {
log(`🚀 七牛云上传机器人启动 (v2)`);
log(`📍 端口:${PORT}`);
setTimeout(cleanupTempFiles, 5000);
});