Files
qiniu-feishu-bot/src/index.js
饭团 e13b86fcd0 修复死循环问题 - 添加状态记忆功能
问题:
选择路径后重新发送文件,又显示选择卡片,导致死循环

解决方案:
1. 添加用户状态临时存储(内存)
2. 选择存储桶/路径后保存状态(5 分钟有效)
3. 重新发送文件时,检测是否有保存的状态
4. 有状态则直接上传,无状态则显示选择卡片

使用流程:
1. 发送文件 → 显示选择卡片
2. 选择存储桶 → 保存状态,提示重新发送
3. 选择路径 → 保存状态,提示重新发送
4. 重新发送文件 → 使用保存的状态直接上传
5. 上传完成后清除状态

状态有效期:5 分钟
2026-03-06 09:12:18 +08:00

678 lines
20 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.
#!/usr/bin/env node
/**
* 七牛云上传 - 飞书独立应用 v3
* 支持存储桶和路径选择
*/
require('dotenv').config();
const express = require('express');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const { FeishuAPI } = require('./feishu-api');
const { QiniuUploader } = require('./qiniu-uploader');
const app = express();
const PORT = process.env.PORT || 3030;
app.use(express.json());
function log(...args) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}]`, ...args);
}
// 加载完整配置(包括预设路径)
function loadFullConfig() {
const configPath = path.join(process.cwd(), 'config', 'qiniu-config.json');
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
// 用户临时状态存储(内存)
const userStates = {};
function getUserState(chatId) {
return userStates[chatId] || {};
}
function setUserState(chatId, state) {
userStates[chatId] = { ...userStates[chatId], ...state };
// 5 分钟后清除状态
setTimeout(() => {
delete userStates[chatId];
}, 5 * 60 * 1000);
}
function clearUserState(chatId) {
delete userStates[chatId];
}
async function handleFeishuEvent(req, res) {
const event = req.body;
log('📩 收到飞书请求');
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;
}
}
const eventType = decryptedEvent.event_type ||
decryptedEvent.header?.event_type ||
decryptedEvent.type;
log('收到飞书事件:', eventType);
if (eventType === 'url_verification') {
res.json({ challenge: decryptedEvent.challenge || event.challenge });
return;
}
if (eventType === 'im.message.receive_v1') {
await handleMessage(decryptedEvent);
}
if (eventType === 'card.action.trigger') {
await handleCardInteraction(decryptedEvent);
res.status(200).json({});
return;
}
res.status(200).send('OK');
}
async function handleMessage(event) {
try {
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 feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: '📎 请发送要上传的文件(直接发送文件即可)' }
});
} else if (text.startsWith('/config') || text.startsWith('/qc ')) {
await handleConfigCommandV2(messageData, messageContent, feishu, uploader);
} else if (text.startsWith('/path')) {
await handlePathCommandV2(messageData, messageContent, feishu);
} else if (text.startsWith('/help') || text.startsWith('/qh')) {
await handleHelpCommandV2(chatId, feishu);
} else if (messageType === 'file' || messageContent.file_key) {
// 检查是否有已保存的配置
const state = getUserState(chatId);
if (state.bucket || state.upload_path) {
log('🔍 收到文件消息 - 使用已保存的配置');
await handleFileUploadWithState(messageData, feishu, uploader, state);
clearUserState(chatId);
} else {
log('🔍 收到文件消息 - 发送配置卡片');
await handleFileReceivedWithCard(messageData, feishu, uploader);
}
} else {
await sendWelcomeCard(chatId, feishu);
}
} catch (error) {
log('❌ 消息处理失败:', error.message);
}
}
async function handleCardInteraction(event) {
try {
const eventData = event.event;
const operator = eventData?.operator;
const actionData = eventData?.action;
const message = eventData?.message;
if (!actionData) {
log('❌ 卡片交互:缺少 action 数据');
return;
}
const action = actionData.value?.action;
let chatId = message?.chat_id || actionData.value?.chat_id;
if (!chatId && operator?.open_id) {
chatId = operator.open_id;
}
log('卡片交互:', action, 'chatId:', chatId);
const feishu = new FeishuAPI();
const uploader = new QiniuUploader();
switch (action) {
case 'confirm_upload': {
const { file_key, file_name, message_id, bucket, upload_path } = actionData.value;
const targetBucket = bucket || 'default';
let targetKey = upload_path || file_name;
if (targetKey.startsWith('/')) targetKey = targetKey.substring(1);
log('📤 开始上传文件:', file_name, 'bucket:', targetBucket, 'path:', targetKey);
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: `📤 上传中:${targetKey}${targetBucket}` }
});
const result = await uploader.upload(tempFile, targetKey, targetBucket);
await uploader.refreshCDN(targetBucket, targetKey);
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: {
text: `✅ 上传成功!\n\n` +
`📦 文件:${targetKey}\n` +
`🔗 链接:${result.url}\n` +
`💾 原文件:${file_name}\n` +
`🪣 存储桶:${targetBucket}`
}
});
fs.unlinkSync(tempFile);
log('🗑️ 临时文件已清理:', tempFile);
} catch (error) {
log('上传失败:', error.message);
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `❌ 上传失败:${error.message}` }
});
}
break;
}
case 'set_bucket': {
const { bucket } = actionData.value;
log('🪣 选择存储桶:', bucket);
setUserState(chatId, { bucket });
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `✅ 已选择存储桶:**${bucket}**\n\n请重新发送文件开始上传(配置 5 分钟内有效)` }
});
break;
}
case 'set_path': {
const { upload_path, path_label } = actionData.value;
const pathDesc = path_label || '原文件名';
log('📁 选择路径:', pathDesc);
setUserState(chatId, { upload_path, path_label });
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `✅ 已选择路径:**${pathDesc}**\n\n请重新发送文件开始上传(配置 5 分钟内有效)` }
});
break;
}
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);
}
}
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 uploader2 = new QiniuUploader();
const configData = await uploader2.listConfig();
const bucketNames = Object.keys(configData.buckets);
// 获取预设路径
const fullConfig = loadFullConfig();
const uploadPaths = fullConfig.uploadPaths || { '原文件名': '' };
// 构建存储桶选择按钮
const bucketButtons = bucketNames.map(name => ({
tag: 'button',
text: { tag: 'plain_text', content: name },
type: 'primary',
value: {
action: 'set_bucket',
bucket: name
}
}));
// 构建路径选择按钮
const pathButtons = Object.entries(uploadPaths).map(([label, pathValue]) => ({
tag: 'button',
text: { tag: 'plain_text', content: label },
type: 'default',
value: {
action: 'set_path',
upload_path: pathValue,
path_label: label
}
}));
// 构建快速上传按钮(使用默认配置)
const quickUploadButtons = [
{
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,
bucket: 'default',
upload_path: ''
}
},
{
tag: 'button',
text: { tag: 'plain_text', content: '❌ 取消' },
type: 'default',
value: {
action: 'cancel_upload'
}
}
];
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\n**1⃣ 选择存储桶:**`
}
},
{
tag: 'action',
actions: bucketButtons
},
{
tag: 'hr'
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**2⃣ 选择路径:**`
}
},
{
tag: 'action',
actions: pathButtons
},
{
tag: 'hr'
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**3⃣ 开始上传:**`
}
},
{
tag: 'action',
actions: quickUploadButtons
},
{
tag: 'hr'
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `💡 **提示:**\n• 先选择存储桶和路径\n• 点击"使用默认配置上传"开始\n• 或使用命令:/upload /路径/文件名 存储桶名`
}
}
]
};
log('发送卡片到 chatId:', chatId);
await feishu.sendCard(chatId, card);
}
// 使用已保存的状态直接上传文件
async function handleFileUploadWithState(messageData, feishu, uploader, state) {
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;
const targetBucket = state.bucket || 'default';
let targetKey = state.upload_path || fileName;
const pathLabel = state.path_label || '原文件名';
if (targetKey.startsWith('/')) targetKey = targetKey.substring(1);
log('📤 使用已保存配置上传:', fileName, 'bucket:', targetBucket, 'path:', targetKey);
try {
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `📥 正在下载:${fileName}` }
});
const tempFile = await feishu.downloadFile(fileKey, messageId, chatId);
log('✅ 文件下载完成:', tempFile);
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `📤 上传中:${targetKey}${targetBucket} (路径:${pathLabel})` }
});
const result = await uploader.upload(tempFile, targetKey, targetBucket);
await uploader.refreshCDN(targetBucket, targetKey);
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: {
text: `✅ 上传成功!\n\n` +
`📦 文件:${targetKey}\n` +
`🔗 链接:${result.url}\n` +
`💾 原文件:${fileName}\n` +
`🪣 存储桶:${targetBucket}\n` +
`📁 路径:${pathLabel}`
}
});
fs.unlinkSync(tempFile);
log('🗑️ 临时文件已清理:', tempFile);
} catch (error) {
log('上传失败:', error.message);
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `❌ 上传失败:${error.message}` }
});
}
}
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+/);
const subCommand = args[0];
try {
if (subCommand === 'list' || !subCommand) {
const configData = await uploader.listConfig();
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>');
await uploader.setConfigValue(keyPath, value);
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `✅ 已设置 ${keyPath} = ${value}` }
});
} else {
throw new Error(`未知命令:${subCommand}`);
}
} catch (error) {
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `❌ 配置失败:${error.message}` }
});
}
}
async function handlePathCommandV2(message, content, feishu) {
const chatId = message.chat_id;
const text = content.text || '';
const args = text.replace(/^\/path\s*/i, '').trim().split(/\s+/);
const subCommand = args[0];
const configPath = path.join(process.cwd(), 'config', 'qiniu-config.json');
try {
const fullConfig = loadFullConfig();
if (subCommand === 'list' || !subCommand) {
// 列出所有预设路径
const paths = fullConfig.uploadPaths || {};
let pathText = '**预设路径列表:**\n\n';
for (const [name, pathValue] of Object.entries(paths)) {
pathText += `• **${name}**: ${pathValue || '(原文件名)'}\n`;
}
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: pathText }
});
} else if (subCommand === 'add') {
// 添加预设路径:/path add <名称> <路径>
if (args.length < 3) {
throw new Error('用法:/path add <名称> <路径>\n示例/path add ipa /ipa/gamehall.ipa');
}
const name = args[1];
const pathValue = args[2];
fullConfig.uploadPaths = fullConfig.uploadPaths || {};
fullConfig.uploadPaths[name] = pathValue;
fs.writeFileSync(configPath, JSON.stringify(fullConfig, null, 2));
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `✅ 已添加预设路径:**${name}** → ${pathValue}` }
});
} else if (subCommand === 'remove' || subCommand === 'del') {
// 删除预设路径:/path remove <名称>
if (args.length < 2) {
throw new Error('用法:/path remove <名称>');
}
const name = args[1];
if (!fullConfig.uploadPaths || !fullConfig.uploadPaths[name]) {
throw new Error(`预设路径 "${name}" 不存在`);
}
delete fullConfig.uploadPaths[name];
fs.writeFileSync(configPath, JSON.stringify(fullConfig, null, 2));
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `✅ 已删除预设路径:**${name}**` }
});
} else {
throw new Error(`未知命令:${subCommand}\n可用命令list, add, remove`);
}
} catch (error) {
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `❌ 路径管理失败:${error.message}` }
});
}
}
async function handleHelpCommandV2(chatId, feishu) {
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `
🍙 七牛云上传 - 使用帮助
📤 上传文件:
1. 直接发送文件给我
2. 选择存储桶和路径
3. 点击"使用默认配置上传"
⚙️ 配置管理:
/config list - 查看配置
/config set <key> <value> - 修改配置
📁 路径管理:
/path list - 查看预设路径
/path add <名称> <路径> - 添加预设路径
/path remove <名称> - 删除预设路径
💡 提示:
- 支持多存储桶配置
- 支持预设路径
- 上传同名文件会自动覆盖
示例:
/path add backup /backup/
/path remove backup
` }
});
}
async function sendWelcomeCard(chatId, feishu) {
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**命令:**\n• /config - 查看配置\n• /help - 查看帮助'
}
},
{
tag: 'action',
actions: [
{
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(), port: PORT });
});
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++;
}
} catch (e) {}
});
if (cleaned > 0) log(`✅ 清理完成:${cleaned} 个文件`);
}
setInterval(cleanupTempFiles, 60 * 60 * 1000);
log('⏰ 临时文件清理任务已启动(每小时执行一次)');
app.listen(PORT, () => {
log(`🚀 七牛云上传机器人启动 (v3 - 支持存储桶和路径选择)`);
log(`📍 端口:${PORT}`);
setTimeout(cleanupTempFiles, 5000);
});