@xmanrui/dsh-im 4.9.1 → 4.11.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 (72) hide show
  1. package/PROACTIVE_DELIVERY.en.md +23 -1
  2. package/PROACTIVE_DELIVERY.md +23 -1
  3. package/README.en.md +5 -1
  4. package/README.md +6 -2
  5. package/lib/client.js +2077 -673
  6. package/lib/index.js +277 -272
  7. package/package.json +13 -1
  8. package/plugin-src/client/channel-card-meta.js +9 -1
  9. package/plugin-src/client/channels/dingtalk/api.js +4 -0
  10. package/plugin-src/client/channels/dingtalk/index.js +25 -2
  11. package/plugin-src/client/channels/feishu/api.js +6 -0
  12. package/plugin-src/client/channels/feishu/group-settings.js +581 -0
  13. package/plugin-src/client/channels/feishu/index.js +26 -150
  14. package/plugin-src/client/channels/qq/api.js +4 -0
  15. package/plugin-src/client/channels/qq/index.js +25 -2
  16. package/plugin-src/client/channels/shared/token-api.js +4 -0
  17. package/plugin-src/client/channels/shared/token-channel.js +25 -3
  18. package/plugin-src/client/channels/wecom/api.js +4 -0
  19. package/plugin-src/client/channels/wecom/index.js +25 -2
  20. package/plugin-src/client/channels/weixin/api.js +4 -0
  21. package/plugin-src/client/channels/weixin/index.js +25 -2
  22. package/plugin-src/client/channels/whatsapp/api.js +4 -0
  23. package/plugin-src/client/channels/whatsapp/index.js +24 -2
  24. package/plugin-src/client/delivery-settings.js +73 -5
  25. package/plugin-src/client/global-settings.js +341 -0
  26. package/plugin-src/client/i18n.js +49 -0
  27. package/plugin-src/client/index.js +47 -6
  28. package/plugin-src/client/model-setting.js +104 -0
  29. package/plugin-src/client/styles.js +99 -0
  30. package/plugin-src/host/channels/dingtalk/production.mjs +15 -0
  31. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  32. package/plugin-src/host/channels/feishu/production.mjs +17 -0
  33. package/plugin-src/host/channels/feishu/rpc.mjs +32 -0
  34. package/plugin-src/host/channels/qq/production.mjs +15 -0
  35. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  36. package/plugin-src/host/channels/shared/model-setting-rpc.mjs +19 -0
  37. package/plugin-src/host/channels/shared/production.mjs +15 -0
  38. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  39. package/plugin-src/host/channels/shared/workspace-rpc.mjs +2 -0
  40. package/plugin-src/host/channels/slack/production.mjs +15 -0
  41. package/plugin-src/host/channels/slack/rpc.mjs +9 -0
  42. package/plugin-src/host/channels/wecom/production.mjs +15 -0
  43. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  44. package/plugin-src/host/channels/weixin/production.mjs +15 -0
  45. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  46. package/plugin-src/host/channels/whatsapp/production.mjs +15 -0
  47. package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
  48. package/plugin-src/host/delivery-adapter.mjs +142 -3
  49. package/plugin-src/host/delivery-rpc.mjs +12 -0
  50. package/plugin-src/host/delivery-service.mjs +125 -3
  51. package/plugin-src/host/delivery-suggestions.mjs +46 -0
  52. package/plugin-src/host/harness-session-coordinator.mjs +32 -10
  53. package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
  54. package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
  55. package/plugin-src/host/inbound-ttl-service.mjs +179 -0
  56. package/plugin-src/host/index.mjs +26 -1
  57. package/plugin-src/host/modern-harness-api.mjs +58 -2
  58. package/plugin-src/host/session-sync-coordinator.mjs +217 -0
  59. package/scripts/verify-package.mjs +12 -6
  60. package/src/channels/feishu/bridge.mjs +164 -4
  61. package/src/channels/feishu/feishu-channel.mjs +45 -10
  62. package/src/channels/feishu/feishu-runtime.mjs +9 -0
  63. package/src/channels/feishu/message-utils.mjs +13 -0
  64. package/src/channels/feishu/multi-bot-controller.mjs +16 -0
  65. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  66. package/src/channels/feishu/state-store.mjs +25 -0
  67. package/src/channels/shared/bot-workspace-store.mjs +291 -38
  68. package/src/channels/shared/harness-client.mjs +124 -43
  69. package/src/channels/shared/inbound-file.mjs +117 -2
  70. package/src/channels/shared/inbound-ttl-store.mjs +110 -0
  71. package/src/channels/shared/inbound-ttl.mjs +22 -0
  72. package/src/channels/shared/model-setting.mjs +110 -0
