Initial commit: 七牛云上传飞书机器人
功能: - 飞书交互卡片支持 - 七牛云文件上传 - 自动 CDN 刷新 - 多存储桶配置 - 跨平台部署(Linux/macOS/Windows) - Docker 支持
This commit is contained in:
311
src/index.js
Normal file
311
src/index.js
Normal file
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 七牛云上传 - 飞书独立应用
|
||||
*
|
||||
* 功能:
|
||||
* 1. 监听飞书消息事件
|
||||
* 2. 支持交互式卡片上传
|
||||
* 3. 支持命令触发上传
|
||||
* 4. 配置管理
|
||||
*/
|
||||
|
||||
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 { UploadCard } = require('./cards/upload-card');
|
||||
const { ConfigCard } = require('./cards/config-card');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// 中间件
|
||||
app.use(express.json());
|
||||
|
||||
// 日志
|
||||
function log(...args) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[${timestamp}]`, ...args);
|
||||
}
|
||||
|
||||
// ============ 飞书事件处理 ============
|
||||
|
||||
async function handleFeishuEvent(req, res) {
|
||||
const event = req.body;
|
||||
const headers = req.headers;
|
||||
|
||||
log('收到飞书事件:', event.type);
|
||||
|
||||
// URL 验证
|
||||
if (event.type === 'url_verification') {
|
||||
log('✅ URL 验证请求');
|
||||
res.json({ challenge: event.challenge });
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
const timestamp = headers['x-feishu-request-timestamp'];
|
||||
const nonce = headers['x-feishu-request-nonce'];
|
||||
const signature = headers['x-feishu-request-signature'];
|
||||
|
||||
if (!verifySignature(timestamp, nonce, signature)) {
|
||||
log('❌ 签名验证失败');
|
||||
res.status(401).send('Invalid signature');
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理消息事件
|
||||
if (event.type === 'im.message.receive_v1') {
|
||||
await handleMessage(event);
|
||||
}
|
||||
|
||||
res.status(200).send('OK');
|
||||
}
|
||||
|
||||
function verifySignature(timestamp, nonce, signature) {
|
||||
const encryptKey = process.env.FEISHU_ENCRYPT_KEY;
|
||||
if (!encryptKey) return true;
|
||||
|
||||
const arr = [encryptKey, timestamp, nonce];
|
||||
arr.sort();
|
||||
const str = arr.join('');
|
||||
const hash = crypto.createHash('sha1').update(str).digest('hex');
|
||||
|
||||
return hash === signature;
|
||||
}
|
||||
|
||||
// ============ 消息处理 ============
|
||||
|
||||
async function handleMessage(event) {
|
||||
try {
|
||||
const message = event.message;
|
||||
const content = JSON.parse(message.content);
|
||||
const text = content.text || '';
|
||||
const chatId = message.chat_id;
|
||||
const senderId = message.sender?.sender_id?.user_id || message.sender?.sender_id?.open_id;
|
||||
|
||||
log(`处理消息:${chatId} - ${text.substring(0, 50)}`);
|
||||
|
||||
// 初始化 API
|
||||
const feishu = new FeishuAPI();
|
||||
const uploader = new QiniuUploader();
|
||||
|
||||
// 卡片交互回调
|
||||
if (event.type === 'im.message.receive_v1' && content.interaction?.type) {
|
||||
await handleCardInteraction(event, feishu, uploader);
|
||||
return;
|
||||
}
|
||||
|
||||
// 命令处理
|
||||
if (text.startsWith('/upload') || text.startsWith('/u ')) {
|
||||
await handleUploadCommand(message, content, feishu, uploader);
|
||||
} else if (text.startsWith('/config') || text.startsWith('/qc ')) {
|
||||
await handleConfigCommand(message, content, feishu, uploader);
|
||||
} else if (text.startsWith('/help') || text.startsWith('/qh')) {
|
||||
await handleHelpCommand(message, feishu);
|
||||
} else {
|
||||
// 默认回复交互卡片
|
||||
await sendWelcomeCard(chatId, feishu);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log('❌ 消息处理失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCardInteraction(event, feishu, uploader) {
|
||||
const interaction = event.message.content.interaction;
|
||||
const chatId = event.message.chat_id;
|
||||
const action = interaction.value?.action;
|
||||
|
||||
log('卡片交互:', action);
|
||||
|
||||
switch (action) {
|
||||
case 'upload_file':
|
||||
await feishu.sendMessage(chatId, {
|
||||
msg_type: 'text',
|
||||
content: { text: '📎 请发送要上传的文件,我会自动处理~' }
|
||||
});
|
||||
break;
|
||||
|
||||
case 'config':
|
||||
const configData = await uploader.listConfig();
|
||||
const configCard = ConfigCard.create(configData);
|
||||
await feishu.sendCard(chatId, configCard);
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
await handleHelpCommand(event.message, feishu);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadCommand(message, content, feishu, uploader) {
|
||||
const chatId = message.chat_id;
|
||||
const attachments = message.attachments || [];
|
||||
|
||||
// 解析命令参数
|
||||
const text = content.text || '';
|
||||
const args = text.replace(/^\/(upload|u)\s*/i, '').trim().split(/\s+/);
|
||||
let targetPath = null;
|
||||
let useOriginal = false;
|
||||
let bucket = 'default';
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg === '--original') useOriginal = true;
|
||||
else if (arg.startsWith('/') || arg.includes('.')) targetPath = arg;
|
||||
else bucket = arg;
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
await feishu.sendMessage(chatId, {
|
||||
msg_type: 'text',
|
||||
content: {
|
||||
text: '❌ 请附上要上传的文件\n\n💡 使用示例:\n/upload /config/test/file.txt default\n[附上文件]'
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const attachment = attachments[0];
|
||||
const fileKey = attachment.file_key;
|
||||
const fileName = attachment.file_name;
|
||||
|
||||
try {
|
||||
// 下载文件
|
||||
await feishu.sendMessage(chatId, {
|
||||
msg_type: 'text',
|
||||
content: { text: `📥 正在下载:${fileName}` }
|
||||
});
|
||||
|
||||
const tempFile = await feishu.downloadFile(fileKey);
|
||||
|
||||
// 确定目标路径
|
||||
let key = targetPath;
|
||||
if (!key || useOriginal) {
|
||||
key = fileName;
|
||||
} else if (key.startsWith('/')) {
|
||||
key = key.substring(1);
|
||||
}
|
||||
|
||||
// 上传到七牛云
|
||||
await feishu.sendMessage(chatId, {
|
||||
msg_type: 'text',
|
||||
content: { text: `📤 上传中:${key} → ${bucket}` }
|
||||
});
|
||||
|
||||
const result = await uploader.upload(tempFile, key, bucket);
|
||||
|
||||
// 刷新 CDN
|
||||
await uploader.refreshCDN(bucket, key);
|
||||
|
||||
// 回复结果
|
||||
await feishu.sendMessage(chatId, {
|
||||
msg_type: 'text',
|
||||
content: {
|
||||
text: `✅ 上传成功!\n\n` +
|
||||
`📦 文件:${key}\n` +
|
||||
`🔗 链接:${result.url}\n` +
|
||||
`💾 原文件:${fileName}\n` +
|
||||
`🪣 存储桶:${bucket}`
|
||||
}
|
||||
});
|
||||
|
||||
// 清理临时文件
|
||||
fs.unlinkSync(tempFile);
|
||||
|
||||
} catch (error) {
|
||||
log('上传失败:', error.message);
|
||||
await feishu.sendMessage(chatId, {
|
||||
msg_type: 'text',
|
||||
content: { text: `❌ 上传失败:${error.message}` }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfigCommand(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 = ConfigCard.create(configData);
|
||||
await feishu.sendCard(chatId, configCard);
|
||||
} else if (subCommand === 'set') {
|
||||
const [keyPath, value] = args.slice(1);
|
||||
if (!keyPath || !value) {
|
||||
throw new Error('用法:/config set <key> <value>');
|
||||
}
|
||||
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 handleHelpCommand(message, feishu) {
|
||||
const helpText = `
|
||||
🍙 七牛云上传 - 使用帮助
|
||||
|
||||
📤 上传文件:
|
||||
/upload [目标路径] [存储桶名]
|
||||
/upload --original [存储桶名]
|
||||
|
||||
示例:
|
||||
/upload /config/test/file.txt default
|
||||
/upload --original default
|
||||
|
||||
⚙️ 配置管理:
|
||||
/config list # 查看配置
|
||||
/config set <key> <value> # 修改配置
|
||||
|
||||
💡 提示:
|
||||
- 直接发送文件给我也会收到上传卡片
|
||||
- 支持多存储桶配置
|
||||
- 上传同名文件会自动覆盖
|
||||
`;
|
||||
|
||||
await feishu.sendMessage(message.chat_id, {
|
||||
msg_type: 'text',
|
||||
content: { text: helpText }
|
||||
});
|
||||
}
|
||||
|
||||
async function sendWelcomeCard(chatId, feishu) {
|
||||
const card = UploadCard.create();
|
||||
await feishu.sendCard(chatId, card);
|
||||
}
|
||||
|
||||
// ============ 路由 ============
|
||||
|
||||
app.post('/feishu/event', handleFeishuEvent);
|
||||
|
||||
// 健康检查
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// ============ 启动服务 ============
|
||||
|
||||
app.listen(PORT, () => {
|
||||
log(`🚀 七牛云上传机器人启动`);
|
||||
log(`📍 端口:${PORT}`);
|
||||
log(`🔗 事件地址:https://your-domain.com/feishu/event`);
|
||||
});
|
||||
Reference in New Issue
Block a user