Files
openclaw-skill-qiniu/scripts/feishu-card-server.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

411 lines
10 KiB
JavaScript
Raw 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
/**
* 飞书卡片交互服务器
*
* 功能:
* 1. 接收飞书卡片按钮点击回调
* 2. 处理交互逻辑(上传、配置、帮助)
* 3. 回复交互式消息
*
* 使用方式:
* node scripts/feishu-card-server.js [port]
*
* 默认端口3000
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// ============ 配置 ============
const PORT = process.argv[2] || 3000;
const CARD_TEMPLATE_PATH = path.join(__dirname, '../cards/upload-card.json');
const QINIU_CONFIG_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.openclaw/credentials/qiniu-config.json');
// 飞书验证令牌(在飞书开发者后台设置)
const FEISHU_VERIFICATION_TOKEN = process.env.FEISHU_VERIFICATION_TOKEN || 'your_verification_token';
// ============ 工具函数 ============
function loadConfig(configPath = QINIU_CONFIG_PATH) {
if (!fs.existsSync(configPath)) {
throw new Error(`配置文件不存在:${configPath}`);
}
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
function loadCardTemplate(templatePath = CARD_TEMPLATE_PATH) {
if (!fs.existsSync(templatePath)) {
throw new Error(`卡片模板不存在:${templatePath}`);
}
return JSON.parse(fs.readFileSync(templatePath, 'utf-8'));
}
function renderCard(template, variables) {
let cardJson = JSON.stringify(template);
for (const [key, value] of Object.entries(variables)) {
cardJson = cardJson.replace(new RegExp(`{{${key}}}`, 'g'), value);
}
return JSON.parse(cardJson);
}
function getRegionName(regionCode) {
const regions = {
'z0': '华东',
'z1': '华北',
'z2': '华南',
'na0': '北美',
'as0': '东南亚'
};
return regions[regionCode] || '未知';
}
// ============ 飞书鉴权 ============
/**
* 验证飞书请求签名
* 文档https://open.feishu.cn/document/ukTMukTMukTM/uYjNwYjL2YDM14SM2ATN
*/
function verifyFeishuSignature(req, body) {
const signature = req.headers['x-feishu-signature'];
if (!signature) return false;
// 简单验证,生产环境需要严格验证
return true;
}
// ============ 卡片交互处理 ============
/**
* 处理卡片按钮点击
*/
async function handleCardInteraction(req, res) {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', async () => {
try {
const data = JSON.parse(body);
// 飞书挑战验证
if (data.type === 'url_verification') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ challenge: data.challenge }));
return;
}
// 处理交互事件
if (data.type === 'interactive_card.action') {
const action = data.action?.value?.action;
const userId = data.user?.user_id;
const openId = data.user?.open_id;
const tenantKey = data.tenant_key;
console.log(`收到卡片交互:${action}, 用户:${userId}`);
let responseCard;
switch (action) {
case 'upload_select':
responseCard = await handleUploadSelect(data);
break;
case 'config_view':
responseCard = await handleConfigView(data);
break;
case 'help':
responseCard = await handleHelp(data);
break;
default:
responseCard = createErrorResponse('未知操作');
}
// 回复卡片
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
type: 'interactive_card.response',
card: responseCard
}));
return;
}
// 未知类型
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
} catch (error) {
console.error('处理交互失败:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
});
}
/**
* 处理"选择文件上传"按钮
*/
async function handleUploadSelect(data) {
const config = loadConfig();
const bucketName = data.action?.value?.bucket || 'default';
const bucketConfig = config.buckets[bucketName];
if (!bucketConfig) {
return createErrorResponse(`存储桶 "${bucketName}" 不存在`);
}
// 回复引导用户上传文件
return {
config: {
wide_screen_mode: true
},
header: {
template: "green",
title: {
content: "📎 选择文件",
tag: "plain_text"
}
},
elements: [
{
tag: "div",
text: {
content: `请点击下方按钮选择要上传的文件,文件将上传到 **${bucketName}** 存储桶。`,
tag: "lark_md"
}
},
{
tag: "action",
actions: [
{
tag: "button",
text: {
content: "📁 选择文件",
tag: "plain_text"
},
type: "primary",
url: "feishu://attachment/select" // 飞书内部协议,触发文件选择
}
]
}
]
};
}
/**
* 处理"查看配置"按钮
*/
async function handleConfigView(data) {
const config = loadConfig();
let bucketList = '';
for (const [name, bucket] of Object.entries(config.buckets)) {
bucketList += `**${name}**: ${bucket.bucket} (${bucket.region})\n`;
}
return {
config: {
wide_screen_mode: true
},
header: {
template: "blue",
title: {
content: "📋 当前配置",
tag: "plain_text"
}
},
elements: [
{
tag: "div",
text: {
content: bucketList || '暂无配置',
tag: "lark_md"
}
},
{
tag: "hr"
},
{
tag: "note",
elements: [
{
tag: "plain_text",
content: `配置文件:${QINIU_CONFIG_PATH}`
}
]
}
]
};
}
/**
* 处理"帮助"按钮
*/
async function handleHelp(data) {
return {
config: {
wide_screen_mode: true
},
header: {
template: "grey",
title: {
content: "❓ 帮助",
tag: "plain_text"
}
},
elements: [
{
tag: "div",
text: {
content: `**七牛云上传帮助**
📤 **上传文件**
- 点击"选择文件上传"按钮
- 选择要上传的文件
- 自动上传到七牛云
⚙️ **快捷命令**
- \`/u\` - 快速上传
- \`/qc\` - 查看配置
- \`/qh\` - 显示帮助
📦 **存储桶**
- 支持多存储桶配置
- 上传时可指定目标桶`,
tag: "lark_md"
}
}
]
};
}
/**
* 创建错误响应
*/
function createErrorResponse(message) {
return {
config: {
wide_screen_mode: true
},
header: {
template: "red",
title: {
content: "❌ 错误",
tag: "plain_text"
}
},
elements: [
{
tag: "div",
text: {
content: message,
tag: "lark_md"
}
}
]
};
}
// ============ 主页面(测试用) ============
function serveHomePage(res) {
const html = `
<!DOCTYPE html>
<html>
<head>
<title>七牛云上传 - 飞书卡片服务器</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
h1 { color: #333; }
.status { padding: 10px; background: #e8f5e9; border-radius: 4px; margin: 20px 0; }
.config { background: #f5f5f5; padding: 15px; border-radius: 4px; }
code { background: #eee; padding: 2px 6px; border-radius: 3px; }
</style>
</head>
<body>
<h1>🍙 七牛云上传 - 飞书卡片服务器</h1>
<div class="status">
✅ 服务器运行中
<br>端口:<code>${PORT}</code>
</div>
<div class="config">
<h3>配置信息</h3>
<p>卡片模板:<code>${CARD_TEMPLATE_PATH}</code></p>
<p>七牛配置:<code>${QINIU_CONFIG_PATH}</code></p>
</div>
<h3>飞书开发者后台配置</h3>
<ol>
<li>请求网址:<code>http://你的服务器IP:${PORT}/feishu/card</code></li>
<li>数据加密方式:选择"不加密"</li>
<li>验证令牌:在环境变量中设置 <code>FEISHU_VERIFICATION_TOKEN</code></li>
</ol>
<h3>测试</h3>
<p>使用 curl 测试:</p>
<pre><code>curl -X POST http://localhost:${PORT}/feishu/card \\
-H "Content-Type: application/json" \\
-d '{"type":"url_verification","challenge":"test123"}'</code></pre>
</body>
</html>
`;
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
}
// ============ HTTP 服务器 ============
const server = http.createServer((req, res) => {
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
// CORS 头(飞书回调需要)
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Feishu-Signature');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// 主页
if (req.url === '/' || req.url === '/health') {
serveHomePage(res);
return;
}
// 卡片交互回调
if (req.url === '/feishu/card' && req.method === 'POST') {
handleCardInteraction(req, res);
return;
}
// 404
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
});
// ============ 启动服务器 ============
server.listen(PORT, () => {
console.log(`🍙 七牛云卡片服务器已启动`);
console.log(`端口:${PORT}`);
console.log(`主页http://localhost:${PORT}/`);
console.log(`回调地址http://localhost:${PORT}/feishu/card`);
console.log(`\n在飞书开发者后台配置请求网址为http://你的服务器IP:${PORT}/feishu/card`);
});
// 优雅退出
process.on('SIGINT', () => {
console.log('\n正在关闭服务器...');
server.close(() => {
console.log('服务器已关闭');
process.exit(0);
});
});