imtoagent 0.2.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 (47) hide show
  1. package/README.md +234 -0
  2. package/bin/imtoagent +453 -0
  3. package/index.ts +1129 -0
  4. package/modules/agent/claude-adapter.ts +258 -0
  5. package/modules/agent/claude.ts +160 -0
  6. package/modules/agent/codex-adapter.ts +232 -0
  7. package/modules/agent/codex-exec-server.ts +513 -0
  8. package/modules/agent/codex.ts +275 -0
  9. package/modules/agent/opencode-adapter.ts +308 -0
  10. package/modules/agent/opencode.ts +247 -0
  11. package/modules/bot-context.ts +26 -0
  12. package/modules/capabilities.ts +189 -0
  13. package/modules/cli/setup.ts +424 -0
  14. package/modules/core/config.ts +275 -0
  15. package/modules/core/error.ts +124 -0
  16. package/modules/core/index.ts +39 -0
  17. package/modules/core/runtime.ts +282 -0
  18. package/modules/core/session.ts +256 -0
  19. package/modules/core/stats.ts +92 -0
  20. package/modules/core/types.ts +250 -0
  21. package/modules/im/feishu.ts +731 -0
  22. package/modules/im/telegram.ts +639 -0
  23. package/modules/im/wechat.ts +1094 -0
  24. package/modules/im/wecom.ts +603 -0
  25. package/modules/media/feishu-inbound-adapter.ts +108 -0
  26. package/modules/media/index.ts +27 -0
  27. package/modules/media/media-store.ts +273 -0
  28. package/modules/media/resolver.ts +178 -0
  29. package/modules/media/telegram-inbound-adapter.ts +124 -0
  30. package/modules/media/types.ts +76 -0
  31. package/modules/prompt-builder.ts +123 -0
  32. package/modules/proxy/anthropic-proxy.ts +1083 -0
  33. package/modules/proxy/codex-proxy.ts +657 -0
  34. package/modules/rate-limiter.ts +58 -0
  35. package/modules/types.ts +144 -0
  36. package/modules/utils/backend-check.ts +121 -0
  37. package/modules/utils/paths.ts +218 -0
  38. package/package.json +53 -0
  39. package/scripts/postinstall.ts +70 -0
  40. package/templates/config.template.json +57 -0
  41. package/templates/opencode.template.json +28 -0
  42. package/templates/providers.template.json +19 -0
  43. package/templates/soul.template/identity.md +6 -0
  44. package/templates/soul.template/profile.md +11 -0
  45. package/templates/soul.template/rules.md +7 -0
  46. package/templates/soul.template/skills.md +3 -0
  47. package/templates/soul.template/workspace.md +4 -0
