flashclaw 1.0.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.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +305 -0
  3. package/config/plugins.json +23 -0
  4. package/dist/agent-runner.d.ts +103 -0
  5. package/dist/agent-runner.d.ts.map +1 -0
  6. package/dist/agent-runner.js +530 -0
  7. package/dist/agent-runner.js.map +1 -0
  8. package/dist/cli.d.ts +7 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +497 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/commands.d.ts +68 -0
  13. package/dist/commands.d.ts.map +1 -0
  14. package/dist/commands.js +252 -0
  15. package/dist/commands.js.map +1 -0
  16. package/dist/config-schema.d.ts +21 -0
  17. package/dist/config-schema.d.ts.map +1 -0
  18. package/dist/config-schema.js +26 -0
  19. package/dist/config-schema.js.map +1 -0
  20. package/dist/config.d.ts +11 -0
  21. package/dist/config.d.ts.map +1 -0
  22. package/dist/config.js +36 -0
  23. package/dist/config.js.map +1 -0
  24. package/dist/core/api-client.d.ts +236 -0
  25. package/dist/core/api-client.d.ts.map +1 -0
  26. package/dist/core/api-client.js +369 -0
  27. package/dist/core/api-client.js.map +1 -0
  28. package/dist/core/memory.d.ts +291 -0
  29. package/dist/core/memory.d.ts.map +1 -0
  30. package/dist/core/memory.js +754 -0
  31. package/dist/core/memory.js.map +1 -0
  32. package/dist/core/model-capabilities.d.ts +45 -0
  33. package/dist/core/model-capabilities.d.ts.map +1 -0
  34. package/dist/core/model-capabilities.js +85 -0
  35. package/dist/core/model-capabilities.js.map +1 -0
  36. package/dist/db.d.ts +103 -0
  37. package/dist/db.d.ts.map +1 -0
  38. package/dist/db.js +380 -0
  39. package/dist/db.js.map +1 -0
  40. package/dist/errors.d.ts +22 -0
  41. package/dist/errors.d.ts.map +1 -0
  42. package/dist/errors.js +44 -0
  43. package/dist/errors.js.map +1 -0
  44. package/dist/health.d.ts +27 -0
  45. package/dist/health.d.ts.map +1 -0
  46. package/dist/health.js +55 -0
  47. package/dist/health.js.map +1 -0
  48. package/dist/index.d.ts +11 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +1181 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/logger.d.ts +9 -0
  53. package/dist/logger.d.ts.map +1 -0
  54. package/dist/logger.js +19 -0
  55. package/dist/logger.js.map +1 -0
  56. package/dist/message-queue.d.ts +69 -0
  57. package/dist/message-queue.d.ts.map +1 -0
  58. package/dist/message-queue.js +198 -0
  59. package/dist/message-queue.js.map +1 -0
  60. package/dist/metrics.d.ts +46 -0
  61. package/dist/metrics.d.ts.map +1 -0
  62. package/dist/metrics.js +101 -0
  63. package/dist/metrics.js.map +1 -0
  64. package/dist/paths.d.ts +81 -0
  65. package/dist/paths.d.ts.map +1 -0
  66. package/dist/paths.js +127 -0
  67. package/dist/paths.js.map +1 -0
  68. package/dist/plugins/index.d.ts +9 -0
  69. package/dist/plugins/index.d.ts.map +1 -0
  70. package/dist/plugins/index.js +13 -0
  71. package/dist/plugins/index.js.map +1 -0
  72. package/dist/plugins/installer.d.ts +120 -0
  73. package/dist/plugins/installer.d.ts.map +1 -0
  74. package/dist/plugins/installer.js +1008 -0
  75. package/dist/plugins/installer.js.map +1 -0
  76. package/dist/plugins/loader.d.ts +37 -0
  77. package/dist/plugins/loader.d.ts.map +1 -0
  78. package/dist/plugins/loader.js +429 -0
  79. package/dist/plugins/loader.js.map +1 -0
  80. package/dist/plugins/manager.d.ts +72 -0
  81. package/dist/plugins/manager.d.ts.map +1 -0
  82. package/dist/plugins/manager.js +187 -0
  83. package/dist/plugins/manager.js.map +1 -0
  84. package/dist/plugins/types.d.ts +101 -0
  85. package/dist/plugins/types.d.ts.map +1 -0
  86. package/dist/plugins/types.js +12 -0
  87. package/dist/plugins/types.js.map +1 -0
  88. package/dist/session-tracker.d.ts +81 -0
  89. package/dist/session-tracker.d.ts.map +1 -0
  90. package/dist/session-tracker.js +228 -0
  91. package/dist/session-tracker.js.map +1 -0
  92. package/dist/task-scheduler.d.ts +47 -0
  93. package/dist/task-scheduler.d.ts.map +1 -0
  94. package/dist/task-scheduler.js +331 -0
  95. package/dist/task-scheduler.js.map +1 -0
  96. package/dist/types.d.ts +57 -0
  97. package/dist/types.d.ts.map +1 -0
  98. package/dist/types.js +2 -0
  99. package/dist/types.js.map +1 -0
  100. package/dist/utils/env-substitute.d.ts +63 -0
  101. package/dist/utils/env-substitute.d.ts.map +1 -0
  102. package/dist/utils/env-substitute.js +133 -0
  103. package/dist/utils/env-substitute.js.map +1 -0
  104. package/dist/utils/log-rotate.d.ts +19 -0
  105. package/dist/utils/log-rotate.d.ts.map +1 -0
  106. package/dist/utils/log-rotate.js +85 -0
  107. package/dist/utils/log-rotate.js.map +1 -0
  108. package/dist/utils/rate-limiter.d.ts +38 -0
  109. package/dist/utils/rate-limiter.d.ts.map +1 -0
  110. package/dist/utils/rate-limiter.js +79 -0
  111. package/dist/utils/rate-limiter.js.map +1 -0
  112. package/dist/utils/retry.d.ts +10 -0
  113. package/dist/utils/retry.d.ts.map +1 -0
  114. package/dist/utils/retry.js +47 -0
  115. package/dist/utils/retry.js.map +1 -0
  116. package/dist/utils.d.ts +86 -0
  117. package/dist/utils.d.ts.map +1 -0
  118. package/dist/utils.js +218 -0
  119. package/dist/utils.js.map +1 -0
  120. package/package.json +78 -0
  121. package/plugins/cancel-task/index.ts +161 -0
  122. package/plugins/cancel-task/plugin.json +9 -0
  123. package/plugins/feishu/index.ts +944 -0
  124. package/plugins/feishu/plugin.json +29 -0
  125. package/plugins/list-tasks/index.ts +150 -0
  126. package/plugins/list-tasks/plugin.json +9 -0
  127. package/plugins/memory/index.ts +190 -0
  128. package/plugins/memory/plugin.json +7 -0
  129. package/plugins/pause-task/index.ts +95 -0
  130. package/plugins/pause-task/plugin.json +8 -0
  131. package/plugins/register-group/index.ts +147 -0
  132. package/plugins/register-group/plugin.json +7 -0
  133. package/plugins/resume-task/index.ts +92 -0
  134. package/plugins/resume-task/plugin.json +8 -0
  135. package/plugins/schedule-task/index.ts +248 -0
  136. package/plugins/schedule-task/plugin.json +9 -0
  137. package/plugins/send-message/index.ts +75 -0
  138. package/plugins/send-message/plugin.json +9 -0
