initial: 七牛云上传 OpenClaw Skill

功能特性:
- 支持 /upload, /u 命令上传文件到七牛云
- 支持 /qiniu-config 配置管理
- 支持飞书卡片交互
- 支持指定上传路径和存储桶
- 自动刷新 CDN 缓存
- 支持文件覆盖上传

包含组件:
- OpenClaw 处理器 (openclaw-processor.js)
- 独立监听器 (scripts/feishu-listener.js)
- 核心上传脚本 (scripts/upload-to-qiniu.js)
- 部署脚本 (deploy.sh)
- 完整文档

部署方式:
1. 复制 skill 到 ~/.openclaw/workspace/skills/
2. 配置 ~/.openclaw/credentials/qiniu-config.json
3. 重启 OpenClaw Gateway
This commit is contained in:
daoqi
2026-03-07 16:02:18 +08:00
commit 1aeae9cc51
36 changed files with 6826 additions and 0 deletions

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env node
/**
* 七牛云存储桶覆盖设置检查脚本
*
* 用途:检查存储桶是否允许覆盖上传
*
* 用法:
* node check-bucket-override.js [bucket-name]
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const https = require('https');
const http = require('http');
const DEFAULT_CONFIG_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.openclaw/credentials/qiniu-config.json');
function loadConfig() {
if (!fs.existsSync(DEFAULT_CONFIG_PATH)) {
throw new Error(`配置文件不存在:${DEFAULT_CONFIG_PATH}`);
}
return JSON.parse(fs.readFileSync(DEFAULT_CONFIG_PATH, 'utf-8'));
}
function hmacSha1(data, secret) {
return crypto.createHmac('sha1', secret).update(data).digest();
}
function urlSafeBase64(data) {
return Buffer.from(data).toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function generateAccessToken(accessKey, secretKey, method, path, body = '') {
const host = 'kodo.qiniu.com';
const contentType = 'application/json';
// 格式Method Path\nHost: Host\nContent-Type: ContentType\n\nBody
const signData = `${method} ${path}\nHost: ${host}\nContent-Type: ${contentType}\n\n${body}`;
const signature = hmacSha1(signData, secretKey);
const encodedSign = urlSafeBase64(signature);
return `Qiniu ${accessKey}:${encodedSign}`;
}
function 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();
});
}
async function checkBucket(bucketName) {
const config = loadConfig();
const bucketConfig = config.buckets[bucketName || 'default'];
if (!bucketConfig) {
throw new Error(`存储桶配置 "${bucketName || 'default'}" 不存在`);
}
const { accessKey, secretKey, bucket, region } = bucketConfig;
console.log('🔍 检查存储桶覆盖设置...\n');
console.log(`存储桶:${bucket}`);
console.log(`区域:${region}`);
console.log(`AccessKey: ${accessKey.substring(0, 4)}...${accessKey.substring(accessKey.length - 4)}`);
console.log('');
// 1. 获取存储桶列表
// 七牛云 API 文档https://developer.qiniu.com/kodo/api/1314/list-buckets
const listBucketsUrl = 'https://kodo.qiniu.com/v2/buckets';
const accessToken = generateAccessToken(accessKey, secretKey, 'GET', '/v2/buckets');
const listOptions = {
method: 'GET',
headers: {
'Host': 'kodo.qiniu.com',
'Authorization': accessToken
}
};
console.log('📋 获取存储桶列表...');
const listResult = await httpRequest(listBucketsUrl, listOptions);
if (listResult.status !== 200) {
console.error('❌ 获取存储桶列表失败:', listResult.data);
return;
}
const buckets = listResult.data;
const targetBucket = buckets.find(b => b.name === bucket);
if (!targetBucket) {
console.error(`❌ 未找到存储桶:${bucket}`);
console.log('\n可用的存储桶:');
buckets.forEach(b => console.log(` - ${b.name}`));
return;
}
console.log('✅ 存储桶存在\n');
// 2. 获取存储桶详细信息
const bucketInfoUrl = `https://kodo.qiniu.com/v2/buckets/${bucket}`;
const bucketInfoToken = generateAccessToken(accessKey, secretKey, 'GET', `/v2/buckets/${bucket}`);
const infoOptions = {
method: 'GET',
headers: {
'Host': 'kodo.qiniu.com',
'Authorization': bucketInfoToken
}
};
console.log('📋 获取存储桶详细信息...');
const infoResult = await httpRequest(bucketInfoUrl, infoOptions);
if (infoResult.status !== 200) {
console.error('❌ 获取存储桶信息失败:', infoResult.data);
console.log('\n⚠ 可能是权限不足,请检查 AccessKey/SecretKey 是否有存储桶管理权限');
return;
}
const bucketInfo = infoResult.data;
console.log('\n📊 存储桶配置信息:');
console.log('─────────────────────────────────────');
console.log(` 名称:${bucketInfo.name || 'N/A'}`);
console.log(` 区域:${bucketInfo.region || bucketInfo.info?.region || 'N/A'}`);
console.log(` 创建时间:${bucketInfo.createdAt || bucketInfo.info?.createdAt || 'N/A'}`);
// 检查覆盖相关设置
const info = bucketInfo.info || bucketInfo;
console.log('\n🔒 安全设置:');
console.log('─────────────────────────────────────');
// 防覆盖设置(关键!)
const noOverwrite = info.noOverwrite !== undefined ? info.noOverwrite : '未设置';
console.log(` 防覆盖:${noOverwrite === true || noOverwrite === 1 ? '❌ 已启用(禁止覆盖)' : '✅ 未启用(允许覆盖)'}`);
// 私有空间设置
const private = info.private !== undefined ? info.private : '未知';
console.log(` 空间类型:${private === true || private === 1 ? '私有空间' : '公共空间'}`);
// 其他设置
if (info.maxSpace !== undefined) {
console.log(` 容量限制:${info.maxSpace} bytes`);
}
console.log('\n💡 解决方案:');
console.log('─────────────────────────────────────');
if (noOverwrite === true || noOverwrite === 1) {
console.log('⚠️ 存储桶已启用"防覆盖"设置,需要关闭才能覆盖上传同名文件。\n');
console.log('关闭方法:');
console.log('1. 登录七牛云控制台https://portal.qiniu.com/');
console.log(`2. 进入"对象存储" → 选择存储桶 "${bucket}"`);
console.log('3. 点击"设置" → "空间设置"');
console.log('4. 找到"防覆盖"选项,关闭它');
console.log('5. 保存设置后重试上传\n');
console.log('或者使用命令行关闭:');
console.log(`node scripts/update-bucket-setting.js ${bucket} noOverwrite 0`);
} else {
console.log('✅ 存储桶允许覆盖上传');
console.log('\n如果仍然无法覆盖可能原因:');
console.log('1. 上传凭证 scope 指定了具体 key但上传时使用了不同的 key');
console.log('2. 上传 API 端点不正确');
console.log('3. 文件正在被其他进程占用');
console.log('\n建议:');
console.log('- 检查上传日志中的实际上传 key 是否一致');
console.log('- 使用相同的完整路径(包括前导 /');
}
}
async function main() {
const bucketName = process.argv[2];
try {
await checkBucket(bucketName);
} catch (error) {
console.error('❌ 错误:', error.message);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { checkBucket };

275
scripts/debug-upload.js Normal file
View File

@@ -0,0 +1,275 @@
#!/usr/bin/env node
/**
* 七牛云上传调试脚本
*
* 用途:测试上传并显示详细错误信息
*
* 用法:
* node debug-upload.js --file <文件路径> --key <目标路径>
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const https = require('https');
const http = require('http');
const DEFAULT_CONFIG_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.openclaw/credentials/qiniu-config.json');
function loadConfig() {
if (!fs.existsSync(DEFAULT_CONFIG_PATH)) {
throw new Error(`配置文件不存在:${DEFAULT_CONFIG_PATH}`);
}
return JSON.parse(fs.readFileSync(DEFAULT_CONFIG_PATH, 'utf-8'));
}
function hmacSha1(data, secret) {
return crypto.createHmac('sha1', secret).update(data).digest();
}
function urlSafeBase64(data) {
return Buffer.from(data).toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function generateUploadToken(accessKey, secretKey, bucket, key = null, expires = 3600) {
const deadline = Math.floor(Date.now() / 1000) + expires;
// 关键修复scope 必须包含 key 才能覆盖上传
let scope = bucket;
if (key) {
scope = `${bucket}:${key}`; // ✅ 添加 key允许覆盖
}
console.log('📝 上传凭证参数:');
console.log(` scope: ${scope} (包含 key 才能覆盖)`);
console.log(` deadline: ${deadline}`);
console.log(` key: ${key || '(未指定,使用表单中的 key)'}`);
const putPolicy = {
scope: scope,
deadline: deadline,
returnBody: JSON.stringify({
success: true,
key: '$(key)',
hash: '$(etag)',
fsize: '$(fsize)',
bucket: '$(bucket)',
url: `$(domain)/$(key)`
})
};
console.log('\n📋 上传凭证策略:');
console.log(JSON.stringify(putPolicy, null, 2));
const encodedPolicy = urlSafeBase64(JSON.stringify(putPolicy));
const encodedSignature = urlSafeBase64(hmacSha1(encodedPolicy, secretKey));
const token = `${accessKey}:${encodedSignature}:${encodedPolicy}`;
console.log('\n🔑 生成的上传凭证:');
console.log(` ${accessKey}:${encodedSignature.substring(0, 20)}...`);
return token;
}
function httpRequest(url, options, body = null) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http;
console.log(`\n📤 发送请求:`);
console.log(` URL: ${url}`);
console.log(` Method: ${options.method}`);
console.log(` Headers:`, JSON.stringify(options.headers, null, 2));
const req = protocol.request(url, options, (res) => {
console.log(`\n📥 收到响应:`);
console.log(` Status: ${res.statusCode}`);
console.log(` Headers:`, JSON.stringify(res.headers, null, 2));
let data = '';
res.on('data', chunk => {
data += chunk;
console.log(` 接收数据块:${chunk.length} bytes`);
});
res.on('end', () => {
console.log(`\n📦 完整响应数据:`);
console.log(data);
try {
const json = JSON.parse(data);
resolve({ status: res.statusCode, data: json });
} catch (e) {
resolve({ status: res.statusCode, data: data, raw: true });
}
});
});
req.on('error', (e) => {
console.error('❌ 请求错误:', e);
reject(e);
});
if (body) {
console.log(`\n📤 请求体大小:${body.length} bytes`);
req.write(body);
}
req.end();
});
}
async function debugUpload() {
const args = process.argv.slice(2);
let filePath = null;
let key = null;
let bucketName = 'default';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--file' && args[i + 1]) {
filePath = args[i + 1];
i++;
} else if (args[i] === '--key' && args[i + 1]) {
key = args[i + 1];
i++;
} else if (args[i] === '--bucket' && args[i + 1]) {
bucketName = args[i + 1];
i++;
}
}
if (!filePath) {
console.error('❌ 缺少必需参数 --file');
console.error('用法node debug-upload.js --file <文件路径> [--key <目标路径>] [--bucket <存储桶名>]');
process.exit(1);
}
if (!fs.existsSync(filePath)) {
console.error(`❌ 文件不存在:${filePath}`);
process.exit(1);
}
const config = loadConfig();
const bucketConfig = config.buckets[bucketName];
if (!bucketConfig) {
console.error(`❌ 存储桶配置 "${bucketName}" 不存在`);
process.exit(1);
}
const { accessKey, secretKey, bucket, region, domain } = bucketConfig;
// 确定目标 key
if (!key) {
key = path.basename(filePath);
} else if (key.startsWith('/')) {
key = key.substring(1);
}
console.log('═══════════════════════════════════════════════════════════');
console.log('🔍 七牛云上传调试');
console.log('═══════════════════════════════════════════════════════════');
console.log(`\n📁 文件信息:`);
console.log(` 本地路径:${filePath}`);
console.log(` 文件大小:${fs.statSync(filePath).size} bytes`);
console.log(` 目标 key: ${key}`);
console.log(` 存储桶:${bucket}`);
console.log(` 区域:${region}`);
console.log(` 域名:${domain}`);
// 生成上传凭证
console.log('\n═══════════════════════════════════════════════════════════');
const uploadToken = generateUploadToken(accessKey, secretKey, bucket, key);
// 构建上传请求
const regionEndpoint = getUploadEndpoint(region);
const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2);
const fileContent = fs.readFileSync(filePath);
const fileName = path.basename(filePath);
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 uploadUrl = `${regionEndpoint}/`;
const uploadOptions = {
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=----${boundary}`,
'Content-Length': bodyBuffer.length
}
};
console.log('\n═══════════════════════════════════════════════════════════');
console.log('📤 开始上传...');
console.log('═══════════════════════════════════════════════════════════');
try {
const result = await httpRequest(uploadUrl, uploadOptions, bodyBuffer);
console.log('\n═══════════════════════════════════════════════════════════');
console.log('📊 上传结果:');
console.log('═══════════════════════════════════════════════════════════');
if (result.status === 200) {
console.log('✅ 上传成功!');
console.log(` key: ${result.data.key}`);
console.log(` hash: ${result.data.hash}`);
console.log(` url: ${domain}/${result.data.key}`);
} else {
console.log('❌ 上传失败!');
console.log(` HTTP Status: ${result.status}`);
console.log(` 错误信息:`, JSON.stringify(result.data, null, 2));
// 解析常见错误
if (result.data.error) {
console.log('\n🔍 错误分析:');
if (result.data.error.includes('file exists')) {
console.log(' ⚠️ 文件已存在,存储桶可能禁止覆盖');
} else if (result.data.error.includes('invalid token')) {
console.log(' ⚠️ 上传凭证无效,检查 AccessKey/SecretKey');
} else if (result.data.error.includes('bucket')) {
console.log(' ⚠️ 存储桶配置问题');
}
}
}
} catch (error) {
console.error('❌ 上传过程出错:', error.message);
}
}
function 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'];
}
debugUpload().catch(console.error);

View File

@@ -0,0 +1,410 @@
#!/usr/bin/env node
/**
* 飞书卡片交互服务器
*
* 功能:
* 1. 接收飞书卡片按钮点击回调
* 2. 处理交互逻辑(上传、配置、帮助)
* 3. 回复交互式消息
*
* 使用方式:
* node scripts/feishu-card-server.js [port]
*
* 默认端口3000
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// ============ 配置 ============
const PORT = process.argv[2] || 3000;
const CARD_TEMPLATE_PATH = path.join(__dirname, '../cards/upload-card.json');
const QINIU_CONFIG_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.openclaw/credentials/qiniu-config.json');
// 飞书验证令牌(在飞书开发者后台设置)
const FEISHU_VERIFICATION_TOKEN = process.env.FEISHU_VERIFICATION_TOKEN || 'your_verification_token';
// ============ 工具函数 ============
function loadConfig(configPath = QINIU_CONFIG_PATH) {
if (!fs.existsSync(configPath)) {
throw new Error(`配置文件不存在:${configPath}`);
}
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
function loadCardTemplate(templatePath = CARD_TEMPLATE_PATH) {
if (!fs.existsSync(templatePath)) {
throw new Error(`卡片模板不存在:${templatePath}`);
}
return JSON.parse(fs.readFileSync(templatePath, 'utf-8'));
}
function renderCard(template, variables) {
let cardJson = JSON.stringify(template);
for (const [key, value] of Object.entries(variables)) {
cardJson = cardJson.replace(new RegExp(`{{${key}}}`, 'g'), value);
}
return JSON.parse(cardJson);
}
function getRegionName(regionCode) {
const regions = {
'z0': '华东',
'z1': '华北',
'z2': '华南',
'na0': '北美',
'as0': '东南亚'
};
return regions[regionCode] || '未知';
}
// ============ 飞书鉴权 ============
/**
* 验证飞书请求签名
* 文档https://open.feishu.cn/document/ukTMukTMukTM/uYjNwYjL2YDM14SM2ATN
*/
function verifyFeishuSignature(req, body) {
const signature = req.headers['x-feishu-signature'];
if (!signature) return false;
// 简单验证,生产环境需要严格验证
return true;
}
// ============ 卡片交互处理 ============
/**
* 处理卡片按钮点击
*/
async function handleCardInteraction(req, res) {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', async () => {
try {
const data = JSON.parse(body);
// 飞书挑战验证
if (data.type === 'url_verification') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ challenge: data.challenge }));
return;
}
// 处理交互事件
if (data.type === 'interactive_card.action') {
const action = data.action?.value?.action;
const userId = data.user?.user_id;
const openId = data.user?.open_id;
const tenantKey = data.tenant_key;
console.log(`收到卡片交互:${action}, 用户:${userId}`);
let responseCard;
switch (action) {
case 'upload_select':
responseCard = await handleUploadSelect(data);
break;
case 'config_view':
responseCard = await handleConfigView(data);
break;
case 'help':
responseCard = await handleHelp(data);
break;
default:
responseCard = createErrorResponse('未知操作');
}
// 回复卡片
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
type: 'interactive_card.response',
card: responseCard
}));
return;
}
// 未知类型
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
} catch (error) {
console.error('处理交互失败:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
});
}
/**
* 处理"选择文件上传"按钮
*/
async function handleUploadSelect(data) {
const config = loadConfig();
const bucketName = data.action?.value?.bucket || 'default';
const bucketConfig = config.buckets[bucketName];
if (!bucketConfig) {
return createErrorResponse(`存储桶 "${bucketName}" 不存在`);
}
// 回复引导用户上传文件
return {
config: {
wide_screen_mode: true
},
header: {
template: "green",
title: {
content: "📎 选择文件",
tag: "plain_text"
}
},
elements: [
{
tag: "div",
text: {
content: `请点击下方按钮选择要上传的文件,文件将上传到 **${bucketName}** 存储桶。`,
tag: "lark_md"
}
},
{
tag: "action",
actions: [
{
tag: "button",
text: {
content: "📁 选择文件",
tag: "plain_text"
},
type: "primary",
url: "feishu://attachment/select" // 飞书内部协议,触发文件选择
}
]
}
]
};
}
/**
* 处理"查看配置"按钮
*/
async function handleConfigView(data) {
const config = loadConfig();
let bucketList = '';
for (const [name, bucket] of Object.entries(config.buckets)) {
bucketList += `**${name}**: ${bucket.bucket} (${bucket.region})\n`;
}
return {
config: {
wide_screen_mode: true
},
header: {
template: "blue",
title: {
content: "📋 当前配置",
tag: "plain_text"
}
},
elements: [
{
tag: "div",
text: {
content: bucketList || '暂无配置',
tag: "lark_md"
}
},
{
tag: "hr"
},
{
tag: "note",
elements: [
{
tag: "plain_text",
content: `配置文件:${QINIU_CONFIG_PATH}`
}
]
}
]
};
}
/**
* 处理"帮助"按钮
*/
async function handleHelp(data) {
return {
config: {
wide_screen_mode: true
},
header: {
template: "grey",
title: {
content: "❓ 帮助",
tag: "plain_text"
}
},
elements: [
{
tag: "div",
text: {
content: `**七牛云上传帮助**
📤 **上传文件**
- 点击"选择文件上传"按钮
- 选择要上传的文件
- 自动上传到七牛云
⚙️ **快捷命令**
- \`/u\` - 快速上传
- \`/qc\` - 查看配置
- \`/qh\` - 显示帮助
📦 **存储桶**
- 支持多存储桶配置
- 上传时可指定目标桶`,
tag: "lark_md"
}
}
]
};
}
/**
* 创建错误响应
*/
function createErrorResponse(message) {
return {
config: {
wide_screen_mode: true
},
header: {
template: "red",
title: {
content: "❌ 错误",
tag: "plain_text"
}
},
elements: [
{
tag: "div",
text: {
content: message,
tag: "lark_md"
}
}
]
};
}
// ============ 主页面(测试用) ============
function serveHomePage(res) {
const html = `
<!DOCTYPE html>
<html>
<head>
<title>七牛云上传 - 飞书卡片服务器</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
h1 { color: #333; }
.status { padding: 10px; background: #e8f5e9; border-radius: 4px; margin: 20px 0; }
.config { background: #f5f5f5; padding: 15px; border-radius: 4px; }
code { background: #eee; padding: 2px 6px; border-radius: 3px; }
</style>
</head>
<body>
<h1>🍙 七牛云上传 - 飞书卡片服务器</h1>
<div class="status">
✅ 服务器运行中
<br>端口:<code>${PORT}</code>
</div>
<div class="config">
<h3>配置信息</h3>
<p>卡片模板:<code>${CARD_TEMPLATE_PATH}</code></p>
<p>七牛配置:<code>${QINIU_CONFIG_PATH}</code></p>
</div>
<h3>飞书开发者后台配置</h3>
<ol>
<li>请求网址:<code>http://你的服务器IP:${PORT}/feishu/card</code></li>
<li>数据加密方式:选择"不加密"</li>
<li>验证令牌:在环境变量中设置 <code>FEISHU_VERIFICATION_TOKEN</code></li>
</ol>
<h3>测试</h3>
<p>使用 curl 测试:</p>
<pre><code>curl -X POST http://localhost:${PORT}/feishu/card \\
-H "Content-Type: application/json" \\
-d '{"type":"url_verification","challenge":"test123"}'</code></pre>
</body>
</html>
`;
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
}
// ============ HTTP 服务器 ============
const server = http.createServer((req, res) => {
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
// CORS 头(飞书回调需要)
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Feishu-Signature');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// 主页
if (req.url === '/' || req.url === '/health') {
serveHomePage(res);
return;
}
// 卡片交互回调
if (req.url === '/feishu/card' && req.method === 'POST') {
handleCardInteraction(req, res);
return;
}
// 404
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
});
// ============ 启动服务器 ============
server.listen(PORT, () => {
console.log(`🍙 七牛云卡片服务器已启动`);
console.log(`端口:${PORT}`);
console.log(`主页http://localhost:${PORT}/`);
console.log(`回调地址http://localhost:${PORT}/feishu/card`);
console.log(`\n在飞书开发者后台配置请求网址为http://你的服务器IP:${PORT}/feishu/card`);
});
// 优雅退出
process.on('SIGINT', () => {
console.log('\n正在关闭服务器...');
server.close(() => {
console.log('服务器已关闭');
process.exit(0);
});
});

