evolclaw 2.2.0 → 2.3.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 (43) hide show
  1. package/README.md +49 -27
  2. package/data/evolclaw.sample.json +6 -3
  3. package/dist/agents/claude-runner.js +125 -52
  4. package/dist/agents/codex-runner.js +10 -5
  5. package/dist/agents/gemini-runner.js +425 -0
  6. package/dist/channels/aun.js +247 -84
  7. package/dist/channels/feishu.js +556 -96
  8. package/dist/channels/wechat.js +98 -74
  9. package/dist/cli.js +132 -50
  10. package/dist/config.js +185 -31
  11. package/dist/core/channel-loader.js +11 -4
  12. package/dist/core/command-handler.js +750 -209
  13. package/dist/core/interaction-router.js +68 -0
  14. package/dist/core/message/message-bridge.js +216 -0
  15. package/dist/core/{message-processor.js → message/message-processor.js} +386 -105
  16. package/dist/core/{message-queue.js → message/message-queue.js} +1 -1
  17. package/dist/{utils → core/message}/stream-debouncer.js +1 -1
  18. package/dist/{utils → core/message}/stream-flusher.js +73 -13
  19. package/dist/core/permission.js +212 -11
  20. package/dist/core/{adapters → session/adapters}/claude-session-file-adapter.js +2 -2
  21. package/dist/core/{adapters → session/adapters}/codex-session-file-adapter.js +117 -52
  22. package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
  23. package/dist/{utils → core/session}/session-file-health.js +1 -1
  24. package/dist/core/{session-manager.js → session/session-manager.js} +57 -11
  25. package/dist/index.js +138 -54
  26. package/dist/{core/ipc-server.js → ipc.js} +36 -1
  27. package/dist/types.js +3 -0
  28. package/dist/utils/cross-platform.js +38 -1
  29. package/dist/utils/error-utils.js +130 -5
  30. package/dist/utils/init-channel.js +649 -0
  31. package/dist/utils/init.js +55 -150
  32. package/dist/utils/logger.js +8 -3
  33. package/dist/utils/media-cache.js +207 -0
  34. package/dist/{core → utils}/stats-collector.js +16 -0
  35. package/package.json +3 -3
  36. package/dist/core/message-bridge.js +0 -187
  37. package/dist/utils/init-feishu.js +0 -263
  38. package/dist/utils/init-wechat.js +0 -172
  39. package/dist/utils/ipc-client.js +0 -36
  40. package/dist/utils/permission-utils.js +0 -71
  41. /package/dist/{utils → core/message}/message-cache.js +0 -0
  42. /package/dist/{utils → core/message}/stream-idle-monitor.js +0 -0
  43. /package/dist/core/{session-file-adapter.js → session/session-file-adapter.js} +0 -0
@@ -2,7 +2,7 @@ import * as lark from '@larksuiteoapi/node-sdk';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
  import imageType from 'image-type';
5
- import { ensureDir } from '../config.js';
5
+ import { sanitizeFileName, saveToUploads, validateImage } from '../utils/media-cache.js';
6
6
  import { logger } from '../utils/logger.js';
7
7
  import { hasRichContent, renderAllRichContent, checkDependencies } from '../utils/rich-content-renderer.js';
