删除的废弃文件: - test-feishu-upload.js (测试文件) - debug-upload.js (调试工具) - check-bucket-override.js (诊断工具) - feishu-card-server.js (废弃的卡片服务器) - feishu-websocket-listener.js (废弃的 WebSocket 监听器) - openclaw-bridge.js (废弃的桥接代码) - setup.sh, start-listener.sh, verify-url.js (废弃脚本) - cards/ 目录 (未使用的卡片模板) - ARCHITECTURE.md, INTEGRATION.md 等废弃文档 优化: - openclaw-processor.js: 添加 DEBUG 环境变量控制日志输出 - 移除生产环境不必要的调试日志 清理后核心文件: - openclaw-processor.js (OpenClaw 处理器) - openclaw-handler.js (HTTP 处理器) - scripts/upload-to-qiniu.js (核心上传脚本) - scripts/feishu-listener.js (独立监听器) - scripts/update-bucket-setting.js (存储桶设置工具) - deploy.sh (部署脚本)
438 lines
11 KiB
JavaScript
Executable File
438 lines
11 KiB
JavaScript
Executable File
#!/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'
|
||
}
|
||
};
|
||
|
||
// ============ 工具函数 ============
|
||
|
||
// 调试日志(生产环境可禁用)
|
||
const DEBUG = process.env.QINIU_DEBUG === 'true';
|
||
function log(...args) {
|
||
if (!DEBUG) return;
|
||
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');
|
||
|
||
// 直接返回完整回复
|
||
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 };
|