@@ -0,0 +1,944 @@
1
+ /**
2
+ * 飞书通讯渠道插件 v2.0
3
+ * 参考 feishu-openclaw 项目实现
4
+ *
5
+ * 功能:
6
+ * - WebSocket 长连接(无需公网服务器)
7
+ * - 图片收发
8
+ * - 视频/文件/音频支持
9
+ * - 富文本 (post) 消息解析
10
+ * - "正在思考..." 提示
11
+ * - 群聊智能响应
12
+ * - 消息更新/删除
13
+ */
14
+
15
+ import * as lark from '@larksuiteoapi/node-sdk';
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+ import * as os from 'node:os';
19
+ import { pipeline } from 'node:stream/promises';
20
+ import { Readable } from 'node:stream';
21
+ import pino from 'pino';
22
+ import {
23
+ ChannelPlugin,
24
+ PluginConfig,
25
+ MessageHandler,
26
+ Message,
27
+ Attachment,
28
+ SendMessageOptions,
29
+ SendMessageResult
30
+ } from '../../src/plugins/types.js';
31
+
32
+ const logger = pino({
33
+ level: process.env.LOG_LEVEL || 'info',
34
+ transport: { target: 'pino-pretty', options: { colorize: true } }
35
+ });
36
+
37
+ // ─── 配置 ────────────────────────────────────────────────────────
38
+
39
+ const MAX_IMAGE_MB = Number(process.env.FEISHU_MAX_IMAGE_MB ?? 12);
40
+ const MAX_FILE_MB = Number(process.env.FEISHU_MAX_FILE_MB ?? 40);
41
+ const THINKING_THRESHOLD_MS = Number(process.env.FEISHU_THINKING_THRESHOLD_MS ?? 2500);
42
+ const DEBUG = process.env.FEISHU_DEBUG === '1';
43
+
44
+ // ─── 工具函数 ─────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * 解码 HTML 实体
48
+ */
49
+ function decodeHtmlEntities(s: string): string {
50
+ return String(s ?? '')
51
+ .replace(/ /gi, ' ')
52
+ .replace(/&lt;/gi, '<')
53
+ .replace(/&gt;/gi, '>')
54
+ .replace(/&amp;/gi, '&')
55
+ .replace(/&quot;/gi, '"')
56
+ .replace(/&#39;/g, "'");
57
+ }
58
+
59
+ /**
60
+ * 规范化飞书文本
61
+ * - 转换 HTML 标签
62
+ * - 修复列表格式(-\n1 → - 1)
63
+ */
64
+ function normalizeFeishuText(raw: string): string {
65
+ let t = String(raw ?? '');
66
+
67
+ // 转换 HTML 块为换行
68
+ t = t.replace(/<\s*br\s*\/?>/gi, '\n');
69
+ t = t.replace(/<\s*\/p\s*>\s*<\s*p\s*>/gi, '\n');
70
+ t = t.replace(/<\s*p\s*>/gi, '');
71
+ t = t.replace(/<\s*\/p\s*>/gi, '');
72
+
73
+ // 移除剩余标签
74
+ t = t.replace(/<[^>]+>/g, '');
75
+
76
+ t = decodeHtmlEntities(t);
77
+
78
+ // 规范化换行
79
+ t = t.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
80
+ t = t.replace(/\n{3,}/g, '\n\n');
81
+
82
+ // 修复飞书列表问题:"-\n1" -> "- 1"
83
+ t = t.replace(/(^|\n)([-*•])\n(?=\S)/g, '$1$2 ');
84
+ t = t.replace(/(^|\n)(\d+[.)])\n(?=\S)/g, '$1$2 ');
85
+
86
+ return t.trim();
87
+ }
88
+
89
+ /**
90
+ * 根据扩展名猜测 MIME 类型
91
+ */
92
+ function guessMimeByExt(filePath: string): string {
93
+ const ext = path.extname(filePath || '').toLowerCase().replace(/^\./, '');
94
+ const mimeMap: Record<string, string> = {
95
+ png: 'image/png',
96
+ jpg: 'image/jpeg',
97
+ jpeg: 'image/jpeg',
98
+ gif: 'image/gif',
99
+ webp: 'image/webp',
100
+ mp4: 'video/mp4',
101
+ mov: 'video/quicktime',
102
+ mp3: 'audio/mpeg',
103
+ wav: 'audio/wav',
104
+ m4a: 'audio/mp4',
105
+ opus: 'audio/opus',
106
+ };
107
+ return mimeMap[ext] || 'application/octet-stream';
108
+ }
109
+
110
+ /**
111
+ * 判断是否为图片路径
112
+ */
113
+ function isImagePath(p: string): boolean {
114
+ return /\.(png|jpg|jpeg|gif|webp|bmp)$/i.test(p);
115
+ }
116
+
117
+ /**
118
+ * 判断是否为视频路径
119
+ */
120
+ function isVideoPath(p: string): boolean {
121
+ return /\.(mp4|mov|avi|mkv|webm)$/i.test(p);
122
+ }
123
+
124
+ /**
125
+ * 判断是否为音频路径
126
+ */
127
+ function isAudioPath(p: string): boolean {
128
+ return /\.(opus|mp3|wav|m4a|aac|ogg)$/i.test(p);
129
+ }
130
+
131
+ /**
132
+ * 文件转 data URL
133
+ */
134
+ function fileToDataUrl(filePath: string, mimeType: string): string {
135
+ const buf = fs.readFileSync(filePath);
136
+ const b64 = buf.toString('base64');
137
+ return `data:${mimeType};base64,${b64}`;
138
+ }
139
+
140
+ /**
141
+ * 将流转换为 Node.js 可读流
142
+ */
143
+ function toNodeReadableStream(maybeStream: any): Readable | null {
144
+ if (!maybeStream) return null;
145
+ if (typeof maybeStream.pipe === 'function') return maybeStream;
146
+ if (typeof maybeStream.getReader === 'function' && typeof Readable.fromWeb === 'function') {
147
+ return Readable.fromWeb(maybeStream as any);
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * 判断群聊中是否应该响应
154
+ */
155
+ function shouldRespondInGroup(text: string, mentions: string[]): boolean {
156
+ // 被 @ 了
157
+ if (mentions.length > 0) return true;
158
+
159
+ const t = text.toLowerCase();
160
+
161
+ // 以问号结尾
162
+ if (/[??]$/.test(text)) return true;
163
+
164
+ // 包含疑问词
165
+ if (/\b(why|how|what|when|where|who|help)\b/.test(t)) return true;
166
+
167
+ // 包含请求类动词
168
+ const verbs = ['帮', '麻烦', '请', '能否', '可以', '解释', '看看', '排查', '分析', '总结', '写', '改', '修', '查', '对比', '翻译'];
169
+ if (verbs.some((k) => text.includes(k))) return true;
170
+
171
+ // 用名字呼唤
172
+ if (/^(bot|助手|智能体|小助手|机器人)[\s,:,:]/i.test(text)) return true;
173
+
174
+ return false;
175
+ }
176
+
177
+ // ─── 插件配置 ─────────────────────────────────────────────────────
178
+
179
+ interface FeishuPluginConfig extends PluginConfig {
180
+ appId: string;
181
+ appSecret: string;
182
+ }
183
+
184
+ // ─── 飞书渠道插件 ─────────────────────────────────────────────────
185
+
186
+ class FeishuChannelPlugin implements ChannelPlugin {
187
+ name = 'feishu';
188
+ version = '2.0.0';
189
+
190
+ private client: lark.Client | null = null;
191
+ private wsClient: lark.WSClient | null = null;
192
+ private messageHandler: MessageHandler | null = null;
193
+ private seenMessages: Map<string, number> = new Map();
194
+ private readonly SEEN_TTL_MS = 10 * 60 * 1000;
195
+ private running = false;
196
+ private config: FeishuPluginConfig | null = null;
197
+
198
+ // ─── 生命周期 ───────────────────────────────────────────────────
199
+
200
+ async init(config: PluginConfig): Promise<void> {
201
+ const feishuConfig = config as FeishuPluginConfig;
202
+ const { appId, appSecret } = feishuConfig;
203
+
204
+ if (!appId || !appSecret) {
205
+ throw new Error('飞书插件需要 appId 和 appSecret 配置');
206
+ }
207
+
208
+ this.config = feishuConfig;
209
+
210
+ const sdkConfig = {
211
+ appId,
212
+ appSecret,
213
+ domain: lark.Domain.Feishu,
214
+ appType: lark.AppType.SelfBuild,
215
+ };
216
+
217
+ this.client = new lark.Client(sdkConfig);
218
+ this.wsClient = new lark.WSClient({
219
+ ...sdkConfig,
220
+ loggerLevel: lark.LoggerLevel.info,
221
+ });
222
+
223
+ logger.info({ plugin: this.name }, '飞书插件初始化完成');
224
+ }
225
+
226
+ onMessage(handler: MessageHandler): void {
227
+ this.messageHandler = handler;
228
+ logger.info({ plugin: this.name }, '消息处理器已注册');
229
+ }
230
+
231
+ async start(): Promise<void> {
232
+ if (this.running) {
233
+ logger.warn({ plugin: this.name }, '飞书插件已在运行中');
234
+ return;
235
+ }
236
+
237
+ if (!this.wsClient) {
238
+ throw new Error('飞书插件未初始化');
239
+ }
240
+
241
+ if (!this.messageHandler) {
242
+ logger.warn({ plugin: this.name }, '警告:消息处理器未设置');
243
+ }
244
+
245
+ this.running = true;
246
+
247
+ const eventDispatcher = new lark.EventDispatcher({}).register({
248
+ 'im.message.receive_v1': async (data: any) => {
249
+ try {
250
+ await this.handleMessageEvent(data);
251
+ } catch (err) {
252
+ logger.error({ err, plugin: this.name }, '处理消息事件时出错');
253
+ }
254
+ },
255
+ });
256
+
257
+ this.wsClient.start({ eventDispatcher });
258
+ logger.info({ plugin: this.name }, '飞书 WebSocket 已启动');
259
+ }
260
+
261
+ async stop(): Promise<void> {
262
+ this.running = false;
263
+ logger.info({ plugin: this.name }, '飞书插件已停止');
264
+ }
265
+
266
+ async reload(): Promise<void> {
267
+ if (!this.config) return;
268
+ logger.info({ plugin: this.name }, '🔄 热重载中...');
269
+ await this.stop();
270
+ await this.init(this.config);
271
+ await this.start();
272
+ logger.info({ plugin: this.name }, '✅ 热重载完成');
273
+ }
274
+
275
+ // ─── 消息发送 ───────────────────────────────────────────────────
276
+
277
+ async sendMessage(chatId: string, content: string, options?: SendMessageOptions): Promise<SendMessageResult> {
278
+ if (!this.client) {
279
+ return { success: false, error: '飞书插件未初始化' };
280
+ }
281
+
282
+ try {
283
+ // 如果有占位消息,更新它
284
+ if (options?.placeholderMessageId) {
285
+ await this.updateMessage(options.placeholderMessageId, content);
286
+ return { success: true, messageId: options.placeholderMessageId };
287
+ }
288
+
289
+ // 发送新消息
290
+ const res = await this.client.im.message.create({
291
+ params: { receive_id_type: 'chat_id' },
292
+ data: {
293
+ receive_id: chatId,
294
+ msg_type: 'text',
295
+ content: JSON.stringify({ text: content }),
296
+ },
297
+ });
298
+
299
+ const messageId = (res as any)?.data?.message_id;
300
+ logger.info({ chatId, length: content.length, plugin: this.name }, '消息发送成功');
301
+ return { success: true, messageId };
302
+ } catch (err: any) {
303
+ logger.error({ chatId, err, plugin: this.name }, '发送消息失败');
304
+ return { success: false, error: err?.message || String(err) };
305
+ }
306
+ }
307
+
308
+ async updateMessage(messageId: string, content: string): Promise<void> {
309
+ if (!this.client) throw new Error('飞书插件未初始化');
310
+
311
+ // 飞书只支持更新卡片消息,普通文本消息会失败
312
+ // 抛出错误让主程序执行降级逻辑(删除并重发)
313
+ try {
314
+ await this.client.im.message.patch({
315
+ path: { message_id: messageId },
316
+ data: {
317
+ content: JSON.stringify({ text: content }),
318
+ },
319
+ });
320
+ logger.debug({ messageId, plugin: this.name }, '消息已更新');
321
+ } catch (err: any) {
322
+ // 普通文本消息无法更新,抛出错误触发降级
323
+ logger.debug({ messageId, plugin: this.name }, '消息更新失败,将触发降级发送');
324
+ throw new Error('飞书不支持更新普通文本消息');
325
+ }
326
+ }
327
+
328
+ async deleteMessage(messageId: string): Promise<void> {
329
+ if (!this.client) throw new Error('飞书插件未初始化');
330
+
331
+ await this.client.im.message.delete({
332
+ path: { message_id: messageId },
333
+ });
334
+ logger.debug({ messageId, plugin: this.name }, '消息已删除');
335
+ }
336
+
337
+ async sendImage(chatId: string, imageData: string | Buffer, caption?: string): Promise<SendMessageResult> {
338
+ if (!this.client) {
339
+ return { success: false, error: '飞书插件未初始化' };
340
+ }
341
+
342
+ try {
343
+ let imagePath: string;
344
+ let isTemp = false;
345
+
346
+ // 处理不同格式的图片数据
347
+ if (typeof imageData === 'string') {
348
+ if (imageData.startsWith('data:')) {
349
+ // data URL
350
+ const match = imageData.match(/^data:([^;]+);base64,(.*)$/);
351
+ if (!match) {
352
+ return { success: false, error: '无效的 data URL' };
353
+ }
354
+ const b64 = match[2];
355
+ imagePath = path.join(os.tmpdir(), `feishu_upload_${Date.now()}.png`);
356
+ fs.writeFileSync(imagePath, Buffer.from(b64, 'base64'));
357
+ isTemp = true;
358
+ } else if (imageData.startsWith('http')) {
359
+ // HTTP URL - 需要先下载
360
+ return { success: false, error: '暂不支持 HTTP URL 图片' };
361
+ } else {
362
+ // 本地路径
363
+ imagePath = imageData;
364
+ }
365
+ } else {
366
+ // Buffer
367
+ imagePath = path.join(os.tmpdir(), `feishu_upload_${Date.now()}.png`);
368
+ fs.writeFileSync(imagePath, imageData);
369
+ isTemp = true;
370
+ }
371
+
372
+ // 上传图片
373
+ const uploadRes = await this.client.im.image.create({
374
+ data: {
375
+ image_type: 'message',
376
+ image: fs.createReadStream(imagePath),
377
+ },
378
+ });
379
+
380
+ const imageKey = (uploadRes as any)?.data?.image_key || (uploadRes as any)?.image_key;
381
+ if (!imageKey) {
382
+ throw new Error('上传图片失败:未获取到 image_key');
383
+ }
384
+
385
+ // 发送图片消息
386
+ const sendRes = await this.client.im.message.create({
387
+ params: { receive_id_type: 'chat_id' },
388
+ data: {
389
+ receive_id: chatId,
390
+ msg_type: 'image',
391
+ content: JSON.stringify({ image_key: imageKey }),
392
+ },
393
+ });
394
+
395
+ // 如果有说明文字,再发一条
396
+ if (caption?.trim()) {
397
+ await this.sendMessage(chatId, caption.trim());
398
+ }
399
+
400
+ // 清理临时文件
401
+ if (isTemp) {
402
+ try { fs.unlinkSync(imagePath); } catch {}
403
+ }
404
+
405
+ const messageId = (sendRes as any)?.data?.message_id;
406
+ logger.info({ chatId, plugin: this.name }, '图片发送成功');
407
+ return { success: true, messageId };
408
+ } catch (err: any) {
409
+ logger.error({ chatId, err, plugin: this.name }, '发送图片失败');
410
+ return { success: false, error: err?.message || String(err) };
411
+ }
412
+ }
413
+
414
+ async sendFile(chatId: string, filePath: string, fileName?: string): Promise<SendMessageResult> {
415
+ if (!this.client) {
416
+ return { success: false, error: '飞书插件未初始化' };
417
+ }
418
+
419
+ try {
420
+ const actualFileName = fileName || path.basename(filePath);
421
+ const ext = path.extname(filePath).toLowerCase().replace('.', '');
422
+
423
+ // 根据文件类型选择上传方式
424
+ let fileType: string;
425
+ let msgType: string;
426
+
427
+ if (isImagePath(filePath)) {
428
+ // 图片走 sendImage
429
+ return this.sendImage(chatId, filePath);
430
+ } else if (ext === 'mp4') {
431
+ fileType = 'mp4';
432
+ msgType = 'media';
433
+ } else if (ext === 'opus') {
434
+ fileType = 'opus';
435
+ msgType = 'audio';
436
+ } else {
437
+ fileType = 'stream';
438
+ msgType = 'file';
439
+ }
440
+
441
+ const uploadRes = await this.client.im.file.create({
442
+ data: {
443
+ file_type: fileType as any,
444
+ file_name: actualFileName,
445
+ file: fs.createReadStream(filePath),
446
+ },
447
+ });
448
+
449
+ const fileKey = (uploadRes as any)?.data?.file_key || (uploadRes as any)?.file_key;
450
+ if (!fileKey) {
451
+ throw new Error('上传文件失败:未获取到 file_key');
452
+ }
453
+
454
+ const sendRes = await this.client.im.message.create({
455
+ params: { receive_id_type: 'chat_id' },
456
+ data: {
457
+ receive_id: chatId,
458
+ msg_type: msgType,
459
+ content: JSON.stringify({ file_key: fileKey }),
460
+ },
461
+ });
462
+
463
+ const messageId = (sendRes as any)?.data?.message_id;
464
+ logger.info({ chatId, fileName: actualFileName, plugin: this.name }, '文件发送成功');
465
+ return { success: true, messageId };
466
+ } catch (err: any) {
467
+ logger.error({ chatId, err, plugin: this.name }, '发送文件失败');
468
+ return { success: false, error: err?.message || String(err) };
469
+ }
470
+ }
471
+
472
+ // ─── 消息去重 ───────────────────────────────────────────────────
473
+
474
+ private isDuplicate(messageId: string): boolean {
475
+ const now = Date.now();
476
+ for (const [k, ts] of this.seenMessages) {
477
+ if (now - ts > this.SEEN_TTL_MS) {
478
+ this.seenMessages.delete(k);
479
+ }
480
+ }
481
+ if (!messageId) return false;
482
+ if (this.seenMessages.has(messageId)) return true;
483
+ this.seenMessages.set(messageId, now);
484
+ return false;
485
+ }
486
+
487
+ // ─── 图片下载 ───────────────────────────────────────────────────
488
+
489
+ private async downloadImage(messageId: string, imageKey: string): Promise<string | null> {
490
+ if (!this.client) return null;
491
+
492
+ const tmpPath = path.join(os.tmpdir(), `feishu_recv_${Date.now()}_${Math.random().toString(16).slice(2)}.png`);
493
+
494
+ try {
495
+ if (DEBUG) {
496
+ logger.debug({ messageId, imageKey, plugin: this.name }, '下载图片中...');
497
+ }
498
+
499
+ const response = await this.client.im.messageResource.get({
500
+ path: { message_id: messageId, file_key: imageKey },
501
+ params: { type: 'image' },
502
+ });
503
+
504
+ const data = response as any;
505
+ const payload = (data && typeof data === 'object' && 'data' in data) ? data.data : data;
506
+
507
+ // 处理不同的响应格式
508
+ if (payload && typeof payload.writeFile === 'function') {
509
+ await payload.writeFile(tmpPath);
510
+ } else if (payload && typeof payload.getReadableStream === 'function') {
511
+ const rs = payload.getReadableStream();
512
+ const nodeRs = toNodeReadableStream(rs);
513
+ if (!nodeRs) throw new Error('getReadableStream() 返回非流');
514
+ const out = fs.createWriteStream(tmpPath);
515
+ await pipeline(nodeRs, out);
516
+ } else if (payload && typeof payload.pipe === 'function') {
517
+ const out = fs.createWriteStream(tmpPath);
518
+ await pipeline(payload, out);
519
+ } else if (Buffer.isBuffer(payload)) {
520
+ fs.writeFileSync(tmpPath, payload);
521
+ } else if (payload instanceof ArrayBuffer) {
522
+ fs.writeFileSync(tmpPath, Buffer.from(payload));
523
+ } else {
524
+ throw new Error(`未知响应类型: ${typeof data}`);
525
+ }
526
+
527
+ // 大小检查
528
+ const st = fs.statSync(tmpPath);
529
+ const maxBytes = MAX_IMAGE_MB * 1024 * 1024;
530
+ if (st.size > maxBytes) {
531
+ fs.unlinkSync(tmpPath);
532
+ throw new Error(`图片过大 (${st.size} bytes > ${maxBytes})`);
533
+ }
534
+
535
+ if (DEBUG) {
536
+ logger.debug({ messageId, size: st.size, plugin: this.name }, '图片下载完成');
537
+ }
538
+
539
+ // 转为 data URL
540
+ const dataUrl = fileToDataUrl(tmpPath, 'image/png');
541
+
542
+ // 清理临时文件
543
+ try { fs.unlinkSync(tmpPath); } catch {}
544
+
545
+ return dataUrl;
546
+ } catch (err: any) {
547
+ logger.error({ messageId, imageKey, err: err?.message, plugin: this.name }, '下载图片失败');
548
+ try { fs.unlinkSync(tmpPath); } catch {}
549
+ return null;
550
+ }
551
+ }
552
+
553
+ // ─── 文件下载 ───────────────────────────────────────────────────
554
+
555
+ private async downloadFile(messageId: string, fileKey: string, fileName: string, type: string = 'file'): Promise<string | null> {
556
+ if (!this.client) return null;
557
+
558
+ const ext = path.extname(fileName || '') || '.bin';
559
+ const tmpPath = path.join(os.tmpdir(), `feishu_recv_${Date.now()}_${Math.random().toString(16).slice(2)}${ext}`);
560
+
561
+ try {
562
+ const response = await this.client.im.messageResource.get({
563
+ path: { message_id: messageId, file_key: fileKey },
564
+ params: { type: type as any },
565
+ });
566
+
567
+ const data = response as any;
568
+ const payload = (data && typeof data === 'object' && 'data' in data) ? data.data : data;
569
+
570
+ if (payload && typeof payload.writeFile === 'function') {
571
+ await payload.writeFile(tmpPath);
572
+ } else if (payload && typeof payload.getReadableStream === 'function') {
573
+ const rs = payload.getReadableStream();
574
+ const nodeRs = toNodeReadableStream(rs);
575
+ if (!nodeRs) throw new Error('getReadableStream() 返回非流');
576
+ const out = fs.createWriteStream(tmpPath);
577
+ await pipeline(nodeRs, out);
578
+ } else if (payload && typeof payload.pipe === 'function') {
579
+ const out = fs.createWriteStream(tmpPath);
580
+ await pipeline(payload, out);
581
+ } else if (Buffer.isBuffer(payload)) {
582
+ fs.writeFileSync(tmpPath, payload);
583
+ } else {
584
+ throw new Error(`未知响应类型: ${typeof data}`);
585
+ }
586
+
587
+ // 大小检查
588
+ const st = fs.statSync(tmpPath);
589
+ const maxBytes = MAX_FILE_MB * 1024 * 1024;
590
+ if (st.size > maxBytes) {
591
+ fs.unlinkSync(tmpPath);
592
+ throw new Error(`文件过大 (${st.size} bytes > ${maxBytes})`);
593
+ }
594
+
595
+ logger.debug({ messageId, fileName, size: st.size, plugin: this.name }, '文件下载完成');
596
+ return tmpPath;
597
+ } catch (err: any) {
598
+ logger.error({ messageId, fileKey, err: err?.message, plugin: this.name }, '下载文件失败');
599
+ try { fs.unlinkSync(tmpPath); } catch {}
600
+ return null;
601
+ }
602
+ }
603
+
604
+ // ─── 富文本解析 ─────────────────────────────────────────────────
605
+
606
+ private extractFromPostJson(postJson: any): { text: string; imageKeys: string[] } {
607
+ const lines: string[] = [];
608
+ const imageKeys: string[] = [];
609
+
610
+ const inline = (node: any): string => {
611
+ if (!node) return '';
612
+ if (Array.isArray(node)) return node.map(inline).join('');
613
+ if (typeof node !== 'object') return '';
614
+
615
+ const tag = node.tag;
616
+ if (typeof tag === 'string') {
617
+ if (tag === 'text') return String(node.text ?? '');
618
+ if (tag === 'a') return String(node.text ?? node.href ?? '');
619
+ if (tag === 'at') return node.user_name ? `@${node.user_name}` : '@';
620
+ if (tag === 'md') return String(node.text ?? '');
621
+ if (tag === 'img') {
622
+ if (node.image_key) imageKeys.push(String(node.image_key));
623
+ return '[图片]';
624
+ }
625
+ if (tag === 'file') return '[文件]';
626
+ if (tag === 'media') return '[视频]';
627
+ if (tag === 'hr') return '\n';
628
+ if (tag === 'code_block') {
629
+ const lang = String(node.language || '').trim();
630
+ const code = String(node.text || '');
631
+ return `\n\n\`\`\`${lang ? ` ${lang}` : ''}\n${code}\n\`\`\`\n\n`;
632
+ }
633
+ }
634
+
635
+ // 递归处理子节点
636
+ let acc = '';
637
+ for (const v of Object.values(node)) {
638
+ if (v && (typeof v === 'object' || Array.isArray(v))) acc += inline(v);
639
+ }
640
+ return acc;
641
+ };
642
+
643
+ if (postJson?.title) {
644
+ lines.push(normalizeFeishuText(postJson.title));
645
+ }
646
+
647
+ const content = postJson?.content;
648
+ if (Array.isArray(content)) {
649
+ for (const paragraph of content) {
650
+ if (Array.isArray(paragraph)) {
651
+ const joined = paragraph.map(inline).join('');
652
+ const normalized = normalizeFeishuText(joined);
653
+ if (normalized) lines.push(normalized);
654
+ } else {
655
+ const normalized = normalizeFeishuText(inline(paragraph));
656
+ if (normalized) lines.push(normalized);
657
+ }
658
+ }
659
+ } else if (content) {
660
+ const normalized = normalizeFeishuText(inline(content));
661
+ if (normalized) lines.push(normalized);
662
+ }
663
+
664
+ return {
665
+ text: lines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
666
+ imageKeys: [...new Set(imageKeys)],
667
+ };
668
+ }
669
+
670
+ // ─── 构建消息对象 ───────────────────────────────────────────────
671
+
672
+ private async buildMessage(message: any, sender: any): Promise<Message | null> {
673
+ const messageId = message?.message_id;
674
+ const messageType = message?.message_type;
675
+ const rawContent = message?.content;
676
+ const chatId = message?.chat_id;
677
+ const chatType = message?.chat_type;
678
+
679
+ let text = '';
680
+ const attachments: Attachment[] = [];
681
+ const mentions: string[] = [];
682
+
683
+ // 提取 @ 提及
684
+ if (Array.isArray(message?.mentions)) {
685
+ for (const m of message.mentions) {
686
+ if (m?.name) mentions.push(m.name);
687
+ }
688
+ }
689
+
690
+ // 根据消息类型解析
691
+ switch (messageType) {
692
+ case 'text': {
693
+ try {
694
+ const parsed = JSON.parse(rawContent);
695
+ text = normalizeFeishuText(parsed?.text ?? '');
696
+ } catch {
697
+ text = '';
698
+ }
699
+ break;
700
+ }
701
+
702
+ case 'post': {
703
+ try {
704
+ const parsed = JSON.parse(rawContent);
705
+ const { text: postText, imageKeys } = this.extractFromPostJson(parsed);
706
+ text = postText;
707
+
708
+ // 下载嵌入的图片
709
+ for (const key of imageKeys.slice(0, 4)) {
710
+ const dataUrl = await this.downloadImage(messageId, key);
711
+ if (dataUrl) {
712
+ attachments.push({
713
+ type: 'image',
714
+ content: dataUrl,
715
+ mimeType: 'image/png',
716
+ fileName: 'feishu.png',
717
+ });
718
+ }
719
+ }
720
+ } catch (err: any) {
721
+ logger.error({ err: err?.message, plugin: this.name }, '解析 post 消息失败');
722
+ }
723
+ break;
724
+ }
725
+
726
+ case 'image': {
727
+ try {
728
+ const parsed = JSON.parse(rawContent);
729
+ const imageKey = parsed?.image_key;
730
+ if (imageKey) {
731
+ const dataUrl = await this.downloadImage(messageId, imageKey);
732
+ if (dataUrl) {
733
+ attachments.push({
734
+ type: 'image',
735
+ content: dataUrl,
736
+ mimeType: 'image/png',
737
+ fileName: 'feishu.png',
738
+ });
739
+ }
740
+ text = '[图片]';
741
+ }
742
+ } catch (err: any) {
743
+ text = '[图片]';
744
+ logger.error({ err: err?.message, plugin: this.name }, '解析图片消息失败');
745
+ }
746
+ break;
747
+ }
748
+
749
+ case 'media': {
750
+ try {
751
+ const parsed = JSON.parse(rawContent);
752
+ const fileKey = parsed?.file_key;
753
+ const fileName = parsed?.file_name || 'video.mp4';
754
+ const thumbKey = parsed?.image_key;
755
+
756
+ text = `[视频] ${fileName}`;
757
+
758
+ // 下载缩略图
759
+ if (thumbKey) {
760
+ const dataUrl = await this.downloadImage(messageId, thumbKey);
761
+ if (dataUrl) {
762
+ attachments.push({
763
+ type: 'image',
764
+ content: dataUrl,
765
+ mimeType: 'image/png',
766
+ fileName: 'video-thumb.png',
767
+ });
768
+ }
769
+ }
770
+
771
+ // 下载视频文件
772
+ if (fileKey) {
773
+ const filePath = await this.downloadFile(messageId, fileKey, fileName, 'file');
774
+ if (filePath) {
775
+ attachments.push({
776
+ type: 'video',
777
+ content: `file://${filePath}`,
778
+ fileName,
779
+ });
780
+ }
781
+ }
782
+ } catch (err: any) {
783
+ text = '[视频]';
784
+ logger.error({ err: err?.message, plugin: this.name }, '解析视频消息失败');
785
+ }
786
+ break;
787
+ }
788
+
789
+ case 'file': {
790
+ try {
791
+ const parsed = JSON.parse(rawContent);
792
+ const fileKey = parsed?.file_key;
793
+ const fileName = parsed?.file_name || 'file.bin';
794
+
795
+ text = `[文件] ${fileName}`;
796
+
797
+ if (fileKey) {
798
+ const filePath = await this.downloadFile(messageId, fileKey, fileName, 'file');
799
+ if (filePath) {
800
+ attachments.push({
801
+ type: 'file',
802
+ content: `file://${filePath}`,
803
+ fileName,
804
+ });
805
+ }
806
+ }
807
+ } catch (err: any) {
808
+ text = '[文件]';
809
+ logger.error({ err: err?.message, plugin: this.name }, '解析文件消息失败');
810
+ }
811
+ break;
812
+ }
813
+
814
+ case 'audio': {
815
+ try {
816
+ const parsed = JSON.parse(rawContent);
817
+ const fileKey = parsed?.file_key;
818
+ const fileName = parsed?.file_name || 'audio.opus';
819
+
820
+ text = `[语音] ${fileName}`;
821
+
822
+ if (fileKey) {
823
+ const filePath = await this.downloadFile(messageId, fileKey, fileName, 'file');
824
+ if (filePath) {
825
+ attachments.push({
826
+ type: 'audio',
827
+ content: `file://${filePath}`,
828
+ fileName,
829
+ });
830
+ }
831
+ }
832
+ } catch (err: any) {
833
+ text = '[语音]';
834
+ logger.error({ err: err?.message, plugin: this.name }, '解析语音消息失败');
835
+ }
836
+ break;
837
+ }
838
+
839
+ default:
840
+ logger.debug({ messageType, plugin: this.name }, '不支持的消息类型');
841
+ return null;
842
+ }
843
+
844
+ // 清理 @提及 标记
845
+ text = text.replace(/@_user_\d+\s*/g, '').trim();
846
+
847
+ if (!text && attachments.length === 0) {
848
+ return null;
849
+ }
850
+
851
+ // 构建时间戳
852
+ let timestamp = new Date().toISOString();
853
+ if (message.create_time) {
854
+ const ms = parseInt(message.create_time, 10);
855
+ if (!isNaN(ms)) {
856
+ timestamp = new Date(ms).toISOString();
857
+ }
858
+ }
859
+
860
+ return {
861
+ id: messageId,
862
+ chatId,
863
+ senderId: sender?.sender_id?.open_id || 'unknown',
864
+ senderName: sender?.sender_id?.open_id || 'Unknown',
865
+ content: text || '[附件]',
866
+ timestamp,
867
+ chatType: chatType === 'p2p' ? 'p2p' : 'group',
868
+ platform: 'feishu',
869
+ attachments: attachments.length > 0 ? attachments : undefined,
870
+ mentions: mentions.length > 0 ? mentions : undefined,
871
+ };
872
+ }
873
+
874
+ // ─── 消息事件处理 ───────────────────────────────────────────────
875
+
876
+ private async handleMessageEvent(data: any): Promise<void> {
877
+ const { message, sender } = data || {};
878
+ const chatId = message?.chat_id;
879
+ const messageId = message?.message_id;
880
+ const chatType = message?.chat_type;
881
+
882
+ if (!chatId || !messageId) {
883
+ logger.debug({ plugin: this.name }, '消息缺少 chatId 或 messageId');
884
+ return;
885
+ }
886
+
887
+ // 去重
888
+ if (this.isDuplicate(messageId)) {
889
+ logger.debug({ messageId, plugin: this.name }, '重复消息,已忽略');
890
+ return;
891
+ }
892
+
893
+ // 构建消息对象
894
+ const msg = await this.buildMessage(message, sender);
895
+ if (!msg) {
896
+ return;
897
+ }
898
+
899
+ // 群聊智能响应
900
+ if (chatType === 'group') {
901
+ const mentions = msg.mentions || [];
902
+ const hasAttachment = (msg.attachments?.length || 0) > 0;
903
+
904
+ logger.debug({
905
+ chatId,
906
+ mentions,
907
+ content: msg.content.slice(0, 50),
908
+ hasAttachment,
909
+ plugin: this.name
910
+ }, '>>> 群聊响应检查');
911
+
912
+ // 纯附件消息需要 @ 才响应
913
+ if (hasAttachment && mentions.length === 0 && (!msg.content || msg.content === '[图片]' || msg.content === '[附件]')) {
914
+ logger.debug({ chatId, plugin: this.name }, '群聊附件消息未 @,忽略');
915
+ return;
916
+ }
917
+
918
+ // 纯文本消息应用智能响应规则
919
+ if (!hasAttachment && !shouldRespondInGroup(msg.content, mentions)) {
920
+ logger.debug({ chatId, mentions, content: msg.content, plugin: this.name }, '群聊消息不满足响应条件,忽略');
921
+ return;
922
+ }
923
+ }
924
+
925
+ logger.info({
926
+ chatId,
927
+ chatType: msg.chatType,
928
+ content: msg.content.slice(0, 50),
929
+ attachments: msg.attachments?.length || 0,
930
+ plugin: this.name,
931
+ }, '>>> 收到飞书消息');
932
+
933
+ // 调用消息处理器
934
+ if (this.messageHandler) {
935
+ await this.messageHandler(msg);
936
+ } else {
937
+ logger.warn({ plugin: this.name }, '消息处理器未设置,消息被丢弃');
938
+ }
939
+ }
940
+ }
941
+
942
+ // 导出默认插件实例
943
+ const plugin: ChannelPlugin = new FeishuChannelPlugin();
944
+ export default plugin;