@@ -5,6 +5,8 @@ import {
5
5
  extractText,
6
6
  isAllowedSender,
7
7
  isBotSender,
8
+ isTopicGroupKey,
9
+ managedGroupKey,
8
10
  splitText,
9
11
  } from './message-utils.mjs';
10
12
  import {
@@ -101,6 +103,10 @@ import {
101
103
  // Lazily evaluated: t() must run after setImHostLanguage, not at import time.
102
104
  const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
103
105
  const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
106
+ /** Managed-topic root candidates expire without a returned thread_id. */
107
+ const ROOT_CANDIDATE_TTL_MS = 30 * 60_000;
108
+ /** Bound main-feed roots awaiting a topic so silent drops cannot grow them. */
109
+ const MAX_ROOT_CANDIDATES = 512;
104
110
 
105
111
  const MENU_COMMAND = /^\/m(?:enu)?$/i;
106
112
  const REPAIR_COMMAND_PREFIX = /^\/repair(?:\s|$)/i;
@@ -480,6 +486,14 @@ export class FeishuHarnessBridge {
480
486
  #appId;
481
487
  #botOpenId;
482
488
  #groupResponseMode;
489
+ /** When true, group replies that belong to a Feishu topic ask reply_in_thread. */
490
+ #groupTopicReply = false;
491
+ /** Group chats this bot has seen; only these may use topic replies. */
492
+ #groupChatIds = new Set();
493
+ /** Anchor message id → whether its replies should stay in a Feishu topic. */
494
+ #anchorTopicReply = new Map();
495
+ /** Main-feed roots this bot intends to auto-open as topics, awaiting thread_id. */
496
+ #rootCandidates = new Map();
483
497
  #repair;
484
498
  #repairAttempt = null;
485
499
  #repairMonitorVersion = 0;
@@ -516,6 +530,7 @@ export class FeishuHarnessBridge {
516
530
  appId,
517
531
  botOpenId,
518
532
  groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
533
+ groupTopicReply = false,
519
534
  repair,
520
535
  repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
521
536
  repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
@@ -555,6 +570,7 @@ export class FeishuHarnessBridge {
555
570
  this.#appId = nonEmptyString(appId);
556
571
  this.#botOpenId = nonEmptyString(botOpenId);
557
572
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
573
+ this.#groupTopicReply = groupTopicReply === true;
558
574
  this.#repair = repair ?? null;
559
575
  this.#repairPollIntervalMs = repairPollIntervalMs;
560
576
  this.#repairLinkWaitMs = repairLinkWaitMs;
@@ -576,6 +592,10 @@ export class FeishuHarnessBridge {
576
592
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
577
593
  }
578
594
 
595
+ setGroupTopicReply(value) {
596
+ this.#groupTopicReply = value === true;
597
+ }
598
+
579
599
  #isAddressed(event) {
580
600
  if (event?.message?.chat_type === 'p2p') return true;
581
601
  const mentions = Array.isArray(event?.message?.mentions) ? event.message.mentions : [];
@@ -584,6 +604,106 @@ export class FeishuHarnessBridge {
584
604
  || mention?.open_id === this.#botOpenId);
585
605
  }
586
606
 
607
+ /**
608
+ * Conversation key for one inbound event. When group-topic replies are on,
609
+ * a main-feed question addressed to the bot opens a fresh managed topic
610
+ * session instead of joining the shared group session, and messages inside
611
+ * a topic we already opened resolve back to that same managed session.
612
+ */
613
+ #resolveKey(event) {
614
+ const chatType = event?.message?.chat_type;
615
+ const chatId = nonEmptyString(event?.message?.chat_id);
616
+ if (chatType !== 'group') return conversationKey(event);
617
+ const messageId = nonEmptyString(event?.message?.message_id);
618
+ const threadId = nonEmptyString(event?.message?.thread_id);
619
+ if (threadId) {
620
+ const root = this.#state?.topicRootFor?.(threadId) ?? null;
621
+ return root && chatId
622
+ ? managedGroupKey(chatId, root.rootMessageId)
623
+ : `group:${chatId}:thread:${threadId}`;
624
+ }
625
+ if (this.#groupTopicReply && chatId && messageId && this.#isAddressed(event)) {
626
+ return managedGroupKey(chatId, messageId);
627
+ }
628
+ return `group:${chatId}`;
629
+ }
630
+
631
+ /**
632
+ * Record whether replies anchored on `messageId` should stay in a topic.
633
+ * `asRoot` marks a main-feed question this bot will auto-open as a topic.
634
+ */
635
+ #rememberTopicReply(messageId, key) {
636
+ if (!nonEmptyString(messageId)) return;
637
+ this.#anchorTopicReply.set(messageId, this.#groupTopicReply && isTopicGroupKey(key));
638
+ if (this.#anchorTopicReply.size > 2048) {
639
+ const oldest = this.#anchorTopicReply.keys().next().value;
640
+ if (oldest !== undefined) this.#anchorTopicReply.delete(oldest);
641
+ }
642
+ if (typeof key === 'string' && key.startsWith('group:')) {
643
+ const chatId = key.split(':')[1];
644
+ if (chatId) this.#groupChatIds.add(chatId);
645
+ }
646
+ }
647
+
648
+ /** Whether a reply anchored on `replyTo` must ask Feishu to keep it in a topic. */
649
+ #replyInThreadFor(replyTo) {
650
+ return this.#groupTopicReply
651
+ && nonEmptyString(replyTo)
652
+ && this.#anchorTopicReply.get(replyTo) === true;
653
+ }
654
+
655
+ /** Drop expired root candidates so a silently dropped reply cannot leak memory. */
656
+ #pruneRootCandidates(now = Date.now()) {
657
+ for (const [messageId, expiresAt] of this.#rootCandidates) {
658
+ if (expiresAt <= now) this.#rootCandidates.delete(messageId);
659
+ }
660
+ }
661
+
662
+ /** Track a main-feed root this bot will auto-open as a topic, bounded + TTL'd. */
663
+ #rememberTopicRoot(messageId) {
664
+ if (!nonEmptyString(messageId)) return;
665
+ const now = Date.now();
666
+ this.#pruneRootCandidates(now);
667
+ this.#rootCandidates.delete(messageId);
668
+ this.#rootCandidates.set(messageId, now + ROOT_CANDIDATE_TTL_MS);
669
+ while (this.#rootCandidates.size > MAX_ROOT_CANDIDATES) {
670
+ const oldest = this.#rootCandidates.keys().next().value;
671
+ if (oldest === undefined) break;
672
+ this.#rootCandidates.delete(oldest);
673
+ }
674
+ }
675
+
676
+ /** Forget a root candidate once its topic opened or the reply degraded. */
677
+ #forgetTopicRoot(messageId) {
678
+ if (!nonEmptyString(messageId)) return;
679
+ this.#rootCandidates.delete(messageId);
680
+ }
681
+
682
+ /** Persist thread_id once Feishu auto-opened a topic from a managed root. */
683
+ async #registerTopicFromReply(replyTo, chatId, response) {
684
+ const threadId = nonEmptyString(response?.data?.thread_id);
685
+ if (threadId) {
686
+ await this.#registerTopicThreadId(threadId, replyTo, chatId);
687
+ } else {
688
+ // No thread_id means the topic did not open; drop the candidate so a
689
+ // later addressed question opens a fresh session, not a stale slot.
690
+ this.#forgetTopicRoot(replyTo);
691
+ }
692
+ }
693
+
694
+ /** Persist thread_id when the reply anchor is a root this bot auto-opened. */
695
+ async #registerTopicThreadId(threadId, rootMessageId, chatId) {
696
+ this.#pruneRootCandidates();
697
+ if (!threadId || !this.#rootCandidates.has(rootMessageId)) return;
698
+ this.#rootCandidates.delete(rootMessageId);
699
+ if (!chatId || typeof this.#state?.setTopic !== 'function') return;
700
+ try {
701
+ await this.#state.setTopic(threadId, { rootMessageId, chatId });
702
+ } catch (error) {
703
+ this.#logger.warn?.('[dsh-feishu] failed to record managed topic:', error?.message ?? String(error));
704
+ }
705
+ }
706
+
587
707
  accept(event) {
588
708
  if (this.#signal?.aborted) return Promise.resolve();
589
709
  const messageId = nonEmptyString(event?.message?.message_id);
@@ -606,12 +726,13 @@ export class FeishuHarnessBridge {
606
726
 
607
727
  let key;
608
728
  try {
609
- key = conversationKey(event);
729
+ key = this.#resolveKey(event);
610
730
  } catch {
611
731
  this.#status.messagesRejected += 1;
612
732
  this.#status.lastRejectedAt = new Date().toISOString();
613
733
  return Promise.resolve();
614
734
  }
735
+ this.#rememberTopicReply(messageId, key);
615
736
 
616
737
  const commandMessage = extractInboundMessage(event, this.#client);
617
738
  const commandText = nonEmptyString(commandMessage.content) ?? '';
@@ -635,6 +756,15 @@ export class FeishuHarnessBridge {
635
756
  this.#acceptedMessageIds.set(messageId, null);
636
757
  return this.#finishAccessDecision(event, messageId, access);
637
758
  }
759
+ // Register a managed-topic root only after access is allowed, so a
760
+ // rejected or ignored question never reserves a candidate slot.
761
+ if (this.#groupTopicReply
762
+ && event?.message?.chat_type === 'group'
763
+ && !nonEmptyString(event?.message?.thread_id)
764
+ && isTopicGroupKey(key)
765
+ && key.includes(':managed:')) {
766
+ this.#rememberTopicRoot(messageId);
767
+ }
638
768
  if (event.message.chat_type === 'p2p') {
639
769
  const chatId = nonEmptyString(event.message.chat_id);
640
770
  if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
@@ -1910,6 +2040,7 @@ export class FeishuHarnessBridge {
1910
2040
  }) {
1911
2041
  // Confirmations triggered by a card interaction stay anchored to the
1912
2042
  // card's message so they land inside the same Feishu topic.
2043
+ this.#rememberTopicReply(messageId, key);
1913
2044
  const reply = (text) => this.#send(chatId, text, { replyTo: messageId });
1914
2045
  // Approval card buttons: approve:<approvalId> / reject:<approvalId>
1915
2046
  if (action.startsWith('approve:') || action.startsWith('reject:')) {
@@ -2383,18 +2514,25 @@ export class FeishuHarnessBridge {
2383
2514
  try {
2384
2515
  const response = await this.#client.im.v1.message.reply({
2385
2516
  path: { message_id: replyTo },
2386
- data: { msg_type: 'interactive', content },
2517
+ data: {
2518
+ msg_type: 'interactive',
2519
+ content,
2520
+ ...(this.#replyInThreadFor(replyTo) ? { reply_in_thread: true } : {}),
2521
+ },
2387
2522
  });
2388
2523
  if (response?.code && response.code !== 0) {
2389
2524
  throw new Error(`Feishu card reply failed: ${response.msg || response.code}`);
2390
2525
  }
2391
2526
  const repliedMessageId = nonEmptyString(response?.data?.message_id);
2527
+ await this.#registerTopicFromReply(replyTo, chatId, response);
2392
2528
  if (repliedMessageId) {
2393
2529
  this.#rememberCardRoute(repliedMessageId, chatId, options);
2394
2530
  return repliedMessageId;
2395
2531
  }
2396
2532
  } catch (error) {
2397
2533
  this.#logger.warn?.('[dsh-feishu] threaded card reply failed; sending a plain card:', error?.message ?? String(error));
2534
+ // The topic never opened; a managed root candidate must not linger.
2535
+ this.#forgetTopicRoot(replyTo);
2398
2536
  }
2399
2537
  }
2400
2538
  const response = await this.#client.im.v1.message.create({
@@ -3206,6 +3344,10 @@ export class FeishuHarnessBridge {
3206
3344
  || (validLastSeq(entry.lastSeq) && entry.lastSeq >= event.seq)
3207
3345
  || (validEventSeq(failedSeq) && event.seq > failedSeq)) continue;
3208
3346
  try {
3347
+ // Re-derive topic intent at delivery time: the push may outlive the
3348
+ // bounded in-memory anchor cache, but the conversation key still tells
3349
+ // us whether the completion belongs to a Feishu topic.
3350
+ this.#rememberTopicReply(entry.replyToMessageId ?? null, key);
3209
3351
  await this.#sendCard(
3210
3352
  entry.chatId,
3211
3353
  completionCard(sessionId, entry.title, reason),
@@ -3346,12 +3488,18 @@ export class FeishuHarnessBridge {
3346
3488
  ? (file) => this.#channel.sendImage(chatId, file, {
3347
3489
  replyTo,
3348
3490
  signal: this.#signal,
3491
+ ...(this.#replyInThreadFor(replyTo)
3492
+ ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, replyTo, chatId) }
3493
+ : {}),
3349
3494
  })
3350
3495
  : undefined,
3351
3496
  sendFile: typeof this.#channel?.sendFile === 'function'
3352
3497
  ? (file) => this.#channel.sendFile(chatId, file, {
3353
3498
  replyTo,
3354
3499
  signal: this.#signal,
3500
+ ...(this.#replyInThreadFor(replyTo)
3501
+ ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, replyTo, chatId) }
3502
+ : {}),
3355
3503
  })
