@xmanrui/dsh-im 4.29.1 → 4.31.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.
@@ -253,7 +253,7 @@ export async function createProductionController(ctx, config = {}, internals = {
253
253
  domain: botConfig.domain,
254
254
  botOpenId: botConfig.botOpenId,
255
255
  groupResponseMode: botConfig.groupResponseMode,
256
- groupTopicReply: botConfig.groupTopicReply,
256
+ mentionTopicReply: botConfig.mentionTopicReply,
257
257
  stepPush: botConfig.stepPush,
258
258
  stepPushMode: botConfig.stepPushMode,
259
259
  voice: voiceSource,
@@ -267,6 +267,7 @@ export async function createProductionController(ctx, config = {}, internals = {
267
267
  }),
268
268
  replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
269
269
  slashCommands: config.slashCommands !== false,
270
+ slashPanel: botConfig.slashPanel,
270
271
  ...(wsAgent ? { wsAgent } : {}),
271
272
  logger: {
272
273
  error: (...args) => logger.error?.(`[${botId ?? botConfig.id}]`, ...args),
@@ -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 });
@@ -219,7 +219,7 @@ const directDependencies = {
219
219
  '@tencent-connect/qqbot-nodejs': '1.0.4',
220
220
  '@wecom/aibot-node-sdk': '1.0.7',
221
221
  qrcode: '1.5.4',
222
- undici: '7.29.0',
222
+ undici: '7.29.1',
223
223
  };
224
224
  for (const [name, version] of Object.entries(directDependencies)) {
225
225
  if (manifest.dependencies?.[name] !== version) {
@@ -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
  }
@@ -1175,7 +1264,7 @@ export class FeishuHarnessBridge {
1175
1264
  this.#commandTasks.add(current);
1176
1265
  return current;
1177
1266
  }
1178
- if (this.#isResolvedQuestionReply(event, key)) {
1267
+ if (!hasImages && this.#isResolvedQuestionReply(event, key)) {
1179
1268
  const current = Promise.resolve()
1180
1269
  .then(() => this.#discardResolvedInteractionReply(event, messageId))
1181
1270
  .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
@@ -1228,6 +1317,19 @@ export class FeishuHarnessBridge {
1228
1317
  if (pending && senderOpenId(event) !== pending.actor) {
1229
1318
  return this.#enqueueMessage(event, messageId, key, processingReaction);
1230
1319
  }
1320
+ if (pending && hasImages) {
1321
+ const previous = pending.queue ?? Promise.resolve();
1322
+ const interruption = previous.catch(() => undefined)
1323
+ .then(() => this.#interruptQuestionForImage(event, key, pending))
1324
+ .finally(() => {
1325
+ if (pending.queue === interruption) pending.queue = null;
1326
+ this.#interactionTasks.delete(interruption);
1327
+ });
1328
+ pending.queue = interruption;
1329
+ this.#interactionTasks.add(interruption);
1330
+ // Reserve the image's FIFO position before stopping releases the original turn.
1331
+ return this.#enqueueMessage(event, messageId, key, processingReaction);
1332
+ }
1231
1333
  if (pending?.submitting || pending?.claimedReplyMessageId) {
1232
1334
  return this.#enqueueMessage(event, messageId, key, processingReaction);
1233
1335
  }
@@ -3448,7 +3550,9 @@ export class FeishuHarnessBridge {
3448
3550
 
3449
3551
  async #deliverDeferredOutcome(entry, outcome) {
3450
3552
  this.#rememberTopicReply(entry.replyToMessageId, entry.key);
3451
- if (entry.key === managedGroupKey(entry.chatId, entry.replyToMessageId)) {
3553
+ // A late answer still has to open the topic it was rooted at, so re-arm the
3554
+ // root candidate: the key alone says whether this route was a managed topic.
3555
+ if (managedTopicRoot(entry.key) === nonEmptyString(entry.replyToMessageId)) {
3452
3556
  this.#rememberTopicRoot(entry.replyToMessageId);
3453
3557
  }
3454
3558
  const text = deferredOutcomeText(outcome);
@@ -3470,10 +3574,10 @@ export class FeishuHarnessBridge {
3470
3574
  ...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
3471
3575
  ...(this.#replyInThreadFor(replyToMessageId) ? {
3472
3576
  replyInThread: true,
3473
- onReplyThreadId: async (threadId) => this.#registerTopicThreadId(
3474
- threadId,
3577
+ onReplyThreadId: async (threadId) => this.#registerTopicReply(
3475
3578
  replyToMessageId,
3476
3579
  chatId,
3580
+ threadId,
3477
3581
  ),
3478
3582
  } : {}),
3479
3583
  });
@@ -4280,7 +4384,7 @@ export class FeishuHarnessBridge {
4280
4384
  replyTo,
4281
4385
  signal: this.#signal,
4282
4386
  ...(this.#replyInThreadFor(replyTo)
4283
- ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, replyTo, chatId) }
4387
+ ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicReply(replyTo, chatId, threadId) }
4284
4388
  : {}),
4285
4389
  })
4286
4390
  : undefined,
@@ -4289,7 +4393,7 @@ export class FeishuHarnessBridge {
4289
4393
  replyTo,
4290
4394
  signal: this.#signal,
4291
4395
  ...(this.#replyInThreadFor(replyTo)
4292
- ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, replyTo, chatId) }
4396
+ ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicReply(replyTo, chatId, threadId) }
4293
4397
  : {}),
4294
4398
  })
4295
4399
  : undefined,
@@ -4832,10 +4936,11 @@ export class FeishuHarnessBridge {
4832
4936
  error.feishuCode = response.code;
4833
4937
  throw error;
4834
4938
  }