514
scripts/feishu-listener.js Executable file
View File

@@ -0,0 +1,514 @@
#!/usr/bin/env node
/**
* 飞书消息监听器 - 七牛云上传自动化 v3
*
* 功能:
* 1. 监听飞书消息,解析上传指令
* 2. 支持指定上传路径:/upload /path/to/file.txt
* 3. 支持使用原文件名:/upload --original
* 4. 支持聊天命令修改配置:/qiniu-config set key value
* 5. 非上传命令转发到 OpenClaw Gateway
* 6. 下载附件并上传到七牛云
* 7. 刷新 CDN 并回复结果
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const { exec } = require('child_process');
const crypto = require('crypto');
// ============ 配置 ============
const CONFIG = {
port: process.env.FEISHU_LISTENER_PORT || 3000,
verifyToken: process.env.FEISHU_VERIFY_TOKEN || '',
encryptKey: process.env.FEISHU_ENCRYPT_KEY || '',
appSecret: process.env.FEISHU_APP_SECRET || '',
openclawCredentials: path.join(process.env.HOME || process.env.USERPROFILE, '.openclaw/credentials'),
scriptDir: __dirname,
// OpenClaw Gateway 配置
openclawGateway: {
host: '127.0.0.1',
port: 17733
}
};
// ============ 工具函数 ============
function log(...args) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}]`, ...args);
}
function verifySignature(timestamp, nonce, signature) {
if (!CONFIG.encryptKey) return true;
const arr = [CONFIG.encryptKey, timestamp, nonce];
arr.sort();
const str = arr.join('');
const hash = crypto.createHash('sha1').update(str).digest('hex');
return hash === signature;
}
// ============ 命令解析 ============
function isUploadCommand(text) {
return /^\/upload(?:\s+.+)?$/i.test(text.trim());
}
function isConfigCommand(text) {
return /^\/qiniu-config\s+.+$/i.test(text.trim());
}
function isHelpCommand(text) {
return /^\/(qiniu-)?help$/i.test(text.trim());
}
function parseUploadCommand(text) {
const match = text.match(/^\/upload(?:\s+(.+))?$/i);
if (!match) return null;
const args = (match[1] || '').trim().split(/\s+/).filter(Boolean);
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;
}
}
return {
command: 'upload',
targetPath: targetPath,
useOriginal: useOriginal,
bucket: bucket
};
}
function parseConfigCommand(text) {
const match = text.match(/^\/qiniu-config\s+(.+)$/i);
if (!match) return null;
const args = match[1].trim().split(/\s+/);
const subCommand = args[0];
return {
command: 'config',
subCommand: subCommand,
args: args.slice(1)
};
}
// ============ 飞书 API ============
async function getAccessToken(appId, appSecret) {
const url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal';
const body = JSON.stringify({
app_id: appId,
app_secret: appSecret
});
return new Promise((resolve, reject) => {
const req = https.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const result = JSON.parse(data);
if (result.code === 0) {
resolve(result.tenant_access_token);
} else {
reject(new Error(`获取 token 失败:${result.msg}`));
}
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
async function sendMessageToChat(chatId, text, msgType = 'text') {
try {
const appId = process.env.FEISHU_APP_ID;
const appSecret = process.env.FEISHU_APP_SECRET;
if (!appId || !appSecret) {
log('❌ 缺少飞书 App ID 或 Secret');
return;
}
const token = await getAccessToken(appId, appSecret);
const url = 'https://open.feishu.cn/open-apis/im/v1/messages';
const body = JSON.stringify({
receive_id: chatId,
msg_type: msgType,
content: msgType === 'text' ? JSON.stringify({ text }) : text
});
await new Promise((resolve, reject) => {
const req = https.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
} catch (e) {
log('发送消息失败:', e.message);
}
}
async function downloadFeishuFile(token, fileKey, destPath) {
const url = `https://open.feishu.cn/open-apis/im/v1/files/${fileKey}/download`;
return new Promise((resolve, reject) => {
const req = https.get(url, {
headers: {
'Authorization': `Bearer ${token}`
}
}, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`下载失败:${res.statusCode}`));
return;
}
const file = fs.createWriteStream(destPath);
res.pipe(file);
file.on('finish', () => {
file.close();
resolve(destPath);
});
}).on('error', reject);
});
}
// ============ 转发到 OpenClaw ============
async function forwardToOpenClaw(event) {
return new Promise((resolve, reject) => {
const body = JSON.stringify(event);
const options = {
hostname: CONFIG.openclawGateway.host,
port: CONFIG.openclawGateway.port,
path: '/feishu/event',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': body.length
}
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (res.statusCode === 200) {
resolve(JSON.parse(data));
} else {
reject(new Error(`OpenClaw 返回错误:${res.statusCode}`));
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
// ============ 消息处理 ============
async function handleUploadCommand(message, cmd) {
const { chat_id, attachments } = message;
if (!attachments || attachments.length === 0) {
await sendMessageToChat(chat_id,
'❌ 请附上要上传的文件\n\n' +
'💡 使用示例:\n' +
'/upload /config/test/file.txt default\n' +
'[附上文件]\n\n' +
'或:/upload --original default\n' +
'[附上文件] (使用原文件名)'
);
return;
}
const attachment = attachments[0];
const fileKey = attachment.file_key;
const originalFileName = attachment.file_name;
log(`处理附件:${originalFileName} (${fileKey})`);
let targetKey;
if (cmd.useOriginal) {
targetKey = originalFileName;
} else if (cmd.targetPath) {
targetKey = cmd.targetPath.startsWith('/') ? cmd.targetPath.substring(1) : cmd.targetPath;
} else {
targetKey = originalFileName;
}
const tempDir = path.join(CONFIG.openclawCredentials, 'temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const tempFile = path.join(tempDir, `upload_${Date.now()}_${originalFileName}`);
try {
const appId = process.env.FEISHU_APP_ID;
const appSecret = process.env.FEISHU_APP_SECRET;
if (!appId || !appSecret) {
throw new Error('缺少飞书 App ID 或 App Secret 配置');
}
const token = await getAccessToken(appId, appSecret);
log('下载文件中...');
await sendMessageToChat(chat_id, `📥 正在下载文件:${originalFileName}`);
await downloadFeishuFile(token, fileKey, tempFile);
log('上传到七牛云...');
await sendMessageToChat(chat_id, `📤 正在上传到七牛云:${targetKey}\n存储桶:${cmd.bucket}`);
const uploadScript = path.join(CONFIG.scriptDir, 'upload-to-qiniu.js');
const uploadCmd = `node "${uploadScript}" upload --file "${tempFile}" --key "${targetKey}" --bucket "${cmd.bucket}"`;
const { stdout, stderr } = await new Promise((resolve, reject) => {
exec(uploadCmd, (error, stdout, stderr) => {
if (error) {
reject(new Error(`上传失败:${stderr || error.message}`));
return;
}
resolve({ stdout, stderr });
});
});
log(stdout);
const urlMatch = stdout.match(/🔗 URL: (.+)/);
const fileUrl = urlMatch ? urlMatch[1] : 'N/A';
await sendMessageToChat(chat_id,
`✅ 上传成功!\n\n` +
`📦 文件:${targetKey}\n` +
`🔗 链接:${fileUrl}\n` +
`💾 原文件:${originalFileName}\n` +
`🪣 存储桶:${cmd.bucket}`
);
} catch (error) {
log('处理失败:', error.message);
await sendMessageToChat(chat_id, `❌ 上传失败:${error.message}`);
} finally {
if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
}
}
}
async function handleConfigCommand(message, cmd) {
const { chat_id } = message;
const uploadScript = path.join(CONFIG.scriptDir, 'upload-to-qiniu.js');
const configCmd = `node "${uploadScript}" config ${cmd.subCommand} ${cmd.args.join(' ')}`;
try {
const { stdout, stderr } = await new Promise((resolve, reject) => {
exec(configCmd, (error, stdout, stderr) => {
if (error) {
reject(new Error(stderr || error.message));
return;
}
resolve({ stdout, stderr });
});
});
await sendMessageToChat(chat_id, '```\n' + stdout + '\n```');
} catch (error) {
await sendMessageToChat(chat_id, `❌ 配置命令执行失败:${error.message}`);
}
}
async function showHelp(message) {
const helpText = `
🍙 七牛云上传 - 使用帮助
📤 上传文件:
/upload [目标路径] [存储桶名]
/upload --original [存储桶名]
示例:
/upload /config/test/file.txt default
/upload --original default
⚙️ 配置管理:
/qiniu-config list # 查看配置
/qiniu-config set <key> <value> # 修改配置
/qiniu-config set-bucket <name> <json> # 添加存储桶
/qiniu-config reset # 重置配置
示例:
/qiniu-config set default.accessKey YOUR_KEY
/qiniu-config set default.domain https://cdn.example.com
`;
await sendMessageToChat(message.chat_id, helpText);
}
async function processMessage(event) {
const { message } = event;
log('收到消息:', message.message_id);
const content = JSON.parse(message.content);
const text = content.text || '';
// 检查是否是七牛云命令
if (isUploadCommand(text)) {
const cmd = parseUploadCommand(text);
log('上传命令:', cmd);
await handleUploadCommand(message, cmd);
return;
}
if (isConfigCommand(text)) {
const cmd = parseConfigCommand(text);
log('配置命令:', cmd.subCommand);
await handleConfigCommand(message, cmd);
return;
}
if (isHelpCommand(text)) {
await showHelp(message);
return;
}
// 非七牛云命令,转发到 OpenClaw
log('转发到 OpenClaw:', text.substring(0, 50));
try {
await forwardToOpenClaw(event);
log('✅ 已转发到 OpenClaw');
} catch (error) {
log('❌ 转发失败:', error.message);
}
}
// ============ HTTP 服务器 ============
function startServer() {
const server = http.createServer(async (req, res) => {
if (req.method !== 'POST') {
res.writeHead(405);
res.end('Method Not Allowed');
return;
}
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', async () => {
try {
const event = JSON.parse(body);
// 验证签名
const timestamp = req.headers['x-feishu-request-timestamp'];
const nonce = req.headers['x-feishu-request-nonce'];
const signature = req.headers['x-feishu-request-signature'];
if (!verifySignature(timestamp, nonce, signature)) {
res.writeHead(401);
res.end('Invalid signature');
return;
}
// 处理 URL 验证
if (event.type === 'url_verification') {
log('✅ 收到 URL 验证请求');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ challenge: event.challenge }));
return;
}
// 处理消息事件
if (event.type === 'im.message.receive_v1') {
await processMessage(event);
}
res.writeHead(200);
res.end('OK');
} catch (e) {
log('处理请求失败:', e.message);
res.writeHead(500);
res.end('Internal Server Error');
}
});
});
server.listen(CONFIG.port, () => {
log(`🚀 飞书监听器启动,端口:${CONFIG.port}`);
log(`📍 请求地址http://47.83.185.237:${CONFIG.port}`);
log(`🔄 非七牛云命令将转发到 OpenClaw Gateway (${CONFIG.openclawGateway.host}:${CONFIG.openclawGateway.port})`);
});
}
// ============ 主函数 ============
function main() {
log('🍙 七牛云上传 - 飞书监听器 v3 (带 OpenClaw 转发)');
log('配置文件:~/.openclaw/credentials/qiniu-config.json');
log('');
const configPath = path.join(CONFIG.openclawCredentials, 'qiniu-config.json');
if (!fs.existsSync(configPath)) {
log('⚠️ 警告:七牛云配置文件不存在');
log(' 运行node upload-to-qiniu.js config init');
}
startServer();
}
main();

View File

@@ -0,0 +1,477 @@
#!/usr/bin/env node
/**
* 飞书长连接监听器 - 七牛云上传自动化
*
* 使用飞书 WebSocket 长连接接收事件
*
* 使用方式:
* node scripts/feishu-websocket-listener.js
*/
const WebSocket = require('ws');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const https = require('https');
const { exec } = require('child_process');
// ============ 配置 ============
const CONFIG = {
appId: process.env.FEISHU_APP_ID || 'cli_a92ce47b02381bcc',
appSecret: process.env.FEISHU_APP_SECRET || 'WpCWhqOPKv3F5Lhn11DqubrssJnAodot',
encryptKey: process.env.FEISHU_ENCRYPT_KEY || '',
openclawCredentials: path.join(process.env.HOME || process.env.USERPROFILE, '.openclaw/credentials'),
scriptDir: __dirname
};
// ============ 工具函数 ============
function log(...args) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}]`, ...args);
}
function verifySignature(timestamp, nonce, signature) {
if (!CONFIG.encryptKey) return true;
const arr = [CONFIG.encryptKey, timestamp, nonce];
arr.sort();
const str = arr.join('');
const hash = crypto.createHash('sha1').update(str).digest('hex');
return hash === signature;
}
// ============ 命令解析 ============
function parseUploadCommand(text) {
const match = text.match(/^\/upload(?:\s+(.+))?$/i);
if (!match) return null;
const args = (match[1] || '').trim().split(/\s+/).filter(Boolean);
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;
}
}
return {
command: 'upload',
targetPath: targetPath,
useOriginal: useOriginal,
bucket: bucket
};
}
function parseConfigCommand(text) {
const match = text.match(/^\/qiniu-config\s+(.+)$/i);
if (!match) return null;
const args = match[1].trim().split(/\s+/);
const subCommand = args[0];
return {
command: 'config',
subCommand: subCommand,
args: args.slice(1)
};
}
// ============ 飞书 API ============
async function getAccessToken(appId, appSecret) {
const url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal';
const body = JSON.stringify({
app_id: appId,
app_secret: appSecret
});
return new Promise((resolve, reject) => {
const req = https.request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const result = JSON.parse(data);
if (result.code === 0) {
resolve(result.tenant_access_token);
} else {
reject(new Error(`获取 token 失败:${result.msg}`));
}
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
async function sendMessageToChat(chatId, text) {
try {
const token = await getAccessToken(CONFIG.appId, CONFIG.appSecret);
const url = 'https://open.feishu.cn/open-apis/im/v1/messages';
const body = JSON.stringify({
receive_id: chatId,
msg_type: 'text',
content: JSON.stringify({ text })
});
await new Promise((resolve, reject) => {
const req = https.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
} catch (e) {
log('发送消息失败:', e.message);
}
}
async function downloadFeishuFile(token, fileKey, destPath) {
const url = `https://open.feishu.cn/open-apis/im/v1/files/${fileKey}/download`;
return new Promise((resolve, reject) => {
const req = https.get(url, {
headers: { 'Authorization': `Bearer ${token}` }
}, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`下载失败:${res.statusCode}`));
return;
}
const file = fs.createWriteStream(destPath);
res.pipe(file);
file.on('finish', () => {
file.close();
resolve(destPath);
});
}).on('error', reject);
});
}
// ============ 消息处理 ============
async function handleUploadCommand(message, cmd) {
const { chat_id, attachments } = message;
if (!attachments || attachments.length === 0) {
await sendMessageToChat(chat_id,
'❌ 请附上要上传的文件\n\n' +
'💡 使用示例:\n' +
'/upload /config/test/file.txt default\n' +
'[附上文件]\n\n' +
'或:/upload --original default\n' +
'[附上文件] (使用原文件名)'
);
return;
}
const attachment = attachments[0];
const fileKey = attachment.file_key;
const originalFileName = attachment.file_name;
log(`处理附件:${originalFileName} (${fileKey})`);
let targetKey;
if (cmd.useOriginal) {
targetKey = originalFileName;
} else if (cmd.targetPath) {
targetKey = cmd.targetPath.startsWith('/') ? cmd.targetPath.substring(1) : cmd.targetPath;
} else {
targetKey = originalFileName;
}
const tempDir = path.join(CONFIG.openclawCredentials, 'temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const tempFile = path.join(tempDir, `upload_${Date.now()}_${originalFileName}`);
try {
const token = await getAccessToken(CONFIG.appId, CONFIG.appSecret);
log('下载文件中...');
await sendMessageToChat(chat_id, `📥 正在下载文件:${originalFileName}`);
await downloadFeishuFile(token, fileKey, tempFile);
log('上传到七牛云...');
await sendMessageToChat(chat_id, `📤 正在上传到七牛云:${targetKey}\n存储桶:${cmd.bucket}`);
const uploadScript = path.join(CONFIG.scriptDir, 'upload-to-qiniu.js');
const uploadCmd = `node "${uploadScript}" upload --file "${tempFile}" --key "${targetKey}" --bucket "${cmd.bucket}"`;
const { stdout, stderr } = await new Promise((resolve, reject) => {
exec(uploadCmd, (error, stdout, stderr) => {
if (error) {
reject(new Error(`上传失败:${stderr || error.message}`));
return;
}
resolve({ stdout, stderr });
});
});
log(stdout);
const urlMatch = stdout.match(/🔗 URL: (.+)/);
const fileUrl = urlMatch ? urlMatch[1] : 'N/A';
await sendMessageToChat(chat_id,
`✅ 上传成功!\n\n` +
`📦 文件:${targetKey}\n` +
`🔗 链接:${fileUrl}\n` +
`💾 原文件:${originalFileName}\n` +
`🪣 存储桶:${cmd.bucket}`
);
} catch (error) {
log('处理失败:', error.message);
await sendMessageToChat(chat_id, `❌ 上传失败:${error.message}`);
} finally {
if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
}
}
}
async function handleConfigCommand(message, cmd) {
const { chat_id } = message;
const uploadScript = path.join(CONFIG.scriptDir, 'upload-to-qiniu.js');
const configCmd = `node "${uploadScript}" config ${cmd.subCommand} ${cmd.args.join(' ')}`;
try {
const { stdout, stderr } = await new Promise((resolve, reject) => {
exec(configCmd, (error, stdout, stderr) => {
if (error) {
reject(new Error(stderr || error.message));
return;
}
resolve({ stdout, stderr });
});
});
await sendMessageToChat(chat_id, '```\n' + stdout + '\n```');
} catch (error) {
await sendMessageToChat(chat_id, `❌ 配置命令执行失败:${error.message}`);
}
}
async function showHelp(message) {
const helpText = `
🍙 七牛云上传 - 使用帮助
📤 上传文件:
/upload [目标路径] [存储桶名]
/upload --original [存储桶名]
示例:
/upload /config/test/file.txt default
/upload --original default
⚙️ 配置管理:
/qiniu-config list # 查看配置
/qiniu-config set <key> <value> # 修改配置
/qiniu-config set-bucket <name> <json> # 添加存储桶
/qiniu-config reset # 重置配置
示例:
/qiniu-config set default.accessKey YOUR_KEY
/qiniu-config set default.domain https://cdn.example.com
`;
await sendMessageToChat(message.chat_id, helpText);
}
async function processMessage(message) {
log('收到消息:', message.message_id);
const content = JSON.parse(message.content);
const text = content.text || '';
const configCmd = parseConfigCommand(text.trim());
if (configCmd) {
log('配置命令:', configCmd.subCommand);
await handleConfigCommand(message, configCmd);
return;
}
if (text.trim() === '/qiniu-help' || text.trim() === '/help') {
await showHelp(message);
return;
}
const uploadCmd = parseUploadCommand(text.trim());
if (uploadCmd) {
log('上传命令:', uploadCmd);
await handleUploadCommand(message, uploadCmd);
return;
}
log('不是已知命令,跳过');
}
// ============ WebSocket 长连接 ============
let ws = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 10;
const RECONNECT_DELAY = 5000;
async function getWebSocketUrl() {
// 获取 WebSocket 连接地址
const token = await getAccessToken(CONFIG.appId, CONFIG.appSecret);
const url = 'https://open.feishu.cn/open-apis/connect/v1/ws';
return new Promise((resolve, reject) => {
const req = https.request(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const result = JSON.parse(data);
if (result.code === 0) {
resolve(result.data.ws_url);
} else {
reject(new Error(`获取 WebSocket URL 失败:${result.msg}`));
}
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.write(JSON.stringify({}));
req.end();
});
}
function connectWebSocket() {
getWebSocketUrl().then((wsUrl) => {
log('🔌 连接 WebSocket:', wsUrl);
ws = new WebSocket(wsUrl);
ws.on('open', () => {
log('✅ WebSocket 已连接');
reconnectAttempts = 0;
});
ws.on('message', async (data) => {
try {
const event = JSON.parse(data.toString());
// 处理不同类型的事件
if (event.type === 'im.message.receive_v1') {
await processMessage(event.event.message);
} else if (event.type === 'verification') {
// 验证挑战
log('收到验证挑战');
ws.send(JSON.stringify({ challenge: event.challenge }));
} else {
log('未知事件类型:', event.type);
}
} catch (e) {
log('处理消息失败:', e.message);
}
});
ws.on('close', () => {
log('⚠️ WebSocket 已断开');
scheduleReconnect();
});
ws.on('error', (error) => {
log('❌ WebSocket 错误:', error.message);
});
}).catch((error) => {
log('❌ 获取 WebSocket URL 失败:', error.message);
scheduleReconnect();
});
}
function scheduleReconnect() {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
log('❌ 重连次数已达上限,停止重连');
return;
}
reconnectAttempts++;
const delay = RECONNECT_DELAY * reconnectAttempts;
log(`🔄 ${delay/1000}秒后尝试重连 (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
setTimeout(() => {
connectWebSocket();
}, delay);
}
// ============ 主函数 ============
function main() {
log('🍙 七牛云上传 - 飞书长连接监听器');
log('配置文件:~/.openclaw/credentials/qiniu-config.json');
log('应用 ID:', CONFIG.appId);
log('');
// 检查配置
const configPath = path.join(CONFIG.openclawCredentials, 'qiniu-config.json');
if (!fs.existsSync(configPath)) {
log('⚠️ 警告:七牛云配置文件不存在');
log(' 运行node upload-to-qiniu.js config init');
}
// 连接 WebSocket
connectWebSocket();
}
main();

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* OpenClaw 桥接脚本
*
* 功能:
* 1. 从 OpenClaw 接收消息
* 2. 调用上传脚本
* 3. 回复结果
*
* 使用方式(由 OpenClaw 调用):
* node scripts/openclaw-bridge.js <command> [args...]
*/
const { exec } = require('child_process');
const path = require('path');
const UPLOAD_SCRIPT = path.join(__dirname, 'upload-to-qiniu.js');
// 从命令行获取参数
const args = process.argv.slice(2);
const command = args[0];
if (!command) {
console.error('用法node openclaw-bridge.js <command> [args...]');
console.error('命令upload, config, help');
process.exit(1);
}
// 执行对应的命令
switch (command) {
case 'upload':
executeUpload(args.slice(1));
break;
case 'config':
executeConfig(args.slice(1));
break;
case 'help':
executeHelp();
break;
default:
console.error(`未知命令:${command}`);
process.exit(1);
}
function executeUpload(uploadArgs) {
const cmd = `node ${UPLOAD_SCRIPT} upload ${uploadArgs.join(' ')}`;
console.log(`执行:${cmd}`);
exec(cmd, (error, stdout, stderr) => {
if (error) {
console.error(`上传失败:${error.message}`);
console.error(stderr);
process.exit(1);
}
console.log(stdout);
});
}
function executeConfig(configArgs) {
const cmd = `node ${UPLOAD_SCRIPT} config ${configArgs.join(' ')}`;
console.log(`执行:${cmd}`);
exec(cmd, (error, stdout, stderr) => {
if (error) {
console.error(`配置操作失败:${error.message}`);
console.error(stderr);
process.exit(1);
}
console.log(stdout);
});
}
function executeHelp() {
const cmd = `node ${UPLOAD_SCRIPT} --help`;
exec(cmd, (error, stdout, stderr) => {
if (error) {
// 忽略帮助命令的错误
}
console.log(stdout);
});
}

84
scripts/setup.sh Executable file
View File

@@ -0,0 +1,84 @@
#!/bin/bash
# 🍙 七牛云上传技能 - 快速配置脚本
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "🍙 七牛云上传技能 - 快速配置"
echo "================================"
echo ""
# 1. 检查七牛云配置
QINIU_CONFIG="$HOME/.openclaw/credentials/qiniu-config.json"
if [ ! -f "$QINIU_CONFIG" ]; then
echo "📝 配置七牛云凭证..."
echo ""
echo "请复制配置模板并编辑:"
echo " cp qiniu-config.example.json ~/.openclaw/credentials/qiniu-config.json"
echo ""
read -p "按回车继续..."
if [ ! -f "$QINIU_CONFIG" ]; then
cp qiniu-config.example.json "$QINIU_CONFIG"
echo "✅ 已复制配置模板到:$QINIU_CONFIG"
echo ""
echo "请编辑文件并填写你的七牛云信息:"
echo " - AccessKey"
echo " - SecretKey"
echo " - Bucket 名称"
echo " - 区域代码"
echo " - CDN 域名"
echo ""
read -p "编辑完成后按回车继续..."
fi
else
echo "✅ 七牛云配置已存在"
fi
# 2. 配置飞书环境变量
if [ ! -f ".env" ]; then
echo ""
echo "📝 配置飞书环境变量..."
cp .env.example .env
echo "✅ 已创建 .env 文件"
echo ""
echo "请编辑 .env 文件并填写:"
echo " - FEISHU_VERIFY_TOKEN自定义"
echo " - FEISHU_ENCRYPT_KEY从飞书开放平台获取"
echo ""
read -p "按回车继续..."
else
echo "✅ 飞书环境变量已配置"
fi
# 3. 检查 Node.js
if ! command -v node &> /dev/null; then
echo "❌ 未找到 Node.js请先安装 Node.js"
exit 1
fi
echo ""
echo "✅ 配置完成!"
echo ""
echo "================================"
echo "📋 下一步:"
echo ""
echo "1⃣ 配置飞书开放平台事件订阅"
echo " 查看详细说明cat FEISHU_SETUP.md"
echo ""
echo "2⃣ 启动 URL 验证服务(首次配置)"
echo " ./scripts/verify-url.js"
echo ""
echo "3⃣ 验证通过后,启动正式监听器"
echo " ./scripts/start-listener.sh"
echo ""
echo "4⃣ 在飞书中测试"
echo " 发送:/upload 文件名.pdf"
echo " 附上文件"
echo ""
echo "================================"
echo ""

36
scripts/start-listener.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
# 🍙 七牛云上传 - 飞书监听器启动脚本
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# 检查配置文件
if [ ! -f ".env" ]; then
echo "❌ 配置文件 .env 不存在"
echo ""
echo "请先创建配置文件:"
echo " cp .env.example .env"
echo " # 然后编辑 .env 填写你的配置"
exit 1
fi
# 加载环境变量
set -a
source .env
set +a
# 检查必要的环境变量
if [ -z "$FEISHU_APP_ID" ] || [ -z "$FEISHU_APP_SECRET" ]; then
echo "❌ 缺少必要的环境变量"
echo "请检查 .env 文件中的 FEISHU_APP_ID 和 FEISHU_APP_SECRET"
exit 1
fi
# 启动监听器
echo "🍙 启动飞书监听器..."
echo "📍 工作目录:$SCRIPT_DIR"
echo "🔌 端口:${FEISHU_LISTENER_PORT:-3000}"
echo ""
node scripts/feishu-listener.js

View File

@@ -0,0 +1,166 @@
#!/usr/bin/env node
/**
* 七牛云存储桶设置更新脚本
*
* 用途:更新存储桶的防覆盖等设置
*
* 用法:
* node update-bucket-setting.js <bucket-name> <setting> <value>
*
* 示例:
* node update-bucket-setting.js mybucket noOverwrite 0 # 允许覆盖
* node update-bucket-setting.js mybucket noOverwrite 1 # 禁止覆盖
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const https = require('https');
const http = require('http');
const DEFAULT_CONFIG_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.openclaw/credentials/qiniu-config.json');
function loadConfig() {
if (!fs.existsSync(DEFAULT_CONFIG_PATH)) {
throw new Error(`配置文件不存在:${DEFAULT_CONFIG_PATH}`);
}
return JSON.parse(fs.readFileSync(DEFAULT_CONFIG_PATH, 'utf-8'));
}
function hmacSha1(data, secret) {
return crypto.createHmac('sha1', secret).update(data).digest();
}
function urlSafeBase64(data) {
return Buffer.from(data).toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function generateAccessToken(accessKey, secretKey, method, path, body = '') {
const host = 'api.qiniu.com';
const contentType = 'application/json';
const signData = `${method} ${path}\nHost: ${host}\nContent-Type: ${contentType}\n\n${body}`;
const signature = hmacSha1(signData, secretKey);
const encodedSign = urlSafeBase64(signature);
return `Qiniu ${accessKey}:${encodedSign}`;
}
function 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();
});
}
async function updateBucketSetting(bucketName, setting, value) {
const config = loadConfig();
const bucketConfig = config.buckets['default'];
if (!bucketConfig) {
throw new Error(`默认存储桶配置不存在`);
}
const { accessKey, secretKey, bucket } = bucketConfig;
console.log('🔄 更新存储桶设置...\n');
console.log(`存储桶:${bucket}`);
console.log(`设置项:${setting}`);
console.log(`值:${value}`);
console.log('');
// 七牛云存储桶设置 API
// 文档https://developer.qiniu.com/kodo/api/1313/bucket-settings-update
const updateUrl = `https://api.qiniu.com/buckets/${bucket}/settings`;
const body = JSON.stringify({
[setting]: value === '1' || value === 'true' ? 1 : 0
});
const accessToken = generateAccessToken(accessKey, secretKey, 'PUT', `/buckets/${bucket}/settings`, body);
const options = {
method: 'PUT',
headers: {
'Host': 'api.qiniu.com',
'Content-Type': 'application/json',
'Content-Length': body.length,
'Authorization': accessToken
}
};
console.log('📤 发送更新请求...');
const result = await httpRequest(updateUrl, options, body);
if (result.status !== 200) {
console.error('❌ 更新失败:', result.data);
console.log('\n可能原因:');
console.log('1. AccessKey/SecretKey 权限不足,需要存储桶管理权限');
console.log('2. 存储桶名称不正确');
console.log('3. 设置项不支持通过 API 修改');
return;
}
console.log('✅ 设置已更新成功!\n');
console.log('提示:设置可能需要几分钟生效,请稍后重试上传。');
}
function printUsage() {
console.log(`
用法node update-bucket-setting.js <bucket-name> <setting> <value>
设置项:
noOverwrite - 防覆盖设置 (0=允许覆盖1=禁止覆盖)
示例:
node update-bucket-setting.js mybucket noOverwrite 0 # 允许覆盖
node update-bucket-setting.js mybucket noOverwrite 1 # 禁止覆盖
`);
}
async function main() {
const args = process.argv.slice(2);
if (args.length < 3) {
printUsage();
process.exit(1);
}
const [bucketName, setting, value] = args;
try {
await updateBucketSetting(bucketName, setting, value);
} catch (error) {
console.error('❌ 错误:', error.message);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { updateBucketSetting };

574
scripts/upload-to-qiniu.js Executable file
View File

@@ -0,0 +1,574 @@
#!/usr/bin/env node
/**
* 七牛云文件上传脚本 v2
*
* 功能:
* 1. 上传文件到七牛云对象存储(支持指定路径)
* 2. 使用原文件名或自定义文件名
* 3. 刷新 CDN 缓存
* 4. 支持配置管理
*
* 使用方式:
*
* # 上传文件
* node upload-to-qiniu.js upload --file <文件路径> [--key <目标路径>] [--bucket <存储桶名>]
*
* # 配置管理
* node upload-to-qiniu.js config list # 查看配置
* node upload-to-qiniu.js config set <key> <value> # 修改单个配置
* node upload-to-qiniu.js config set-bucket <name> <json> # 添加/修改存储桶
* node upload-to-qiniu.js config reset # 重置配置
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const https = require('https');
const http = require('http');
// ============ 配置管理 ============
const DEFAULT_CONFIG_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.openclaw/credentials/qiniu-config.json');
function loadConfig(configPath = DEFAULT_CONFIG_PATH) {
if (!fs.existsSync(configPath)) {
throw new Error(`配置文件不存在:${configPath}\n请先创建配置文件或运行node upload-to-qiniu.js config init`);
}
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
function saveConfig(config, configPath = DEFAULT_CONFIG_PATH) {
const dir = path.dirname(configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
}
function initConfig() {
const defaultConfig = {
buckets: {
default: {
accessKey: "YOUR_ACCESS_KEY_HERE",
secretKey: "YOUR_SECRET_KEY_HERE",
bucket: "your-bucket-name",
region: "z0",
domain: "https://your-cdn-domain.com"
}
},
_comment: {
region: "区域代码z0=华东z1=华北z2=华南na0=北美as0=东南亚",
setup: "1. 获取七牛 AccessKey/SecretKey: https://portal.qiniu.com/user/key",
setup2: "2. 创建存储桶并配置 CDN 域名",
setup3: "3. 使用 'config set' 命令修改配置"
}
};
saveConfig(defaultConfig);
console.log('✅ 配置文件已初始化:', DEFAULT_CONFIG_PATH);
console.log('使用 node upload-to-qiniu.js config set <key> <value> 修改配置');
}
function listConfig() {
const config = loadConfig();
console.log('📋 当前配置:\n');
console.log('存储桶配置:');
for (const [name, bucket] of Object.entries(config.buckets)) {
console.log(`\n 🪣 [${name}]`);
console.log(` accessKey: ${maskKey(bucket.accessKey)}`);
console.log(` secretKey: ${maskKey(bucket.secretKey)}`);
console.log(` bucket: ${bucket.bucket}`);
console.log(` region: ${bucket.region}`);
console.log(` domain: ${bucket.domain}`);
}
console.log('\n💡 使用 config set <key> <value> 修改配置');
console.log(' 例如config set default.accessKey YOUR_NEW_KEY');
}
function maskKey(key) {
if (!key || key.length < 8) return '***';
return key.substring(0, 4) + '...' + key.substring(key.length - 4);
}
function setConfigValue(keyPath, value) {
const config = 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;
saveConfig(config);
console.log(`✅ 已设置 ${keyPath} = ${value}`);
}
function setBucket(name, bucketConfig) {
const config = loadConfig();
let newConfig;
try {
newConfig = JSON.parse(bucketConfig);
} catch (e) {
throw new Error('无效的 JSON 配置,格式:{"accessKey":"...","secretKey":"...","bucket":"...","region":"z0","domain":"..."}');
}
config.buckets[name] = newConfig;
saveConfig(config);
console.log(`✅ 已配置存储桶 [${name}]`);
}
function resetConfig() {
if (fs.existsSync(DEFAULT_CONFIG_PATH)) {
fs.unlinkSync(DEFAULT_CONFIG_PATH);
}
initConfig();
console.log('✅ 配置已重置');
}
// ============ 七牛云鉴权 ============
function hmacSha1(data, secret) {
return crypto.createHmac('sha1', secret).update(data).digest();
}
function urlSafeBase64(data) {
return Buffer.from(data).toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function generateUploadToken(accessKey, secretKey, bucket, key = null, expires = 3600) {
const deadline = Math.floor(Date.now() / 1000) + expires;
// 关键修复scope 必须包含 key 才能覆盖上传
// 根据七牛文档:
// - scope = "bucket" → 只能新增,同名文件会失败
// - scope = "bucket:key" → 允许覆盖同名文件
let scope = bucket;
if (key) {
scope = `${bucket}:${key}`; // ✅ 添加 key允许覆盖
}
const putPolicy = {
scope: scope,
deadline: deadline,
// insertOnly: 非 0 值才禁止覆盖,默认或 0 都允许覆盖
returnBody: JSON.stringify({
success: true,
key: '$(key)',
hash: '$(etag)',
fsize: '$(fsize)',
bucket: '$(bucket)',
url: `$(domain)/$(key)`
})
};
const encodedPolicy = urlSafeBase64(JSON.stringify(putPolicy));
const encodedSignature = urlSafeBase64(hmacSha1(encodedPolicy, secretKey));
return `${accessKey}:${encodedSignature}:${encodedPolicy}`;
}
// ============ HTTP 请求工具 ============
function 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();
});
}
// ============ 文件上传 ============
async function uploadFile(config, bucketName, localFile, key) {
const bucketConfig = config.buckets[bucketName];
if (!bucketConfig) {
throw new Error(`存储桶配置 "${bucketName}" 不存在,可用:${Object.keys(config.buckets).join(', ')}`);
}
const { accessKey, secretKey, bucket, region, domain } = bucketConfig;
console.log(`📤 准备上传:${localFile} -> ${bucket}/${key}`);
// 1. 生成上传凭证
const uploadToken = generateUploadToken(accessKey, secretKey, bucket, key);
// 2. 获取区域上传端点
const regionEndpoint = getUploadEndpoint(region);
// 3. 构建 multipart/form-data 请求
const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2);
const fileContent = fs.readFileSync(localFile);
const fileName = path.basename(localFile);
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')
]);
// 使用七牛云标准表单上传 API
// 文档https://developer.qiniu.com/kodo/1312/upload
const uploadUrl = `${regionEndpoint}/`; // 根路径token 在 form-data 中
console.log(`📍 上传端点:${uploadUrl}`);
const uploadOptions = {
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=----${boundary}`,
'Content-Length': bodyBuffer.length
}
};
const result = await httpRequest(uploadUrl, uploadOptions, bodyBuffer);
if (result.status !== 200) {
throw new Error(`上传失败:${JSON.stringify(result.data)}`);
}
console.log('✅ 上传成功');
return {
key: result.data.key,
hash: result.data.hash,
url: `${domain}/${key}`
};
}
function 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'];
}
// ============ CDN 刷新 ============
/**
* 生成七牛云 access_token用于 Fusion CDN API
* 文档https://developer.qiniu.com/kodo/manual/access-token
*/
function generateAccessToken(accessKey, secretKey, method, path, body, contentType = 'application/json') {
const host = 'fusion.qiniuapi.com';
// 1. 生成待签名的原始字符串
// 格式Method Path\nHost: Host\nContent-Type: ContentType\n\nBody
const signData = `${method} ${path}\nHost: ${host}\nContent-Type: ${contentType}\n\n${body}`;
// 2. 使用 HMAC-SHA1 签名
const signature = hmacSha1(signData, secretKey);
// 3. URL 安全的 Base64 编码
const encodedSign = urlSafeBase64(signature);
// 4. 生成 access_token
return `Qiniu ${accessKey}:${encodedSign}`;
}
async function refreshCDN(config, bucketName, key) {
const bucketConfig = config.buckets[bucketName];
if (!bucketConfig) {
throw new Error(`存储桶配置 "${bucketName}" 不存在`);
}
const { accessKey, secretKey, domain } = bucketConfig;
const fileUrl = `${domain}/${key}`;
console.log(`🔄 刷新 CDN: ${fileUrl}`);
const refreshUrl = 'https://fusion.qiniuapi.com/v2/tune/refresh';
const body = JSON.stringify({
urls: [fileUrl]
});
const method = 'POST';
const path = '/v2/tune/refresh';
const contentType = 'application/json';
// 生成正确的 access_token
const accessToken = generateAccessToken(accessKey, secretKey, method, path, body, contentType);
const options = {
method: method,
headers: {
'Host': 'fusion.qiniuapi.com',
'Content-Type': contentType,
'Content-Length': body.length,
'Authorization': accessToken
}
};
const result = await httpRequest(refreshUrl, options, body);
if (result.status !== 200) {
throw new Error(`CDN 刷新失败:${JSON.stringify(result.data)}`);
}
console.log('✅ CDN 刷新请求已提交');
return result.data;
}
// ============ 命令行解析 ============
function parseArgs(args) {
const params = {};
const positional = [];
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith('--')) {
const key = args[i].slice(2);
params[key] = args[i + 1];
i++;
} else {
positional.push(args[i]);
}
}
return { params, positional };
}
// ============ 主函数 ============
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
printUsage();
process.exit(0);
}
const command = args[0];
try {
// 配置管理命令
if (command === 'config') {
const subCommand = args[1];
switch (subCommand) {
case 'init':
initConfig();
break;
case 'list':
listConfig();
break;
case 'set':
if (!args[2] || !args[3]) {
console.error('用法config set <key> <value>');
console.error('示例config set default.accessKey YOUR_KEY');
process.exit(1);
}
setConfigValue(args[2], args[3]);
break;
case 'set-bucket':
if (!args[2] || !args[3]) {
console.error('用法config set-bucket <name> <json>');
console.error('示例config set-bucket prod \'{"accessKey":"...","secretKey":"...","bucket":"prod","region":"z0","domain":"https://..."}\'');
process.exit(1);
}
setBucket(args[2], args[3]);
break;
case 'reset':
resetConfig();
break;
default:
console.error(`未知命令config ${subCommand}`);
printConfigUsage();
process.exit(1);
}
return;
}
// 上传命令
if (command === 'upload') {
const { params, positional } = parseArgs(args.slice(1));
if (!params.file) {
console.error('❌ 缺少必需参数 --file');
console.error('用法upload --file <文件路径> [--key <目标路径>] [--bucket <存储桶名>]');
console.error('示例upload --file ./report.pdf --key /config/test/report.pdf --bucket default');
process.exit(1);
}
const bucketName = params.bucket || 'default';
// 检查文件是否存在
if (!fs.existsSync(params.file)) {
throw new Error(`文件不存在:${params.file}`);
}
// 确定目标文件名
let key = params.key;
if (!key) {
// 使用原文件名
key = path.basename(params.file);
} else if (key.startsWith('/')) {
// 如果指定了路径,保留路径
key = key.substring(1);
}
// 加载配置
const config = loadConfig();
// 获取实际存储桶名称(不是配置别名)
const bucketConfig = config.buckets[bucketName];
const actualBucketName = bucketConfig ? bucketConfig.bucket : bucketName;
// 上传文件
const uploadResult = await uploadFile(config, bucketName, params.file, key);
// 刷新 CDN
const refreshResult = await refreshCDN(config, bucketName, key);
console.log('\n🎉 完成!');
console.log(`📦 文件:${key}`);
console.log(`🔗 URL: ${uploadResult.url}`);
console.log(`☁️ 存储桶:${actualBucketName}`);
console.log(`📊 刷新请求 ID: ${refreshResult.requestId || 'N/A'}`);
return;
}
// 未知命令
console.error(`未知命令:${command}`);
printUsage();
process.exit(1);
} catch (error) {
console.error('❌ 错误:', error.message);
process.exit(1);
}
}
function printUsage() {
console.log(`
🍙 七牛云上传工具 v2
用法:
node upload-to-qiniu.js <command> [options]
命令:
upload 上传文件到七牛云
config 配置管理
上传文件:
node upload-to-qiniu.js upload --file <文件路径> [--key <目标路径>] [--bucket <存储桶名>]
选项:
--file 本地文件路径(必需)
--key 目标路径(可选,默认使用原文件名)
--bucket 存储桶名称(可选,默认 default
示例:
# 使用原文件名上传
node upload-to-qiniu.js upload --file ./report.pdf
# 指定目标路径
node upload-to-qiniu.js upload --file ./report.pdf --key /config/test/report.pdf
# 指定存储桶
node upload-to-qiniu.js upload --file ./report.pdf --key /docs/report.pdf --bucket production
配置管理:
node upload-to-qiniu.js config <subcommand>
子命令:
init 初始化配置文件
list 查看当前配置
set <key> <value> 修改单个配置项
set-bucket <name> <json> 添加/修改存储桶配置
reset 重置配置
示例:
node upload-to-qiniu.js config list
node upload-to-qiniu.js config set default.accessKey YOUR_KEY
node upload-to-qiniu.js config set default.domain https://cdn.example.com
node upload-to-qiniu.js config set-bucket prod '{"accessKey":"...","secretKey":"...","bucket":"prod","region":"z0","domain":"https://..."}'
`);
}
function printConfigUsage() {
console.log(`
配置管理命令:
config init 初始化配置文件
config list 查看当前配置
config set <key> <value> 修改单个配置
config set-bucket <name> <json> 添加存储桶
config reset 重置配置
示例:
config set default.accessKey YOUR_KEY
config set default.domain https://cdn.example.com
`);
}
// 运行
if (require.main === module) {
main();
}
module.exports = { uploadFile, refreshCDN, loadConfig, saveConfig, setConfigValue, setBucket };

