@xmanrui/dsh-im 4.29.1 → 4.30.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.
@@ -31,6 +31,10 @@ import {
31
31
  normalizeFeishuStepPushMode,
32
32
  } from '../../../../src/channels/feishu/step-push-mode.mjs';
33
33
  import { normalizeFeishuVoiceConfig } from '../../../../src/channels/feishu/voice-config.mjs';
34
+ import {
35
+ isSlashPanelConfig,
36
+ normalizeSlashPanelConfig,
37
+ } from '../../../../src/channels/feishu/slash-command-panel.mjs';
34
38
  import {
35
39
  FEISHU_ENDPOINTS as FEISHU_CLIENT_ENDPOINTS,
36
40
  FEISHU_RPC_CHANNEL,
@@ -305,10 +309,11 @@ function publicBotEntry(entry) {
305
309
  contextEnhancement: normalizeContextEnhancementConfig(source.contextEnhancement),
306
310
  accessPolicy: normalizeAccessPolicy(source.accessPolicy),
307
311
  groupResponseMode: normalizeFeishuGroupResponseMode(source.groupResponseMode),
308
- groupTopicReply: source.groupTopicReply === true,
312
+ mentionTopicReply: source.mentionTopicReply !== false,
309
313
  stepPush: source.stepPush === true,
310
314
  stepPushMode: normalizeFeishuStepPushMode(source.stepPushMode),
311
315
  voice: normalizeFeishuVoiceConfig(source.voice),
316
+ slashPanel: normalizeSlashPanelConfig(source.slashPanel),
312
317
  groupMessagePermissionGranted: source.groupMessagePermissionGranted === true,
313
318
  bot: publicBot(source.bot),
314
319
  health: publicHealth(source, connected),
@@ -471,10 +476,10 @@ function validPayload(endpoint, payload) {
471
476
  ? null
472
477
  : '请选择群聊响应方式。';
473
478
  }
474
- if (endpoint === FEISHU_ENDPOINTS.setGroupTopicReply) {
475
- return hasOnlyKeys(payload, new Set(['botId', 'groupTopicReply']))
479
+ if (endpoint === FEISHU_ENDPOINTS.setMentionTopicReply) {
480
+ return hasOnlyKeys(payload, new Set(['botId', 'mentionTopicReply']))
476
481
  && safeOpaqueId(payload.botId)
477
- && typeof payload.groupTopicReply === 'boolean'
482
+ && typeof payload.mentionTopicReply === 'boolean'
478
483
  ? null
479
484
  : '请选择是否以话题方式回复。';
480
485
  }
@@ -503,6 +508,13 @@ function validPayload(endpoint, payload) {
503
508
  ? null
504
509
  : '语音配置无效。';
505
510
  }
511
+ if (endpoint === FEISHU_ENDPOINTS.setSlashPanel) {
512
+ return hasOnlyKeys(payload, new Set(['botId', 'slashPanel']))
513
+ && safeOpaqueId(payload.botId)
514
+ && isSlashPanelConfig(payload.slashPanel)
515
+ ? null
516
+ : '请选择面板要显示的指令与顺序。';
517
+ }
506
518
  return 'Unknown Feishu endpoint.';
507
519
  }
508
520
 
@@ -786,12 +798,12 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
786
798
  await controller.updateGroupResponseMode(payload.botId, payload.groupResponseMode),
787
799
  { encodeQr: cachedEncodeQr },
788
800
  );
789
- } else if (endpoint === FEISHU_ENDPOINTS.setGroupTopicReply) {
790
- if (typeof controller.updateGroupTopicReply !== 'function') {
791
- throw new Error('Group topic reply update is unavailable');
801
+ } else if (endpoint === FEISHU_ENDPOINTS.setMentionTopicReply) {
802
+ if (typeof controller.updateMentionTopicReply !== 'function') {
803
+ throw new Error('Mention topic reply update is unavailable');
792
804
  }
793
805
  value = await toPublicFeishuStatus(
794
- await controller.updateGroupTopicReply(payload.botId, payload.groupTopicReply),
806
+ await controller.updateMentionTopicReply(payload.botId, payload.mentionTopicReply),
795
807
  { encodeQr: cachedEncodeQr },
796
808
  );
797
809
  } else if (endpoint === FEISHU_ENDPOINTS.setStepPush) {
@@ -818,6 +830,14 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
818
830
  await controller.updateVoice(payload.botId, payload.voice),
819
831
  { encodeQr: cachedEncodeQr },
820
832
  );
833
+ } else if (endpoint === FEISHU_ENDPOINTS.setSlashPanel) {
834
+ if (typeof controller.updateSlashPanel !== 'function') {
835
+ throw new Error('Slash panel update is unavailable');
836
+ }
837
+ value = await toPublicFeishuStatus(
838
+ await controller.updateSlashPanel(payload.botId, payload.slashPanel),
839
+ { encodeQr: cachedEncodeQr },
840
+ );
821
841
  } else {
822
842
  if (typeof controller.deleteBot !== 'function') throw new Error('Multi-bot delete is unavailable');
823
843
  value = await toPublicFeishuStatus(await controller.deleteBot(payload.botId), { encodeQr: cachedEncodeQr });
@@ -1,12 +1,14 @@
1
1
  import QRCode from 'qrcode';
2
2
  import {
3
3
  conversationKey,
4
+ conversationScope,
4
5
  extractInboundMessage,
5
6
  extractText,
6
7
  isAllowedSender,
7
8
  isBotSender,
8
- isTopicGroupKey,
9
- managedGroupKey,
9
+ isTopicKey,
10
+ managedTopicKey,
11
+ managedTopicRoot,
10
12
  splitText,
11
13
  } from './message-utils.mjs';
12
14
  import {
@@ -331,6 +333,26 @@ function isBareMentionMenuRequest(event, text, {
331
333
  return !String(text ?? '').trim();
332
334
  }
333
335
 
336
+ /**
337
+ * A message that is nothing but a mention of the bot, with no body of its own.
338
+ * There is no question to answer — a direct chat opens the menu card for it and
339
+ * a group gets the "text, image and file only" notice — so it must not open a
340
+ * topic either: an empty topic would leave an empty session behind, and every
341
+ * look at the menu would create another one.
342
+ *
343
+ * Mirrors the reply-reference test (`feishuReplyTargetId`): an explicit quote
344
+ * keeps the message a real question even when the mention is all the text it
345
+ * carries.
346
+ */
347
+ function isBareMentionMessage(event) {
348
+ if (event?.message?.message_type !== 'text') return false;
349
+ const messageId = nonEmptyString(event?.message?.message_id);
350
+ const parentId = nonEmptyString(event?.message?.parent_id);
351
+ const rootId = nonEmptyString(event?.message?.root_id);
352
+ if (parentId || (rootId && rootId !== messageId)) return false;
353
+ return !String(extractText(event) ?? '').trim();
354
+ }
355
+
334
356
  /** Canonical workspace/session help advertised by every bridge family. */
335
357
  const WORKSPACE_HELP_LINES = [
336
358
  '/workspace 工作区序号或绝对路径 切换工作区',
@@ -639,8 +661,13 @@ export class FeishuHarnessBridge {
639
661
  #appId;
640
662
  #botOpenId;
641
663
  #groupResponseMode;
642
- /** When true, group replies that belong to a Feishu topic ask reply_in_thread. */
643
- #groupTopicReply = false;
664
+ /**
665
+ * When true (the default), a question addressed to the bot opens a Feishu
666
+ * topic of its own — in groups and in direct chats alike — and the answer
667
+ * stays inside it. Turning it off keeps every question in the chat's main
668
+ * session and answers it in the main feed.
669
+ */
670
+ #mentionTopicReply = true;
644
671
  /** When true, streaming turns push tool calls and interim notes as discrete messages. */
645
672
  #stepPush = false;
646
673
  /** Step push presentation: discrete posts, a CardKit card, or native live CoT. */
@@ -706,7 +733,7 @@ export class FeishuHarnessBridge {
706
733
  appId,
707
734
  botOpenId,
708
735
  groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
709
- groupTopicReply = false,
736
+ mentionTopicReply = true,
710
737
  stepPush = false,
711
738
  stepPushMode = FEISHU_STEP_PUSH_MODES.POST,
712
739
  stepPushClock = null,
@@ -764,7 +791,7 @@ export class FeishuHarnessBridge {
764
791
  this.#appId = nonEmptyString(appId);
765
792
  this.#botOpenId = nonEmptyString(botOpenId);
766
793
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
767
- this.#groupTopicReply = groupTopicReply === true;
794
+ this.#mentionTopicReply = mentionTopicReply !== false;
768
795
  this.#stepPush = stepPush === true;
769
796
  this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
770
797
  this.#stepPushClock = stepPushClock ?? DEFAULT_STEP_PUSH_CLOCK;
@@ -850,8 +877,8 @@ export class FeishuHarnessBridge {
850
877
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
851
878
  }
852
879
 
853
- setGroupTopicReply(value) {
854
- this.#groupTopicReply = value === true;
880
+ setMentionTopicReply(value) {
881
+ this.#mentionTopicReply = value !== false;
855
882
  }
856
883
 
857
884
  setStepPush(value) {
@@ -872,6 +899,16 @@ export class FeishuHarnessBridge {
872
899
 
873
900
  #isAddressed(event) {
874
901
  if (event?.message?.chat_type === 'p2p') return true;
902
+ return this.#isMentioned(event);
903
+ }
904
+
905
+ /**
906
+ * Whether this message mentions the bot. Unlike #isAddressed — which answers
907
+ * "does this chat expect a reply at all" and is therefore always true in a
908
+ * direct chat — this asks the literal question, so a topic is only opened for
909
+ * a message the reader actually addressed to the bot.
910
+ */
911
+ #isMentioned(event) {
875
912
  const mentions = Array.isArray(event?.message?.mentions) ? event.message.mentions : [];
876
913
  if (!this.#botOpenId) return mentions.length > 0;
877
914
  return mentions.some((mention) => mention?.id?.open_id === this.#botOpenId
@@ -879,27 +916,31 @@ export class FeishuHarnessBridge {
879
916
  }
880
917
 
881
918
  /**
882
- * Conversation key for one inbound event. When group-topic replies are on,
883
- * a main-feed question addressed to the bot opens a fresh managed topic
884
- * session instead of joining the shared group session, and messages inside
885
- * a topic we already opened resolve back to that same managed session.
919
+ * Conversation key for one inbound event, for groups and direct chats alike.
920
+ *
921
+ * - A message inside a topic resolves back to the managed key when the bot
922
+ * rooted that topic, so the whole topic keeps one session; every other
923
+ * thread keeps the key the channel would have used without managed topics.
924
+ * - A main-feed message that mentions the bot opens a fresh managed topic
925
+ * session instead of joining the chat's shared session. A bare "@bot" is
926
+ * excluded: it is a menu request, not a question.
927
+ * - Everything else stays in the chat's shared session.
886
928
  */
887
929
  #resolveKey(event) {
888
- const chatType = event?.message?.chat_type;
889
- const chatId = nonEmptyString(event?.message?.chat_id);
890
- if (chatType !== 'group') return conversationKey(event);
891
930
  const messageId = nonEmptyString(event?.message?.message_id);
892
931
  const threadId = nonEmptyString(event?.message?.thread_id);
893
932
  if (threadId) {
894
933
  const root = this.#state?.topicRootFor?.(threadId) ?? null;
895
- return root && chatId
896
- ? managedGroupKey(chatId, root.rootMessageId)
897
- : `group:${chatId}:thread:${threadId}`;
934
+ if (root) return managedTopicKey(conversationScope(event), root.rootMessageId);
935
+ return conversationKey(event);
898
936
  }
899
- if (this.#groupTopicReply && chatId && messageId && this.#isAddressed(event)) {
900
- return managedGroupKey(chatId, messageId);
937
+ if (this.#mentionTopicReply
938
+ && messageId
939
+ && this.#isMentioned(event)
940
+ && !isBareMentionMessage(event)) {
941
+ return managedTopicKey(conversationScope(event), messageId);
901
942
  }
902
- return `group:${chatId}`;
943
+ return conversationKey(event);
903
944
  }
904
945
 
905
946
  /**
@@ -911,7 +952,7 @@ export class FeishuHarnessBridge {
911
952
  */
912
953
  #rememberTopicReply(messageId, key) {
913
954
  if (!nonEmptyString(messageId)) return;
914
- this.#anchorTopicReply.set(messageId, isTopicGroupKey(key));
955
+ this.#anchorTopicReply.set(messageId, isTopicKey(key));
915
956
  if (this.#anchorTopicReply.size > 2048) {
916
957
  const oldest = this.#anchorTopicReply.keys().next().value;
917
958
  if (oldest !== undefined) this.#anchorTopicReply.delete(oldest);
@@ -960,13 +1001,62 @@ export class FeishuHarnessBridge {
960
1001
 
961
1002
  /** Persist thread_id once Feishu auto-opened a topic from a managed root. */
962
1003
  async #registerTopicFromReply(replyTo, chatId, response) {
963
- const threadId = nonEmptyString(response?.data?.thread_id);
1004
+ await this.#registerTopicReply(replyTo, chatId, response?.data?.thread_id);
1005
+ }
1006
+
1007
+ /**
1008
+ * Record the topic for a root this bot asked Feishu to open. The reply
1009
+ * response carries the thread_id in most cases; when it does not — we have
1010
+ * seen a topic come back without one — read it back from the message we
1011
+ * replied to. Feishu opened the topic either way, and a topic left
1012
+ * unrecorded would answer its follow-ups from the chat's own session instead
1013
+ * of the topic's.
1014
+ */
1015
+ async #registerTopicReply(rootMessageId, chatId, threadId) {
1016
+ const known = nonEmptyString(threadId);
1017
+ if (known) {
1018
+ await this.#registerTopicThreadId(known, rootMessageId, chatId);
1019
+ return;
1020
+ }
1021
+ await this.#ensureTopicRegistered(rootMessageId, chatId);
1022
+ }
1023
+
1024
+ /**
1025
+ * Fallback for a threaded reply that reported no thread_id: ask Feishu which
1026
+ * topic the replied-to message now belongs to. Only runs while that message
1027
+ * is still a pending root candidate, so a topic already recorded — or a
1028
+ * conversation that has no topics at all — costs no extra API call.
1029
+ */
1030
+ async #ensureTopicRegistered(rootMessageId, chatId) {
1031
+ this.#pruneRootCandidates();
1032
+ if (!nonEmptyString(rootMessageId) || !this.#rootCandidates.has(rootMessageId)) return;
1033
+ const threadId = await this.#readTopicThreadId(rootMessageId);
964
1034
  if (threadId) {
965
- await this.#registerTopicThreadId(threadId, replyTo, chatId);
966
- } else {
967
- // No thread_id means the topic did not open; drop the candidate so a
968
- // later addressed question opens a fresh session, not a stale slot.
969
- this.#forgetTopicRoot(replyTo);
1035
+ await this.#registerTopicThreadId(threadId, rootMessageId, chatId);
1036
+ return;
1037
+ }
1038
+ // Still no topic: drop the candidate so a later addressed question opens a
1039
+ // fresh session rather than reusing a stale slot.
1040
+ this.#forgetTopicRoot(rootMessageId);
1041
+ }
1042
+
1043
+ /** The topic a message belongs to, or null when it is not in one. */
1044
+ async #readTopicThreadId(messageId) {
1045
+ const read = this.#client?.im?.v1?.message?.get;
1046
+ if (typeof read !== 'function') return null;
1047
+ try {
1048
+ const response = await read({ path: { message_id: messageId } });
1049
+ if (response?.code && response.code !== 0) return null;
1050
+ const items = Array.isArray(response?.data?.items) ? response.data.items : [];
1051
+ const item = items.find((candidate) => nonEmptyString(candidate?.message_id) === messageId)
1052
+ ?? items[0];
1053
+ return nonEmptyString(item?.thread_id);
1054
+ } catch (error) {
1055
+ this.#logger.warn?.(
1056
+ '[dsh-feishu] could not read the topic of a replied-to message:',
1057
+ error?.message ?? String(error),
1058
+ );
1059
+ return null;
970
1060
  }
971
1061
  }
972
1062
 
@@ -1053,10 +1143,9 @@ export class FeishuHarnessBridge {
1053
1143
  }
1054
1144
  // Register a managed-topic root only after access is allowed, so a
1055
1145
  // rejected or ignored question never reserves a candidate slot.
1056
- if (this.#groupTopicReply
1057
- && event?.message?.chat_type === 'group'
1146
+ if (this.#mentionTopicReply
1058
1147
  && !nonEmptyString(event?.message?.thread_id)
1059
- && isTopicGroupKey(key)
1148
+ && isTopicKey(key)
1060
1149
  && key.includes(':managed:')) {
1061
1150
  this.#rememberTopicRoot(messageId);
1062
1151
  }
@@ -3448,7 +3537,9 @@ export class FeishuHarnessBridge {
3448
3537
 
3449
3538
  async #deliverDeferredOutcome(entry, outcome) {
3450
3539
  this.#rememberTopicReply(entry.replyToMessageId, entry.key);
3451
- if (entry.key === managedGroupKey(entry.chatId, entry.replyToMessageId)) {
3540
+ // A late answer still has to open the topic it was rooted at, so re-arm the
3541
+ // root candidate: the key alone says whether this route was a managed topic.
3542
+ if (managedTopicRoot(entry.key) === nonEmptyString(entry.replyToMessageId)) {
3452
3543
  this.#rememberTopicRoot(entry.replyToMessageId);
3453
3544
  }
3454
3545
  const text = deferredOutcomeText(outcome);
@@ -3470,10 +3561,10 @@ export class FeishuHarnessBridge {
3470
3561
  ...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
3471
3562
  ...(this.#replyInThreadFor(replyToMessageId) ? {
3472
3563
  replyInThread: true,
3473
- onReplyThreadId: async (threadId) => this.#registerTopicThreadId(
3474
- threadId,
3564
+ onReplyThreadId: async (threadId) => this.#registerTopicReply(
3475
3565
  replyToMessageId,
3476
3566
  chatId,
3567
+ threadId,
3477
3568
  ),
3478
3569
  } : {}),
3479
3570
  });
@@ -4280,7 +4371,7 @@ export class FeishuHarnessBridge {
4280
4371
  replyTo,
4281
4372
  signal: this.#signal,
4282
4373
  ...(this.#replyInThreadFor(replyTo)
4283
- ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, replyTo, chatId) }
4374
+ ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicReply(replyTo, chatId, threadId) }
4284
4375
  : {}),
4285
4376
  })
4286
4377
  : undefined,
@@ -4289,7 +4380,7 @@ export class FeishuHarnessBridge {
4289
4380
  replyTo,
4290
4381
  signal: this.#signal,
4291
4382
  ...(this.#replyInThreadFor(replyTo)
4292
- ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, replyTo, chatId) }
4383
+ ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicReply(replyTo, chatId, threadId) }
4293
4384
  : {}),
4294
4385
  })
4295
4386
  : undefined,
@@ -4832,10 +4923,11 @@ export class FeishuHarnessBridge {
4832
4923
  error.feishuCode = response.code;
4833
4924
  throw error;
4834
4925
  }
4835
- const threadId = nonEmptyString(response?.data?.thread_id);
4836
- if (threadId) {
4837
- await this.#registerTopicThreadId(threadId, messageId, chatId);
4838
- }
4926
+ // 与其他回复路径同一条登记逻辑:响应通常带 thread_id,没带就回读被回复的消息
4927
+ // (#registerTopicReply → #ensureTopicRegistered)。逐步消息以前只认响应,
4928
+ // 于是话题开出来了却没登记,话题内的追问会从托管键掉回聊天自己的会话。
4929
+ // 两个分支都以「该消息仍是待开话题的候选根」为前提,不是候选就是空操作。
4930
+ await this.#registerTopicReply(messageId, chatId, response?.data?.thread_id);
4839
4931
  return response;
4840
4932
  }
