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

437
openclaw-processor.js Executable file
View File

@@ -0,0 +1,437 @@
#!/usr/bin/env node
/**
* OpenClaw Skill - 七牛云上传处理器
*
* 用途:处理 OpenClaw 转发的七牛云相关命令
* 使用方式:作为 OpenClaw 的工具脚本被调用
*/
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');
const https = require('https');
const http = require('http');
// ============ 配置 ============
const CONFIG = {
scriptDir: __dirname,
credentials: path.join(process.env.HOME || process.env.USERPROFILE, '.openclaw/credentials'),
tempDir: path.join(process.env.HOME || process.env.USERPROFILE, '.openclaw/credentials/temp'),
// 飞书 API 配置
feishu: {
appId: process.env.FEISHU_APP_ID || 'cli_a92ce47b02381bcc',
appSecret: process.env.FEISHU_APP_SECRET || 'WpCWhqOPKv3F5Lhn11DqubrssJnAodot'
}
};
// ============ 工具函数 ============
function log(...args) {
const timestamp = new Date().toISOString();
console.error(`[${timestamp}]`, ...args);
}
function ensureTempDir() {
if (!fs.existsSync(CONFIG.tempDir)) {
fs.mkdirSync(CONFIG.tempDir, { recursive: true });
}
}
// ============ 飞书 API ============
async function getAccessToken() {
const url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal';
const body = JSON.stringify({
app_id: CONFIG.feishu.appId,
app_secret: CONFIG.feishu.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 downloadFeishuFile(fileKey, destPath) {
const token = await getAccessToken();
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 sendMessageToChat(chatId, text) {
const token = await getAccessToken();
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 })
});
return 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();
});
}
// ============ 命令解析 ============
function parseUploadCommand(text) {
// 支持 /upload 和 /u 两种命令
const match = text.match(/^\/(upload|u)(?:\s+(.+))?$/i);
if (!match) return null;
// match[1] = 命令名 (upload/u), match[2] = 参数
const args = (match[2] || '').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 { targetPath, useOriginal, bucket };
}
function parseConfigCommand(text) {
const match = text.match(/^\/qiniu-config\s+(.+)$/i);
if (!match) return null;
const args = match[1].trim().split(/\s+/);
return {
subCommand: args[0],
args: args.slice(1)
};
}
// ============ 命令处理 ============
async function handleUpload(message) {
const content = typeof message.content === 'string' ? JSON.parse(message.content) : message.content;
const text = content.text || '';
const attachments = message.attachments || [];
const cmd = parseUploadCommand(text);
if (!cmd) {
return { handled: false };
}
log('处理上传命令:', cmd);
// 检查附件
if (!attachments || attachments.length === 0) {
return {
handled: true,
reply: `❌ 请附上要上传的文件
💡 使用示例:
/upload /config/test/file.txt default
[附上文件]
或:/upload --original default
[附上文件] (使用原文件名)`
};
}
const attachment = attachments[0];
const fileKey = attachment.file_key;
const originalFileName = attachment.file_name;
log(`下载文件:${originalFileName} (${fileKey})`);
try {
// 确保临时目录存在
ensureTempDir();
// 下载文件
const tempFile = path.join(CONFIG.tempDir, `upload_${Date.now()}_${originalFileName}`);
await downloadFeishuFile(fileKey, tempFile);
log('文件已下载:', tempFile);
// 确定目标文件名
let targetKey;
if (cmd.useOriginal) {
targetKey = originalFileName;
} else if (cmd.targetPath) {
// 如果指定了路径,保留完整路径(去掉前导 /
targetKey = cmd.targetPath.startsWith('/') ? cmd.targetPath.substring(1) : cmd.targetPath;
} else {
// 没有指定路径时,使用原文件名
targetKey = originalFileName;
}
// 确保 targetKey 不为空
if (!targetKey || targetKey.trim() === '') {
targetKey = originalFileName;
}
log('目标 key:', targetKey);
log('原始文件名:', originalFileName);
log('命令参数:', cmd);
// 调用上传脚本
log('上传到七牛云:', targetKey);
const uploadScript = path.join(CONFIG.scriptDir, 'scripts/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);
// 清理临时文件
if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
}
// 解析结果
const urlMatch = stdout.match(/🔗 URL: (.+)/);
const fileUrl = urlMatch ? urlMatch[1] : 'N/A';
// 解析存储桶名称(从输出中获取实际桶名)
const bucketMatch = stdout.match(/☁️ 存储桶:(.+)/);
const actualBucket = bucketMatch ? bucketMatch[1].trim() : (cmd.bucket || 'default');
// 调试输出
log('存储桶解析:配置别名=', cmd.bucket, '实际桶名=', actualBucket);
// 直接返回完整回复
return {
handled: true,
reply: `✅ 上传成功!
📦 文件:${targetKey}
🔗 链接:${fileUrl}
💾 原文件:${originalFileName}
🪣 存储桶:${actualBucket}`
};
} catch (error) {
log('上传失败:', error.message);
// 清理临时文件
const tempFiles = fs.readdirSync(CONFIG.tempDir);
tempFiles.forEach(f => {
if (f.startsWith('upload_')) {
try {
fs.unlinkSync(path.join(CONFIG.tempDir, f));
} catch (e) {}
}
});
return {
handled: true,
reply: `❌ 上传失败:${error.message}`
};
}
}
async function handleConfig(message) {
const content = typeof message.content === 'string' ? JSON.parse(message.content) : message.content;
const text = content.text || '';
const cmd = parseConfigCommand(text);
if (!cmd) {
return { handled: false };
}
log('处理配置命令:', cmd.subCommand);
try {
const uploadScript = path.join(CONFIG.scriptDir, 'scripts/upload-to-qiniu.js');
const configCmd = `node "${uploadScript}" config ${cmd.subCommand} ${cmd.args.join(' ')}`;
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 });
});
});
return {
handled: true,
reply: '```\n' + stdout + '\n```'
};
} catch (error) {
return {
handled: true,
reply: `❌ 配置命令执行失败:${error.message}`
};
}
}
async function handleHelp() {
return {
handled: true,
reply: `
🍙 七牛云上传 - 使用帮助
📤 上传文件:
/upload [目标路径] [存储桶名]
/upload --original [存储桶名]
示例:
/upload /config/test/file.txt default
/upload --original default
/upload docs/report.pdf
⚙️ 配置管理:
/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
`
};
}
// ============ 主处理函数 ============
async function processMessage(message) {
const content = typeof message.content === 'string' ? JSON.parse(message.content) : message.content;
const text = content.text || '';
const trimmed = text.trim();
// 检查是否是七牛云命令
if (/^\/upload/i.test(trimmed)) {
return await handleUpload(message);
}
if (/^\/qiniu-config/i.test(trimmed)) {
return await handleConfig(message);
}
if (/^\/(qiniu-)?help/i.test(trimmed)) {
return await handleHelp();
}
return { handled: false };
}
// ============ 命令行接口 ============
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.log('七牛云上传 Skill 处理器');
console.log('');
console.log('用法:');
console.log(' node openclaw-processor.js --message "<JSON 消息>"');
console.log('');
console.log('示例:');
console.log(' node openclaw-processor.js --message "{\"content\":{\"text\":\"/qiniu-config list\"}}"');
process.exit(0);
}
if (args[0] === '--message' && args[1]) {
try {
const message = JSON.parse(args[1]);
const result = await processMessage(message);
console.log(JSON.stringify(result, null, 2));
if (result.handled && result.reply) {
// 如果有 chat_id直接发送消息
if (message.chat_id) {
await sendMessageToChat(message.chat_id, result.reply);
}
}
} catch (e) {
console.error('处理失败:', e.message);
process.exit(1);
}
}
}
// 导出给 OpenClaw 调用
if (require.main === module) {
main();
}
module.exports = { processMessage, handleUpload, handleConfig, handleHelp };