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:
437
openclaw-processor.js
Executable file
437
openclaw-processor.js
Executable 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 };
|
||||
Reference in New Issue
Block a user