8
8
  export class FeishuChannel {
@@ -16,11 +16,12 @@ export class FeishuChannel {
16
16
  seenThreads = new Set(); // 已见的 thread_id,用于判断话题创建消息
17
17
  userNameCache = new Map(); // userId -> userName
18
18
  recallHandler;
19
+ interactionCallback;
19
20
  connected = false;
20
21
  enableRichContent;
21
22
  constructor(config) {
22
23
  this.config = config;
23
- this.enableRichContent = config.enableRichContent ?? true; // 默认启用
24
+ this.enableRichContent = config.enableRichContent ?? false; // 默认关闭
24
25
  }
25
26
  /**
26
27
  * 预填充已知的 thread_id(重启后从数据库恢复,避免误判话题创建)
@@ -112,7 +113,7 @@ export class FeishuChannel {
112
113
  const quotedContent = res.data.items[0].body.content;
113
114
  if (quotedMsgType === 'text') {
114
115
  const parsed = JSON.parse(quotedContent);
115
- quotedText = `> ${parsed.text}\n\n`;
116
+ quotedText = `> 以下是引用的原消息\n> ================\n> ${parsed.text}\n> ================\n\n`;
116
117
  }
117
118
  else if (quotedMsgType === 'post') {
118
119
  const parsed = JSON.parse(quotedContent);
@@ -128,7 +129,7 @@ export class FeishuChannel {
128
129
  text += '\n';
129
130
  }
130
131
  }
131
- quotedText = `> ${text.trim()}\n\n`;
132
+ quotedText = `> 以下是引用的原消息\n> ================\n> ${text.trim()}\n> ================\n\n`;
132
133
  }
133
134
  else if (quotedMsgType === 'image') {
134
135
  const parsed = JSON.parse(quotedContent);
@@ -139,10 +140,10 @@ export class FeishuChannel {
139
140
  const imageData = await this.downloadAndSaveImage(imageKey, msg.chat_id, msg.parent_id, projectPath);
140
141
  if (imageData) {
141
142
  quotedImages.push(imageData);
142
- quotedText = `> [引用的图片]\n\n`;
143
+ quotedText = `> 以下是引用的原消息\n> ================\n> [引用的图片]\n> ================\n\n`;
143
144
  }
144
145
  else {
145
- quotedText = `> [图片消息]\n\n`;
146
+ quotedText = `> 以下是引用的原消息\n> ================\n> [图片消息]\n> ================\n\n`;
146
147
  }
147
148
  }
148
149
  else if (quotedMsgType === 'file') {
@@ -154,14 +155,14 @@ export class FeishuChannel {
154
155
  : process.cwd();
155
156
  const quotedFilePath = await this.downloadFile(quotedFileKey, quotedFileName, msg.parent_id, projectPath);
156
157
  if (quotedFilePath) {
157
- quotedText = `> [引用的文件:${quotedFileName}]\n> 文件已保存到:${quotedFilePath}\n\n`;
158
+ quotedText = `> 以下是引用的原消息\n> ================\n> [引用的文件:${quotedFileName}]\n> 文件已保存到:${quotedFilePath}\n> ================\n\n`;
158
159
  }
159
160
  else {
160
- quotedText = `> [文件消息]\n\n`;
161
+ quotedText = `> 以下是引用的原消息\n> ================\n> [文件消息]\n> ================\n\n`;
161
162
  }
162
163
  }
163
164
  else {
164
- quotedText = `> [${quotedMsgType}消息]\n\n`;
165
+ quotedText = `> 以下是引用的原消息\n> ================\n> [${quotedMsgType}消息]\n> ================\n\n`;
165
166
  }
166
167
  }
167
168
  catch (err) {
@@ -267,7 +268,46 @@ export class FeishuChannel {
267
268
  }
268
269
  },
269
270
  'im.message.message_read_v1': async () => { },
270
- 'im.message.reaction.created_v1': async () => { }
271
+ 'im.message.reaction.created_v1': async () => { },
272
+ 'card.action.trigger': async (data) => {
273
+ try {
274
+ const action = data?.action;
275
+ if (!action?.value)
276
+ return;
277
+ const value = action.value;
278
+ const requestId = value._request_id;
279
+ if (!requestId) {
280
+ logger.debug('[Feishu] Card action without _request_id, ignoring');
281
+ return;
282
+ }
283
+ // Legacy field change (non-form select_static with _field_key): ignore silently
284
+ if (value._field_key) {
285
+ logger.debug(`[Feishu] Legacy field change: requestId=${requestId}, field=${value._field_key}`);
286
+ return;
287
+ }
288
+ // Form submit: `action.form_value` contains all field values from form container
289
+ const formValues = action.form_value || {};
290
+ const response = {
291
+ type: 'interaction.response',
292
+ id: requestId,
293
+ action: value._action || 'submit',
294
+ values: { ...formValues, ...value },
295
+ operatorId: data.operator?.open_id,
296
+ };
297
+ // Remove internal fields from values
298
+ delete response.values._request_id;
299
+ delete response.values._action;
300
+ delete response.values._card_title;
301
+ logger.info(`[Feishu] Card action: requestId=${requestId}, action=${response.action}, values=${JSON.stringify(response.values)}`);
302
+ this.interactionCallback?.(response);
303
+ // Return updated card (buttons disabled + result shown)
304
+ const cardTitle = value._card_title || '操作';
305
+ return this.buildResolvedCard(cardTitle, response);
306
+ }
307
+ catch (err) {
308
+ logger.error('[Feishu] Failed to handle card action:', err);
309
+ }
310
+ },
271
311
  });
272
312
  this.wsClient = new lark.WSClient({
273
313
  appId: this.config.appId,
@@ -290,6 +330,9 @@ export class FeishuChannel {
290
330
  onRecall(handler) {
291
331
  this.recallHandler = handler;
292
332
  }
333
+ onInteraction(callback) {
334
+ this.interactionCallback = callback;
335
+ }
293
336
  onProjectPathRequest(provider) {
294
337
  this.projectPathProvider = provider;
295
338
  }
@@ -323,6 +366,19 @@ export class FeishuChannel {
323
366
  logger.warn('[Feishu] Attempted to send empty message, skipping');
324
367
  return;
325
368
  }
369
+ // 飞书消息内容限制约 30KB(text)/ 150KB(post),安全阈值 28000 字符
370
+ // 超长消息自动拆分,按段落边界分割
371
+ const MAX_CONTENT_LENGTH = 28000;
372
+ if (content.length > MAX_CONTENT_LENGTH) {
373
+ logger.info(`[Feishu] Message too long (${content.length} chars), splitting into parts`);
374
+ const parts = splitLongMessage(content, MAX_CONTENT_LENGTH);
375
+ for (let i = 0; i < parts.length; i++) {
376
+ // 首条消息保留 reply 选项,后续消息不再 reply
377
+ const partOptions = i === 0 ? options : { ...options, replyToMessageId: undefined };
378
+ await this.sendMessage(chatId, parts[i], partOptions);
379
+ }
380
+ return;
381
+ }
326
382
  logger.debug(`[Feishu] sendMessage called, chatId: ${chatId}, content length: ${content.length}`);
327
383
  try {
328
384
  // 检测富内容并渲染(受 enableRichContent 开关控制,且依赖必须可用)
@@ -348,7 +404,7 @@ export class FeishuChannel {
348
404
  const useMarkdown = !options?.forceText && hasMarkdownSyntax(content);
349
405
  const hasMention = !!(options?.mentionUserIds && options.mentionUserIds.length > 0);
350
406
  const hasRichImages = richItemsWithKeys.length > 0;
351
- // 如果有富内容图片、Markdown @,使用 post 格式
407
+ // 消息类型决策:有 Markdown / @ / 富内容图片 → post,否则 text
352
408
  const msgType = (useMarkdown || hasMention || hasRichImages) ? 'post' : 'text';
353
409
  let msgContent;
354
410
  if (msgType === 'post') {
@@ -406,7 +462,7 @@ export class FeishuChannel {
406
462
  }
407
463
  else {
408
464
  await this.client.im.message.create({
409
- params: { receive_id_type: 'chat_id' },
465
+ params: { receive_id_type: chatId.startsWith('ou_') ? 'open_id' : chatId.startsWith('on_') ? 'union_id' : 'chat_id' },
410
466
  data: { receive_id: chatId, msg_type: msgType, content: msgContent }
411
467
  });
412
468
  }
@@ -423,6 +479,12 @@ export class FeishuChannel {
423
479
  logger.warn('[Feishu] Message withdrawn (230011), retrying without reply');
424
480
  return this.sendMessage(chatId, content, { ...options, replyToMessageId: undefined });
425
481
  }
482
+ // 230025: 消息内容超长,截断后重试
483
+ if (error.response?.data?.code === 230025) {
484
+ logger.warn(`[Feishu] Message too long (230025, ${content.length} chars), truncating`);
485
+ const truncated = content.slice(0, 28000) + '\n\n⚠️ 消息过长,已截断';
486
+ return this.sendMessage(chatId, truncated, options);
487
+ }
426
488
  logger.error('[Feishu] Failed to send message:', error);
427
489
  throw error;
428
490
  }
@@ -471,7 +533,7 @@ export class FeishuChannel {
471
533
  }
472
534
  else {
473
535
  await this.client.im.message.create({
474
- params: { receive_id_type: 'chat_id' },
536
+ params: { receive_id_type: chatId.startsWith('ou_') ? 'open_id' : chatId.startsWith('on_') ? 'union_id' : 'chat_id' },
475
537
  data: {
476
538
  receive_id: chatId,
477
539
  msg_type: 'file',
@@ -519,7 +581,7 @@ export class FeishuChannel {
519
581
  }
520
582
  else {
521
583
  await this.client.im.message.create({
522
- params: { receive_id_type: 'chat_id' },
584
+ params: { receive_id_type: chatId.startsWith('ou_') ? 'open_id' : chatId.startsWith('on_') ? 'union_id' : 'chat_id' },
523
585
  data: { receive_id: chatId, msg_type: 'image', content: msgContent }
524
586
  });
525
587
  }
@@ -609,28 +671,17 @@ export class FeishuChannel {
609
671
  logger.warn('[Feishu] Empty response from image download');
610
672
  return null;
611
673
  }
612
- // 使用 image-type 检测真实的图片格式
613
- const type = await imageType(buffer);
614
- if (!type) {
615
- logger.warn('[Feishu] Unable to detect image type');
616
- return null;
617
- }
618
- // 白名单验证:只允许常见的图片格式
619
- const allowedMimes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
620
- if (!allowedMimes.includes(type.mime)) {
621
- logger.warn('[Feishu] Unsupported image type:', type.mime);
622
- return null;
623
- }
624
- // 大小限制:10MB
625
- if (buffer.length > 10 * 1024 * 1024) {
626
- logger.warn('[Feishu] Image too large:', buffer.length, 'bytes');
674
+ // 统一图片验证(类型白名单 + 大小限制)
675
+ const result = await validateImage(buffer);
676
+ if (result.mime === null) {
677
+ logger.warn(`[Feishu] Image validation failed: ${result.reason}`);
627
678
  return null;
628
679
  }
629
680
  const base64Data = buffer.toString('base64');
630
- logger.debug('[Feishu] Image downloaded successfully, type:', type.mime, 'size:', base64Data.length);
681
+ logger.debug('[Feishu] Image downloaded successfully, type:', result.mime, 'size:', base64Data.length);
631
682
  return {
632
683
  data: base64Data,
633
- mimeType: type.mime // 使用真实检测的 MIME 类型
684
+ mimeType: result.mime
634
685
  };
635
686
  }
636
687
  logger.error('[Feishu] Image download failed: no valid method');
@@ -671,11 +722,7 @@ export class FeishuChannel {
671
722
  logger.warn('[Feishu] Empty response from file download');
672
723
  return null;
673
724
  }
674
- const uploadsDir = path.join(projectPath, '.evolclaw', 'uploads');
675
- ensureDir(uploadsDir);
676
- const filePath = path.join(uploadsDir, fileName);
677
- fs.writeFileSync(filePath, buffer);
678
- logger.info('[Feishu] File downloaded successfully:', filePath, 'size:', buffer.length);
725
+ const { filePath } = saveToUploads(buffer, sanitizeFileName(fileName), projectPath);
679
726
  return filePath;
680
727
  }
681
728
  logger.error('[Feishu] File download failed: no valid method');
@@ -686,6 +733,102 @@ export class FeishuChannel {
686
733
  return null;
687
734
  }
688
735
  }
736
+ async sendInteraction(chatId, interaction, options) {
737
+ if (!this.client)
738
+ return false;
739
+ const card = buildInteractionCard(interaction);
740
+ if (!card)
741
+ return false;
742
+ try {
743
+ let messageId;
744
+ if (options?.replyToMessageId) {
745
+ const replyData = {
746
+ msg_type: 'interactive',
747
+ content: JSON.stringify(card),
748
+ };
749
+ if (options.replyInThread)
750
+ replyData.reply_in_thread = true;
751
+ const res = await this.client.im.message.reply({
752
+ path: { message_id: options.replyToMessageId },
753
+ data: replyData,
754
+ });
755
+ messageId = res?.data?.message_id;
756
+ }
757
+ else {
758
+ const res = await this.client.im.message.create({
759
+ params: { receive_id_type: chatId.startsWith('ou_') ? 'open_id' : chatId.startsWith('on_') ? 'union_id' : 'chat_id' },
760
+ data: {
761
+ receive_id: chatId,
762
+ msg_type: 'interactive',
763
+ content: JSON.stringify(card),
764
+ },
765
+ });
766
+ messageId = res?.data?.message_id;
767
+ }
768
+ logger.info(`[Feishu] Sent interaction card: ${interaction.id}, messageId=${messageId}`);
769
+ return messageId || false;
770
+ }
771
+ catch (error) {
772
+ const detail = error?.response?.data || error?.message || error;
773
+ logger.error(`[Feishu] Failed to send interaction card (id=${interaction.id}, replyTo=${options?.replyToMessageId || 'none'}):`, detail);
774
+ return false;
775
+ }
776
+ }
777
+ async patchInteractionCard(messageId, card) {
778
+ if (!this.client)
779
+ return;
780
+ try {
781
+ await this.client.im.message.patch({
782
+ path: { message_id: messageId },
783
+ data: { content: JSON.stringify(card) },
784
+ });
785
+ }
786
+ catch (error) {
787
+ logger.warn(`[Feishu] Failed to patch card ${messageId}:`, error?.response?.data || error?.message);
788
+ }
789
+ }
790
+ buildResolvedCard(cardTitle, response) {
791
+ const action = response.action;
792
+ const labelMap = {
793
+ 'allow': '✅ 已允许',
794
+ 'always': '🔓 已设为始终允许',
795
+ 'deny': '❌ 已拒绝',
796
+ 'cancel': '取消',
797
+ 'submit': '✅ 已提交',
798
+ };
799
+ const statusText = labelMap[action] || `✅ ${action}`;
800
+ const now = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
801
+ // Build summary of selected values
802
+ const elements = [];
803
+ if (response.values && action === 'submit') {
804
+ const entries = Object.entries(response.values).filter(([k]) => !k.startsWith('_'));
805
+ if (entries.length > 0) {
806
+ const lines = entries.map(([k, v]) => {
807
+ const display = Array.isArray(v) ? v.join(', ') : String(v);
808
+ return `**${k}**: ${display}`;
809
+ });
810
+ elements.push({ tag: 'markdown', content: lines.join('\n') });
811
+ }
812
+ }
813
+ elements.push({ tag: 'markdown', content: `操作时间:${now}` });
814
+ return {
815
+ toast: {
816
+ type: 'success',
817
+ content: statusText,
818
+ },
819
+ card: {
820
+ type: 'raw',
821
+ data: {
822
+ config: { wide_screen_mode: true },
823
+ header: {
824
+ template: action === 'deny' ? 'red' : 'green',
825
+ title: { tag: 'plain_text', content: `${cardTitle} — ${statusText}` },
826
+ },
827
+ elements,
828
+ },
829
+ },
830
+ };
831
+ }
689
832
  addAckReaction(messageId) {
690
833
  if (!this.client)
691
834
  return;
@@ -697,6 +840,260 @@ export class FeishuChannel {
697
840
  }).catch(() => { });
698
841
  }
699
842
  }
843
+ // ── 交互卡片构建工具 ──
844
+ export function buildInteractionCard(interaction) {
845
+ const { kind } = interaction;
846
+ if (kind.kind === 'action') {
847
+ return buildActionCard(interaction.id, kind);
848
+ }
849
+ if (kind.kind === 'form') {
850
+ return buildFormCard(interaction.id, kind);
851
+ }
852
+ // menu kind: not rendered as card (handled via menu.response JSON)
853
+ return null;
854
+ }
855
+ export function buildActionCard(requestId, action) {
856
+ const elements = [];
857
+ // Body text
858
+ if (action.body) {
859
+ elements.push({ tag: 'markdown', content: action.body });
860
+ }
861
+ // Buttons row
862
+ const buttons = action.buttons.map(btn => {
863
+ const buttonEl = {
864
+ tag: 'button',
865
+ text: { tag: 'plain_text', content: btn.label },
866
+ type: btn.style === 'danger' ? 'danger' : btn.style === 'primary' ? 'primary' : 'default',
867
+ value: {
868
+ _request_id: requestId,
869
+ _action: btn.key,
870
+ _card_title: action.title,
871
+ },
872
+ };
873
+ if (btn.confirm) {
874
+ buttonEl.confirm = {
875
+ title: { tag: 'plain_text', content: btn.confirm.title },
876
+ text: { tag: 'plain_text', content: btn.confirm.body },
877
+ };
878
+ }
879
+ return buttonEl;
880
+ });
881
+ elements.push({
882
+ tag: 'action',
883
+ actions: buttons,
884
+ });
885
+ return {
886
+ config: { wide_screen_mode: true },
887
+ header: {
888
+ template: 'blue',
889
+ title: { tag: 'plain_text', content: action.title },
890
+ },
891
+ elements,
892
+ };
893
+ }
894
+ export function buildFormCard(requestId, form) {
895
+ // Use Feishu card v2 form container: all fields wrapped in a `form` tag.
896
+ // On submit, callback receives `action.form_value` with all field values keyed by `name`.
897
+ // This eliminates per-field callbacks when selecting dropdown options.
898
+ const formElements = [];
899
+ // Body text
900
+ if (form.body) {
901
+ formElements.push({ tag: 'markdown', content: form.body });
902
+ }
903
+ // Fields — inside form, components use `name` (not `value`) for identification
904
+ for (const field of form.fields) {
905
+ formElements.push(buildFormFieldElement(field));
906
+ if (field.hint) {
907
+ formElements.push({
908
+ tag: 'note',
909
+ elements: [{ tag: 'plain_text', content: field.hint }],
910
+ });
911
+ }
912
+ }
913
+ // Submit button (inside form, uses form_action_type)
914
+ const submitBtn = {
915
+ tag: 'button',
916
+ text: { tag: 'plain_text', content: form.submitLabel || '确认' },
917
+ type: form.submitStyle === 'danger' ? 'danger' : 'primary',
918
+ form_action_type: 'submit',
919
+ name: 'submit',
920
+ value: {
921
+ _request_id: requestId,
922
+ _action: 'submit',
923
+ _card_title: form.title,
924
+ },
925
+ };
926
+ if (form.submitConfirm) {
927
+ submitBtn.confirm = {
928
+ title: { tag: 'plain_text', content: form.submitConfirm.title },
929
+ text: { tag: 'plain_text', content: form.submitConfirm.body },
930
+ };
931
+ }
932
+ const actions = [submitBtn];
933
+ if (form.cancelable !== false) {
934
+ actions.push({
935
+ tag: 'button',
936
+ text: { tag: 'plain_text', content: '取消' },
937
+ type: 'default',
938
+ // Cancel is NOT form_action_type — it's a regular button that triggers callback directly
939
+ value: {
940
+ _request_id: requestId,
941
+ _action: 'cancel',
942
+ _card_title: form.title,
943
+ },
944
+ });
945
+ }
946
+ formElements.push({ tag: 'action', actions });
947
+ return {
948
+ schema: '2.0',
949
+ config: { update_multi: true },
950
+ header: {
951
+ template: 'blue',
952
+ title: { tag: 'plain_text', content: form.title },
953
+ },
954
+ body: {
955
+ elements: [
956
+ {
957
+ tag: 'form',
958
+ name: requestId,
959
+ elements: formElements,
960
+ },
961
+ ],
962
+ },
963
+ };
964
+ }
965
+ /** Build a field element for use inside a form container (uses `name` for identification) */
966
+ export function buildFormFieldElement(field) {
967
+ switch (field.type) {
968
+ case 'select': {
969
+ const options = field.options.map(opt => ({
970
+ text: { tag: 'plain_text', content: opt.label },
971
+ value: opt.value,
972
+ }));
973
+ const selectedOpt = field.options.find(opt => opt.selected);
974
+ return {
975
+ tag: 'select_static',
976
+ name: field.key,
977
+ placeholder: { tag: 'plain_text', content: field.placeholder || `选择${field.label}` },
978
+ options,
979
+ ...(selectedOpt ? { initial_option: selectedOpt.value } : {}),
980
+ };
981
+ }
982
+ case 'text': {
983
+ return {
984
+ tag: 'input',
985
+ name: field.key,
986
+ placeholder: { tag: 'plain_text', content: field.placeholder || `输入${field.label}` },
987
+ ...(field.defaultValue != null ? { default_value: String(field.defaultValue) } : {}),
988
+ };
989
+ }
990
+ case 'toggle': {
991
+ const checked = field.defaultValue ?? false;
992
+ return {
993
+ tag: 'select_static',
994
+ name: field.key,
995
+ placeholder: { tag: 'plain_text', content: field.label },
996
+ options: [
997
+ { text: { tag: 'plain_text', content: '开启' }, value: 'true' },
998
+ { text: { tag: 'plain_text', content: '关闭' }, value: 'false' },
999
+ ],
1000
+ initial_option: checked ? 'true' : 'false',
1001
+ };
1002
+ }
1003
+ case 'multi-select': {
1004
+ const options = field.options.map(opt => ({
1005
+ text: { tag: 'plain_text', content: opt.label },
1006
+ value: opt.value,
1007
+ }));
1008
+ const selectedValues = field.options.filter(opt => opt.selected).map(opt => opt.value);
1009
+ return {
1010
+ tag: 'multi_select_static',
1011
+ name: field.key,
1012
+ placeholder: { tag: 'plain_text', content: `选择${field.label}` },
1013
+ options,
1014
+ ...(selectedValues.length > 0 ? { initial_options: selectedValues } : {}),
1015
+ };
1016
+ }
1017
+ default:
1018
+ return { tag: 'markdown', content: `[不支持的字段类型: ${field.type}]` };
1019
+ }
1020
+ }
1021
+ export function buildFieldElement(requestId, field) {
1022
+ switch (field.type) {
1023
+ case 'select': {
1024
+ const options = field.options.map(opt => ({
1025
+ text: { tag: 'plain_text', content: opt.label },
1026
+ value: opt.value,
1027
+ }));
1028
+ const selectedOpt = field.options.find(opt => opt.selected);
1029
+ return {
1030
+ tag: 'action',
1031
+ actions: [{
1032
+ tag: 'select_static',
1033
+ placeholder: { tag: 'plain_text', content: field.placeholder || `选择${field.label}` },
1034
+ options,
1035
+ ...(selectedOpt ? { initial_option: selectedOpt.value } : {}),
1036
+ value: {
1037
+ _request_id: requestId,
1038
+ _field_key: field.key,
1039
+ },
1040
+ }],
1041
+ };
1042
+ }
1043
+ case 'text': {
1044
+ // Feishu cards don't have a native text input component.
1045
+ // Use a note element as placeholder label; actual input via form submit.
1046
+ return {
1047
+ tag: 'note',
1048
+ elements: [
1049
+ { tag: 'plain_text', content: `${field.label}: ${field.placeholder || '(请在提交时输入)'}` },
1050
+ ],
1051
+ };
1052
+ }
1053
+ case 'toggle': {
1054
+ const checked = field.defaultValue ?? false;
1055
+ return {
1056
+ tag: 'action',
1057
+ actions: [{
1058
+ tag: 'select_static',
1059
+ placeholder: { tag: 'plain_text', content: field.label },
1060
+ options: [
1061
+ { text: { tag: 'plain_text', content: '开启' }, value: 'true' },
1062
+ { text: { tag: 'plain_text', content: '关闭' }, value: 'false' },
1063
+ ],
1064
+ initial_option: checked ? 'true' : 'false',
1065
+ value: {
1066
+ _request_id: requestId,
1067
+ _field_key: field.key,
1068
+ },
1069
+ }],
1070
+ };
1071
+ }
1072
+ case 'multi-select': {
1073
+ // Feishu cards: use multi_select_static (checker)
1074
+ const options = field.options.map(opt => ({
1075
+ text: { tag: 'plain_text', content: opt.label },
1076
+ value: opt.value,
1077
+ }));
1078
+ const selectedValues = field.options.filter(opt => opt.selected).map(opt => opt.value);
1079
+ return {
1080
+ tag: 'action',
1081
+ actions: [{
1082
+ tag: 'multi_select_static',
1083
+ placeholder: { tag: 'plain_text', content: `选择${field.label}` },
1084
+ options,
1085
+ ...(selectedValues.length > 0 ? { initial_options: selectedValues } : {}),
1086
+ value: {
1087
+ _request_id: requestId,
1088
+ _field_key: field.key,
1089
+ },
1090
+ }],
1091
+ };
1092
+ }
1093
+ default:
1094
+ return { tag: 'markdown', content: `[不支持的字段类型: ${field.type}]` };
1095
+ }
1096
+ }
700
1097
  function displayWidth(str) {
701
1098
  let width = 0;
702
1099
  for (const ch of str) {
@@ -735,6 +1132,26 @@ function convertTablesToText(text) {
735
1132
  return '```\n' + [headerStr, sepStr, ...rowStrs].join('\n') + '\n```';
736
1133
  });
737
1134
  }
1135
+ /**
1136
+ * 按段落边界拆分超长消息
1137
+ * 优先在 \n\n 处分割,其次 \n,最后强制截断
1138
+ */
1139
+ function splitLongMessage(content, maxLength) {
1140
+ const parts = [];
1141
+ let remaining = content;
1142
+ while (remaining.length > maxLength) {
1143
+ let splitAt = remaining.lastIndexOf('\n\n', maxLength);
1144
+ if (splitAt <= 0)
1145
+ splitAt = remaining.lastIndexOf('\n', maxLength);
1146
+ if (splitAt <= 0)
1147
+ splitAt = maxLength;
1148
+ parts.push(remaining.slice(0, splitAt).trimEnd());
1149
+ remaining = remaining.slice(splitAt).trimStart();
1150
+ }
1151
+ if (remaining)
1152
+ parts.push(remaining);
1153
+ return parts;
1154
+ }
738
1155
  export function markdownToFeishuPost(markdown, defaultTitle) {
739
1156
  const match = markdown.match(/^# (.+)$/m);
740
1157
  const title = match?.[1] ?? defaultTitle ?? '';
@@ -747,6 +1164,29 @@ export function markdownToFeishuPost(markdown, defaultTitle) {
747
1164
  }
748
1165
  };
749
1166
  }
1167
+ /**
1168
+ * 将 Markdown 内容转为飞书消息卡片格式(interactive msg_type)
1169
+ * 飞书卡片的 markdown 组件支持完整 Markdown 渲染(代码块、表格、列表等)
1170
+ * 当前消息类型决策统一走 post + md tag,此函数为 interactive 卡片场景预留。
1171
+ */
1172
+ export function markdownToFeishuCard(markdown, defaultTitle) {
1173
+ const match = markdown.match(/^# (.+)$/m);
1174
+ const title = match?.[1] ?? defaultTitle;
1175
+ let body = match ? markdown.replace(/^# .+\n?/, '') : markdown;
1176
+ body = convertTablesToText(body).trim();
1177
+ const card = {
1178
+ config: { wide_screen_mode: true },
1179
+ elements: [
1180
+ { tag: 'markdown', content: body }
1181
+ ]
1182
+ };
1183
+ if (title) {
1184
+ card.header = {
1185
+ title: { tag: 'plain_text', content: title }
1186
+ };
1187
+ }
1188
+ return card;
1189
+ }
750
1190
  export function hasMarkdownSyntax(text) {
751
1191
  const markdownPatterns = [
752
1192
  /^#{1,6}\s/m, /\*\*.*?\*\*/, /\*.*?\*/, /__.*?__/, /_.*?_/, /~~.*?~~/,
@@ -755,73 +1195,93 @@ export function hasMarkdownSyntax(text) {
755
1195
  ];
756
1196
  return markdownPatterns.some(pattern => pattern.test(text));
757
1197
  }
1198
+ import { normalizeChannelInstances } from '../config.js';
758
1199
  export class FeishuChannelPlugin {
759
1200
  name = 'feishu';
760
1201
  isEnabled(config) {
761
- const feishuConfig = config.channels?.feishu;
762
- if (feishuConfig?.enabled === false)
1202
+ const raw = config.channels?.feishu;
1203
+ if (!raw)
1204
+ return false;
1205
+ if (Array.isArray(raw)) {
1206
+ return raw.some(inst => inst.enabled !== false && inst.appId && inst.appSecret);
1207
+ }
1208
+ if (raw.enabled === false)
763
1209
  return false;
764
- return !!(feishuConfig?.appId && feishuConfig?.appSecret);
1210
+ return !!(raw.appId && raw.appSecret);
1211
+ }
1212
+ async createChannels(config) {
1213
+ const instances = normalizeChannelInstances(config.channels?.feishu, 'feishu');
1214
+ const result = [];
1215
+ for (const inst of instances) {
1216
+ if (inst.enabled === false || !inst.appId || !inst.appSecret)
1217
+ continue;
1218
+ const channel = new FeishuChannel({
1219
+ appId: inst.appId,
1220
+ appSecret: inst.appSecret,
1221
+ enableRichContent: config.enableRichContent,
1222
+ });
1223
+ const adapter = {
1224
+ channelName: inst.name,
1225
+ sendText: (id, text, context) => channel.sendMessage(id, text, context),
1226
+ sendFile: (id, filePath, context) => channel.sendFile(id, filePath, context),
1227
+ sendImage: (id, png, context) => channel.sendImage(id, png, context),
1228
+ acknowledge: (messageId) => { channel.addAckReaction(messageId); return Promise.resolve(); },
1229
+ sendInteraction: (id, interaction, context) => channel.sendInteraction(id, interaction, context),
1230
+ patchInteractionCard: (messageId, card) => channel.patchInteractionCard(messageId, card),
1231
+ onInteraction: (callback) => channel.onInteraction(callback),
1232
+ };
1233
+ const policy = {
1234
+ canSwitchProject: (chatType, identity) => identity === 'owner',
1235
+ canListProjects: (chatType, identity) => identity === 'owner',
1236
+ canCreateSession: (chatType, identity) => true,
1237
+ canDeleteSession: (chatType, identity) => true,
1238
+ canImportCliSession: (chatType, identity) => identity === 'owner',
1239
+ messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
1240
+ showMiddleResult: (chatType, identity) => {
1241
+ const mode = inst.showActivities ?? config.showActivities ?? 'all';
1242
+ if (mode === 'none')
1243
+ return false;
1244
+ if (mode === 'dm-only')
1245
+ return chatType === 'private';
1246
+ if (mode === 'owner-dm-only')
1247
+ return chatType === 'private' && identity === 'owner';
1248
+ return true;
1249
+ },
1250
+ showIdleMonitor: (chatType, identity) => {
1251
+ const mode = inst.showActivities ?? config.showActivities ?? 'all';
1252
+ if (mode === 'none')
1253
+ return false;
1254
+ if (mode === 'dm-only')
1255
+ return chatType === 'private';
1256
+ if (mode === 'owner-dm-only')
1257
+ return chatType === 'private' && identity === 'owner';
1258
+ return true;
1259
+ },
1260
+ accumulateErrors: (chatType, identity) => true,
1261
+ };
1262
+ const options = {
1263
+ fileMarkerPattern: /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g,
1264
+ supportsImages: true,
1265
+ flushDelay: inst.flushDelay,
1266
+ };
1267
+ result.push({
1268
+ channelType: 'feishu',
1269
+ adapter,
1270
+ channel,
1271
+ policy,
1272
+ options,
1273
+ connect: () => channel.connect(),
1274
+ disconnect: () => channel.disconnect(),
1275
+ onProjectPathRequest: (channelId) => Promise.resolve(config.projects?.defaultPath || process.cwd()),
1276
+ });
1277
+ }
1278
+ return result;
765
1279
  }
766
1280
  async createChannel(config) {
767
- const feishuConfig = config.channels?.feishu;
768
- if (!feishuConfig?.appId || !feishuConfig?.appSecret) {
1281
+ const instances = await this.createChannels(config);
1282
+ if (instances.length === 0) {
769
1283
  throw new Error('Feishu config missing');
770
1284
  }
771
- const channel = new FeishuChannel({
772
- appId: feishuConfig.appId,
773
- appSecret: feishuConfig.appSecret,
774
- enableRichContent: feishuConfig.enableRichContent,
775
- });
776
- const adapter = {
777
- name: 'feishu',
778
- sendText: (id, text, context) => channel.sendMessage(id, text, context),
779
- sendFile: (id, filePath, context) => channel.sendFile(id, filePath, context),
780
- sendImage: (id, png, context) => channel.sendImage(id, png, context),
781
- acknowledge: (messageId) => { channel.addAckReaction(messageId); return Promise.resolve(); },
782
- };
783
- const policy = {
784
- canSwitchProject: (chatType, identity) => identity === 'owner',
785
- canListProjects: (chatType, identity) => identity === 'owner',
786
- canCreateSession: (chatType, identity) => true,
787
- canDeleteSession: (chatType, identity) => true,
788
- canImportCliSession: (chatType, identity) => identity === 'owner',
789
- messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
790
- showMiddleResult: (chatType, identity) => {
791
- const mode = feishuConfig.showActivities ?? config.showActivities ?? 'all';
792
- if (mode === 'none')
793
- return false;
794
- if (mode === 'dm-only')
795
- return chatType === 'private';
796
- if (mode === 'owner-dm-only')
797
- return chatType === 'private' && identity === 'owner';
798
- return true;
799
- },
800
- showIdleMonitor: (chatType, identity) => {
801
- const mode = feishuConfig.showActivities ?? config.showActivities ?? 'all';
802
- if (mode === 'none')
803
- return false;
804
- if (mode === 'dm-only')
805
- return chatType === 'private';
806
- if (mode === 'owner-dm-only')
807
- return chatType === 'private' && identity === 'owner';
808
- return true;
809
- },
810
- accumulateErrors: (chatType, identity) => true,
811
- };
812
- const options = {
813
- fileMarkerPattern: /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g,
814
- supportsImages: true,
815
- flushDelay: feishuConfig.flushDelay,
816
- };
817
- return {
818
- adapter,
819
- channel,
820
- policy,
821
- options,
822
- connect: () => channel.connect(),
823
- disconnect: () => channel.disconnect(),
824
- onProjectPathRequest: (channelId) => Promise.resolve(config.projects?.defaultPath || process.cwd()),
825
- };
1285
+ return instances[0];
826
1286
  }
827
1287
  }