Files
qiniu-feishu-bot/src/index.js
饭团 cc961e3b4e 完善欢迎卡片和帮助信息
修改:
- 欢迎卡片添加帮助按钮
- 帮助信息使用卡片形式展示,更美观
- 帮助卡片包含:
  - 上传方式说明
  - 配置命令列表
  - 路径命令列表
  - 使用示例
  - 快捷操作按钮
2026-03-06 12:06:31 +08:00

895 lines
26 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
/**
* 七牛云上传 - 飞书独立应用 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) {
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 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}`);
});