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:
574
scripts/upload-to-qiniu.js
Executable file
574
scripts/upload-to-qiniu.js
Executable 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 };
|
||||
Reference in New Issue
Block a user