99
scripts/verify-url.js Executable file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env node
/**
* 飞书事件订阅 URL 验证处理器
*
* 用途:处理飞书开放平台的事件订阅 URL 验证请求
* 使用方式node verify-url.js
*/
const http = require('http');
const crypto = require('crypto');
// 配置
const CONFIG = {
port: 3000,
verifyToken: process.env.FEISHU_VERIFY_TOKEN || 'qiniu_upload_token_2026',
encryptKey: process.env.FEISHU_ENCRYPT_KEY || ''
};
console.log('🍙 飞书 URL 验证服务');
console.log('验证 Token:', CONFIG.verifyToken);
console.log('加密密钥:', CONFIG.encryptKey ? '已配置' : '未配置');
console.log('监听端口:', CONFIG.port);
console.log('');
console.log('📋 配置步骤:');
console.log('1. 在飞书开放平台设置请求地址http://你的 IP:3000');
console.log('2. 设置验证 Token:', CONFIG.verifyToken);
console.log('3. 点击保存,等待验证');
console.log('');
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
console.log(`[${new Date().toISOString()}] ${req.method} ${url.pathname}`);
// 处理飞书验证请求
if (url.pathname === '/' && req.method === 'POST') {
let body = '';
req.on('data', chunk => {
body += chunk;
});
req.on('end', () => {
try {
const event = JSON.parse(body);
// 验证类型url_verification
if (event.type === 'url_verification') {
console.log('✅ 收到验证请求');
console.log('Challenge:', event.challenge);
// 返回 challenge
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ challenge: event.challenge }));
console.log('✅ 验证成功!请在飞书开放平台确认状态');
return;
}
// 其他事件类型
console.log('事件类型:', event.type);
console.log('事件内容:', JSON.stringify(event, null, 2));
res.writeHead(200);
res.end('OK');
} catch (e) {
console.error('❌ 解析失败:', e.message);
res.writeHead(400);
res.end('Invalid JSON');
}
});
return;
}
// 健康检查
if (url.pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', timestamp: Date.now() }));
return;
}
// 其他请求
res.writeHead(404);
res.end('Not Found');
});
server.listen(CONFIG.port, () => {
console.log('');
console.log('🚀 服务已启动');
console.log(`📍 监听地址http://0.0.0.0:${CONFIG.port}`);
console.log('');
console.log('💡 提示:');
console.log(' - 按 Ctrl+C 停止服务');
console.log(' - 访问 http://localhost:3000/health 检查服务状态');
console.log('');
});