iflow-feishu 1.0.7 → 1.0.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iflow-feishu",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "iFlow CLI 飞书插件 - 将 iFlow AI 助手接入飞书机器人",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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 };
@@ -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
  */
@@ -287,6 +287,7 @@ class FeishuService {
287
287
 
288
288
  // 提取响应
289
289
  const extracted = this.streamHandler.extractResponse(stdoutSoFar, chatId, modelName);
290
+ // 内容变化会自动触发卡片更新,无需显式调用 update()
290
291
  updater.setReasoning(extracted.reasoning);
291
292
  updater.setContent(extracted.content || '');
292
293
 
@@ -294,8 +295,6 @@ class FeishuService {
294
295
  const currentLength = (stdoutSoFar || '').length;
295
296
  const percent = this.messageProcessor.calculateContentLeftPercent(chatId, currentLength, modelName);
296
297
  updater.setPercent(percent);
297
-
298
- updater.update();
299
298
  },
300
299
  { mode: 'default', thinking: false }
301
300
  );
@@ -155,6 +155,7 @@ class StreamHandler {
155
155
 
156
156
  /**
157
157
  * 创建卡片更新器
158
+ * 优化:只在内容变化时才调用API,计时变化不单独触发更新
158
159
  */
159
160
  createCardUpdater(cardId, startTime, modelName, initialPercent) {
160
161
  let lastUpdate = 0;
@@ -167,6 +168,9 @@ class StreamHandler {
167
168
  let isCompleted = false;
168
169
  let thinkingStartTime = null;
169
170
  let thinkingEndTime = null;
171
+ let lastReasoning = '';
172
+ let lastContent = '';
173
+ let pendingUpdate = false;
170
174
 
171
175
  const update = async (force = false) => {
172
176
  if (!cardId || isCompleted) return;
@@ -175,6 +179,9 @@ class StreamHandler {
175
179
  if (!force && now - lastUpdate < CARD_UPDATE_INTERVAL) return;
176
180
 
177
181
  lastUpdate = now;
182
+ lastReasoning = currentReasoning;
183
+ lastContent = currentContent;
184
+ pendingUpdate = false;
178
185
 
179
186
  const elapsed = isStreamEnded && streamEndTime
180
187
  ? streamEndTime - startTime
@@ -198,10 +205,30 @@ class StreamHandler {
198
205
  }
199
206
  };
200
207
 
208
+ // 检查内容是否有变化
209
+ const hasContentChanged = () => {
210
+ return currentReasoning !== lastReasoning || currentContent !== lastContent;
211
+ };
212
+
213
+ // 内容变化时触发更新
214
+ const triggerUpdate = () => {
215
+ if (hasContentChanged() && !pendingUpdate) {
216
+ pendingUpdate = true;
217
+ update();
218
+ }
219
+ };
220
+
201
221
  return {
202
222
  update,
203
- setReasoning: (r) => { currentReasoning = r; },
204
- setContent: (c) => { currentContent = c; },
223
+ triggerUpdate,
224
+ setReasoning: (r) => {
225
+ currentReasoning = r;
226
+ triggerUpdate();
227
+ },
228
+ setContent: (c) => {
229
+ currentContent = c;
230
+ triggerUpdate();
231
+ },
205
232
  setPercent: (p) => { currentPercent = p; },
206
233
  setThinking: (isThinking) => { isInThinking = isThinking; },
207
234
  setThinkingStart: (t) => { thinkingStartTime = t; },
@@ -218,12 +245,12 @@ class StreamHandler {
218
245
 
219
246
  /**
220
247
  * 创建定时器
248
+ * 优化:定时器不再主动更新,只在完成时强制更新一次显示最终耗时
221
249
  */
222
250
  createTimer(updater) {
223
251
  return setInterval(() => {
224
- if (!updater.isCompleted()) {
225
- updater.update();
226
- }
252
+ // 不再主动更新,只检查是否完成
253
+ // 卡片更新由内容变化触发
227
254
  }, TIMER_UPDATE_INTERVAL);
228
255
  }
229
256
  }