#!/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 '); 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 - 修改配置 📁 路径管理: /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); });