3356
3504
  : undefined,
3357
3505
  onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
@@ -3499,7 +3647,12 @@ export class FeishuHarnessBridge {
3499
3647
  completedArtifacts = completed.artifacts ?? [];
3500
3648
  await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
3501
3649
  },
3502
- }, { replyTo: messageId });
3650
+ }, {
3651
+ replyTo: messageId,
3652
+ ...(this.#replyInThreadFor(messageId)
3653
+ ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, messageId, chatId) }
3654
+ : {}),
3655
+ });
3503
3656
  } catch (error) {
3504
3657
  this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
3505
3658
  if (completedAnswer || completedArtifacts.length > 0) {
@@ -4007,11 +4160,16 @@ export class FeishuHarnessBridge {
4007
4160
  try {
4008
4161
  const response = await this.#client.im.v1.message.reply({
4009
4162
  path: { message_id: replyTo },
4010
- data: { msg_type: 'text', content },
4163
+ data: {
4164
+ msg_type: 'text',
4165
+ content,
4166
+ ...(this.#replyInThreadFor(replyTo) ? { reply_in_thread: true } : {}),
4167
+ },
4011
4168
  });
4012
4169
  if (response?.code && response.code !== 0) {
4013
4170
  throw new Error(`Feishu reply failed: ${response.msg || response.code}`);
4014
4171
  }
4172
+ await this.#registerTopicFromReply(replyTo, chatId, response);
4015
4173
  return nonEmptyString(response?.data?.message_id);
4016
4174
  } catch (error) {
4017
4175
  // The referenced message may be gone (recalled/deleted); keep the
@@ -4020,6 +4178,8 @@ export class FeishuHarnessBridge {
4020
4178
  '[dsh-feishu] threaded reply failed; falling back to a plain message:',
4021
4179
  error?.message ?? String(error),
4022
4180
  );
4181
+ // The topic never opened; a managed root candidate must not linger.
4182
+ this.#forgetTopicRoot(replyTo);
4023
4183
  }
4024
4184
  }
4025
4185
  const response = await this.#client.im.v1.message.create({
@@ -208,7 +208,7 @@ export class VerifiedFeishuChannel {
208
208
  let activeCard = null;
209
209
  let rotating = false;
210
210
  try {
211
- activeCard = await this.#createStreamCard(chatId, options.replyTo);
211
+ activeCard = await this.#createStreamCard(chatId, options);
212
212
  cards.push(activeCard);
213
213
  let lastContent = this.#initialText;
214
214
  // issue #86:独立交互消息(提问/审批)落在占位卡下方后,最终答案不得
@@ -217,7 +217,7 @@ export class VerifiedFeishuChannel {
217
217
  // 交互消息之后。旧卡纳入 cards,参与 recall 与 providerMessageIds。
218
218
  const ensureActiveCard = async () => {
219
219
  if (!rotating) return activeCard;
220
- activeCard = await this.#createStreamCard(chatId, options.replyTo);
220
+ activeCard = await this.#createStreamCard(chatId, options);
221
221
  cards.push(activeCard);
222
222
  rotating = false;
223
223
  return activeCard;
@@ -255,7 +255,7 @@ export class VerifiedFeishuChannel {
255
255
  for (const [index, chunk] of chunks.entries()) {
256
256
  const card = index === 0
257
257
  ? await ensureActiveCard()
258
- : await this.#createStreamCard(chatId, options.replyTo);
258
+ : await this.#createStreamCard(chatId, options);
259
259
  if (index > 0) cards.push(card);
260
260
  await this.#updateStreamCard(card, chunk);
261
261
  await this.#finishStreamCard(card);
@@ -272,19 +272,33 @@ export class VerifiedFeishuChannel {
272
272
  }
273
273
  }
274
274
 
275
- async sendFile(chatId, file, { replyTo, signal } = {}) {
275
+ async sendFile(chatId, file, {
276
+ replyTo,
277
+ signal,
278
+ replyInThread = false,
279
+ onReplyThreadId,
280
+ } = {}) {
276
281
  return this.#sendArtifact(chatId, file, {
277
282
  replyTo,
278
283
  signal,
284
+ replyInThread,
285
+ onReplyThreadId,
279
286
  messageType: 'file',
280
287
  presentation: 'feishu-file',
281
288
  });
282
289
  }
283
290
 
284
- async sendImage(chatId, file, { replyTo, signal } = {}) {
291
+ async sendImage(chatId, file, {
292
+ replyTo,
293
+ signal,
294
+ replyInThread = false,
295
+ onReplyThreadId,
296
+ } = {}) {
285
297
  return this.#sendArtifact(chatId, file, {
286
298
  replyTo,
287
299
  signal,
300
+ replyInThread,
301
+ onReplyThreadId,
288
302
  messageType: 'image',
289
303
  presentation: 'feishu-image',
290
304
  });
@@ -293,6 +307,8 @@ export class VerifiedFeishuChannel {
293
307
  async #sendArtifact(chatId, file, {
294
308
  replyTo,
295
309
  signal,
310
+ replyInThread = false,
311
+ onReplyThreadId,
296
312
  messageType,
297
313
  presentation,
298
314
  }) {
@@ -349,7 +365,12 @@ export class VerifiedFeishuChannel {
349
365
  const request = replyTo
350
366
  ? {
351
367
  path: { message_id: replyTo },
352
- data: { msg_type: messageType, content, uuid },
368
+ data: {
369
+ msg_type: messageType,
370
+ content,
371
+ uuid,
372
+ ...(replyInThread === true ? { reply_in_thread: true } : {}),
373
+ },
353
374
  }
354
375
  : {
355
376
  params: { receive_id_type: 'chat_id' },
@@ -399,6 +420,10 @@ export class VerifiedFeishuChannel {
399
420
  if (typeof messageId !== 'string' || !messageId) {
400
421
  throw fileDeliveryError('message send', undefined, undefined, { uncertain: true });
401
422
  }
423
+ if (replyTo && typeof onReplyThreadId === 'function') {
424
+ const threadId = response?.data?.thread_id;
425
+ if (typeof threadId === 'string' && threadId) await onReplyThreadId(threadId);
426
+ }
402
427
  return createDeliveryReceipt({
403
428
  deliveryId: file.deliveryKey,
404
429
  presentation,
@@ -410,7 +435,7 @@ export class VerifiedFeishuChannel {
410
435
  });
411
436
  }
412
437
 
413
- async #createStreamCard(chatId, replyTo) {
438
+ async #createStreamCard(chatId, options = {}) {
414
439
  const content = streamPreview(this.#initialText);
415
440
  const response = assertApiSuccess('Feishu card.create', await this.#client.cardkit.v1.card.create({
416
441
  data: {
@@ -420,7 +445,7 @@ export class VerifiedFeishuChannel {
420
445
  }));
421
446
  const cardId = response?.data?.card_id;
422
447
  if (!cardId) throw new Error('Feishu card.create returned no card_id');
423
- const messageId = await this.#sendCard(chatId, cardId, replyTo);
448
+ const messageId = await this.#sendCard(chatId, cardId, options);
424
449
  return { cardId, messageId, content, sequence: 0 };
425
450
  }
426
451
 
@@ -455,12 +480,18 @@ export class VerifiedFeishuChannel {
455
480
  assertApiSuccess('Feishu card.settings', response);
456
481
  }
457
482
 
458
- async #sendCard(chatId, cardId, replyTo) {
483
+ async #sendCard(chatId, cardId, options = {}) {
484
+ const replyTo = options.replyTo;
485
+ const replyInThread = options.replyInThread === true;
459
486
  const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
460
487
  const response = replyTo
461
488
  ? await this.#client.im.v1.message.reply({
462
489
  path: { message_id: replyTo },
463
- data: { msg_type: 'interactive', content },
490
+ data: {
491
+ msg_type: 'interactive',
492
+ content,
493
+ ...(replyInThread ? { reply_in_thread: true } : {}),
494
+ },
464
495
  })
465
496
  : await this.#client.im.v1.message.create({
466
497
  params: { receive_id_type: 'chat_id' },
@@ -469,6 +500,10 @@ export class VerifiedFeishuChannel {
469
500
  assertApiSuccess('Feishu message send', response);
470
501
  const messageId = response?.data?.message_id;
471
502
  if (!messageId) throw new Error('Feishu message send returned no message_id');
503
+ if (replyTo && typeof options.onReplyThreadId === 'function') {
504
+ const threadId = response?.data?.thread_id;
505
+ if (typeof threadId === 'string' && threadId) await options.onReplyThreadId(threadId);
506
+ }
472
507
  return messageId;
473
508
  }
474
509
 
@@ -110,6 +110,7 @@ export class FeishuRuntime {
110
110
  #domain;
111
111
  #botOpenId;
112
112
  #groupResponseMode;
113
+ #groupTopicReply;
113
114
  #ownerOpenIds;
114
115
  #harness;
115
116
  #state;
@@ -139,6 +140,7 @@ export class FeishuRuntime {
139
140
  domain = 'feishu',
140
141
  botOpenId,
141
142
  groupResponseMode,
143
+ groupTopicReply = false,
142
144
  ownerOpenId,
143
145
  ownerOpenIds,
144
146
  harness,
@@ -174,6 +176,7 @@ export class FeishuRuntime {
174
176
  this.#domain = domain;
175
177
  this.#botOpenId = nonEmptyString(botOpenId);
176
178
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
179
+ this.#groupTopicReply = groupTopicReply === true;
177
180
  this.#ownerOpenIds = normalizedOwners;
178
181
  this.#harness = harness;
179
182
  this.#state = state;
@@ -198,6 +201,11 @@ export class FeishuRuntime {
198
201
  this.#bridge?.setGroupResponseMode(this.#groupResponseMode);
199
202
  }
200
203
 
204
+ setGroupTopicReply(value) {
205
+ this.#groupTopicReply = value === true;
206
+ this.#bridge?.setGroupTopicReply(this.#groupTopicReply);
207
+ }
208
+
201
209
  async start() {
202
210
  while (true) {
203
211
  while (this.#stopping) await this.#stopping;
@@ -286,6 +294,7 @@ export class FeishuRuntime {
286
294
  appId: this.#appId,
287
295
  botOpenId: this.#botOpenId,
288
296
  groupResponseMode: this.#groupResponseMode,
297
+ groupTopicReply: this.#groupTopicReply,
289
298
  repair: this.#repair,
290
299
  replyTimeoutMs: this.#replyTimeoutMs,
291
300
  // Interaction cards (approval/question buttons) are on by default.
@@ -13,6 +13,19 @@ const FEISHU_CARD_UNAVAILABLE_TEXTS = new Set([
13
13
  const FEISHU_IMAGE_PERMISSION_MESSAGE =
14
14
  '飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
15
15
 
16
+ /** Managed-topic group key: one per Feishu topic auto-rooted at a message. */
17
+ export function managedGroupKey(chatId, rootMessageId) {
18
+ if (!chatId) throw new Error('Feishu managed-topic key needs a chat_id');
19
+ if (!rootMessageId) throw new Error('Feishu managed-topic key needs a root message id');
20
+ return `group:${chatId}:managed:${rootMessageId}`;
21
+ }
22
+
23
+ /** Whether a key denotes a topic-isolated group conversation (thread or managed). */
24
+ export function isTopicGroupKey(key) {
25
+ return typeof key === 'string' && key.startsWith('group:')
26
+ && (key.includes(':thread:') || key.includes(':managed:'));
27
+ }
28
+
16
29
  export function conversationKey(event) {
17
30
  const chatType = event?.message?.chat_type;
18
31
  if (chatType === 'p2p') {
@@ -90,6 +90,7 @@ function configuredBotFingerprint(config) {
90
90
  botOpenId: config.botOpenId,
91
91
  activated: config.activated,
92
92
  groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
93
+ groupTopicReply: config.groupTopicReply === true,
93
94
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
94
95
  deletionPending: config.deletionPending === true,
95
96
  connectedAt: config.connectedAt ?? null,
@@ -584,6 +585,20 @@ export class MultiBotDshFeishuController {
584
585
  }));
585
586
  }
586
587
 
588
+ async updateGroupTopicReply(botId, groupTopicReply) {
589
+ this.#assertOpen();
590
+ if (typeof groupTopicReply !== 'boolean') {
591
+ throw new TypeError('Invalid Feishu group topic reply flag');
592
+ }
593
+ return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
594
+ const config = this.#requireBot(botId);
595
+ const saved = await this.#configStore.saveBot({ ...config, groupTopicReply });
596
+ this.#runtimes.get(botId)?.setGroupTopicReply?.(saved.groupTopicReply);
597
+ this.#touch();
598
+ return this.status(botId);
599
+ }));
600
+ }
601
+
587
602
  async deleteBot(botId) {
588
603
  this.#assertOpen();
589
604
  return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
@@ -646,6 +661,7 @@ export class MultiBotDshFeishuController {
646
661
  connected,
647
662
  configured: true,
648
663
  groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
664
+ groupTopicReply: config.groupTopicReply === true,
649
665
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
650
666
  bot: publicBot(config),
651
667
  connection,
@@ -45,6 +45,7 @@ function normalizeBot(value, { legacy = false } = {}) {
45
45
  botOpenId: cleanString(value.botOpenId),
46
46
  activated: value.activated ?? null,
47
47
  groupResponseMode: normalizeFeishuGroupResponseMode(value.groupResponseMode),
48
+ groupTopicReply: value.groupTopicReply === true,
48
49
  groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
49
50
  deletionPending: value.deletionPending === true,
50
51
  connectedAt: cleanString(value.connectedAt),
@@ -7,6 +7,7 @@ const EMPTY_STATE = Object.freeze({
7
7
  seenMessageIds: [],
8
8
  watches: {},
9
9
  includeArchivedSessions: false,
10
+ topics: {},
10
11
  });
11
12
 
12
13
  /** One conversation key may watch at most this many sessions. */
@@ -40,6 +41,7 @@ export class StateStore {
40
41
  includeArchivedSessions: typeof parsed.includeArchivedSessions === 'boolean'
41
42
  ? parsed.includeArchivedSessions
42
43
  : false,
44
+ topics: parsed.topics && typeof parsed.topics === 'object' ? parsed.topics : {},
43
45
  };
44
46
  } catch (error) {
45
47
  if (error?.code !== 'ENOENT') throw error;
@@ -136,6 +138,29 @@ export class StateStore {
136
138
  return [...ids];
137
139
  }
138
140
 
141
+ // ── Managed Feishu topics (thread_id → root message, persisted) ────────
142
+
143
+ topicRootFor(threadId) {
144
+ const entry = this.#state.topics[threadId];
145
+ return entry && typeof entry === 'object'
146
+ && typeof entry.rootMessageId === 'string' && entry.rootMessageId.length > 0
147
+ && typeof entry.chatId === 'string' && entry.chatId.length > 0
148
+ ? { rootMessageId: entry.rootMessageId, chatId: entry.chatId }
149
+ : null;
150
+ }
151
+
152
+ async setTopic(threadId, root) {
153
+ const validRoot = root && typeof root === 'object'
154
+ && typeof root.rootMessageId === 'string' && root.rootMessageId.length > 0
155
+ && typeof root.chatId === 'string' && root.chatId.length > 0;
156
+ if (!validRoot) throw new TypeError('Invalid Feishu topic root');
157
+ this.#state.topics[threadId] = {
158
+ rootMessageId: root.rootMessageId,
159
+ chatId: root.chatId,
160
+ };
161
+ await this.#persist();
162
+ }
163
+
139
164
  // ── Session-list archived policy (per bot) ───────────────────
140
165
 
141
166
  includesArchivedSessions() {