evolclaw 2.8.3 → 3.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.
Files changed (142) hide show
  1. package/README.md +21 -12
  2. package/bin/ec.js +29 -0
  3. package/dist/agents/baseagent-normalize.js +19 -0
  4. package/dist/agents/claude-runner.js +108 -46
  5. package/dist/agents/codex-runner.js +13 -14
  6. package/dist/agents/gemini-runner.js +15 -17
  7. package/dist/agents/kit-renderer.js +281 -0
  8. package/dist/agents/resolve.js +134 -0
  9. package/dist/aun/aid/agentmd.js +186 -0
  10. package/dist/aun/aid/client.js +134 -0
  11. package/dist/aun/aid/identity.js +159 -0
  12. package/dist/aun/aid/index.js +3 -0
  13. package/dist/aun/aid/lifecycle-log.js +33 -0
  14. package/dist/aun/aid/types.js +1 -0
  15. package/dist/aun/aid/validation.js +21 -0
  16. package/dist/aun/msg/group.js +293 -0
  17. package/dist/aun/msg/index.js +4 -0
  18. package/dist/aun/msg/p2p.js +147 -0
  19. package/dist/aun/msg/payload-type.js +27 -0
  20. package/dist/aun/msg/upload.js +98 -0
  21. package/dist/aun/outbox.js +138 -0
  22. package/dist/aun/rpc/caller.js +42 -0
  23. package/dist/aun/rpc/connection.js +34 -0
  24. package/dist/aun/rpc/index.js +2 -0
  25. package/dist/aun/storage/download.js +29 -0
  26. package/dist/aun/storage/index.js +3 -0
  27. package/dist/aun/storage/manage.js +10 -0
  28. package/dist/aun/storage/upload.js +35 -0
  29. package/dist/channels/aun.js +1340 -349
  30. package/dist/channels/dingtalk.js +59 -5
  31. package/dist/channels/feishu.js +381 -32
  32. package/dist/channels/qqbot.js +68 -12
  33. package/dist/channels/wechat.js +63 -4
  34. package/dist/channels/wecom.js +59 -5
  35. package/dist/cli/agent.js +800 -0
  36. package/dist/cli/bench.js +1219 -0
  37. package/dist/cli/index.js +4513 -0
  38. package/dist/{utils → cli}/init-channel.js +211 -621
  39. package/dist/cli/init.js +178 -0
  40. package/dist/cli/link-rules.js +245 -0
  41. package/dist/cli/net-check.js +640 -0
  42. package/dist/cli/watch-msg.js +589 -0
  43. package/dist/config-store.js +645 -0
  44. package/dist/core/{agent-loader.js → baseagent-loader.js} +6 -12
  45. package/dist/core/channel-loader.js +176 -12
  46. package/dist/core/command-handler.js +883 -848
  47. package/dist/core/evolagent-registry.js +191 -371
  48. package/dist/core/evolagent.js +202 -238
  49. package/dist/core/interaction-router.js +52 -5
  50. package/dist/core/message/im-renderer.js +486 -0
  51. package/dist/core/message/items-formatter.js +68 -0
  52. package/dist/core/message/message-bridge.js +109 -56
  53. package/dist/core/message/message-log.js +93 -0
  54. package/dist/core/message/message-processor.js +430 -212
  55. package/dist/core/message/message-queue.js +13 -6
  56. package/dist/core/permission.js +116 -11
  57. package/dist/core/session/adapters/codex-session-file-adapter.js +24 -2
  58. package/dist/core/session/session-fs-store.js +230 -0
  59. package/dist/core/session/session-manager.js +740 -777
  60. package/dist/core/session/session-mapper.js +87 -0
  61. package/dist/core/trigger/manager.js +122 -0
  62. package/dist/core/trigger/parser.js +128 -0
  63. package/dist/core/trigger/scheduler.js +224 -0
  64. package/dist/data/error-dict.json +118 -0
  65. package/dist/eck/baseagent-caps.js +18 -0
  66. package/dist/eck/detect.js +47 -0
  67. package/dist/eck/init.js +77 -0
  68. package/dist/eck/rules-loader.js +28 -0
  69. package/dist/index.js +560 -283
  70. package/dist/ipc.js +49 -0
  71. package/dist/net-check.js +640 -0
  72. package/dist/paths.js +73 -9
  73. package/dist/types.js +8 -2
  74. package/dist/utils/aid-lifecycle-log.js +33 -0
  75. package/dist/utils/atomic-write.js +89 -0
  76. package/dist/utils/channel-helpers.js +46 -0
  77. package/dist/utils/cross-platform.js +17 -26
  78. package/dist/utils/error-utils.js +10 -2
  79. package/dist/utils/instance-registry.js +434 -0
  80. package/dist/utils/log-writer.js +217 -0
  81. package/dist/utils/logger.js +34 -77
  82. package/dist/utils/media-cache.js +23 -0
  83. package/dist/utils/npm-ops.js +163 -0
  84. package/dist/utils/process-introspect.js +122 -0
  85. package/dist/utils/stats.js +192 -0
  86. package/dist/watch-msg.js +544 -0
  87. package/evolclaw-install-aun.md +127 -47
  88. package/kits/docs/GUIDE.md +20 -0
  89. package/kits/docs/INDEX.md +52 -0
  90. package/kits/docs/aun/CHEATSHEET.md +17 -0
  91. package/kits/docs/aun/SYNC_PROTOCOL.md +15 -0
  92. package/kits/docs/channels/aun.md +25 -0
  93. package/kits/docs/channels/feishu.md +27 -0
  94. package/kits/docs/eck_templates/GUIDE.template.md +22 -0
  95. package/kits/docs/eck_templates/INDEX.template.md +28 -0
  96. package/kits/docs/eck_templates/path-registry.template.md +33 -0
  97. package/kits/docs/eck_templates/runtime.template.md +19 -0
  98. package/kits/docs/evolclaw/AGENT_CMD.md +31 -0
  99. package/kits/docs/evolclaw/MSG_GROUP.md +30 -0
  100. package/kits/docs/evolclaw/MSG_PRIVATE.md +25 -0
  101. package/kits/docs/evolclaw/self-summary.md +29 -0
  102. package/kits/docs/evolclaw/tools.md +25 -0
  103. package/kits/docs/identity/AID_PROFILE_SPEC.md +27 -0
  104. package/kits/docs/identity/PATH_OPS.md +16 -0
  105. package/kits/docs/identity/ROLE_DETAIL.md +20 -0
  106. package/kits/docs/identity/identity-tools.md +26 -0
  107. package/kits/docs/path-registry.md +43 -0
  108. package/kits/eck_manifest.json +95 -0
  109. package/kits/rules/01-overview.md +120 -0
  110. package/kits/rules/02-navigation.md +75 -0
  111. package/kits/rules/03-identity.md +34 -0
  112. package/kits/rules/04-relation.md +49 -0
  113. package/kits/rules/05-venue.md +45 -0
  114. package/kits/rules/06-channel.md +43 -0
  115. package/kits/templates/system-fragments/baseagent.md +2 -0
  116. package/kits/templates/system-fragments/channel.md +10 -0
  117. package/kits/templates/system-fragments/identity.md +12 -0
  118. package/kits/templates/system-fragments/relation.md +9 -0
  119. package/kits/templates/system-fragments/runtime.md +19 -0
  120. package/kits/templates/system-fragments/venue.md +5 -0
  121. package/package.json +10 -6
  122. package/data/evolclaw.sample.json +0 -60
  123. package/dist/agents/templates.js +0 -122
  124. package/dist/channels/aun-ops.js +0 -275
  125. package/dist/cli.js +0 -2178
  126. package/dist/config.js +0 -591
  127. package/dist/core/agent-registry.js +0 -450
  128. package/dist/core/evolagent-schema.js +0 -72
  129. package/dist/core/message/stream-flusher.js +0 -238
  130. package/dist/core/message/thought-emitter.js +0 -162
  131. package/dist/core/reload-hooks.js +0 -87
  132. package/dist/prompts/templates.js +0 -122
  133. package/dist/templates/prompts.md +0 -104
  134. package/dist/templates/skills.md +0 -66
  135. package/dist/utils/channel-fingerprint.js +0 -59
  136. package/dist/utils/error-dict.js +0 -63
  137. package/dist/utils/format.js +0 -32
  138. package/dist/utils/init.js +0 -645
  139. package/dist/utils/migrate-project.js +0 -122
  140. package/dist/utils/reload-hooks.js +0 -87
  141. package/dist/utils/stats-collector.js +0 -99
  142. package/dist/utils/upgrade.js +0 -100
