#!/usr/bin/env node /** * 七牛云上传 - 飞书独立应用 v5 * 简化流程:上传配置 + 一键上传 */ require('dotenv').config(); const express = require('express'); 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) { if (process.env.NODE_ENV !== 'production') { 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 }; 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) { res.status(500).send('Decrypt error'); return; } } const eventType = decryptedEvent.event_type || decryptedEvent.header?.event_type || decryptedEvent.type; 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) { const messageData = event.event?.message || event.message; if (!messageData) return; const messageContent = JSON.parse(messageData.content); const text = messageContent.text || ''; const chatId = messageData.chat_id; const messageType = messageData.message_type || 'text'; const feishu = new FeishuAPI(); const uploader = new QiniuUploader(); if (text.startsWith('/upload') || text.startsWith('/u ')) { // 显示上传配置卡片 await showProfileCard(chatId, feishu, uploader); } 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('/profile')) { await handleProfileCommandV2(messageData, messageContent, feishu); } else if (text.startsWith('/help') || text.startsWith('/qh')) { await handleHelpCommandV2(chatId, feishu); } else if (text.startsWith('/skill')) { await handleSkillCommandV2(chatId, feishu, text); } else if (messageType === 'file' || messageContent.file_key) { // 收到文件,显示配置选择 await handleFileReceived(messageData, feishu, uploader); } else { await sendWelcomeCard(chatId, feishu); } } async function handleCardInteraction(event) { const eventData = event.event; const actionData = eventData?.action; const message = eventData?.message; if (!actionData) return; const action = actionData.value?.action; let chatId = message?.chat_id || actionData.value?.chat_id; if (!chatId && eventData?.operator?.open_id) { chatId = eventData.operator.open_id; } log('卡片交互:', action, 'chatId:', chatId); const feishu = new FeishuAPI(); const uploader = new QiniuUploader(); switch (action) { case 'start_upload': await showProfileCard(chatId, feishu, uploader); break; case 'select_profile': { const { profile_name, bucket, path_key } = actionData.value; log('📋 选择上传配置:', profile_name); // path_key 是预设路径的名称,需要从配置中获取实际路径 setUserState(chatId, { profile_name, bucket, path_key }); await feishu.sendMessage(chatId, { msg_type: 'text', content: { text: `✅ 已选择配置:**${profile_name}**\n\n📤 请发送文件` } }); break; } case 'upload_with_profile': { // 从配置卡片直接上传(需要先发送文件) const { profile_name, bucket, path_key } = actionData.value; const state = getUserState(chatId); if (!state.file_key) { await feishu.sendMessage(chatId, { msg_type: 'text', content: { text: '📎 请先发送文件' } }); return; } // 从预设路径配置中获取实际路径 const fullConfig = loadFullConfig(); const uploadPaths = fullConfig.uploadPaths || {}; const upload_path = uploadPaths[path_key] || ''; await doUpload(chatId, feishu, uploader, { file_key: state.file_key, file_name: state.file_name, message_id: state.message_id, bucket, upload_path, path_label: profile_name, path_key }); clearUserState(chatId); break; } case 'confirm_upload': { const { file_key, file_name, message_id, bucket, path_key, path_label } = actionData.value; // 从预设路径配置中获取实际路径 const fullConfig = loadFullConfig(); const uploadPaths = fullConfig.uploadPaths || {}; const upload_path = uploadPaths[path_key] || ''; await doUpload(chatId, feishu, uploader, { file_key, file_name, message_id, bucket, upload_path, path_label, path_key }); clearUserState(chatId); break; } case 'cancel': clearUserState(chatId); await feishu.sendMessage(chatId, { msg_type: 'text', content: { text: '❌ 已取消' } }); break; case 'config': { const configData = await uploader.listConfig(); await feishu.sendCard(chatId, createConfigCard(configData)); break; } case 'help': await handleHelpCommandV2(chatId, feishu); break; } } // 显示上传配置卡片 async function showProfileCard(chatId, feishu, uploader) { const fullConfig = loadFullConfig(); const profiles = fullConfig.uploadProfiles || {}; const profileButtons = Object.entries(profiles).map(([name, config]) => { // config.path 是预设路径的名称(键) const pathKey = config.path || ''; const uploadPaths = fullConfig.uploadPaths || {}; const pathValue = uploadPaths[pathKey] || ''; const pathDisplay = pathValue || '(原文件名)'; return { tag: 'button', text: { tag: 'plain_text', content: `${name}` }, type: 'primary', value: { action: 'select_profile', profile_name: name, bucket: config.bucket, path_key: pathKey } }; }); const card = { config: { wide_screen_mode: true }, header: { template: 'blue', title: { content: '📤 选择上传配置', tag: 'plain_text' } }, elements: [ { tag: 'div', text: { tag: 'lark_md', content: '**选择一个上传配置,然后发送文件:**' } }, { tag: 'action', actions: profileButtons }, { tag: 'hr' }, { tag: 'div', text: { tag: 'lark_md', content: '💡 **提示:**\n• 选择配置后发送文件\n• 或直接发送文件后选择配置' } }, { 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); } // 处理文件接收 async function handleFileReceived(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; // 保存文件信息到状态 setUserState(chatId, { file_key: fileKey, file_name: fileName, message_id: messageId }); const state = getUserState(chatId); // 如果已选择配置,显示确认卡片 if (state.bucket && state.upload_path !== undefined) { await showConfirmCard(chatId, feishu, { file_key: fileKey, file_name: fileName, message_id: messageId, bucket: state.bucket, upload_path: state.upload_path, path_label: state.profile_name || '自定义' }); } else { // 未选择配置,显示配置选择卡片 const fullConfig = loadFullConfig(); const profiles = fullConfig.uploadProfiles || {}; const uploadPaths = fullConfig.uploadPaths || {}; const profileButtons = Object.entries(profiles).map(([name, config]) => { // config.path 是预设路径的名称(键) const pathKey = config.path || ''; return { tag: 'button', text: { tag: 'plain_text', content: `${name}` }, type: 'primary', value: { action: 'confirm_upload', file_key: fileKey, file_name: fileName, message_id: messageId, chat_id: chatId, bucket: config.bucket, path_key: pathKey, path_label: name } }; }); 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**选择配置后确认上传:**` } }, { tag: 'action', actions: profileButtons }, { tag: 'hr' }, { tag: 'div', text: { tag: 'lark_md', content: '💡 点击配置按钮直接上传' } }, { tag: 'action', actions: [ { tag: 'button', text: { tag: 'plain_text', content: '❌ 取消' }, type: 'default', value: { action: 'cancel' } } ] } ] }; await feishu.sendCard(chatId, card); } } // 显示确认卡片 async function showConfirmCard(chatId, feishu, info) { const { file_name, bucket, path_key, path_label } = info; // 从预设路径配置中获取实际路径 const fullConfig = loadFullConfig(); const uploadPaths = fullConfig.uploadPaths || {}; const upload_path = uploadPaths[path_key] || ''; let targetKey = upload_path || file_name; if (targetKey.startsWith('/')) targetKey = targetKey.substring(1); const card = { config: { wide_screen_mode: true }, header: { template: 'green', title: { content: '✅ 确认上传', tag: 'plain_text' } }, elements: [ { tag: 'div', text: { tag: 'lark_md', content: `**文件:** ${file_name}\n**配置:** ${path_label}\n**存储桶:** ${bucket}\n**路径:** ${targetKey || '(原文件名)'}\n\n点击"确认上传"开始上传` } }, { tag: 'action', actions: [ { tag: 'button', text: { tag: 'plain_text', content: '✅ 确认上传' }, type: 'primary', value: { action: 'confirm_upload', file_key: info.file_key, file_name: info.file_name, message_id: info.message_id, chat_id: chatId, bucket, path_key, path_label } }, { tag: 'button', text: { tag: 'plain_text', content: '❌ 取消' }, type: 'default', value: { action: 'cancel' } } ] } ] }; await feishu.sendCard(chatId, card); } // 执行上传 async function doUpload(chatId, feishu, uploader, info) { const { file_key, file_name, message_id, bucket, upload_path, path_label } = info; let targetKey = upload_path || file_name; if (targetKey.startsWith('/')) targetKey = targetKey.substring(1); log('📤 开始上传:', file_name, '→', bucket, '/', targetKey); try { await feishu.sendMessage(chatId, { msg_type: 'text', content: { text: `📥 正在下载:${file_name}` } }); const tempFile = await feishu.downloadFile(file_key, message_id, chatId); log('✅ 文件下载完成:', tempFile); await feishu.sendMessage(chatId, { msg_type: 'text', content: { text: `📤 上传中:${targetKey} → ${bucket}` } }); const result = await uploader.upload(tempFile, targetKey, bucket); await uploader.refreshCDN(bucket, targetKey); await feishu.sendMessage(chatId, { msg_type: 'text', content: { text: `✅ 上传成功!\n\n` + `📦 文件:${targetKey}\n` + `🔗 链接:${result.url}\n` + `💾 原文件:${file_name}\n` + `🪣 存储桶:${bucket}\n` + `📁 配置:${path_label}` } }); fs.unlinkSync(tempFile); log('🗑️ 临时文件已清理'); } 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(); await feishu.sendCard(chatId, createBucketsListCard(configData)); } else if (subCommand === 'set') { const [keyPath, value] = args.slice(1); await uploader.setConfigValue(keyPath, value); await feishu.sendMessage(chatId, { msg_type: 'text', content: { text: `✅ 已设置 ${keyPath} = ${value}` } }); } } 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') { const paths = fullConfig.uploadPaths || {}; await feishu.sendCard(chatId, createPathsListCard(paths)); } else if (subCommand === 'add') { const name = args[1]; const pathValue = args[2]; fullConfig.uploadPaths[name] = pathValue; fs.writeFileSync(configPath, JSON.stringify(fullConfig, null, 2)); await feishu.sendMessage(chatId, { msg_type: 'text', content: { text: `✅ 已添加预设路径:**${name}** → ${pathValue}` } }); } } catch (error) { await feishu.sendMessage(chatId, { msg_type: 'text', content: { text: `❌ 路径管理失败:${error.message}` } }); } } async function handleProfileCommandV2(message, content, feishu) { const chatId = message.chat_id; const text = content.text || ''; const args = text.replace(/^\/profile\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 profiles = fullConfig.uploadProfiles || {}; const uploadPaths = fullConfig.uploadPaths || {}; await feishu.sendCard(chatId, createProfilesListCard(profiles, uploadPaths)); } else if (subCommand === 'add') { // /profile add <名称> <存储桶> [路径键名] if (args.length < 3) { throw new Error('用法:/profile add <名称> <存储桶> [路径键名]\n示例:/profile add IPA 上传 default ipa'); } const name = args[1]; const bucket = args[2]; const pathKey = args[3] || ''; // 验证存储桶是否存在 if (!fullConfig.buckets[bucket]) { throw new Error(`存储桶 "${bucket}" 不存在,可用:${Object.keys(fullConfig.buckets).join(', ')}`); } // 验证路径键名是否存在(如果有提供) if (pathKey && (!fullConfig.uploadPaths || !fullConfig.uploadPaths[pathKey])) { const availablePaths = Object.keys(fullConfig.uploadPaths || {}).join(', '); throw new Error(`路径 "${pathKey}" 不存在,可用:${availablePaths || '无'}`); } fullConfig.uploadProfiles = fullConfig.uploadProfiles || {}; fullConfig.uploadProfiles[name] = { bucket: bucket, path: pathKey // 存储路径键名,不是路径值 }; fs.writeFileSync(configPath, JSON.stringify(fullConfig, null, 2)); const pathDisplay = pathKey ? `${pathKey} (${fullConfig.uploadPaths[pathKey]})` : '(原文件名)'; await feishu.sendMessage(chatId, { msg_type: 'text', content: { text: `✅ 已添加上传配置:**${name}**\n存储桶:${bucket}\n路径:${pathDisplay}` } }); } else if (subCommand === 'remove' || subCommand === 'del') { if (args.length < 2) { throw new Error('用法:/profile remove <名称>'); } const name = args[1]; if (!fullConfig.uploadProfiles || !fullConfig.uploadProfiles[name]) { throw new Error(`上传配置 "${name}" 不存在`); } delete fullConfig.uploadProfiles[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) { const helpCard = { config: { wide_screen_mode: true }, header: { template: 'green', title: { content: '❓ 使用帮助', tag: 'plain_text' } }, elements: [ { tag: 'div', text: { tag: 'lark_md', content: '**🍙 七牛云上传机器人**\n快速上传文件到七牛云存储' } }, { tag: 'hr' }, { tag: 'div', text: { tag: 'lark_md', content: '**📤 上传方式**\n\n**方式 1:选择配置 → 发送文件**\n1️⃣ 发送 /upload\n2️⃣ 选择上传配置\n3️⃣ 发送文件\n4️⃣ 确认上传\n\n**方式 2:发送文件 → 选择配置**\n1️⃣ 直接发送文件\n2️⃣ 选择上传配置\n3️⃣ 确认上传' } }, { tag: 'hr' }, { tag: 'div', text: { tag: 'lark_md', content: '**⚙️ 配置命令**\n\n• /config list - 查看存储桶\n• /profile list - 查看上传配置\n• /profile add <名称> <桶> [路径] - 添加配置\n• /profile remove <名称> - 删除配置\n\n**📁 路径命令**\n\n• /path list - 查看预设路径\n• /path add <名称> <路径> - 添加路径' } }, { tag: 'hr' }, { tag: 'div', text: { tag: 'lark_md', content: '**💡 示例**\n\n`/profile add IPA 上传 default ipa`\n`/path add backup /backup/`\n\n**提示:** 上传同名文件会自动覆盖' } }, { tag: 'action', actions: [ { tag: 'button', text: { tag: 'plain_text', content: '📤 上传文件' }, type: 'primary', value: { action: 'start_upload' } }, { tag: 'button', text: { tag: 'plain_text', content: '⚙️ 配置' }, type: 'default', value: { action: 'config' } } ] } ] }; await feishu.sendCard(chatId, helpCard); } async function handleSkillCommandV2(chatId, feishu, text) { const args = text.replace(/^\/skill\s*/i, '').trim().split(/\s+/); const subCommand = args[0]; if (subCommand === 'list' || !subCommand) { const skillCard = { config: { wide_screen_mode: true }, header: { template: 'blue', title: { content: '🔧 可用技能列表', tag: 'plain_text' } }, elements: [ { tag: 'div', text: { tag: 'lark_md', content: '**📦 七牛云上传** `qiniu-uploader`\n\n七牛云文件上传和管理。支持命令触发和飞书卡片交互。\n\n**命令:**\n• `/upload` - 上传文件\n• `/qiniu-config` - 管理配置\n• `/qiniu-help` - 查看帮助' } }, { tag: 'hr' }, { tag: 'div', text: { tag: 'lark_md', content: '**🔍 搜索工具** `searxng`\n\n隐私保护的元搜索引擎。使用本地 SearXNG 实例搜索。\n\n**命令:**\n• `/search` - 搜索内容\n• `/image` - 搜索图片' } }, { tag: 'hr' }, { tag: 'div', text: { tag: 'lark_md', content: '💡 **提示:** 使用 `/skill <名称>` 查看技能详情' } } ] }; await feishu.sendCard(chatId, skillCard); } } 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• 发送 /upload 选择配置\n• 直接发送文件\n\n**⚙️ 常用命令:**\n• /config - 查看存储桶配置\n• /path - 管理预设路径\n• /profile - 管理上传配置' } }, { tag: 'action', actions: [ { tag: 'button', text: { tag: 'plain_text', content: '📤 上传文件' }, type: 'primary', value: { action: 'start_upload' } }, { tag: 'button', text: { tag: 'plain_text', content: '❓ 帮助' }, type: 'default', value: { action: 'help' } } ] }, { tag: 'hr' }, { tag: 'div', text: { tag: 'lark_md', content: '💡 **提示:** 点击"帮助"查看详细使用指南' } } ] }; await feishu.sendCard(chatId, card); } // 存储桶列表卡片(表格形式) function createBucketsListCard(configData) { const buckets = configData.buckets || {}; const entries = Object.entries(buckets); if (entries.length === 0) { return { config: { wide_screen_mode: true }, header: { template: 'grey', title: { content: '⚙️ 七牛云存储桶配置', tag: 'plain_text' } }, elements: [{ tag: 'div', text: { tag: 'lark_md', content: '暂无配置' } }] }; } // 构建表格内容 let tableContent = '| 名称 | 存储桶 | 区域 | CDN 域名 |\n|------|--------|------|----------|\n'; for (const [name, bucket] of entries) { const domain = bucket.domain.length > 30 ? bucket.domain.substring(0, 27) + '...' : bucket.domain; tableContent += `| ${name} | ${bucket.bucket} | ${bucket.region} | ${domain} |\n`; } return { config: { wide_screen_mode: true }, header: { template: 'green', title: { content: '⚙️ 七牛云存储桶配置', tag: 'plain_text' } }, elements: [ { tag: 'div', text: { tag: 'lark_md', content: tableContent } } ] }; } // 预设路径列表卡片(表格形式) function createPathsListCard(paths) { const entries = Object.entries(paths); if (entries.length === 0) { return { config: { wide_screen_mode: true }, header: { template: 'grey', title: { content: '📁 预设路径列表', tag: 'plain_text' } }, elements: [{ tag: 'div', text: { tag: 'lark_md', content: '暂无预设路径' } }] }; } // 构建表格内容 let tableContent = '| 名称 | 路径 |\n|------|------|\n'; for (const [name, pathValue] of entries) { const pathDisplay = pathValue || '(原文件名)'; tableContent += `| **${name}** | ${pathDisplay} |\n`; } return { config: { wide_screen_mode: true }, header: { template: 'blue', title: { content: '📁 预设路径列表', tag: 'plain_text' } }, elements: [ { tag: 'div', text: { tag: 'lark_md', content: tableContent } } ] }; } // 上传配置模板列表卡片(表格形式) function createProfilesListCard(profiles, uploadPaths) { const entries = Object.entries(profiles); if (entries.length === 0) { return { config: { wide_screen_mode: true }, header: { template: 'grey', title: { content: '📤 上传配置模板', tag: 'plain_text' } }, elements: [{ tag: 'div', text: { tag: 'lark_md', content: '暂无上传配置模板' } }] }; } // 构建表格内容 let tableContent = '| 名称 | 存储桶 | 路径 |\n|------|--------|------|\n'; for (const [name, config] of entries) { const pathKey = config.path || ''; const pathValue = uploadPaths && uploadPaths[pathKey] ? uploadPaths[pathKey] : '(原文件名)'; const pathDisplay = pathKey ? `${pathKey} → ${pathValue}` : '(原文件名)'; tableContent += `| **${name}** | ${config.bucket} | ${pathDisplay} |\n`; } return { config: { wide_screen_mode: true }, header: { template: 'blue', title: { content: '📤 上传配置模板', tag: 'plain_text' } }, elements: [ { tag: 'div', text: { tag: 'lark_md', content: tableContent } } ] }; } // 旧的配置卡片(保留兼容) function createConfigCard(configData) { return createBucketsListCard(configData); } app.post('/feishu/event', handleFeishuEvent); app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), port: PORT }); }); app.listen(PORT, () => { log(`🚀 七牛云上传机器人启动 (v5 - 简化流程)`); log(`📍 端口:${PORT}`); });