@@ -0,0 +1,731 @@
1
+ // 飞书 IM 模块
2
+ // 封装 Lark SDK:WS 长连接(含自动重连)、消息收发
3
+ // 支持:纯文本、富文本卡片、图片、文件、表格、语音、富文本帖子
4
+
5
+ import * as Lark from '@larksuiteoapi/node-sdk';
6
+ import type { IMModule, IMCapabilities, MessageHandler } from '../types';
7
+ import type { UnifiedBlock } from '../capabilities';
8
+ import type { MessageAttachment } from '../core/types';
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import { getDataDir } from '../utils/paths';
12
+ import { FeishuInboundAdapter, MediaStore, InboundMediaResolver, InboundMediaAdapter } from '../media';
13
+
14
+ export interface FeishuConfig {
15
+ appId: string;
16
+ appSecret: string;
17
+ }
18
+
19
+ // 飞书消息卡片元素类型
20
+ interface CardElement {
21
+ tag: string;
22
+ [key: string]: any;
23
+ }
24
+
25
+ interface CardAction {
26
+ tag: string;
27
+ [key: string]: any;
28
+ }
29
+
30
+ // Token 缓存条目,含过期时间
31
+ interface TokenEntry {
32
+ token: string;
33
+ expiresAt: number; // 毫秒时间戳
34
+ }
35
+
36
+ export class FeishuIMModule implements IMModule {
37
+ private client: Lark.Client;
38
+ private wsClient: any = null;
39
+ private appId: string;
40
+ private appSecret: string;
41
+ private messageHandler: MessageHandler | null = null;
42
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
43
+ private reconnectAttempts = 0;
44
+ private running = false;
45
+ private _tenantAccessToken: TokenEntry | null = null;
46
+ private _appAccessToken: TokenEntry | null = null;
47
+
48
+ // Inbound Media — 适配器 + 抽象层
49
+ private _inboundAdapter: InboundMediaAdapter;
50
+ private _mediaStore: MediaStore;
51
+ private _mediaResolver: InboundMediaResolver;
52
+
53
+ constructor(cfg: FeishuConfig) {
54
+ this.appId = cfg.appId;
55
+ this.appSecret = cfg.appSecret;
56
+ this.client = new Lark.Client({
57
+ appId: cfg.appId,
58
+ appSecret: cfg.appSecret,
59
+ loggerLevel: Lark.LoggerLevel.info,
60
+ });
61
+
62
+ // 初始化 Inbound Media 层(适配器 + 存储 + 解析器)
63
+ this._inboundAdapter = new FeishuInboundAdapter({ appId: cfg.appId, appSecret: cfg.appSecret });
64
+ this._mediaStore = new MediaStore();
65
+ this._mediaResolver = new InboundMediaResolver(this._inboundAdapter, this._mediaStore);
66
+ }
67
+
68
+ // ================================================================
69
+ // 认证(带过期检查,提前 5 分钟刷新)
70
+ // ================================================================
71
+
72
+ private async getTenantToken(): Promise<string> {
73
+ const now = Date.now();
74
+ if (this._tenantAccessToken && this._tenantAccessToken.expiresAt > now + 5 * 60 * 1000) {
75
+ return this._tenantAccessToken.token;
76
+ }
77
+
78
+ try {
79
+ const res = await this.client.request({
80
+ method: 'POST',
81
+ url: '/open-apis/auth/v3/tenant_access_token/internal',
82
+ data: { app_id: this.appId, app_secret: this.appSecret },
83
+ });
84
+ if (res.code === 0 && res.tenant_access_token) {
85
+ // 飞书 tenant_token 有效期约 2 小时
86
+ this._tenantAccessToken = {
87
+ token: res.tenant_access_token,
88
+ expiresAt: now + 2 * 60 * 60 * 1000,
89
+ };
90
+ return this._tenantAccessToken.token;
91
+ }
92
+ throw new Error(`获取 token 失败: ${res.code} ${res.msg}`);
93
+ } catch (e: any) {
94
+ throw new Error(`获取 token 失败: ${e.message}`);
95
+ }
96
+ }
97
+
98
+ private async getAppToken(): Promise<string> {
99
+ const now = Date.now();
100
+ if (this._appAccessToken && this._appAccessToken.expiresAt > now + 5 * 60 * 1000) {
101
+ return this._appAccessToken.token;
102
+ }
103
+
104
+ try {
105
+ const res = await this.client.request({
106
+ method: 'POST',
107
+ url: '/open-apis/auth/v3/app_access_token/internal',
108
+ data: { app_id: this.appId, app_secret: this.appSecret },
109
+ });
110
+ if (res.code === 0 && res.app_access_token) {
111
+ // 飞书 app_token 有效期约 2 小时
112
+ this._appAccessToken = {
113
+ token: res.app_access_token,
114
+ expiresAt: now + 2 * 60 * 60 * 1000,
115
+ };
116
+ return this._appAccessToken.token;
117
+ }
118
+ throw new Error(`获取 app token 失败: ${res.code} ${res.msg}`);
119
+ } catch (e: any) {
120
+ throw new Error(`获取 app token 失败: ${e.message}`);
121
+ }
122
+ }
123
+
124
+ // ================================================================
125
+ // 基础发送
126
+ // ================================================================
127
+
128
+ async reply(chatId: string, text: string, maxLen = 140000) {
129
+ const safe = text.length > maxLen ? text.slice(0, maxLen) + '\n\n...(截断)' : text;
130
+ try {
131
+ await this.client.im.message.create({
132
+ params: { receive_id_type: 'chat_id' },
133
+ data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text: safe }) },
134
+ });
135
+ } catch (e: any) {
136
+ console.error(`[Feishu] 回复失败: ${e.message}`);
137
+ }
138
+ }
139
+
140
+ async sendProgress(chatId: string, text: string) {
141
+ try {
142
+ await this.client.im.message.create({
143
+ params: { receive_id_type: 'chat_id' },
144
+ data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text }) },
145
+ });
146
+ } catch (e: any) {
147
+ console.error(`[Feishu] 进度推送失败: ${e.message}`);
148
+ }
149
+ }
150
+
151
+ // ================================================================
152
+ // 富文本卡片发送
153
+ // ================================================================
154
+
155
+ async sendBlocks(chatId: string, blocks: UnifiedBlock[]) {
156
+ // 拆分:文件 block 必须单独发飞书文件消息,不能进卡片
157
+ const fileBlocks = blocks.filter(b => b.type === 'file' && b.url);
158
+ const cardBlocks = blocks.filter(b => b.type !== 'file');
159
+
160
+ // 先发送文件消息(飞书原生文件类型,可下载)
161
+ for (const fb of fileBlocks) {
162
+ try {
163
+ let fileKey: string | null = null;
164
+ if (fb.url.startsWith('file://')) {
165
+ const localPath = fb.url.replace('file://', '');
166
+ fileKey = await this.uploadFileFromPath(localPath);
167
+ } else {
168
+ fileKey = await this.uploadFileFromUrl(fb.url, fb.filename);
169
+ }
170
+ if (fileKey) {
171
+ await this.sendFile(chatId, fileKey, fb.filename);
172
+ console.log(`[Feishu] 文件已发送: ${fb.filename}`);
173
+ }
174
+ } catch (e: any) {
175
+ console.error(`[Feishu] 文件发送失败: ${fb.filename} - ${e.message}`);
176
+ }
177
+ }
178
+
179
+ // 如果只剩下一个文本块,直接发文本
180
+ if (cardBlocks.length === 1 && cardBlocks[0].type === 'text') {
181
+ if (fileBlocks.length === 0) return this.reply(chatId, cardBlocks[0].content);
182
+ // 有文件在前,文本块附后
183
+ await this.reply(chatId, cardBlocks[0].content);
184
+ return;
185
+ }
186
+
187
+ // 如果没有非文件块了,只发文件就够了
188
+ if (cardBlocks.length === 0) return;
189
+
190
+ // 构建飞书消息卡片
191
+ const cardElements: CardElement[] = [];
192
+
193
+ for (const block of cardBlocks) {
194
+ switch (block.type) {
195
+ case 'text':
196
+ cardElements.push({
197
+ tag: 'markdown',
198
+ content: this.escapeCardMarkdown(block.content),
199
+ });
200
+ break;
201
+
202
+ case 'code_block':
203
+ cardElements.push({
204
+ tag: 'markdown',
205
+ content: `\`\`\`${block.language || ''}
206
+ ${this.escapeCodeBlock(block.code)}
207
+ \`\`\``,
208
+ });
209
+ break;
210
+
211
+ case 'image':
212
+ if (block.url) {
213
+ try {
214
+ const imageKey = await this.uploadImageFromUrl(block.url);
215
+ if (imageKey) {
216
+ cardElements.push({ tag: 'img', img_key: imageKey, alt: { tag: 'plain_text', content: block.alt || '' } });
217
+ }
218
+ } catch (e: any) {
219
+ console.error(`[Feishu] 图片上传失败: ${e.message}`);
220
+ cardElements.push({ tag: 'markdown', content: `⚠️ 图片加载失败` });
221
+ }
222
+ }
223
+ break;
224
+
225
+ case 'card':
226
+ cardElements.push({
227
+ tag: 'markdown',
228
+ content: `**${this.escapeCardMarkdown(block.title)}**
229
+ ${this.escapeCardMarkdown(block.content || '')}`,
230
+ });
231
+ if (block.buttons?.length) {
232
+ const actions: CardAction[] = [];
233
+ for (const b of block.buttons) {
234
+ actions.push({
235
+ tag: 'button',
236
+ text: { tag: 'plain_text', content: b.label },
237
+ type: 'primary',
238
+ multi_url: b.url ? { url: b.url, pc_url: b.url, android_url: b.url, ios_url: b.url } : undefined,
239
+ });
240
+ }
241
+ cardElements.push({ tag: 'action', actions });
242
+ }
243
+ break;
244
+
245
+ case 'table':
246
+ const mdTable = this.renderMarkdownTable(block.headers, block.rows, block.caption);
247
+ cardElements.push({ tag: 'markdown', content: mdTable });
248
+ break;
249
+
250
+ case 'divider':
251
+ cardElements.push({ tag: 'hr' });
252
+ break;
253
+ }
254
+ }
255
+
256
+ // 构建卡片 JSON
257
+ const card: any = {
258
+ config: { wide_screen_mode: true },
259
+ elements: cardElements,
260
+ };
261
+
262
+ try {
263
+ await this.client.im.message.create({
264
+ params: { receive_id_type: 'chat_id' },
265
+ data: {
266
+ receive_id: chatId,
267
+ msg_type: 'interactive',
268
+ content: JSON.stringify(card),
269
+ },
270
+ });
271
+ console.log(`[Feishu] 卡片消息已发送 (${cardBlocks.length} blocks)`);
272
+ } catch (e: any) {
273
+ console.error(`[Feishu] 卡片发送失败: ${e.message}`);
274
+ // 降级:拼接为纯文本发送
275
+ const fallback = cardBlocks.map(b => {
276
+ switch (b.type) {
277
+ case 'code_block': return `\`\`\`${b.language || ''}
278
+ ${b.code}
279
+ \`\`\``;
280
+ case 'image': return `![${b.alt || ''}](${b.url})`;
281
+ case 'text': return b.content;
282
+ case 'card': return `**${b.title}**
283
+ ${b.content || ''}`;
284
+ case 'table': return this.renderMarkdownTable(b.headers, b.rows, b.caption);
285
+ case 'divider': return '---';
286
+ default: return '';
287
+ }
288
+ }).join('\n\n');
289
+ await this.reply(chatId, fallback);
290
+ }
291
+ }
292
+
293
+ // ================================================================
294
+ // 图片发送
295
+ // ================================================================
296
+
297
+ async sendImage(chatId: string, imageKey: string, _alt?: string) {
298
+ try {
299
+ await this.client.im.message.create({
300
+ params: { receive_id_type: 'chat_id' },
301
+ data: {
302
+ receive_id: chatId,
303
+ msg_type: 'image',
304
+ content: JSON.stringify({ image_key: imageKey }),
305
+ },
306
+ });
307
+ } catch (e: any) {
308
+ console.error(`[Feishu] 图片发送失败: ${e.message}`);
309
+ }
310
+ }
311
+
312
+ // 从 URL 上传图片到飞书,返回 image_key
313
+ async uploadImageFromUrl(url: string): Promise<string | null> {
314
+ try {
315
+ let buffer: Buffer | null = null;
316
+ // 判断是本地文件路径还是远程 URL
317
+ if (url.startsWith('http://') || url.startsWith('https://')) {
318
+ buffer = await this.downloadFile(url);
319
+ } else if (url.startsWith('file://')) {
320
+ const filePath = url.replace('file://', '');
321
+ buffer = require('fs').readFileSync(filePath);
322
+ } else {
323
+ // 可能是相对/绝对本地路径
324
+ try {
325
+ buffer = require('fs').readFileSync(url);
326
+ } catch {
327
+ buffer = await this.downloadFile(url);
328
+ }
329
+ }
330
+ if (!buffer) return null;
331
+
332
+ // 使用 SDK 原生上传方法
333
+ const r = await (this.client as any).im.v1.image.create({
334
+ data: { image_type: 'message', image: buffer },
335
+ });
336
+ const key = r?.image_key || r?.data?.image_key;
337
+ if (key) return key;
338
+ console.error(`[Feishu] 图片上传失败: image_key missing`);
339
+ return null;
340
+ } catch (e: any) {
341
+ console.error(`[Feishu] 图片上传异常: ${e.message}`);
342
+ return null;
343
+ }
344
+ }
345
+
346
+ // 从本地文件上传到飞书,返回 image_key
347
+ async uploadImageFromFile(filePath: string): Promise<string | null> {
348
+ try {
349
+ const buffer = fs.readFileSync(filePath);
350
+ // 使用 SDK 原生上传方法
351
+ const r = await (this.client as any).im.v1.image.create({
352
+ data: { image_type: 'message', image: buffer },
353
+ });
354
+ const key = r?.image_key || r?.data?.image_key;
355
+ if (key) return key;
356
+ console.error(`[Feishu] 图片上传失败: image_key missing`);
357
+ return null;
358
+ } catch (e: any) {
359
+ console.error(`[Feishu] 图片上传失败: ${e.message}`);
360
+ return null;
361
+ }
362
+ }
363
+
364
+ // ================================================================
365
+ // 文件上传
366
+ // ================================================================
367
+
368
+ // 从 URL 下载并上传到飞书,返回 file_key
369
+ async uploadFileFromUrl(url: string, filename: string): Promise<string | null> {
370
+ try {
371
+ const buffer = await this.downloadFile(url);
372
+ if (!buffer) return null;
373
+ return this.uploadFileFromBuffer(buffer, filename || path.basename(new URL(url).pathname) || 'file');
374
+ } catch (e: any) {
375
+ console.error(`[Feishu] 文件上传异常: ${e.message}`);
376
+ return null;
377
+ }
378
+ }
379
+
380
+ // 从本地路径上传文件到飞书,返回 file_key
381
+ async uploadFileFromPath(filePath: string): Promise<string | null> {
382
+ try {
383
+ const buffer = fs.readFileSync(filePath);
384
+ return this.uploadFileFromBuffer(buffer, path.basename(filePath));
385
+ } catch (e: any) {
386
+ console.error(`[Feishu] 文件上传失败: ${e.message}`);
387
+ return null;
388
+ }
389
+ }
390
+
391
+ // 从 Buffer 上传文件到飞书,返回 file_key
392
+ private async uploadFileFromBuffer(buffer: Buffer, filename: string): Promise<string | null> {
393
+ try {
394
+ const token = await this.getAppToken();
395
+ const form = new FormData();
396
+ form.append('file_type', 'stream');
397
+ form.append('file_name', filename);
398
+ form.append('file', new Blob([buffer]), filename);
399
+
400
+ const res = await fetch('https://open.feishu.cn/open-apis/im/v1/files', {
401
+ method: 'POST',
402
+ headers: { Authorization: `Bearer ${token}` },
403
+ body: form,
404
+ });
405
+ const data = await res.json();
406
+ if (data.code === 0 && data.data?.file_key) {
407
+ return data.data.file_key;
408
+ }
409
+ console.error(`[Feishu] 文件上传失败: ${data.code} ${data.msg}`);
410
+ return null;
411
+ } catch (e: any) {
412
+ console.error(`[Feishu] 文件上传异常: ${e.message}`);
413
+ return null;
414
+ }
415
+ }
416
+
417
+ // 发送文件(直接传 file_key)
418
+ async sendFile(chatId: string, fileKey: string, fileName: string) {
419
+ try {
420
+ await this.client.im.message.create({
421
+ params: { receive_id_type: 'chat_id' },
422
+ data: {
423
+ receive_id: chatId,
424
+ msg_type: 'file',
425
+ content: JSON.stringify({ file_key: fileKey }),
426
+ },
427
+ });
428
+ } catch (e: any) {
429
+ console.error(`[Feishu] 文件发送失败: ${e.message}`);
430
+ }
431
+ }
432
+
433
+ // ================================================================
434
+ // 能力声明
435
+ // ================================================================
436
+
437
+ getCapabilities(): IMCapabilities {
438
+ return {
439
+ text: true,
440
+ codeBlock: true, // 卡片 markdown 支持 ``` 语法
441
+ cardMessage: true,
442
+ fileSend: true,
443
+ imageSend: true,
444
+ audioSend: true,
445
+ buttonAction: true,
446
+ maxTextLength: 30000,
447
+ };
448
+ }
449
+
450
+ // ================================================================
451
+ // 生命周期
452
+ // ================================================================
453
+
454
+ start(handler: MessageHandler) {
455
+ this.messageHandler = handler;
456
+ this.running = true;
457
+ this._connect();
458
+ }
459
+
460
+ stop() {
461
+ this.running = false;
462
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
463
+ try { this.wsClient?.stop?.(); } catch {}
464
+ console.log(`[Feishu] WS 已停止 (appId=${this.appId.slice(-8)})`);
465
+ }
466
+
467
+ private _connect() {
468
+ if (!this.running || !this.messageHandler) return;
469
+
470
+ const dispatcher = new Lark.EventDispatcher({}).register({
471
+ 'im.message.receive_v1': async (data: any) => {
472
+ try {
473
+ const { message } = data;
474
+ if (!message) return;
475
+
476
+ const chatId = message.chat_id;
477
+ const senderId = message.sender_id;
478
+ const userId = senderId?.open_id || senderId?.user_id || senderId?.union_id || chatId;
479
+ const msgType = message.message_type;
480
+
481
+ let text = '';
482
+ const attachments: MessageAttachment[] = [];
483
+
484
+ switch (msgType) {
485
+ case 'text':
486
+ text = parseFeishuMessage(message.content || '');
487
+ break;
488
+
489
+ case 'image': {
490
+ const content = JSON.parse(message.content || '{}');
491
+ const resolved = await this._mediaResolver.resolveOne({
492
+ messageId: message.message_id,
493
+ resourceKey: content.image_key,
494
+ type: 'image',
495
+ });
496
+ if (resolved) {
497
+ attachments.push(resolved.attachment);
498
+ text = '[用户发送了一张图片]';
499
+ }
500
+ break;
501
+ }
502
+
503
+ case 'file': {
504
+ const content = JSON.parse(message.content || '{}');
505
+ const resolved = await this._mediaResolver.resolveOne({
506
+ messageId: message.message_id,
507
+ resourceKey: content.file_key,
508
+ type: 'file',
509
+ fileName: content.file_name,
510
+ });
511
+ if (resolved) {
512
+ attachments.push(resolved.attachment);
513
+ text = `[用户发送了文件: ${content.file_name || 'unknown'}]`;
514
+ }
515
+ break;
516
+ }
517
+
518
+ case 'audio': {
519
+ const content = JSON.parse(message.content || '{}');
520
+ const resolved = await this._mediaResolver.resolveOne({
521
+ messageId: message.message_id,
522
+ resourceKey: content.file_key,
523
+ type: 'file', // 飞书音频也用 file 类型下载
524
+ });
525
+ if (resolved) {
526
+ // 补充音频时长(resolver 无法从飞书 API 获取 duration)
527
+ resolved.attachment.durationMs = content.duration;
528
+ attachments.push(resolved.attachment);
529
+ text = `[用户发送了语音消息 (${(content.duration || 0) / 1000}秒)]`;
530
+ }
531
+ break;
532
+ }
533
+
534
+ case 'post':
535
+ text = this.parsePostContent(message.content || '');
536
+ break;
537
+
538
+ case 'media':
539
+ text = '[用户发送了视频消息]';
540
+ break;
541
+
542
+ case 'sticker':
543
+ text = '[用户发送了表情]';
544
+ break;
545
+
546
+ case 'system':
547
+ return;
548
+
549
+ case 'interactive':
550
+ text = this.parseInteractiveContent(message.content || '');
551
+ break;
552
+
553
+ case 'merge_forward':
554
+ text = '[用户转发了合并消息]';
555
+ break;
556
+
557
+ default:
558
+ console.log(`[Feishu] 未处理的消息类型: ${msgType}`);
559
+ return;
560
+ }
561
+
562
+ if (!text && attachments.length === 0) return;
563
+
564
+ await this.messageHandler!(chatId, text, userId, attachments.length > 0 ? attachments : undefined);
565
+ } catch (e: any) {
566
+ console.error(`[Feishu] 消息处理异常: ${e.message}`);
567
+ // 不抛异常,防止 SDK dispatcher 未处理 rejection 导致进程退出
568
+ }
569
+ },
570
+ });
571
+
572
+ // 自定义 Logger — 过滤 SDK 内部 WS 重连噪音
573
+ const quietLogger = {
574
+ info: (...args: any[]) => { /* 静默 info */ },
575
+ warn: (...args: any[]) => { /* 静默 warn */ },
576
+ error: (...args: any[]) => {
577
+ const msg = args.join(' ');
578
+ // 过滤已知的 SDK 内部 WS 重连噪音(不影响功能,SDK 自带自动重连)
579
+ if (msg.includes('[ws]') && (msg.includes('ECONNREFUSED') || msg.includes('connect failed') || msg.includes('system busy') || msg.includes('repeat connection'))) return;
580
+ console.error(`[Feishu-SDK] ${msg}`);
581
+ },
582
+ debug: (...args: any[]) => { /* 静默 debug */ },
583
+ };
584
+
585
+ this.wsClient = new Lark.WSClient({
586
+ appId: this.appId,
587
+ appSecret: this.appSecret,
588
+ logger: quietLogger,
589
+ loggerLevel: Lark.LoggerLevel.info,
590
+ });
591
+
592
+ this.wsClient.start({ eventDispatcher: dispatcher })
593
+ .then(() => {
594
+ this.reconnectAttempts = 0;
595
+ console.log(`[Feishu] WS 已连接`);
596
+ })
597
+ .catch((e: any) => {
598
+ console.error(`[Feishu] WS 连接失败: ${e.message}`);
599
+ this._scheduleReconnect();
600
+ });
601
+
602
+ this.wsClient.on?.('close', () => {
603
+ console.log('[Feishu] WS 断开');
604
+ this._scheduleReconnect();
605
+ });
606
+ this.wsClient.on?.('error', (e: any) => {
607
+ console.error(`[Feishu] WS 错误: ${e.message || e}`);
608
+ });
609
+ }
610
+
611
+ private _scheduleReconnect() {
612
+ if (!this.running) return;
613
+ if (this.reconnectTimer) return;
614
+
615
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
616
+ this.reconnectAttempts++;
617
+ console.log(`[Feishu] ${delay/1000}s 后重连 (第${this.reconnectAttempts}次)`);
618
+
619
+ this.reconnectTimer = setTimeout(() => {
620
+ this.reconnectTimer = null;
621
+ this._connect();
622
+ }, delay);
623
+ }
624
+
625
+ // ================================================================
626
+ // 工具方法
627
+ // ================================================================
628
+
629
+ // 飞书卡片 markdown:仅处理破坏解析的边界情况
630
+ // Agent 输出是可信的 markdown,不应全量转义
631
+ private escapeCardMarkdown(text: string): string {
632
+ return text;
633
+ }
634
+
635
+ // 代码块内容:保护内部的三反引号不被解析器截断
636
+ private escapeCodeBlock(code: string): string {
637
+ return code.replace(/```/g, '\\`\\`\\`');
638
+ }
639
+
640
+ // 渲染 markdown 表格
641
+ private renderMarkdownTable(headers: string[], rows: string[][], caption?: string): string {
642
+ const lines: string[] = [];
643
+ if (caption) lines.push(`**${this.escapeCardMarkdown(caption)}**\n`);
644
+
645
+ // 表头
646
+ lines.push('| ' + headers.map(h => this.escapeCardMarkdown(h)).join(' | ') + ' |');
647
+ // 分隔线
648
+ lines.push('| ' + headers.map(() => '---').join(' | ') + ' |');
649
+ // 数据行
650
+ for (const row of rows) {
651
+ const cells = [];
652
+ for (let i = 0; i < headers.length; i++) {
653
+ cells.push(i < row.length ? this.escapeCardMarkdown(row[i]) : '');
654
+ }
655
+ lines.push('| ' + cells.join(' | ') + ' |');
656
+ }
657
+ return lines.join('\n');
658
+ }
659
+
660
+ // 下载文件/图片(使用 fetch,自动跟随重定向)
661
+ private async downloadFile(url: string): Promise<Buffer | null> {
662
+ try {
663
+ const resp = await fetch(url, { signal: AbortSignal.timeout(30000) });
664
+ if (!resp.ok) {
665
+ console.error(`[Feishu] 下载失败: HTTP ${resp.status}, url=${url.slice(0, 80)}`);
666
+ return null;
667
+ }
668
+ const buf = await resp.arrayBuffer();
669
+ return Buffer.from(buf);
670
+ } catch (e) {
671
+ console.error(`[Feishu] 下载异常: ${(e as Error).message}, url=${url.slice(0, 80)}`);
672
+ return null;
673
+ }
674
+ }
675
+
676
+ // 下载图片(便捷别名)
677
+ private async downloadImage(url: string): Promise<Buffer | null> {
678
+ return this.downloadFile(url);
679
+ }
680
+
681
+ // ================================================================
682
+ private parsePostContent(content: string): string {
683
+ try {
684
+ const parsed = JSON.parse(content);
685
+ const locale = parsed.zh_cn || parsed.en_us || parsed;
686
+ if (!locale?.content) return content.trim();
687
+
688
+ const lines = locale.content.map((paragraph: any[]) => {
689
+ return paragraph.map((elem: any) => {
690
+ switch (elem.tag) {
691
+ case 'text': return elem.text || '';
692
+ case 'a': return `[${elem.text}](${elem.href})`;
693
+ case 'at': return `@${elem.user_name || elem.user_id || 'unknown'}`;
694
+ case 'img': return `[图片]`;
695
+ case 'emotion': return `[表情]`;
696
+ default: return `[${elem.tag}]`;
697
+ }
698
+ }).join('');
699
+ }).join('\n');
700
+
701
+ const title = locale.title ? `**${locale.title}**\n\n` : '';
702
+ return title + lines;
703
+ } catch {
704
+ return content.trim();
705
+ }
706
+ }
707
+
708
+ /**
709
+ * 解析卡片交互回调(用户点击卡片按钮等)
710
+ * content 结构:{"action": {...}}
711
+ */
712
+ private parseInteractiveContent(content: string): string {
713
+ try {
714
+ const parsed = JSON.parse(content);
715
+ if (parsed.action?.value) return parsed.action.value;
716
+ if (parsed.action?.option) return parsed.action.option;
717
+ return parsed.action?.tag || content.trim();
718
+ } catch {
719
+ return content.trim();
720
+ }
721
+ }
722
+ }
723
+
724
+ function parseFeishuMessage(content: string): string {
725
+ try {
726
+ const parsed = JSON.parse(content);
727
+ return (parsed.text || '').trim();
728
+ } catch {
729
+ return content.trim();
730
+ }
731
+ }