@@ -4,6 +4,7 @@ import imageType from 'image-type';
4
4
  import { sanitizeFileName, saveToUploads, validateImage } from '../utils/media-cache.js';
5
5
  import { logger } from '../utils/logger.js';
6
6
  import { hasRichContent, renderAllRichContent, checkDependencies } from '../utils/rich-content-renderer.js';
7
+ import { formatItemsAsText } from '../core/message/items-formatter.js';
7
8
  export class FeishuChannel {
8
9
  config;
9
10
  client = null;
@@ -18,6 +19,8 @@ export class FeishuChannel {
18
19
  interactionCallback;
19
20
  connected = false;
20
21
  enableRichContent;
22
+ // chatId → 该会话内仍 pending 的交互卡片 messageId 集合,用于作废
23
+ pendingCardsByChat = new Map();
21
24
  constructor(config) {
22
25
  this.config = config;
23
26
  this.enableRichContent = config.enableRichContent ?? false; // 默认关闭
@@ -40,7 +43,9 @@ export class FeishuChannel {
40
43
  if (this.config.appId.startsWith('YOUR_') || this.config.appSecret.startsWith('YOUR_')) {
41
44
  throw new Error('Feishu credentials not configured (placeholder values detected)');
42
45
  }
43
- const { requireOptional } = await import('../utils/init-channel.js');
46
+ // 加载持久化的已处理消息 ID,防止重启后 Feishu 重推同一条消息
47
+ this.loadSeenMessages();
48
+ const { requireOptional } = await import('../utils/npm-ops.js');
44
49
  const lark = await requireOptional('@larksuiteoapi/node-sdk');
45
50
  try {
46
51
  this.client = new lark.Client({
@@ -57,6 +62,18 @@ export class FeishuChannel {
57
62
  return;
58
63
  }
59
64
  this.markSeen(msg.message_id);
65
+ // 丢弃飞书服务端积压超过 5 分钟才下发的消息:上游观察到 65 分钟级延迟下发的
66
+ // 历史消息(含 /restart 这类破坏性命令),无差别接收会导致非预期重启。
67
+ // create_time 是 ms 字符串。
68
+ {
69
+ const createTimeMs = Number(msg.create_time ?? 0);
70
+ const ageMs = Date.now() - createTimeMs;
71
+ const STALE_THRESHOLD_MS = 5 * 60 * 1000;
72
+ if (createTimeMs > 0 && ageMs > STALE_THRESHOLD_MS) {
73
+ logger.warn(`[Feishu] Dropping stale message: id=${msg.message_id} type=${msg.message_type} age=${Math.round(ageMs / 1000)}s create_time=${createTimeMs}`);
74
+ return;
75
+ }
76
+ }
60
77
  if (!this.messageHandler)
61
78
  return;
62
79
  // 提取 chatType(从 SDK 事件直接获取)
@@ -65,8 +82,6 @@ export class FeishuChannel {
65
82
  if (msg.thread_id) {
66
83
  logger.info('[Feishu] Thread message, thread_id:', msg.thread_id, 'root_id:', msg.root_id);
67
84
  }
68
- // [DEBUG] 临时:记录所有消息的 root_id/thread_id,用于排查图片回复带引用问题
69
- logger.info('[Feishu][DEBUG] msg_type:', msg.message_type, 'root_id:', msg.root_id ?? '(empty)', 'thread_id:', msg.thread_id ?? '(empty)', 'parent_id:', msg.parent_id ?? '(empty)');
70
85
  // 提取 @ 提及列表(排除机器人自身)
71
86
  const mentions = (msg.mentions || []).map((m) => ({
72
87
  userId: m.id?.open_id || '',
@@ -164,6 +179,16 @@ export class FeishuChannel {
164
179
  quotedText = `> 以下是引用的原消息\n> ================\n> [文件消息]\n> ================\n\n`;
165
180
  }
166
181
  }
182
+ else if (quotedMsgType === 'merge_forward') {
183
+ const { text: mergedText, images: mergedImages } = await this.extractMergeForwardContent(msg.parent_id, msg.chat_id);
184
+ if (mergedText) {
185
+ quotedText = `> 以下是引用的原消息\n> ================\n> [合并转发消息]\n> ================\n\n${mergedText}\n\n`;
186
+ quotedImages.push(...mergedImages);
187
+ }
188
+ else {
189
+ quotedText = `> 以下是引用的原消息\n> ================\n> [合并转发消息]\n> ================\n\n`;
190
+ }
191
+ }
167
192
  else {
168
193
  quotedText = `> 以下是引用的原消息\n> ================\n> [${quotedMsgType}消息]\n> ================\n\n`;
169
194
  }
@@ -172,6 +197,7 @@ export class FeishuChannel {
172
197
  logger.warn({ err }, '[Feishu] Failed to fetch quoted message');
173
198
  }
174
199
  }
200
+ logger.info(`[Feishu] Incoming message_type=${msg.message_type} content=${msg.content?.substring(0, 200)}`);
175
201
  // 处理文本消息
176
202
  if (msg.message_type === 'text') {
177
203
  const parsed = JSON.parse(msg.content);
@@ -252,6 +278,19 @@ export class FeishuChannel {
252
278
  const allImages = [...quotedImages, ...postImages];
253
279
  await this.messageHandler({ channelId: msg.chat_id, content: finalContent, images: allImages.length > 0 ? allImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
254
280
  }
281
+ // 处理合并转发消息
282
+ else if (msg.message_type === 'merge_forward') {
283
+ const { text: mergedText, images: mergedImages } = await this.extractMergeForwardContent(msg.message_id, msg.chat_id);
284
+ if (mergedText) {
285
+ const finalContent = quotedText + mergedText;
286
+ const allImages = [...quotedImages, ...mergedImages];
287
+ await this.messageHandler({ channelId: msg.chat_id, content: finalContent, images: allImages.length > 0 ? allImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
288
+ }
289
+ else {
290
+ const prompt = quotedText + '[合并转发消息解析失败]';
291
+ await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: quotedImages.length > 0 ? quotedImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
292
+ }
293
+ }
255
294
  // 处理其他类型消息
256
295
  else {
257
296
  logger.debug('[Feishu] Unsupported message type:', msg.message_type);
@@ -278,11 +317,49 @@ export class FeishuChannel {
278
317
  if (!action?.value)
279
318
  return;
280
319
  const value = action.value;
320
+ const operatorId = data.operator?.open_id;
321
+ const chatId = data.context?.open_chat_id || data.open_chat_id;
322
+ const cardMessageId = data.open_message_id || data.context?.open_message_id;
323
+ // ── CommandCard 分支:按钮直接触发命令 ──
324
+ if (value._command) {
325
+ if (value._initiator && operatorId && operatorId !== value._initiator) {
326
+ return {
327
+ toast: { type: 'warning', content: '⚠️ 仅卡片发起者可操作' },
328
+ };
329
+ }
330
+ logger.info(`[Feishu] CommandCard trigger: command=${value._command}, operator=${operatorId}`);
331
+ if (this.messageHandler) {
332
+ // Feishu chatId 前缀:oc_ = group chat,ou_ = private user open_id
333
+ const chatType = typeof chatId === 'string' && chatId.startsWith('oc_') ? 'group' : 'private';
334
+ await this.messageHandler({
335
+ channelId: chatId,
336
+ content: value._command,
337
+ chatType,
338
+ peerId: operatorId,
339
+ messageId: `card-trigger-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
340
+ source: 'card-trigger',
341
+ });
342
+ }
343
+ // 点击后从作废集合移除——已 resolved 的卡片不再被新卡片到来时 PATCH
344
+ if (chatId && cardMessageId)
345
+ this.untrackPendingCard(chatId, cardMessageId);
346
+ const cardTitle = value._card_title || '操作';
347
+ const btnLabel = value._btn_label || value._command;
348
+ const cardBody = value._card_body || '';
349
+ return this.buildResolvedCard(cardTitle, { type: 'interaction.response', id: '', action: value._command, operatorId }, cardBody, btnLabel);
350
+ }
351
+ // ── ActionInteraction 分支 ──
281
352
  const requestId = value._request_id;
282
353
  if (!requestId) {
283
- logger.debug('[Feishu] Card action without _request_id, ignoring');
354
+ logger.debug('[Feishu] Card action without _request_id or _command, ignoring');
284
355
  return;
285
356
  }
357
+ // initiator 校验
358
+ if (value._initiator && operatorId && operatorId !== value._initiator) {
359
+ return {
360
+ toast: { type: 'warning', content: '⚠️ 仅卡片发起者可操作' },
361
+ };
362
+ }
286
363
  // Legacy field change (non-form select_static with _field_key): ignore silently
287
364
  if (value._field_key) {
288
365
  logger.debug(`[Feishu] Legacy field change: requestId=${requestId}, field=${value._field_key}`);
@@ -295,11 +372,12 @@ export class FeishuChannel {
295
372
  id: requestId,
296
373
  action: value._action || 'submit',
297
374
  values: { ...formValues, ...value },
298
- operatorId: data.operator?.open_id,
375
+ operatorId,
299
376
  };
300
377
  // Remove internal fields from values
301
378
  delete response.values._request_id;
302
379
  delete response.values._action;
380
+ delete response.values._initiator;
303
381
  delete response.values._card_title;
304
382
  const cardBody = value._card_body || '';
305
383
  delete response.values._card_body;
@@ -307,6 +385,9 @@ export class FeishuChannel {
307
385
  delete response.values._btn_label;
308
386
  logger.info(`[Feishu] Card action: requestId=${requestId}, action=${response.action}, values=${JSON.stringify(response.values)}`);
309
387
  this.interactionCallback?.(response);
388
+ // 点击后从作废集合移除——已 resolved 的卡片不再被新卡片到来时 PATCH
389
+ if (chatId && cardMessageId)
390
+ this.untrackPendingCard(chatId, cardMessageId);
310
391
  // Return updated card (buttons disabled + result shown)
311
392
  const cardTitle = value._card_title || '操作';
312
393
  return this.buildResolvedCard(cardTitle, response, cardBody, btnLabel);
@@ -604,7 +685,35 @@ export class FeishuChannel {
604
685
  return this.seenMessages.has(msgId);
605
686
  }
606
687
  markSeen(msgId) {
607
- this.seenMessages.set(msgId, Date.now());
688
+ const now = Date.now();
689
+ this.seenMessages.set(msgId, now);
690
+ // 持久化到文件,供重启后去重
691
+ if (this.config.seenMsgFile) {
692
+ try {
693
+ fs.appendFileSync(this.config.seenMsgFile, JSON.stringify({ id: msgId, ts: now }) + '\n');
694
+ }
695
+ catch { }
696
+ }
697
+ }
698
+ loadSeenMessages() {
699
+ if (!this.config.seenMsgFile)
700
+ return;
701
+ try {
702
+ if (!fs.existsSync(this.config.seenMsgFile))
703
+ return;
704
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
705
+ const lines = fs.readFileSync(this.config.seenMsgFile, 'utf-8').split('\n').filter(Boolean);
706
+ for (const line of lines) {
707
+ try {
708
+ const { id, ts } = JSON.parse(line);
709
+ if (ts > cutoff)
710
+ this.seenMessages.set(id, ts);
711
+ }
712
+ catch { }
713
+ }
714
+ logger.info(`[Feishu] Loaded ${this.seenMessages.size} seen message ID(s) from disk`);
715
+ }
716
+ catch { }
608
717
  }
609
718
  startCleanupTask() {
610
719
  this.cleanupInterval = setInterval(() => {
@@ -621,6 +730,16 @@ export class FeishuChannel {
621
730
  // seenThreads 无时间戳,仅限容量(话题持久存在,不按时间清理)
622
731
  if (this.seenThreads.size > 1000)
623
732
  this.seenThreads.clear();
733
+ // 重写文件,去掉过期条目
734
+ if (this.config.seenMsgFile && this.seenMessages.size > 0) {
735
+ try {
736
+ const lines = [...this.seenMessages.entries()]
737
+ .map(([id, ts]) => JSON.stringify({ id, ts }))
738
+ .join('\n') + '\n';
739
+ fs.writeFileSync(this.config.seenMsgFile, lines);
740
+ }
741
+ catch { }
742
+ }
624
743
  }, 60 * 60 * 1000);
625
744
  }
626
745
  async disconnect() {
@@ -705,6 +824,94 @@ export class FeishuChannel {
705
824
  return null;
706
825
  }
707
826
  }
827
+ /**
828
+ * 提取合并转发消息的子消息内容。
829
+ * 调用 im.message.get 获取子消息列表,逐条解析 text/image/post/file 类型。
830
+ */
831
+ async extractMergeForwardContent(messageId, chatId) {
832
+ const empty = { text: '', images: [] };
833
+ if (!this.client)
834
+ return empty;
835
+ try {
836
+ const res = await this.client.im.message.get({
837
+ path: { message_id: messageId }
838
+ });
839
+ const items = res.data?.items;
840
+ if (!items || items.length === 0) {
841
+ logger.warn('[Feishu] merge_forward: no sub-messages found');
842
+ return empty;
843
+ }
844
+ logger.info(`[Feishu] merge_forward: ${items.length} sub-messages`);
845
+ const projectPath = this.projectPathProvider
846
+ ? await this.projectPathProvider(chatId)
847
+ : process.cwd();
848
+ const textParts = [];
849
+ const images = [];
850
+ const MAX_IMAGES = 10;
851
+ textParts.push('以下是用户转发的合并消息:\n---');
852
+ for (const item of items) {
853
+ const msgType = item.msg_type;
854
+ const content = item.body?.content;
855
+ if (!content)
856
+ continue;
857
+ try {
858
+ if (msgType === 'text') {
859
+ const parsed = JSON.parse(content);
860
+ textParts.push(parsed.text || '');
861
+ }
862
+ else if (msgType === 'post') {
863
+ const parsed = JSON.parse(content);
864
+ let text = '';
865
+ const postContent = parsed.zh_cn?.content || parsed.en_us?.content || parsed.content;
866
+ if (postContent) {
867
+ for (const line of postContent) {
868
+ for (const elem of line) {
869
+ if (elem.tag === 'img' && elem.image_key && item.message_id && images.length < MAX_IMAGES) {
870
+ const imageData = await this.downloadAndSaveImage(elem.image_key, chatId, item.message_id, projectPath);
871
+ if (imageData)
872
+ images.push(imageData);
873
+ }
874
+ else if (elem.text) {
875
+ text += elem.text;
876
+ }
877
+ }
878
+ text += '\n';
879
+ }
880
+ }
881
+ const title = parsed.zh_cn?.title || parsed.en_us?.title || parsed.title;
882
+ textParts.push(title ? `${title}\n${text.trim()}` : text.trim());
883
+ }
884
+ else if (msgType === 'image' && item.message_id) {
885
+ const parsed = JSON.parse(content);
886
+ if (parsed.image_key && images.length < MAX_IMAGES) {
887
+ const imageData = await this.downloadAndSaveImage(parsed.image_key, chatId, item.message_id, projectPath);
888
+ if (imageData) {
889
+ images.push(imageData);
890
+ textParts.push('[图片]');
891
+ }
892
+ }
893
+ }
894
+ else if (msgType === 'file') {
895
+ const parsed = JSON.parse(content);
896
+ textParts.push(`[文件: ${parsed.file_name || 'unknown'}]`);
897
+ }
898
+ else {
899
+ textParts.push(`[${msgType}]`);
900
+ }
901
+ }
902
+ catch (parseErr) {
903
+ logger.debug('[Feishu] merge_forward: failed to parse sub-message:', parseErr);
904
+ textParts.push(`[${msgType}: 解析失败]`);
905
+ }
906
+ }
907
+ textParts.push('---');
908
+ return { text: textParts.join('\n'), images };
909
+ }
910
+ catch (error) {
911
+ logger.error('[Feishu] Failed to extract merge_forward content:', error);
912
+ return empty;
913
+ }
914
+ }
708
915
  async downloadFile(fileKey, fileName, messageId, projectPath) {
709
916
  if (!this.client)
710
917
  return null;
@@ -741,12 +948,63 @@ export class FeishuChannel {
741
948
  return null;
742
949
  }
743
950
  }
951
+ /** 跟踪 pending 交互卡片,等待后续作废 */
952
+ trackPendingCard(chatId, messageId) {
953
+ let set = this.pendingCardsByChat.get(chatId);
954
+ if (!set) {
955
+ set = new Set();
956
+ this.pendingCardsByChat.set(chatId, set);
957
+ }
958
+ set.add(messageId);
959
+ }
960
+ /** 卡片已 resolved(用户点击了按钮,飞书已用回调返回值替换卡片),从作废集合移除 */
961
+ untrackPendingCard(chatId, messageId) {
962
+ const set = this.pendingCardsByChat.get(chatId);
963
+ if (!set)
964
+ return;
965
+ set.delete(messageId);
966
+ if (set.size === 0)
967
+ this.pendingCardsByChat.delete(chatId);
968
+ }
969
+ /**
970
+ * 作废 chatId 下所有未被点击的旧卡片:PATCH 为"已过期"灰色卡片。
971
+ * 卡片需在 config 中声明 update_multi: true 才能被 PATCH。
972
+ */
973
+ async invalidatePendingCards(chatId) {
974
+ const set = this.pendingCardsByChat.get(chatId);
975
+ if (!set || set.size === 0)
976
+ return;
977
+ const expiredCard = {
978
+ config: { wide_screen_mode: true, update_multi: true },
979
+ header: {
980
+ template: 'grey',
981
+ title: { tag: 'plain_text', content: '已过期' },
982
+ },
983
+ elements: [{ tag: 'markdown', content: '此卡片已过期,请查看最新卡片。' }],
984
+ };
985
+ const ids = Array.from(set);
986
+ this.pendingCardsByChat.delete(chatId);
987
+ await Promise.all(ids.map(async (msgId) => {
988
+ try {
989
+ await this.client.im.message.patch({
990
+ path: { message_id: msgId },
991
+ data: { content: JSON.stringify(expiredCard) },
992
+ });
993
+ }
994
+ catch (err) {
995
+ const detail = err?.response?.data ?? err?.message ?? err;
996
+ logger.debug(`[Feishu] Patch expired card failed (msgId=${msgId}): ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`);
997
+ }
998
+ }));
999
+ }
744
1000
  async sendInteraction(chatId, interaction, options) {
745
1001
  if (!this.client)
746
1002
  return false;
747
1003
  const card = buildInteractionCard(interaction);
748
1004
  if (!card)
749
1005
  return false;
1006
+ // 在新卡发送前作废旧卡(PATCH 为"已过期"),避免历史卡片仍可点击
1007
+ await this.invalidatePendingCards(chatId);
750
1008
  try {
751
1009
  let messageId;
752
1010
  if (options?.replyToMessageId) {
@@ -774,6 +1032,8 @@ export class FeishuChannel {
774
1032
  messageId = res?.data?.message_id;
775
1033
  }
776
1034
  logger.info(`[Feishu] Sent interaction card: ${interaction.id}, messageId=${messageId}`);
1035
+ if (messageId)
1036
+ this.trackPendingCard(chatId, messageId);
777
1037
  return messageId || false;
778
1038
  }
779
1039
  catch (error) {
@@ -790,19 +1050,6 @@ export class FeishuChannel {
790
1050
  return false;
791
1051
  }
792
1052
  }
793
- async patchInteractionCard(messageId, card) {
794
- if (!this.client)
795
- return;
796
- try {
797
- await this.client.im.message.patch({
798
- path: { message_id: messageId },
799
- data: { content: JSON.stringify(card) },
800
- });
801
- }
802
- catch (error) {
803
- logger.warn(`[Feishu] Failed to patch card ${messageId}:`, error?.response?.data || error?.message);
804
- }
805
- }
806
1053
  buildResolvedCard(cardTitle, response, cardBody, btnLabel) {
807
1054
  const action = response.action;
808
1055
  const labelMap = {
@@ -812,7 +1059,6 @@ export class FeishuChannel {
812
1059
  'cancel': '取消',
813
1060
  };
814
1061
  const statusText = labelMap[action] || (btnLabel ? `✅ ${btnLabel}` : `✅ ${action}`);
815
- // Build elements: original body only
816
1062
  const elements = [];
817
1063
  if (cardBody) {
818
1064
  elements.push({ tag: 'markdown', content: cardBody });
@@ -825,7 +1071,7 @@ export class FeishuChannel {
825
1071
  card: {
826
1072
  type: 'raw',
827
1073
  data: {
828
- config: { wide_screen_mode: true },
1074
+ config: { wide_screen_mode: true, update_multi: true },
829
1075
  header: {
830
1076
  template: action === 'deny' ? 'red' : 'green',
831
1077
  title: { tag: 'plain_text', content: `${cardTitle} — ${statusText}` },
@@ -849,12 +1095,57 @@ export class FeishuChannel {
849
1095
  // ── 交互卡片构建工具 ──
850
1096
  export function buildInteractionCard(interaction) {
851
1097
  const { kind } = interaction;
1098
+ if (kind.kind === 'command-card') {
1099
+ return buildCommandCardFeishu(kind, interaction.initiatorId);
1100
+ }
852
1101
  if (kind.kind === 'action') {
853
- return buildActionCard(interaction.id, kind);
1102
+ return buildActionCard(interaction.id, kind, interaction.initiatorId);
854
1103
  }
855
1104
  return null;
856
1105
  }
857
- export function buildActionCard(requestId, action) {
1106
+ function buildCommandCardFeishu(card, initiatorId) {
1107
+ const elements = [];
1108
+ if (card.body) {
1109
+ elements.push({ tag: 'markdown', content: card.body });
1110
+ }
1111
+ // Build full card body for resolved state: original body + button labels
1112
+ const btnLabels = card.buttons.map(btn => btn.label).join(' · ');
1113
+ const fullCardBody = [card.body, btnLabels].filter(Boolean).join('\n\n');
1114
+ const buttons = card.buttons.map(btn => {
1115
+ const buttonEl = {
1116
+ tag: 'button',
1117
+ text: { tag: 'plain_text', content: btn.label },
1118
+ type: btn.style === 'danger' ? 'danger' : btn.style === 'primary' ? 'primary' : 'default',
1119
+ value: {
1120
+ _command: btn.command,
1121
+ _initiator: initiatorId,
1122
+ _card_title: card.title,
1123
+ _card_body: fullCardBody,
1124
+ _btn_label: btn.label,
1125
+ },
1126
+ };
1127
+ if (btn.disabled) {
1128
+ buttonEl.disabled = true;
1129
+ }
1130
+ if (btn.confirm) {
1131
+ buttonEl.confirm = {
1132
+ title: { tag: 'plain_text', content: btn.confirm.title },
1133
+ text: { tag: 'plain_text', content: btn.confirm.body },
1134
+ };
1135
+ }
1136
+ return buttonEl;
1137
+ });
1138
+ elements.push({ tag: 'action', actions: buttons });
1139
+ return {
1140
+ config: { wide_screen_mode: true, update_multi: true },
1141
+ header: {
1142
+ template: 'blue',
1143
+ title: { tag: 'plain_text', content: card.title },
1144
+ },
1145
+ elements,
1146
+ };
1147
+ }
1148
+ export function buildActionCard(requestId, action, initiatorId) {
858
1149
  const elements = [];
859
1150
  // Body text
860
1151
  if (action.body) {
@@ -872,6 +1163,7 @@ export function buildActionCard(requestId, action) {
872
1163
  value: {
873
1164
  _request_id: requestId,
874
1165
  _action: btn.key,
1166
+ _initiator: initiatorId,
875
1167
  _card_title: action.title,
876
1168
  _card_body: fullCardBody,
877
1169
  _btn_label: btn.label,
@@ -890,7 +1182,7 @@ export function buildActionCard(requestId, action) {
890
1182
  actions: buttons,
891
1183
  });
892
1184
  return {
893
- config: { wide_screen_mode: true },
1185
+ config: { wide_screen_mode: true, update_multi: true },
894
1186
  header: {
895
1187
  template: 'blue',
896
1188
  title: { tag: 'plain_text', content: action.title },
@@ -999,7 +1291,8 @@ export function hasMarkdownSyntax(text) {
999
1291
  ];
1000
1292
  return markdownPatterns.some(pattern => pattern.test(text));
1001
1293
  }
1002
- import { normalizeChannelInstances, getChannelShowActivities } from '../config.js';
1294
+ import { normalizeChannelInstances, getChannelShowActivities } from '../utils/channel-helpers.js';
1295
+ import { resolvePaths } from '../paths.js';
1003
1296
  export class FeishuChannelPlugin {
1004
1297
  name = 'feishu';
1005
1298
  isEnabled(config) {
@@ -1023,16 +1316,59 @@ export class FeishuChannelPlugin {
1023
1316
  appId: inst.appId,
1024
1317
  appSecret: inst.appSecret,
1025
1318
  enableRichContent: config.enableRichContent,
1319
+ seenMsgFile: path.join(resolvePaths().dataDir, `feishu-seen-${inst.name}.jsonl`),
1026
1320
  });
1027
1321
  const adapter = {
1028
1322
  channelName: inst.name,
1029
- sendText: (id, text, context) => channel.sendMessage(id, text, context),
1030
- sendFile: (id, filePath, context) => channel.sendFile(id, filePath, context),
1031
- sendImage: (id, png, context) => channel.sendImage(id, png, context),
1032
- acknowledge: (messageId) => { channel.addAckReaction(messageId); return Promise.resolve(); },
1033
- sendInteraction: (id, interaction, context) => channel.sendInteraction(id, interaction, context),
1034
- patchInteractionCard: (messageId, card) => channel.patchInteractionCard(messageId, card),
1035
- onInteraction: (callback) => channel.onInteraction(callback),
1323
+ capabilities: { file: true, image: true, interaction: true, markdown: true, thought: false, status: true },
1324
+ send: async (envelope, payload) => {
1325
+ const ctx = envelope.replyContext;
1326
+ const channelId = envelope.channelId;
1327
+ switch (payload.kind) {
1328
+ case 'result.text':
1329
+ case 'command.result':
1330
+ case 'command.error':
1331
+ case 'system.notice':
1332
+ case 'system.error':
1333
+ case 'result.error': {
1334
+ const sendCtx = { ...(ctx ?? {}) };
1335
+ if (payload.kind === 'result.text' && payload.isFinal)
1336
+ sendCtx.title = '✅ 最终回复:';
1337
+ await channel.sendMessage(channelId, payload.text, sendCtx);
1338
+ return;
1339
+ }
1340
+ case 'result.file':
1341
+ await channel.sendFile(channelId, payload.filePath, ctx);
1342
+ return;
1343
+ case 'result.image':
1344
+ await channel.sendImage(channelId, payload.data, ctx);
1345
+ return;
1346
+ case 'activity.batch': {
1347
+ // Feishu 不发送成功的 tool_result(信息密度低,刷屏)
1348
+ const filtered = payload.items.filter((i) => !(i.kind === 'tool_result' && i.ok));
1349
+ const text = formatItemsAsText(filtered);
1350
+ if (text) {
1351
+ await channel.sendMessage(channelId, text, ctx);
1352
+ }
1353
+ return;
1354
+ }
1355
+ case 'status.started':
1356
+ case 'status.completed':
1357
+ case 'status.interrupted':
1358
+ case 'status.error':
1359
+ case 'status.timeout':
1360
+ // Feishu 通过 acknowledge (✓ 表情) 表达状态,由 channel 自行处理
1361
+ return;
1362
+ case 'interaction':
1363
+ await channel.sendInteraction(channelId, payload.interaction, ctx);
1364
+ return;
1365
+ case 'custom':
1366
+ // Feishu 不支持自定义 payload
1367
+ return;
1368
+ default:
1369
+ logger.warn(`[Feishu] Unhandled payload kind: ${payload.kind}`);
1370
+ }
1371
+ }, acknowledge: (messageId) => { channel.addAckReaction(messageId); return Promise.resolve(); }, onInteraction: (callback) => channel.onInteraction(callback),
1036
1372
  };
1037
1373
  const policy = {
1038
1374
  canSwitchProject: (chatType, identity) => identity === 'owner' || identity === 'admin',
@@ -1077,6 +1413,19 @@ export class FeishuChannelPlugin {
1077
1413
  connect: () => channel.connect(),
1078
1414
  disconnect: () => channel.disconnect(),
1079
1415
  onProjectPathRequest: (channelId) => Promise.resolve(config.projects?.defaultPath || process.cwd()),
1416
+ registerBridge(bridge, channelType) {
1417
+ bridge.register(adapter.channelName, (handler) => channel.onMessage(async ({ channelId: chatId, content, images, peerId, peerName, messageId, mentions, threadId, rootId, chatType, source }) => {
1418
+ await handler({
1419
+ channel: adapter.channelName, channelType, channelId: chatId, content, images, chatType,
1420
+ peerId: peerId || '', peerName, messageId, mentions, threadId,
1421
+ replyContext: threadId ? { replyToMessageId: rootId ?? threadId, replyInThread: true } : undefined,
1422
+ source,
1423
+ });
1424
+ }), (channelId, text, replyContext) => channel.sendMessage(channelId, text, {
1425
+ replyToMessageId: replyContext?.replyToMessageId,
1426
+ replyInThread: replyContext?.replyInThread,
1427
+ }), adapter, channelType);
1428
+ },
1080
1429
  });
1081
1430
  }
1082
1431
  return result;