4835
- const threadId = nonEmptyString(response?.data?.thread_id);
4836
- if (threadId) {
4837
- await this.#registerTopicThreadId(threadId, messageId, chatId);
4838
- }
4939
+ // 与其他回复路径同一条登记逻辑:响应通常带 thread_id,没带就回读被回复的消息
4940
+ // (#registerTopicReply → #ensureTopicRegistered)。逐步消息以前只认响应,
4941
+ // 于是话题开出来了却没登记,话题内的追问会从托管键掉回聊天自己的会话。
4942
+ // 两个分支都以「该消息仍是待开话题的候选根」为前提,不是候选就是空操作。
4943
+ await this.#registerTopicReply(messageId, chatId, response?.data?.thread_id);
4839
4944
  return response;
4840
4945
  }
4841
4946
 
@@ -5539,7 +5644,7 @@ export class FeishuHarnessBridge {
5539
5644
  }, {
5540
5645
  replyTo: messageId,
5541
5646
  ...(this.#replyInThreadFor(messageId)
5542
- ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, messageId, chatId) }
5647
+ ? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicReply(messageId, chatId, threadId) }
5543
5648
  : {}),
5544
5649
  });
5545
5650
  } catch (error) {
@@ -5717,6 +5822,40 @@ export class FeishuHarnessBridge {
5717
5822
  });
5718
5823
  }
5719
5824
 
5825
+ async #interruptQuestionForImage(event, key, pending) {
5826
+ if (this.#signal?.aborted || this.#pendingInteractions.get(key) !== pending) return;
5827
+ const question = pending.questions[pending.index];
5828
+ const questionMessageId = pending.questionCardMessageId;
5829
+ const index = pending.index;
5830
+ const wasSubmitting = pending.submitting;
5831
+ pending.submitting = true;
5832
+ try {
5833
+ await runControlCommand('/stop', this.#harness, this.#state, key, {
5834
+ signal: this.#signal,
5835
+ control: { owner: this, key },
5836
+ });
5837
+ } catch (error) {
5838
+ if (this.#pendingInteractions.get(key) === pending) pending.submitting = wasSubmitting;
5839
+ this.#logger.warn?.('[dsh-feishu] could not stop the question turn for an image:', error.message);
5840
+ await this.#send(event.message.chat_id,
5841
+ t('图片已排队,但暂时无法结束当前提问。请先回答问题或发送 /stop,结束后会继续处理图片。'),
5842
+ { replyTo: event.message.message_id }).catch(() => undefined);
5843
+ return;
5844
+ }
5845
+ await this.#cancelPendingInteraction(key, pending.interactionId);
5846
+ const resolvedText = t('已结束本轮提问,将在当前会话中继续处理图片。');
5847
+ if (questionMessageId) {
5848
+ await this.#patchCardMessage(event.message.chat_id, questionMessageId, questionCard({
5849
+ ...question,
5850
+ interactionId: pending.interactionId,
5851
+ index,
5852
+ total: pending.questions.length,
5853
+ resolvedText,
5854
+ }));
5855
+ }
5856
+ await this.#send(event.message.chat_id, resolvedText, { replyTo: event.message.message_id }).catch(() => undefined);
5857
+ }
5858
+
5720
5859
  async #submitQuestionAnswer(pending, answerText, { chatId, messageId, questionMessageId } = {}) {
5721
5860
  const question = pending.questions[pending.index];
5722
5861
  if (!question) return;
@@ -6054,8 +6193,8 @@ export class FeishuHarnessBridge {
6054
6193
  return this.#takePendingInteraction(key, interactionId) !== null;
6055
6194
  }
6056
6195
 
6057
- async #cancelPendingInteraction(key) {
6058
- const pending = this.#takePendingInteraction(key);
6196
+ async #cancelPendingInteraction(key, interactionId) {
6197
+ const pending = this.#takePendingInteraction(key, interactionId);
6059
6198
  if (!pending || pending.kind !== 'question') return;
6060
6199
  this.#rememberResolvedInteraction(key, pending);
6061
6200
  try {
@@ -1211,9 +1211,10 @@ export function splitStepStreamCardBlocks(
1211
1211
  * rendered as its own button; the selected option label is submitted via a
1212
1212
  * card callback. Multi-select questions fall back to the plain-text flow (the
1213
1213
  * caller decides), because a multi-select needs a confirm step.
1214
+ * A resolvedText replaces the buttons when an image ends the question flow.
1214
1215
  * Action: answer:<interactionId>:<optionLabel>
1215
1216
  */
1216
- export function questionCard({ interactionId, header, question, detail, options, index, total }) {
1217
+ export function questionCard({ interactionId, header, question, detail, options, index, total, resolvedText }) {
1217
1218
  const elements = [];
1218
1219
  const progress = total > 1 ? `(${index + 1}/${total})` : '';
1219
1220
  if (header) elements.push({ tag: 'div', text: markdown(String(header)) });
@@ -1221,6 +1222,11 @@ export function questionCard({ interactionId, header, question, detail, options,
1221
1222
  elements.push({ tag: 'div', text: markdown(String(qText)) });
1222
1223
  if (detail) elements.push({ tag: 'div', text: markdown(String(detail)) });
1223
1224
 
1225
+ if (resolvedText) {
1226
+ elements.push({ tag: 'hr' }, { tag: 'div', text: markdown(resolvedText) });
1227
+ return cardWith(t('⏹ 提问已结束{progress}', { progress }), elements);
1228
+ }
1229
+
1224
1230
  if (Array.isArray(options) && options.length > 0) {
1225
1231
  elements.push({ tag: 'hr' });
1226
1232
  for (const option of options) {
@@ -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
  }