Files
qiniu-feishu-bot/src/feishu-api.js
饭团 f7776aaf69 v2 版本 - 飞书文件上传功能修复完成
主要修复:
1. 使用飞书 SDK im.messageResource.get API 下载文件(和 OpenClaw 一致)
2. 修复 sendMessage 方法,自动判断 receive_id_type(oc_=chat_id, ou_=open_id)
3. 修复 sendCard 方法,传递正确的 receive_id_type 参数
4. 修复事件类型识别,支持飞书 v2 schema(header.event_type)
5. 添加临时文件清理机制(每小时清理 1 小时前的文件)
6. 完善卡片交互(确认上传/取消按钮)
7. 完善错误处理和日志记录

功能:
-  飞书文件接收和卡片回复
-  卡片交互(确认/取消)
-  七牛云上传(支持多存储桶)
-  CDN 自动刷新
-  临时文件自动清理
-  配置管理命令(/config)

配置文件:
- config/qiniu-config.json - 七牛云配置
- .env - 飞书应用配置
2026-03-06 08:38:52 +08:00

182 lines
4.9 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.
/**
* 飞书 API 封装 v2 - 使用飞书 SDK
*/
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const https = require('https');
const lark = require('@larksuiteoapi/node-sdk');
// 日志函数
function log(...args) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [FeishuAPI]`, ...args);
}
class FeishuAPI {
constructor() {
this.appId = process.env.FEISHU_APP_ID;
this.appSecret = process.env.FEISHU_APP_SECRET;
this.baseURL = 'https://open.feishu.cn/open-apis';
this.tokenCache = null;
this.tokenExpiry = 0;
// 创建飞书 SDK 客户端
this.client = new lark.Client({
appId: this.appId,
appSecret: this.appSecret
});
}
// 获取访问令牌
async getAccessToken() {
if (this.tokenCache && Date.now() < this.tokenExpiry) {
return this.tokenCache;
}
try {
const response = await axios.post(
`${this.baseURL}/auth/v3/tenant_access_token/internal`,
{
app_id: this.appId,
app_secret: this.appSecret
},
{
headers: { 'Content-Type': 'application/json' }
}
);
const { tenant_access_token, expire } = response.data;
if (response.data.code !== 0) {
throw new Error(`获取 token 失败:${response.data.msg}`);
}
this.tokenCache = tenant_access_token;
this.tokenExpiry = Date.now() + (expire - 300) * 1000; // 提前 5 分钟过期
return tenant_access_token;
} catch (error) {
throw new Error(`飞书 API 错误:${error.message}`);
}
}
// 发送文本消息
async sendMessage(chatId, payload, receiveIdType = 'chat_id') {
const token = await this.getAccessToken();
try {
// 判断 receive_id_typeoc_开头的是 chat_idou_开头的是 open_id
let idType = receiveIdType;
if (chatId.startsWith('oc_')) {
idType = 'chat_id';
} else if (chatId.startsWith('ou_')) {
idType = 'open_id';
}
log('发送消息到 chatId:', chatId, 'receive_id_type:', idType, 'msg_type:', payload.msg_type);
const response = await axios.post(
`${this.baseURL}/im/v1/messages?receive_id_type=${idType}`,
{
receive_id: chatId,
msg_type: payload.msg_type,
content: typeof payload.content === 'string' ? payload.content : JSON.stringify(payload.content)
},
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
);
log('消息发送响应:', response.data.code, response.data.msg || 'ok');
if (response.data.code !== 0) {
throw new Error(`发送消息失败:${response.data.msg}`);
}
return response.data;
} catch (error) {
log('消息发送错误:', error.message);
throw new Error(`飞书消息发送失败:${error.message}`);
}
}
// 发送卡片消息
async sendCard(chatId, card) {
// 判断 receive_id_type
const receiveIdType = chatId.startsWith('oc_') ? 'chat_id' : 'open_id';
return this.sendMessage(chatId, {
msg_type: 'interactive',
content: JSON.stringify(card)
}, receiveIdType);
}
// 下载文件 - 使用飞书 SDK 的 messageResource APIOpenClaw 使用的方式)
async downloadFile(fileKey, messageId, chatId) {
const tempDir = path.join(process.cwd(), 'temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const tempFile = path.join(tempDir, `feishu_${Date.now()}_${path.basename(fileKey)}`);
log('下载文件:', fileKey, 'messageId:', messageId);
try {
// 使用 SDK 的 im.messageResource.get 方法
const result = await this.client.im.messageResource.get({
path: {
message_id: messageId,
file_key: fileKey
},
params: {
type: 'file'
}
});
// SDK 返回一个带有 writeFile 方法的对象
await result.writeFile(tempFile);
log('文件下载完成:', tempFile, fs.statSync(tempFile).size, 'bytes');
return tempFile;
} catch (error) {
log('下载错误:', error.message);
throw new Error(`飞书文件下载失败:${error.message}`);
}
}
// 回复消息
async replyMessage(messageId, payload) {
const token = await this.getAccessToken();
try {
const response = await axios.post(
`${this.baseURL}/im/v1/messages`,
{
reply_id: messageId,
msg_type: payload.msg_type,
content: payload.content
},
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
);
return response.data;
} catch (error) {
throw new Error(`飞书回复失败:${error.message}`);
}
}
}
module.exports = { FeishuAPI };