4841
4933
 
@@ -5539,7 +5631,7 @@ export class FeishuHarnessBridge {
5539
5631
  }, {
5540
5632
  replyTo: messageId,
5541
5633
  ...(this.#replyInThreadFor(messageId)
5542
- ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, messageId, chatId) }
5634
+ ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicReply(messageId, chatId, threadId) }
5543
5635
  : {}),
5544
5636
  });
5545
5637
  } catch (error) {
@@ -516,9 +516,12 @@ export class VerifiedFeishuChannel {
516
516
  if (typeof messageId !== 'string' || !messageId) {
517
517
  throw fileDeliveryError('message send', undefined, undefined, { uncertain: true });
518
518
  }
519
- if (replyTo && typeof onReplyThreadId === 'function') {
519
+ if (replyTo && replyInThread && typeof onReplyThreadId === 'function') {
520
+ // The reply itself opens the topic, and the response reports its
521
+ // thread_id only sometimes (a direct-chat topic comes back without one).
522
+ // Hand over what we have and let the bridge read it back when missing.
520
523
  const threadId = response?.data?.thread_id;
521
- if (typeof threadId === 'string' && threadId) await onReplyThreadId(threadId);
524
+ await onReplyThreadId(typeof threadId === 'string' && threadId ? threadId : null);
522
525
  }
523
526
  return createDeliveryReceipt({
524
527
  deliveryId: file.deliveryKey,
@@ -596,9 +599,11 @@ export class VerifiedFeishuChannel {
596
599
  assertApiSuccess('Feishu message send', response);
597
600
  const messageId = response?.data?.message_id;
598
601
  if (!messageId) throw new Error('Feishu message send returned no message_id');
599
- if (replyTo && typeof options.onReplyThreadId === 'function') {
602
+ if (replyTo && replyInThread && typeof options.onReplyThreadId === 'function') {
603
+ // See #sendArtifact: the topic is created by the reply, but the response
604
+ // may omit its thread_id, so pass it through as it came.
600
605
  const threadId = response?.data?.thread_id;
601
- if (typeof threadId === 'string' && threadId) await options.onReplyThreadId(threadId);
606
+ await options.onReplyThreadId(typeof threadId === 'string' && threadId ? threadId : null);
602
607
  }
603
608
  return messageId;
604
609
  }
@@ -7,9 +7,10 @@ import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
7
7
  import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
8
8
  import { createVoice } from './voice.mjs';
9
9
  import {
10
- registerSlashCommands,
10
+ syncSlashCommands,
11
11
  SLASH_COMMAND_MANIFEST,
12
12
  } from './slash-command-registry.mjs';
13
+ import { normalizeSlashPanelConfig } from './slash-command-panel.mjs';
13
14
  import {
14
15
  connectionTestTargetUnavailable,
15
16
  sendRememberedConnectionTest,
@@ -95,6 +96,7 @@ export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
95
96
  slashCommandsRegistered: 0,
96
97
  slashCommandsExisting: 0,
97
98
  slashCommandsFailed: 0,
99
+ slashCommandsRemoved: 0,
98
100
  slashCommandsError: null,
99
101
  };
100
102
  }
@@ -113,7 +115,7 @@ export class FeishuRuntime {
113
115
  #domain;
114
116
  #botOpenId;
115
117
  #groupResponseMode;
116
- #groupTopicReply;
118
+ #mentionTopicReply;
117
119
  #stepPush;
118
120
  #stepPushMode;
119
121
  #voice = null;
@@ -139,6 +141,19 @@ export class FeishuRuntime {
139
141
  #pendingCardActionProbes = new Map();
140
142
  #status;
141
143
  #slashCommands = true;
144
+ /** Which commands the "/" panel should offer, and in which order. */
145
+ #slashPanel = null;
146
+ /** HTTP instance kept for panel re-syncs after startup. */
147
+ #slashHttpInstance = null;
148
+ /**
149
+ * Bumped on every panel change: a sync started for an older config stops at
150
+ * its next checkpoint instead of racing the newer one (last writer wins).
151
+ */
152
+ #slashSyncEpoch = 0;
153
+ /** Cancels the panel sync in flight; it stops before its next request. */
154
+ #slashSyncCancel = null;
155
+ /** Panel syncs run one after another, so two plans never interleave. */
156
+ #slashSyncQueue = Promise.resolve();
142
157
 
143
158
  constructor({
144
159
  lark,
@@ -148,7 +163,7 @@ export class FeishuRuntime {
148
163
  domain = 'feishu',
149
164
  botOpenId,
150
165
  groupResponseMode,
151
- groupTopicReply = false,
166
+ mentionTopicReply = true,
152
167
  stepPush = false,
153
168
  stepPushMode = 'post',
154
169
  voice = null,
@@ -164,6 +179,7 @@ export class FeishuRuntime {
164
179
  connectTimeoutMs = 15000,
165
180
  requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
166
181
  slashCommands = true,
182
+ slashPanel = null,
167
183
  wsAgent,
168
184
  logger = console,
169
185
  }) {
@@ -188,7 +204,7 @@ export class FeishuRuntime {
188
204
  this.#domain = domain;
189
205
  this.#botOpenId = nonEmptyString(botOpenId);
190
206
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
191
- this.#groupTopicReply = groupTopicReply === true;
207
+ this.#mentionTopicReply = mentionTopicReply !== false;
192
208
  this.#stepPush = stepPush === true;
193
209
  this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
194
210
  // voice 为 { config, secret } 来源;凭据缺失时 createVoice 返回禁用对象,
@@ -209,6 +225,7 @@ export class FeishuRuntime {
209
225
  this.#connectTimeoutMs = connectTimeoutMs;
210
226
  this.#requestTimeoutMs = requestTimeoutMs;
211
227
  this.#slashCommands = Boolean(slashCommands);
228
+ this.#slashPanel = normalizeSlashPanelConfig(slashPanel);
212
229
  this.#wsAgent = wsAgent;
213
230
  this.#logger = logger; this.#diagnostics = createConnectionDiagnostics({ channel: 'feishu', logger });
214
231
  this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length });
@@ -223,9 +240,9 @@ export class FeishuRuntime {
223
240
  this.#bridge?.setGroupResponseMode(this.#groupResponseMode);
224
241
  }
225
242
 
226
- setGroupTopicReply(value) {
227
- this.#groupTopicReply = value === true;
228
- this.#bridge?.setGroupTopicReply(this.#groupTopicReply);
243
+ setMentionTopicReply(value) {
244
+ this.#mentionTopicReply = value !== false;
245
+ this.#bridge?.setMentionTopicReply(this.#mentionTopicReply);
229
246
  }
230
247
 
231
248
  setStepPush(value) {
@@ -245,6 +262,17 @@ export class FeishuRuntime {
245
262
  this.#bridge?.setVoice(this.#voice);
246
263
  }
247
264
 
265
+ /**
266
+ * Record which commands the "/" panel should offer. The panel itself lives on
267
+ * Feishu's side, so a connected bot re-syncs it in the background; the newer
268
+ * config wins and any sync still running for the older one stops.
269
+ */
270
+ setSlashPanel(value) {
271
+ this.#slashPanel = normalizeSlashPanelConfig(value);
272
+ this.#slashSyncEpoch += 1;
273
+ this.#scheduleSlashPanelSync();
274
+ }
275
+
248
276
  async start() {
249
277
  while (true) {
250
278
  while (this.#stopping) await this.#stopping;
@@ -333,7 +361,7 @@ export class FeishuRuntime {
333
361
  appId: this.#appId,
334
362
  botOpenId: this.#botOpenId,
335
363
  groupResponseMode: this.#groupResponseMode,
336
- groupTopicReply: this.#groupTopicReply,
364
+ mentionTopicReply: this.#mentionTopicReply,
337
365
  stepPush: this.#stepPush,
338
366
  stepPushMode: this.#stepPushMode,
339
367
  voice: this.#voice,
@@ -444,9 +472,11 @@ export class FeishuRuntime {
444
472
  assertCurrentStart();
445
473
  // Register the native Slash Command panel best-effort and asynchronously
446
474
  // so it never blocks the long-connection startup. The panel is only a
447
- // client-side convenience; failure here must not take the bot down.
475
+ // client-side convenience; failure here must not take the bot down. The
476
+ // HTTP instance is kept so a later panel change can re-sync while running.
448
477
  if (this.#slashCommands && httpInstance) {
449
- void this.#registerSlashCommands(httpInstance, isCurrentStart, signal);
478
+ this.#slashHttpInstance = httpInstance;
479
+ this.#scheduleSlashPanelSync();
450
480
  }
451
481
  return this.status;
452
482
  } catch (error) {
@@ -689,28 +719,69 @@ export class FeishuRuntime {
689
719
  return { sent: true };
690
720
  }
691
721
 
692
- async #registerSlashCommands(httpInstance, isCurrentStart, signal) {
722
+ /**
723
+ * Queue one panel sync for the current config.
724
+ *
725
+ * Two saves in a row used to run two syncs concurrently, and the older one
726
+ * kept deleting and creating commands from a plan nobody wanted anymore. Here
727
+ * the run in flight is cancelled first — it stops at its next checkpoint while
728
+ * requests already sent are left to finish — and the new run starts only once
729
+ * it has settled, so the panel converges to the newest config.
730
+ */
731
+ #scheduleSlashPanelSync() {
732
+ const httpInstance = this.#slashHttpInstance;
733
+ const connectionSignal = this.#abortController?.signal;
734
+ if (!this.#slashCommands || !httpInstance || !connectionSignal || connectionSignal.aborted) return;
735
+ const epoch = this.#slashSyncEpoch;
736
+ this.#slashSyncCancel?.();
737
+ const controller = new AbortController();
738
+ const cancel = () => controller.abort();
739
+ const forwardAbort = () => cancel();
740
+ this.#slashSyncCancel = cancel;
741
+ connectionSignal.addEventListener('abort', forwardAbort, { once: true });
742
+ if (connectionSignal.aborted) cancel();
743
+ const run = this.#slashSyncQueue.then(() => this.#registerSlashCommands(
744
+ httpInstance,
745
+ () => !connectionSignal.aborted && this.#abortController?.signal === connectionSignal,
746
+ controller.signal,
747
+ epoch,
748
+ ));
749
+ const settled = run.then(() => {}, () => {});
750
+ this.#slashSyncQueue = settled;
751
+ void settled.then(() => {
752
+ connectionSignal.removeEventListener('abort', forwardAbort);
753
+ if (this.#slashSyncCancel === cancel) this.#slashSyncCancel = null;
754
+ });
755
+ }
756
+
757
+ async #registerSlashCommands(httpInstance, isCurrentStart, signal, epoch = this.#slashSyncEpoch) {
693
758
  this.#status.slashCommandRegistration = 'registering';
694
759
  this.#status.slashCommandsError = null;
695
760
  try {
696
- const result = await registerSlashCommands({
761
+ const result = await syncSlashCommands({
697
762
  appId: this.#appId,
698
763
  appSecret: this.#appSecret,
699
764
  domain: this.#domain,
700
765
  httpInstance,
701
766
  signal,
702
767
  manifest: SLASH_COMMAND_MANIFEST,
768
+ config: this.#slashPanel,
703
769
  });
704
- if (!isCurrentStart()) return;
770
+ // A newer panel config took over while this sync was running: its own run
771
+ // reports the outcome, so this one only stops.
772
+ if (!isCurrentStart() || epoch !== this.#slashSyncEpoch || result.superseded === true) return;
705
773
  this.#status.slashCommandRegistration = 'done';
706
774
  this.#status.slashCommandsRegistered = result.created.length;
707
775
  this.#status.slashCommandsExisting = result.existing.length;
708
776
  this.#status.slashCommandsFailed = result.failed.length;
777
+ this.#status.slashCommandsRemoved = result.deleted.length;
709
778
  this.#status.slashCommandsError = result.failed.length > 0
710
779
  ? result.failed.map((f) => `/${f.command}: ${f.error?.message ?? String(f.error)}`).join('; ')
711
780
  : null;
712
- if (result.created.length > 0) {
713
- this.#logger.info?.(`[dsh-feishu] registered ${result.created.length} slash command(s)`);
781
+ if (result.created.length > 0 || result.deleted.length > 0) {
782
+ this.#logger.info?.(
783
+ `[dsh-feishu] slash panel synced: ${result.created.length} added, ${result.deleted.length} removed`,
784
+ );
714
785
  }
715
786
  if (result.failed.length > 0) {
716
787
  this.#logger.warn?.(