主要修复: 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 - 飞书应用配置
182 lines
4.9 KiB
JavaScript
182 lines
4.9 KiB
JavaScript
/**
|
||
* 飞书 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_type:oc_开头的是 chat_id,ou_开头的是 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 API(OpenClaw 使用的方式)
|
||
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 };
|