iflow-feishu 1.0.8 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/feishu-client.js +177 -0
- package/src/core/http-server.js +48 -0
package/package.json
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
const http = require('http');
|
|
6
6
|
const https = require('https');
|
|
7
7
|
const { URL } = require('url');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
8
10
|
const { logger } = require('../utils/logger');
|
|
9
11
|
|
|
10
12
|
class FeishuClient {
|
|
@@ -194,6 +196,181 @@ class FeishuClient {
|
|
|
194
196
|
return false;
|
|
195
197
|
}
|
|
196
198
|
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 上传文件到飞书
|
|
202
|
+
* @param {string} filePath - 本地文件路径
|
|
203
|
+
* @returns {Promise<string|null>} - 返回 file_key 或 null
|
|
204
|
+
*/
|
|
205
|
+
async uploadFile(filePath) {
|
|
206
|
+
try {
|
|
207
|
+
const token = await this.getToken();
|
|
208
|
+
|
|
209
|
+
// 检查文件是否存在
|
|
210
|
+
if (!fs.existsSync(filePath)) {
|
|
211
|
+
logger.error('❌ 文件不存在:', filePath);
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 读取文件
|
|
216
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
217
|
+
const fileName = path.basename(filePath);
|
|
218
|
+
|
|
219
|
+
// 生成 boundary
|
|
220
|
+
const boundary = `----iFlowBoundary${Date.now()}`;
|
|
221
|
+
|
|
222
|
+
// 构建 multipart/form-data body
|
|
223
|
+
const parts = [];
|
|
224
|
+
|
|
225
|
+
// 文件类型字段
|
|
226
|
+
parts.push(`--${boundary}\r\n`);
|
|
227
|
+
parts.push(`Content-Disposition: form-data; name="file_type"\r\n\r\n`);
|
|
228
|
+
parts.push(`stream\r\n`);
|
|
229
|
+
|
|
230
|
+
// 文件名字段
|
|
231
|
+
parts.push(`--${boundary}\r\n`);
|
|
232
|
+
parts.push(`Content-Disposition: form-data; name="file_name"\r\n\r\n`);
|
|
233
|
+
parts.push(`${fileName}\r\n`);
|
|
234
|
+
|
|
235
|
+
// 文件内容
|
|
236
|
+
parts.push(`--${boundary}\r\n`);
|
|
237
|
+
parts.push(`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`);
|
|
238
|
+
parts.push(`Content-Type: application/octet-stream\r\n\r\n`);
|
|
239
|
+
|
|
240
|
+
const bodyParts = Buffer.concat([
|
|
241
|
+
Buffer.from(parts.join(''), 'utf8'),
|
|
242
|
+
fileBuffer,
|
|
243
|
+
Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8')
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
logger.info(`📤 正在上传文件: ${fileName} (${(fileBuffer.length / 1024).toFixed(2)} KB)`);
|
|
247
|
+
|
|
248
|
+
// 发送请求
|
|
249
|
+
const result = await this.httpRequestMultipart(
|
|
250
|
+
'https://open.feishu.cn/open-apis/im/v1/files',
|
|
251
|
+
{
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: {
|
|
254
|
+
'Authorization': `Bearer ${token}`,
|
|
255
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
256
|
+
'Content-Length': bodyParts.length
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
bodyParts
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (result.ok && result.data?.data?.file_key) {
|
|
263
|
+
logger.info('✅ 文件上传成功, file_key:', result.data.data.file_key);
|
|
264
|
+
return result.data.data.file_key;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
logger.error('❌ 文件上传失败:', JSON.stringify(result.data));
|
|
268
|
+
return null;
|
|
269
|
+
} catch (err) {
|
|
270
|
+
logger.error('上传文件错误:', err.message);
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 发送 multipart/form-data 请求
|
|
277
|
+
*/
|
|
278
|
+
async httpRequestMultipart(url, options, body) {
|
|
279
|
+
return new Promise((resolve, reject) => {
|
|
280
|
+
const u = new URL(url);
|
|
281
|
+
|
|
282
|
+
const req = https.request({
|
|
283
|
+
hostname: u.hostname,
|
|
284
|
+
port: u.port || 443,
|
|
285
|
+
path: u.pathname + u.search,
|
|
286
|
+
method: options.method || 'POST',
|
|
287
|
+
headers: options.headers || {},
|
|
288
|
+
}, (res) => {
|
|
289
|
+
let data = '';
|
|
290
|
+
res.on('data', chunk => data += chunk);
|
|
291
|
+
res.on('end', () => {
|
|
292
|
+
try {
|
|
293
|
+
resolve({
|
|
294
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
295
|
+
data: JSON.parse(data),
|
|
296
|
+
status: res.statusCode
|
|
297
|
+
});
|
|
298
|
+
} catch {
|
|
299
|
+
resolve({
|
|
300
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
301
|
+
data,
|
|
302
|
+
status: res.statusCode
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
req.on('error', reject);
|
|
309
|
+
req.setTimeout(60000, () => { // 文件上传超时时间更长
|
|
310
|
+
req.destroy();
|
|
311
|
+
reject(new Error('Upload timeout'));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
if (body) req.write(body);
|
|
315
|
+
req.end();
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 发送文件消息
|
|
321
|
+
* @param {string} chatId - 群聊 ID
|
|
322
|
+
* @param {string} fileKey - 文件 key(由 uploadFile 返回)
|
|
323
|
+
* @param {string} fileName - 文件名(可选,用于日志)
|
|
324
|
+
* @returns {Promise<boolean>} - 发送是否成功
|
|
325
|
+
*/
|
|
326
|
+
async sendFileMessage(chatId, fileKey, fileName = '') {
|
|
327
|
+
try {
|
|
328
|
+
const token = await this.getToken();
|
|
329
|
+
|
|
330
|
+
const r = await this.httpRequest(
|
|
331
|
+
'https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id',
|
|
332
|
+
{
|
|
333
|
+
method: 'POST',
|
|
334
|
+
headers: {
|
|
335
|
+
'Authorization': `Bearer ${token}`,
|
|
336
|
+
'Content-Type': 'application/json'
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
JSON.stringify({
|
|
340
|
+
receive_id: chatId,
|
|
341
|
+
msg_type: 'file',
|
|
342
|
+
content: JSON.stringify({ file_key: fileKey })
|
|
343
|
+
})
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
if (r.ok && r.data?.data?.message_id) {
|
|
347
|
+
logger.info(`📤 文件消息已发送: ${fileName || fileKey}`);
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
logger.error('❌ 发送文件消息失败:', JSON.stringify(r.data));
|
|
352
|
+
return false;
|
|
353
|
+
} catch (err) {
|
|
354
|
+
logger.error('发送文件消息错误:', err.message);
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 上传并发送文件(便捷方法)
|
|
361
|
+
* @param {string} chatId - 群聊 ID
|
|
362
|
+
* @param {string} filePath - 本地文件路径
|
|
363
|
+
* @returns {Promise<boolean>} - 发送是否成功
|
|
364
|
+
*/
|
|
365
|
+
async sendFile(chatId, filePath) {
|
|
366
|
+
const fileKey = await this.uploadFile(filePath);
|
|
367
|
+
if (!fileKey) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const fileName = path.basename(filePath);
|
|
372
|
+
return await this.sendFileMessage(chatId, fileKey, fileName);
|
|
373
|
+
}
|
|
197
374
|
}
|
|
198
375
|
|
|
199
376
|
module.exports = { FeishuClient };
|
package/src/core/http-server.js
CHANGED
|
@@ -46,6 +46,12 @@ class HTTPServer {
|
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// 发送文件 API
|
|
50
|
+
if (url.pathname === '/send-file' && req.method === 'POST') {
|
|
51
|
+
await this.handleSendFile(req, res);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
49
55
|
// 404
|
|
50
56
|
res.writeHead(404);
|
|
51
57
|
res.end('Not Found');
|
|
@@ -101,6 +107,48 @@ class HTTPServer {
|
|
|
101
107
|
});
|
|
102
108
|
}
|
|
103
109
|
|
|
110
|
+
/**
|
|
111
|
+
* 处理发送文件请求
|
|
112
|
+
*/
|
|
113
|
+
async handleSendFile(req, res) {
|
|
114
|
+
let body = '';
|
|
115
|
+
|
|
116
|
+
req.on('data', chunk => body += chunk);
|
|
117
|
+
|
|
118
|
+
req.on('end', async () => {
|
|
119
|
+
try {
|
|
120
|
+
const data = JSON.parse(body);
|
|
121
|
+
const { chatId, filePath, message } = data;
|
|
122
|
+
|
|
123
|
+
// 验证必需参数
|
|
124
|
+
if (!chatId || !filePath) {
|
|
125
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
126
|
+
res.end(JSON.stringify({ code: 400, msg: '缺少必需参数: chatId 或 filePath' }));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
logger.info(`发送文件请求: chatId=${chatId}, filePath=${filePath}`);
|
|
131
|
+
|
|
132
|
+
// 如果有消息,先发送文字消息
|
|
133
|
+
if (message) {
|
|
134
|
+
await this.service.feishuClient.sendMessage(chatId, message);
|
|
135
|
+
logger.info(`文字消息已发送: ${message}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 上传并发送文件
|
|
139
|
+
const result = await this.service.feishuClient.sendFile(chatId, filePath);
|
|
140
|
+
logger.info(`文件发送成功: ${JSON.stringify(result)}`);
|
|
141
|
+
|
|
142
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
143
|
+
res.end(JSON.stringify({ code: 0, msg: 'success', data: result }));
|
|
144
|
+
} catch (err) {
|
|
145
|
+
logger.error(`发送文件错误: ${err.message}`);
|
|
146
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
147
|
+
res.end(JSON.stringify({ code: 500, msg: err.message }));
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
104
152
|
/**
|
|
105
153
|
* 关闭服务器
|
|
106
154
|
*/
|