Files
qiniu-feishu-bot/src/index.js
饭团 596869260f profile 路径改为引用 path 的键名
修改:
- profile 配置中的 path 字段现在存储路径键名,而不是直接路径值
- /profile add 命令现在使用路径键名:/profile add IPA 上传 default ipa
- 上传时根据路径键名从 uploadPaths 中获取实际路径
- 列表卡片显示路径键名和对应的值

配置示例:
{
  "uploadPaths": {
    "ipa": "/ipa/gamehall.ipa"
  },
  "uploadProfiles": {
    "IPA 上传": {
      "bucket": "default",
      "path": "ipa"  // 引用 uploadPaths 中的键名
    }
  }
}
2026-03-06 11:57:00 +08:00

858 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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
/**
* 七牛云上传 - 飞书独立应用 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) {
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 (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) {
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `
🍙 七牛云上传 - 使用帮助
📤 上传方式:
**方式 1选择配置 → 发送文件**
1. 发送 /upload
2. 选择上传配置
3. 发送文件
4. 确认上传
**方式 2发送文件 → 选择配置**
1. 直接发送文件
2. 选择上传配置
3. 确认上传
⚙️ 配置管理:
/config list - 查看七牛云配置
/profile list - 查看上传配置模板
/profile add <名称> <存储桶> [路径] - 添加上传配置
/profile remove <名称> - 删除上传配置
📁 路径管理:
/path list - 查看预设路径
/path add <名称> <路径> - 添加预设路径
💡 示例:
/profile add IPA 上传 default /ipa/
/profile add 备份 default /backup/
/profile list
/profile remove 备份
**提示:**
- 上传同名文件会自动覆盖
` }
});
}
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• /help - 帮助'
}
},
{
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, 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}`);
});