Initial commit: 七牛云上传飞书机器人
功能: - 飞书交互卡片支持 - 七牛云文件上传 - 自动 CDN 刷新 - 多存储桶配置 - 跨平台部署(Linux/macOS/Windows) - Docker 支持
This commit is contained in:
185
src/cards/config-card.js
Normal file
185
src/cards/config-card.js
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 配置交互卡片模板
|
||||
*/
|
||||
|
||||
class ConfigCard {
|
||||
static create(configData) {
|
||||
const bucketsText = Object.entries(configData.buckets || {})
|
||||
.map(([name, bucket]) => {
|
||||
return `**🪣 [${name}]**\n` +
|
||||
`• 存储桶:${bucket.bucket}\n` +
|
||||
`• 区域:${bucket.region}\n` +
|
||||
`• 域名:${bucket.domain}\n` +
|
||||
`• AccessKey: ${bucket.accessKey}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
"config": {
|
||||
"wide_screen_mode": true
|
||||
},
|
||||
"header": {
|
||||
"template": "grey",
|
||||
"title": {
|
||||
"content": "⚙️ 七牛云配置",
|
||||
"tag": "plain_text"
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"content": bucketsText || '暂无配置,请先添加存储桶配置。',
|
||||
"tag": "lark_md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "hr"
|
||||
},
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"content": "**💡 修改配置**\n\n使用命令:`/config set <key> <value>`\n\n示例:\n`/config set default.domain https://new-cdn.com`",
|
||||
"tag": "lark_md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {
|
||||
"content": "📤 上传文件",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"type": "primary",
|
||||
"value": {
|
||||
"action": "upload_file",
|
||||
"type": "upload"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {
|
||||
"content": "❓ 帮助",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"type": "default",
|
||||
"value": {
|
||||
"action": "help",
|
||||
"type": "help"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
static createEditForm(bucketName) {
|
||||
return {
|
||||
"config": {
|
||||
"wide_screen_mode": true
|
||||
},
|
||||
"header": {
|
||||
"template": "blue",
|
||||
"title": {
|
||||
"content": `✏️ 编辑配置 - ${bucketName}`,
|
||||
"tag": "plain_text"
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "input",
|
||||
"label": {
|
||||
"content": "AccessKey",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"placeholder": {
|
||||
"content": "请输入七牛云 AccessKey",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"name": "access_key"
|
||||
},
|
||||
{
|
||||
"tag": "input",
|
||||
"label": {
|
||||
"content": "SecretKey",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"placeholder": {
|
||||
"content": "请输入七牛云 SecretKey",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"name": "secret_key"
|
||||
},
|
||||
{
|
||||
"tag": "input",
|
||||
"label": {
|
||||
"content": "存储桶名称",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"placeholder": {
|
||||
"content": "例如:my-bucket",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"name": "bucket_name"
|
||||
},
|
||||
{
|
||||
"tag": "input",
|
||||
"label": {
|
||||
"content": "区域",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"placeholder": {
|
||||
"content": "z0/z1/z2/na0/as0",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"name": "region"
|
||||
},
|
||||
{
|
||||
"tag": "input",
|
||||
"label": {
|
||||
"content": "CDN 域名",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"placeholder": {
|
||||
"content": "https://cdn.example.com",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"name": "domain"
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {
|
||||
"content": "💾 保存",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"type": "primary",
|
||||
"value": {
|
||||
"action": "save_config",
|
||||
"bucket": bucketName
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {
|
||||
"content": "❌ 取消",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"type": "default",
|
||||
"value": {
|
||||
"action": "cancel"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ConfigCard };
|
||||
230
src/cards/upload-card.js
Normal file
230
src/cards/upload-card.js
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 上传交互卡片模板
|
||||
*/
|
||||
|
||||
class UploadCard {
|
||||
static create() {
|
||||
return {
|
||||
"config": {
|
||||
"wide_screen_mode": true
|
||||
},
|
||||
"header": {
|
||||
"template": "blue",
|
||||
"title": {
|
||||
"content": "🍙 七牛云上传助手",
|
||||
"tag": "plain_text"
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"content": "**📤 快速上传文件到七牛云**\n\n支持指定路径、多存储桶、自动 CDN 刷新",
|
||||
"tag": "lark_md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "hr"
|
||||
},
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"content": "**💡 使用方式**\n\n• 直接发送文件给我\n• 或使用命令:`/upload [路径] [存储桶]`\n• 支持 `--original` 使用原文件名",
|
||||
"tag": "lark_md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "hr"
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {
|
||||
"content": "📎 选择文件上传",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"type": "primary",
|
||||
"value": {
|
||||
"action": "upload_file",
|
||||
"type": "upload"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {
|
||||
"content": "⚙️ 配置",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"type": "default",
|
||||
"value": {
|
||||
"action": "config",
|
||||
"type": "config"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {
|
||||
"content": "❓ 帮助",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"type": "default",
|
||||
"value": {
|
||||
"action": "help",
|
||||
"type": "help"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
static createUploading(fileName) {
|
||||
return {
|
||||
"config": {
|
||||
"wide_screen_mode": true
|
||||
},
|
||||
"header": {
|
||||
"template": "blue",
|
||||
"title": {
|
||||
"content": "📤 上传中...",
|
||||
"tag": "plain_text"
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"content": `**文件:** ${fileName}\n\n正在上传到七牛云,请稍候...`,
|
||||
"tag": "lark_md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "progress_bar",
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"content": "上传进度",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"value": 50,
|
||||
"color": "blue"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
static createSuccess(result) {
|
||||
return {
|
||||
"config": {
|
||||
"wide_screen_mode": true
|
||||
},
|
||||
"header": {
|
||||
"template": "green",
|
||||
"title": {
|
||||
"content": "✅ 上传成功",
|
||||
"tag": "plain_text"
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"content": `**📦 文件:** ${result.key}\n\n` +
|
||||
`**🔗 链接:** [点击查看](${result.url})\n\n` +
|
||||
`**🪣 存储桶:** ${result.bucket || 'default'}`,
|
||||
"tag": "lark_md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "hr"
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {
|
||||
"content": "🔗 复制链接",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"type": "default",
|
||||
"value": {
|
||||
"action": "copy_link",
|
||||
"url": result.url
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {
|
||||
"content": "📤 继续上传",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"type": "primary",
|
||||
"value": {
|
||||
"action": "upload_file",
|
||||
"type": "upload"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
static createError(error) {
|
||||
return {
|
||||
"config": {
|
||||
"wide_screen_mode": true
|
||||
},
|
||||
"header": {
|
||||
"template": "red",
|
||||
"title": {
|
||||
"content": "❌ 上传失败",
|
||||
"tag": "plain_text"
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"content": `**错误信息:**\n${error.message}\n\n请检查配置或联系管理员。`,
|
||||
"tag": "lark_md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {
|
||||
"content": "🔄 重试",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"type": "primary",
|
||||
"value": {
|
||||
"action": "upload_file",
|
||||
"type": "upload"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {
|
||||
"content": "⚙️ 检查配置",
|
||||
"tag": "plain_text"
|
||||
},
|
||||
"type": "default",
|
||||
"value": {
|
||||
"action": "config",
|
||||
"type": "config"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UploadCard };
|
||||
151
src/feishu-api.js
Normal file
151
src/feishu-api.js
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 飞书 API 封装
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
|
||||
class FeishuAPI {
|
||||
constructor() {
|
||||
this.appId = process.env.FEISHU_APP_ID;
|
||||
this.appSecret = process.env.FEISHU_APP_SECRET;
|
||||
this.baseURL = 'https://open.feishu.cn/open-apis';
|
||||
this.tokenCache = null;
|
||||
this.tokenExpiry = 0;
|
||||
}
|
||||
|
||||
// 获取访问令牌
|
||||
async getAccessToken() {
|
||||
if (this.tokenCache && Date.now() < this.tokenExpiry) {
|
||||
return this.tokenCache;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.baseURL}/auth/v3/tenant_access_token/internal`,
|
||||
{
|
||||
app_id: this.appId,
|
||||
app_secret: this.appSecret
|
||||
},
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
|
||||
const { tenant_access_token, expire } = response.data;
|
||||
|
||||
if (response.data.code !== 0) {
|
||||
throw new Error(`获取 token 失败:${response.data.msg}`);
|
||||
}
|
||||
|
||||
this.tokenCache = tenant_access_token;
|
||||
this.tokenExpiry = Date.now() + (expire - 300) * 1000; // 提前 5 分钟过期
|
||||
|
||||
return tenant_access_token;
|
||||
} catch (error) {
|
||||
throw new Error(`飞书 API 错误:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送文本消息
|
||||
async sendMessage(chatId, payload) {
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.baseURL}/im/v1/messages`,
|
||||
{
|
||||
receive_id: chatId,
|
||||
msg_type: payload.msg_type,
|
||||
content: payload.content
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.code !== 0) {
|
||||
throw new Error(`发送消息失败:${response.data.msg}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(`飞书消息发送失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送卡片消息
|
||||
async sendCard(chatId, card) {
|
||||
return this.sendMessage(chatId, {
|
||||
msg_type: 'interactive',
|
||||
content: JSON.stringify(card)
|
||||
});
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
async downloadFile(fileKey) {
|
||||
const token = await this.getAccessToken();
|
||||
const tempDir = path.join(process.cwd(), 'temp');
|
||||
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
const tempFile = path.join(tempDir, `feishu_${Date.now()}_${fileKey}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `${this.baseURL}/im/v1/files/${fileKey}/download`;
|
||||
|
||||
https.get(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`下载失败:${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fs.createWriteStream(tempFile);
|
||||
res.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve(tempFile);
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// 回复消息
|
||||
async replyMessage(messageId, payload) {
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.baseURL}/im/v1/messages`,
|
||||
{
|
||||
reply_id: messageId,
|
||||
msg_type: payload.msg_type,
|
||||
content: payload.content
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(`飞书回复失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { FeishuAPI };
|
||||
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`);
|
||||
});
|
||||
253
src/qiniu-uploader.js
Normal file
253
src/qiniu-uploader.js
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 七牛云上传工具
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
class QiniuUploader {
|
||||
constructor() {
|
||||
this.configPath = process.env.QINIU_CONFIG_PATH ||
|
||||
path.join(process.cwd(), 'config', 'qiniu-config.json');
|
||||
this.config = this.loadConfig();
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
loadConfig() {
|
||||
if (!fs.existsSync(this.configPath)) {
|
||||
// 创建默认配置
|
||||
const defaultConfig = {
|
||||
buckets: {
|
||||
default: {
|
||||
accessKey: process.env.QINIU_ACCESS_KEY || 'YOUR_ACCESS_KEY',
|
||||
secretKey: process.env.QINIU_SECRET_KEY || 'YOUR_SECRET_KEY',
|
||||
bucket: process.env.QINIU_BUCKET || 'your-bucket',
|
||||
region: process.env.QINIU_REGION || 'z0',
|
||||
domain: process.env.QINIU_DOMAIN || 'https://your-cdn.com'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dir = path.dirname(this.configPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(this.configPath, JSON.stringify(defaultConfig, null, 2));
|
||||
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
return JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
|
||||
}
|
||||
|
||||
// 列出配置
|
||||
async listConfig() {
|
||||
const config = this.loadConfig();
|
||||
const buckets = {};
|
||||
|
||||
for (const [name, bucket] of Object.entries(config.buckets)) {
|
||||
buckets[name] = {
|
||||
accessKey: this.maskKey(bucket.accessKey),
|
||||
secretKey: this.maskKey(bucket.secretKey),
|
||||
bucket: bucket.bucket,
|
||||
region: bucket.region,
|
||||
domain: bucket.domain
|
||||
};
|
||||
}
|
||||
|
||||
return { buckets };
|
||||
}
|
||||
|
||||
// 设置配置值
|
||||
async setConfigValue(keyPath, value) {
|
||||
const config = this.loadConfig();
|
||||
const keys = keyPath.split('.');
|
||||
let current = config;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) {
|
||||
current[keys[i]] = {};
|
||||
}
|
||||
current = current[keys[i]];
|
||||
}
|
||||
|
||||
const lastKey = keys[keys.length - 1];
|
||||
|
||||
// 类型转换
|
||||
if (value === 'true') value = true;
|
||||
else if (value === 'false') value = false;
|
||||
else if (!isNaN(value) && !value.includes('.')) value = Number(value);
|
||||
|
||||
current[lastKey] = value;
|
||||
|
||||
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2));
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
async upload(localFile, key, bucketName = 'default') {
|
||||
const bucketConfig = this.config.buckets[bucketName];
|
||||
|
||||
if (!bucketConfig) {
|
||||
throw new Error(`存储桶 "${bucketName}" 不存在`);
|
||||
}
|
||||
|
||||
const { accessKey, secretKey, bucket, region, domain } = bucketConfig;
|
||||
|
||||
// 生成上传凭证
|
||||
const uploadToken = this.generateUploadToken(accessKey, secretKey, bucket, key);
|
||||
|
||||
// 获取上传端点
|
||||
const uploadEndpoint = this.getUploadEndpoint(region);
|
||||
|
||||
// 读取文件
|
||||
const fileContent = fs.readFileSync(localFile);
|
||||
const fileName = path.basename(localFile);
|
||||
|
||||
// 构建 multipart/form-data
|
||||
const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2);
|
||||
|
||||
const bodyParts = [
|
||||
`------${boundary}`,
|
||||
'Content-Disposition: form-data; name="token"',
|
||||
'',
|
||||
uploadToken,
|
||||
`------${boundary}`,
|
||||
'Content-Disposition: form-data; name="key"',
|
||||
'',
|
||||
key,
|
||||
`------${boundary}`,
|
||||
`Content-Disposition: form-data; name="file"; filename="${fileName}"`,
|
||||
'Content-Type: application/octet-stream',
|
||||
'',
|
||||
''
|
||||
];
|
||||
|
||||
const bodyBuffer = Buffer.concat([
|
||||
Buffer.from(bodyParts.join('\r\n'), 'utf-8'),
|
||||
fileContent,
|
||||
Buffer.from(`\r\n------${boundary}--\r\n`, 'utf-8')
|
||||
]);
|
||||
|
||||
// 上传
|
||||
const result = await this.httpRequest(`${uploadEndpoint}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=----${boundary}`,
|
||||
'Content-Length': bodyBuffer.length
|
||||
}
|
||||
}, bodyBuffer);
|
||||
|
||||
if (result.status !== 200) {
|
||||
throw new Error(`上传失败:${JSON.stringify(result.data)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
key: result.data.key,
|
||||
hash: result.data.hash,
|
||||
url: `${domain}/${key}`
|
||||
};
|
||||
}
|
||||
|
||||
// 刷新 CDN
|
||||
async refreshCDN(bucketName, key) {
|
||||
const bucketConfig = this.config.buckets[bucketName];
|
||||
if (!bucketConfig) return;
|
||||
|
||||
const { accessKey, secretKey, domain } = bucketConfig;
|
||||
const fileUrl = `${domain}/${key}`;
|
||||
|
||||
const body = JSON.stringify({ urls: [fileUrl] });
|
||||
const accessToken = this.generateAccessToken(accessKey, secretKey, 'POST', '/v2/tune/refresh', body);
|
||||
|
||||
await this.httpRequest('https://fusion.qiniuapi.com/v2/tune/refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Host': 'fusion.qiniuapi.com',
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': body.length,
|
||||
'Authorization': accessToken
|
||||
}
|
||||
}, body);
|
||||
}
|
||||
|
||||
// 生成上传凭证
|
||||
generateUploadToken(accessKey, secretKey, bucket, key) {
|
||||
const deadline = Math.floor(Date.now() / 1000) + 3600;
|
||||
const scope = key ? `${bucket}:${key}` : bucket;
|
||||
|
||||
const putPolicy = {
|
||||
scope: scope,
|
||||
deadline: deadline
|
||||
};
|
||||
|
||||
const encodedPolicy = this.urlSafeBase64(JSON.stringify(putPolicy));
|
||||
const encodedSignature = this.urlSafeBase64(this.hmacSha1(encodedPolicy, secretKey));
|
||||
|
||||
return `${accessKey}:${encodedSignature}:${encodedPolicy}`;
|
||||
}
|
||||
|
||||
// 生成 CDN 刷新令牌
|
||||
generateAccessToken(accessKey, secretKey, method, path, body) {
|
||||
const signData = `${method} ${path}\nHost: fusion.qiniuapi.com\nContent-Type: application/json\n\n${body}`;
|
||||
const signature = this.hmacSha1(signData, secretKey);
|
||||
const encodedSign = this.urlSafeBase64(signature);
|
||||
return `Qiniu ${accessKey}:${encodedSign}`;
|
||||
}
|
||||
|
||||
// 获取上传端点
|
||||
getUploadEndpoint(region) {
|
||||
const endpoints = {
|
||||
'z0': 'https://up.qiniup.com',
|
||||
'z1': 'https://up-z1.qiniup.com',
|
||||
'z2': 'https://up-z2.qiniup.com',
|
||||
'na0': 'https://up-na0.qiniup.com',
|
||||
'as0': 'https://up-as0.qiniup.com'
|
||||
};
|
||||
return endpoints[region] || endpoints['z0'];
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
hmacSha1(data, secret) {
|
||||
return crypto.createHmac('sha1', secret).update(data).digest();
|
||||
}
|
||||
|
||||
urlSafeBase64(data) {
|
||||
return Buffer.from(data).toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
maskKey(key) {
|
||||
if (!key || key.length < 8) return '***';
|
||||
return key.substring(0, 4) + '...' + key.substring(key.length - 4);
|
||||
}
|
||||
|
||||
httpRequest(url, options, body = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http;
|
||||
|
||||
const req = protocol.request(url, options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
resolve({ status: res.statusCode, data: json });
|
||||
} catch (e) {
|
||||
resolve({ status: res.statusCode, data: data });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { QiniuUploader };
|
||||
Reference in New Issue
Block a user