✅ 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:
18
pm2.config.cjs
Normal file
18
pm2.config.cjs
Normal 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
|
||||
}]
|
||||
};
|
||||
@@ -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_type:oc_开头的是 chat_id,ou_开头的是 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 API(OpenClaw 使用的方式)
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 回复消息
|
||||
|
||||
570
src/index.js
570
src/index.js
@@ -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');
|
||||
|
||||
// 飞书 SDK(WebSocket 模式)
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user