Files
openclaw-skill-qiniu/scripts/upload-to-qiniu.js
daoqi 1aeae9cc51 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
2026-03-07 16:02:18 +08:00

575 lines
16 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 七牛云文件上传脚本 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 };