Files
qiniu-feishu-bot/src/index.js

633 lines
17 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('/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: upload_path } = actionData.value;
log('📋 选择上传配置:', profile_name);
setUserState(chatId, {
profile_name,
bucket,
upload_path
});
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `✅ 已选择配置:**${profile_name}**\n\n📤 请发送文件,或点击"📎 选择文件上传"` }
});
break;
}
case 'upload_with_profile': {
// 从配置卡片直接上传(需要先发送文件)
const { profile_name, bucket, path: upload_path } = actionData.value;
const state = getUserState(chatId);
if (!state.file_key) {
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: '📎 请先发送文件' }
});
return;
}
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
});
clearUserState(chatId);
break;
}
case 'confirm_upload': {
const { file_key, file_name, message_id, bucket, upload_path, path_label } = actionData.value;
await doUpload(chatId, feishu, uploader, {
file_key, file_name, message_id, bucket, upload_path, path_label
});
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]) => ({
tag: 'button',
text: { tag: 'plain_text', content: name },
type: 'primary',
value: {
action: 'select_profile',
profile_name: name,
bucket: config.bucket,
path: config.path || ''
}
}));
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 profileButtons = Object.entries(profiles).map(([name, config]) => ({
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,
upload_path: config.path || '',
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, upload_path, path_label } = info;
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,
upload_path,
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, createConfigCard(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 || {};
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') {
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 handleHelpCommandV2(chatId, feishu) {
await feishu.sendMessage(chatId, {
msg_type: 'text',
content: { text: `
🍙 七牛云上传 - 使用帮助
📤 上传方式:
**方式 1选择配置 → 发送文件**
1. 发送 /upload
2. 选择上传配置
3. 发送文件
4. 确认上传
**方式 2发送文件 → 选择配置**
1. 直接发送文件
2. 选择上传配置
3. 确认上传
⚙️ 配置管理:
/config list - 查看配置
/path list - 查看预设路径
/path add <名称> <路径> - 添加路径
💡 提示:
- 上传配置在 config/qiniu-config.json 中配置
- 支持多存储桶
- 上传同名文件会自动覆盖
` }
});
}
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 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 });
});
app.listen(PORT, () => {
log(`🚀 七牛云上传机器人启动 (v5 - 简化流程)`);
log(`📍 端口:${PORT}`);
});