@soimy/dingtalk 3.6.0 → 3.6.2-beta.1

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 (174) hide show
  1. package/README.md +4 -1
  2. package/dist/index.d.ts +14 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +14443 -0
  5. package/dist/index.js.map +7 -0
  6. package/dist/src/access-control.d.ts +43 -0
  7. package/dist/src/access-control.d.ts.map +1 -0
  8. package/dist/src/ack-reaction/dynamic-ack-reaction-controller.d.ts +22 -0
  9. package/dist/src/ack-reaction/dynamic-ack-reaction-controller.d.ts.map +1 -0
  10. package/dist/src/ack-reaction/dynamic-ack-reaction-events.d.ts +36 -0
  11. package/dist/src/ack-reaction/dynamic-ack-reaction-events.d.ts.map +1 -0
  12. package/dist/src/ack-reaction/dynamic-ack-reaction-progress.d.ts +7 -0
  13. package/dist/src/ack-reaction/dynamic-ack-reaction-progress.d.ts.map +1 -0
  14. package/dist/src/ack-reaction-classifier.d.ts +8 -0
  15. package/dist/src/ack-reaction-classifier.d.ts.map +1 -0
  16. package/dist/src/ack-reaction-service.d.ts +15 -0
  17. package/dist/src/ack-reaction-service.d.ts.map +1 -0
  18. package/dist/src/auth.d.ts +7 -0
  19. package/dist/src/auth.d.ts.map +1 -0
  20. package/dist/src/card/card-action-handler.d.ts +14 -0
  21. package/dist/src/card/card-action-handler.d.ts.map +1 -0
  22. package/dist/src/card/card-markdown-image-reroute.d.ts +16 -0
  23. package/dist/src/card/card-markdown-image-reroute.d.ts.map +1 -0
  24. package/dist/src/card/card-run-registry.d.ts +60 -0
  25. package/dist/src/card/card-run-registry.d.ts.map +1 -0
  26. package/dist/src/card/card-stop-handler.d.ts +18 -0
  27. package/dist/src/card/card-stop-handler.d.ts.map +1 -0
  28. package/dist/src/card/card-streaming-mode.d.ts +8 -0
  29. package/dist/src/card/card-streaming-mode.d.ts.map +1 -0
  30. package/dist/src/card/card-template.d.ts +21 -0
  31. package/dist/src/card/card-template.d.ts.map +1 -0
  32. package/dist/src/card/reasoning-answer-split.d.ts +6 -0
  33. package/dist/src/card/reasoning-answer-split.d.ts.map +1 -0
  34. package/dist/src/card/reasoning-block-assembler.d.ts +7 -0
  35. package/dist/src/card/reasoning-block-assembler.d.ts.map +1 -0
  36. package/dist/src/card/statusline-renderer.d.ts +24 -0
  37. package/dist/src/card/statusline-renderer.d.ts.map +1 -0
  38. package/dist/src/card-callback-service.d.ts +23 -0
  39. package/dist/src/card-callback-service.d.ts.map +1 -0
  40. package/dist/src/card-draft-controller.d.ts +75 -0
  41. package/dist/src/card-draft-controller.d.ts.map +1 -0
  42. package/dist/src/card-service.d.ts +99 -0
  43. package/dist/src/card-service.d.ts.map +1 -0
  44. package/dist/src/channel.d.ts +10 -0
  45. package/dist/src/channel.d.ts.map +1 -0
  46. package/dist/src/command/card-stop-command.d.ts +30 -0
  47. package/dist/src/command/card-stop-command.d.ts.map +1 -0
  48. package/dist/src/command/inbound-command-dispatch-service.d.ts +40 -0
  49. package/dist/src/command/inbound-command-dispatch-service.d.ts.map +1 -0
  50. package/dist/src/config-schema.d.ts +8 -0
  51. package/dist/src/config-schema.d.ts.map +1 -0
  52. package/dist/src/config.d.ts +60 -0
  53. package/dist/src/config.d.ts.map +1 -0
  54. package/dist/src/connection-manager.d.ts +148 -0
  55. package/dist/src/connection-manager.d.ts.map +1 -0
  56. package/dist/src/dedup.d.ts +4 -0
  57. package/dist/src/dedup.d.ts.map +1 -0
  58. package/dist/src/device-registration.d.ts +16 -0
  59. package/dist/src/device-registration.d.ts.map +1 -0
  60. package/dist/src/docs-service.d.ts +12 -0
  61. package/dist/src/docs-service.d.ts.map +1 -0
  62. package/dist/src/draft-stream-loop.d.ts +28 -0
  63. package/dist/src/draft-stream-loop.d.ts.map +1 -0
  64. package/dist/src/feedback-learning-service.d.ts +123 -0
  65. package/dist/src/feedback-learning-service.d.ts.map +1 -0
  66. package/dist/src/feedback-learning-store.d.ts +181 -0
  67. package/dist/src/feedback-learning-store.d.ts.map +1 -0
  68. package/dist/src/gateway/channel-gateway.d.ts +4 -0
  69. package/dist/src/gateway/channel-gateway.d.ts.map +1 -0
  70. package/dist/src/http-client.d.ts +9 -0
  71. package/dist/src/http-client.d.ts.map +1 -0
  72. package/dist/src/inbound-handler.d.ts +9 -0
  73. package/dist/src/inbound-handler.d.ts.map +1 -0
  74. package/dist/src/learning-command-service.d.ts +64 -0
  75. package/dist/src/learning-command-service.d.ts.map +1 -0
  76. package/dist/src/logger-context.d.ts +10 -0
  77. package/dist/src/logger-context.d.ts.map +1 -0
  78. package/dist/src/media-utils.d.ts +63 -0
  79. package/dist/src/media-utils.d.ts.map +1 -0
  80. package/dist/src/message-context-store.d.ts +126 -0
  81. package/dist/src/message-context-store.d.ts.map +1 -0
  82. package/dist/src/message-utils.d.ts +12 -0
  83. package/dist/src/message-utils.d.ts.map +1 -0
  84. package/dist/src/messaging/attachment-text-extractor.d.ts +13 -0
  85. package/dist/src/messaging/attachment-text-extractor.d.ts.map +1 -0
  86. package/dist/src/messaging/btw-deliver.d.ts +42 -0
  87. package/dist/src/messaging/btw-deliver.d.ts.map +1 -0
  88. package/dist/src/messaging/channel-actions.d.ts +3 -0
  89. package/dist/src/messaging/channel-actions.d.ts.map +1 -0
  90. package/dist/src/messaging/channel-outbound.d.ts +3 -0
  91. package/dist/src/messaging/channel-outbound.d.ts.map +1 -0
  92. package/dist/src/messaging/quoted-context.d.ts +36 -0
  93. package/dist/src/messaging/quoted-context.d.ts.map +1 -0
  94. package/dist/src/messaging/quoted-file-service.d.ts +25 -0
  95. package/dist/src/messaging/quoted-file-service.d.ts.map +1 -0
  96. package/dist/src/messaging/quoted-ref.d.ts +12 -0
  97. package/dist/src/messaging/quoted-ref.d.ts.map +1 -0
  98. package/dist/src/onboarding.d.ts +4 -0
  99. package/dist/src/onboarding.d.ts.map +1 -0
  100. package/dist/src/path-utils.d.ts +3 -0
  101. package/dist/src/path-utils.d.ts.map +1 -0
  102. package/dist/src/peer-id-registry.d.ts +35 -0
  103. package/dist/src/peer-id-registry.d.ts.map +1 -0
  104. package/dist/src/persistence-store.d.ts +27 -0
  105. package/dist/src/persistence-store.d.ts.map +1 -0
  106. package/dist/src/platform/channel-status.d.ts +3 -0
  107. package/dist/src/platform/channel-status.d.ts.map +1 -0
  108. package/dist/src/plugin-sdk-channel-actions-augment.d.ts +7 -0
  109. package/dist/src/plugin-sdk-channel-actions-augment.d.ts.map +1 -0
  110. package/dist/src/proactive-risk-registry.d.ts +22 -0
  111. package/dist/src/proactive-risk-registry.d.ts.map +1 -0
  112. package/dist/src/reply-strategy-card.d.ts +14 -0
  113. package/dist/src/reply-strategy-card.d.ts.map +1 -0
  114. package/dist/src/reply-strategy-markdown.d.ts +10 -0
  115. package/dist/src/reply-strategy-markdown.d.ts.map +1 -0
  116. package/dist/src/reply-strategy-types.d.ts +85 -0
  117. package/dist/src/reply-strategy-types.d.ts.map +1 -0
  118. package/dist/src/reply-strategy-with-reaction.d.ts +47 -0
  119. package/dist/src/reply-strategy-with-reaction.d.ts.map +1 -0
  120. package/dist/src/reply-strategy.d.ts +14 -0
  121. package/dist/src/reply-strategy.d.ts.map +1 -0
  122. package/dist/src/run-usage-store.d.ts +15 -0
  123. package/dist/src/run-usage-store.d.ts.map +1 -0
  124. package/dist/src/runtime.d.ts +4 -0
  125. package/dist/src/runtime.d.ts.map +1 -0
  126. package/dist/src/secret-input.d.ts +42 -0
  127. package/dist/src/secret-input.d.ts.map +1 -0
  128. package/dist/src/send-service.d.ts +46 -0
  129. package/dist/src/send-service.d.ts.map +1 -0
  130. package/dist/src/session-command-service.d.ts +35 -0
  131. package/dist/src/session-command-service.d.ts.map +1 -0
  132. package/dist/src/session-lock.d.ts +11 -0
  133. package/dist/src/session-lock.d.ts.map +1 -0
  134. package/dist/src/session-peer-store.d.ts +21 -0
  135. package/dist/src/session-peer-store.d.ts.map +1 -0
  136. package/dist/src/session-routing.d.ts +14 -0
  137. package/dist/src/session-routing.d.ts.map +1 -0
  138. package/dist/src/session-state.d.ts +13 -0
  139. package/dist/src/session-state.d.ts.map +1 -0
  140. package/dist/src/signature.d.ts +6 -0
  141. package/dist/src/signature.d.ts.map +1 -0
  142. package/dist/src/targeting/agent-name-matcher.d.ts +63 -0
  143. package/dist/src/targeting/agent-name-matcher.d.ts.map +1 -0
  144. package/dist/src/targeting/agent-routing.d.ts +72 -0
  145. package/dist/src/targeting/agent-routing.d.ts.map +1 -0
  146. package/dist/src/targeting/group-members-store.d.ts +3 -0
  147. package/dist/src/targeting/group-members-store.d.ts.map +1 -0
  148. package/dist/src/targeting/target-directory-adapter.d.ts +13 -0
  149. package/dist/src/targeting/target-directory-adapter.d.ts.map +1 -0
  150. package/dist/src/targeting/target-directory-store.d.ts +45 -0
  151. package/dist/src/targeting/target-directory-store.d.ts.map +1 -0
  152. package/dist/src/targeting/target-input.d.ts +3 -0
  153. package/dist/src/targeting/target-input.d.ts.map +1 -0
  154. package/dist/src/types.d.ts +730 -0
  155. package/dist/src/types.d.ts.map +1 -0
  156. package/dist/src/utils.d.ts +54 -0
  157. package/dist/src/utils.d.ts.map +1 -0
  158. package/openclaw.plugin.json +49 -5
  159. package/package.json +26 -5
  160. package/src/auth.ts +5 -2
  161. package/src/card-service.ts +41 -16
  162. package/src/channel.ts +5 -2
  163. package/src/config-schema.ts +2 -1
  164. package/src/config.ts +24 -67
  165. package/src/device-registration.ts +0 -32
  166. package/src/gateway/channel-gateway.ts +9 -8
  167. package/src/inbound-handler.ts +1239 -1061
  168. package/src/message-context-store.ts +183 -85
  169. package/src/messaging/channel-actions.ts +2 -1
  170. package/src/onboarding.ts +18 -13
  171. package/src/path-utils.ts +49 -0
  172. package/src/secret-input.ts +182 -0
  173. package/src/targeting/agent-routing.ts +30 -5
  174. package/src/types.ts +25 -10
@@ -1,30 +1,24 @@
1
1
  import fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { isAbortRequestText, isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
4
- import axios from "./http-client";
4
+ import { parseInlineDirectives } from "openclaw/plugin-sdk/text-runtime";
5
5
  import { normalizeAllowFrom, isSenderAllowed, resolveGroupAccess } from "./access-control";
6
- import { buildAgentSessionKey, resolveSubAgentRoute, dispatchSubAgents } from "./targeting/agent-routing";
7
- import { getAgentDisplayName } from "./targeting/agent-name-matcher";
8
6
  import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
9
7
  import { attachNativeAckReaction } from "./ack-reaction-service";
10
8
  import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
11
- import { extractAttachmentText } from "./messaging/attachment-text-extractor";
12
9
  import { getAccessToken } from "./auth";
13
10
  import { createAICard, commitAICardBlocks, isCardInTerminalState } from "./card-service";
11
+ import { isCardRunStopRequested, registerCardRun, removeCardRun } from "./card/card-run-registry";
14
12
  import { renderStatusLine } from "./card/statusline-renderer";
15
13
  import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
16
- import { resolveAckReactionSetting, resolveGroupConfig, resolveRelativePath, resolveRobotCode } from "./config";
17
- import { AICardStatus } from "./types";
18
- import {
19
- isCardRunStopRequested,
20
- registerCardRun,
21
- removeCardRun,
22
- } from "./card/card-run-registry";
23
14
  import {
24
- buildLearningContextBlock,
25
- isLearningEnabled,
26
- } from "./feedback-learning-service";
27
- import { formatGroupMembers, noteGroupMember } from "./targeting/group-members-store";
15
+ resolveAckReactionSetting,
16
+ resolveGroupConfig,
17
+ resolveRelativePath,
18
+ resolveRobotCode,
19
+ } from "./config";
20
+ import { buildLearningContextBlock, isLearningEnabled } from "./feedback-learning-service";
21
+ import axios from "./http-client";
28
22
  import { setCurrentLogger } from "./logger-context";
29
23
  import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
30
24
  import {
@@ -33,8 +27,14 @@ import {
33
27
  upsertInboundMessageContext,
34
28
  } from "./message-context-store";
35
29
  import { extractMessageContent } from "./message-utils";
30
+ import { extractAttachmentText } from "./messaging/attachment-text-extractor";
36
31
  import { deliverBtwReply, stripLeadingMentions } from "./messaging/btw-deliver";
37
32
  import { resolveQuotedRuntimeContext } from "./messaging/quoted-context";
33
+ import {
34
+ downloadGroupFile,
35
+ getUnionIdByStaffId,
36
+ resolveQuotedFile,
37
+ } from "./messaging/quoted-file-service";
38
38
  import {
39
39
  buildInboundQuotedRef,
40
40
  createReplyQuotedRef,
@@ -45,7 +45,6 @@ import {
45
45
  clearProactiveRiskObservationsForTest,
46
46
  getProactiveRiskObservationForAny,
47
47
  } from "./proactive-risk-registry";
48
- import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./messaging/quoted-file-service";
49
48
  import { createReplyStrategy } from "./reply-strategy";
50
49
  import type { DeliverPayload } from "./reply-strategy-types";
51
50
  import { getDingTalkRuntime } from "./runtime";
@@ -58,13 +57,26 @@ import {
58
57
  } from "./session-peer-store";
59
58
  import { resolveDingTalkSessionPeer } from "./session-routing";
60
59
  import { getSessionState, initSessionState } from "./session-state";
60
+ import { getAgentDisplayName } from "./targeting/agent-name-matcher";
61
+ import {
62
+ buildAgentSessionKey,
63
+ resolveSubAgentRoute,
64
+ dispatchSubAgents,
65
+ } from "./targeting/agent-routing";
66
+ import { formatGroupMembers, noteGroupMember } from "./targeting/group-members-store";
61
67
  import {
62
68
  upsertObservedGroupTarget,
63
69
  upsertObservedUserTarget,
64
70
  } from "./targeting/target-directory-store";
71
+ import { AICardStatus } from "./types";
65
72
  import type { DingTalkConfig, HandleDingTalkMessageParams, Logger, MediaFile } from "./types";
66
- import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData, parseBooleanLike } from "./utils";
67
- import { parseInlineDirectives } from "openclaw/plugin-sdk/text-runtime";
73
+ import {
74
+ formatDingTalkErrorPayloadLog,
75
+ getErrorMessage,
76
+ getErrorResponseData,
77
+ maskSensitiveData,
78
+ parseBooleanLike,
79
+ } from "./utils";
68
80
 
69
81
  const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
70
82
  const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
@@ -95,10 +107,19 @@ const STANDALONE_MEDIA_PATH_EXTENSIONS = new Set([
95
107
  ".rar",
96
108
  ]);
97
109
  const proactiveHintLastSentAt = new Map<string, number>();
98
- const sessionReasoningLevelCache = new Map<string, {
99
- updatedAt?: number;
100
- reasoningLevel?: string;
101
- }>();
110
+ const sessionReasoningLevelCache = new Map<
111
+ string,
112
+ {
113
+ updatedAt?: number;
114
+ reasoningLevel?: string;
115
+ }
116
+ >();
117
+ // Prevent concurrent card creation for the same conversation.
118
+ // createAICard runs before acquireSessionLock (for immediate visual feedback),
119
+ // but two inbound messages can race past each other's card-run registration.
120
+ // This synchronous Set closes that window because JavaScript check-and-set here
121
+ // is atomic from the caller's perspective.
122
+ const cardCreationInFlight = new Set<string>();
102
123
  type ReplyMode = "card" | "markdown";
103
124
 
104
125
  function resolveQuotedContextAllowFrom(
@@ -133,7 +154,15 @@ function filterQuotedRuntimeContext(params: {
133
154
  currentSenderId: string;
134
155
  currentSenderOriginalId: string;
135
156
  }): ReturnType<typeof resolveQuotedRuntimeContext> {
136
- const { context, config, isDirect, groupId, quotedSenderId, currentSenderId, currentSenderOriginalId } = params;
157
+ const {
158
+ context,
159
+ config,
160
+ isDirect,
161
+ groupId,
162
+ quotedSenderId,
163
+ currentSenderId,
164
+ currentSenderOriginalId,
165
+ } = params;
137
166
  if (!context || isDirect) {
138
167
  return context;
139
168
  }
@@ -150,9 +179,7 @@ function filterQuotedRuntimeContext(params: {
150
179
  currentSenderOriginalId,
151
180
  });
152
181
  const senderAllowed =
153
- allow.hasEntries && !!senderId
154
- ? isSenderAllowed({ allow, senderId })
155
- : false;
182
+ allow.hasEntries && !!senderId ? isSenderAllowed({ allow, senderId }) : false;
156
183
 
157
184
  if (senderAllowed) {
158
185
  return context;
@@ -178,9 +205,9 @@ function readSessionReasoningLevel(params: {
178
205
  const cacheKey = `${params.storePath}:${params.sessionKey}`;
179
206
  const cached = sessionReasoningLevelCache.get(cacheKey);
180
207
  if (
181
- cached
182
- && params.sessionUpdatedAt !== undefined
183
- && cached.updatedAt === params.sessionUpdatedAt
208
+ cached &&
209
+ params.sessionUpdatedAt !== undefined &&
210
+ cached.updatedAt === params.sessionUpdatedAt
184
211
  ) {
185
212
  return cached.reasoningLevel;
186
213
  }
@@ -520,7 +547,16 @@ export async function downloadMedia(
520
547
  }
521
548
 
522
549
  export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
523
- const { cfg, accountId, data, sessionWebhook, log, dingtalkConfig, subAgentOptions, preDownloadedMedia } = params;
550
+ const {
551
+ cfg,
552
+ accountId,
553
+ data,
554
+ sessionWebhook,
555
+ log,
556
+ dingtalkConfig,
557
+ subAgentOptions,
558
+ preDownloadedMedia,
559
+ } = params;
524
560
  const rt = getDingTalkRuntime();
525
561
 
526
562
  // Save logger globally so shared services can log consistently without threading log everywhere.
@@ -649,31 +685,32 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
649
685
  if (groupAccess.legacyFallback) {
650
686
  log?.info?.(
651
687
  `[DingTalk] DEPRECATED: groupPolicy=allowlist is using "allowFrom" for group access control. ` +
652
- `Please migrate to "groups" (group ID allowlist) or "groupAllowFrom" (sender allowlist).`,
688
+ `Please migrate to "groups" (group ID allowlist) or "groupAllowFrom" (sender allowlist).`,
653
689
  );
654
690
  }
655
691
 
656
692
  if (!groupAccess.allowed) {
657
693
  if (groupAccess.reason === "disabled") {
658
- log?.debug?.(`[DingTalk] Group disabled: all group messages dropped (groupPolicy=disabled)`);
694
+ log?.debug?.(
695
+ `[DingTalk] Group disabled: all group messages dropped (groupPolicy=disabled)`,
696
+ );
659
697
  return;
660
698
  }
661
699
 
662
- const denyMessage = groupAccess.reason === "sender_not_allowed"
663
- ? `⛔ 访问受限\n\n您的用户ID:\`${senderId}\`\n\n请联系管理员将此ID添加到群聊允许列表中。`
664
- : `⛔ 访问受限\n\n您的群聊ID:\`${groupId}\`\n\n请联系管理员将此ID添加到允许列表中。`;
700
+ const denyMessage =
701
+ groupAccess.reason === "sender_not_allowed"
702
+ ? `⛔ 访问受限\n\n您的用户ID:\`${senderId}\`\n\n请联系管理员将此ID添加到群聊允许列表中。`
703
+ : `⛔ 访问受限\n\n您的群聊ID:\`${groupId}\`\n\n请联系管理员将此ID添加到允许列表中。`;
665
704
 
666
705
  log?.debug?.(
667
706
  `[DingTalk] Group blocked: conversationId=${groupId} senderId=${senderId} reason=${groupAccess.reason}`,
668
707
  );
669
708
 
670
709
  try {
671
- await sendBySession(
672
- dingtalkConfig,
673
- sessionWebhook,
674
- denyMessage,
675
- { log, atUserId: senderId },
676
- );
710
+ await sendBySession(dingtalkConfig, sessionWebhook, denyMessage, {
711
+ log,
712
+ atUserId: senderId,
713
+ });
677
714
  } catch (err: any) {
678
715
  log?.debug?.(`[DingTalk] Failed to send group access denied message: ${err.message}`);
679
716
  if (err?.response?.data !== undefined) {
@@ -686,9 +723,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
686
723
  return;
687
724
  }
688
725
 
689
- log?.debug?.(
690
- `[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId}`,
691
- );
726
+ log?.debug?.(`[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId}`);
692
727
  }
693
728
 
694
729
  // Calculate account store path and session peer (for session alias feature)
@@ -826,15 +861,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
826
861
  const isBtwBypass = isBtwRequestText(stripLeadingMentions(content.text).trim());
827
862
  const taskInfoConversationId = groupId || to;
828
863
  const sessionTaskState = initSessionState(accountId, taskInfoConversationId);
829
- const initialStatusLine = renderStatusLine({
830
- model: sessionTaskState.model,
831
- effort: sessionTaskState.effort,
832
- agent: getAgentDisplayName({
833
- subAgentOptions,
834
- agentId: route.agentId,
835
- agentsList: cfg.agents?.list,
836
- }),
837
- }, dingtalkConfig) || undefined;
864
+ const initialStatusLine =
865
+ renderStatusLine(
866
+ {
867
+ model: sessionTaskState.model,
868
+ effort: sessionTaskState.effort,
869
+ agent: getAgentDisplayName({
870
+ subAgentOptions,
871
+ agentId: route.agentId,
872
+ agentsList: cfg.agents?.list,
873
+ }),
874
+ },
875
+ dingtalkConfig,
876
+ ) || undefined;
838
877
 
839
878
  // 3) Select response mode (card vs markdown).
840
879
  // Card creation runs BEFORE media download so the user sees immediate visual
@@ -847,6 +886,20 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
847
886
  let useCardMode = dingtalkConfig.messageType === "card";
848
887
  let currentAICard: import("./types").AICardInstance | undefined;
849
888
 
889
+ let cardFlightKey: string | undefined;
890
+ if (useCardMode && !isBtwBypass) {
891
+ const key = `${accountId}:${to}`;
892
+ if (cardCreationInFlight.has(key)) {
893
+ useCardMode = false;
894
+ log?.debug?.(
895
+ `[DingTalk][AICard] Skip card creation - active card already exists for account=${accountId} conversation=${to}`,
896
+ );
897
+ } else {
898
+ cardCreationInFlight.add(key);
899
+ cardFlightKey = key;
900
+ }
901
+ }
902
+
850
903
  if (useCardMode && !isBtwBypass) {
851
904
  try {
852
905
  log?.debug?.(
@@ -877,1149 +930,1274 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
877
930
  }
878
931
  } else {
879
932
  useCardMode = false;
933
+ if (cardFlightKey) {
934
+ cardCreationInFlight.delete(cardFlightKey);
935
+ cardFlightKey = undefined;
936
+ }
880
937
  log?.warn?.(
881
938
  "[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.",
882
939
  );
883
940
  }
884
941
  } catch (err: any) {
885
942
  useCardMode = false;
943
+ if (cardFlightKey) {
944
+ cardCreationInFlight.delete(cardFlightKey);
945
+ cardFlightKey = undefined;
946
+ }
886
947
  log?.warn?.(
887
948
  `[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`,
888
949
  );
889
950
  }
890
951
  }
891
- const hasLegacyQuoteContent =
892
- typeof data.content?.quoteContent === "string" && data.content.quoteContent.trim().length > 0;
893
-
894
- if (hasLegacyQuoteContent && !quotedRef) {
895
- log?.debug?.(
896
- `[DingTalk] Legacy quoteContent present without resolvable quotedRef: ` +
897
- `conversationType=${data.conversationType} conversationId=${data.conversationId} ` +
898
- `msgId=${data.msgId} originalMsgId=${data.originalMsgId || "(none)"}`,
899
- );
900
- }
901
- if (quotedRef) {
902
- log?.debug?.(
903
- `[DingTalk][QuotedRef] Built inbound quotedRef msgId=${data.msgId} scope=${groupId} ` +
904
- `quotedRef=${JSON.stringify(quotedRef)}`,
905
- );
906
- } else if (
907
- data.text?.isReplyMsg ||
908
- data.originalMsgId ||
909
- data.originalProcessQueryKey ||
910
- content.quoted
911
- ) {
912
- log?.debug?.(
913
- `[DingTalk][QuotedRef] Reply metadata present without resolvable quotedRef ` +
914
- `msgId=${data.msgId} scope=${groupId} originalMsgId=${data.originalMsgId || "(none)"} ` +
915
- `originalProcessQueryKey=${data.originalProcessQueryKey || "(none)"}`,
916
- );
917
- }
918
-
952
+ // Outer try/finally guarantees cardFlightKey cleanup even when the handler
953
+ // returns or throws between card creation and session-lock release.
919
954
  try {
920
- upsertInboundMessageContext({
921
- storePath: accountStorePath,
922
- accountId,
923
- conversationId: groupId,
924
- msgId: data.msgId,
925
- messageType: content.messageType,
926
- text: content.text,
927
- quotedRef,
928
- senderId,
929
- senderName,
930
- createdAt: data.createAt,
931
- ttlMs: ttlDaysToMs(journalTTLDays),
932
- ttlReferenceMs: data.createAt,
933
- cleanupCreatedAtTtlDays: journalTTLDays,
934
- topic: null,
935
- });
936
- } catch (err) {
937
- log?.warn?.(`[DingTalk] Message context inbound append failed: ${String(err)}`);
938
- }
955
+ const hasLegacyQuoteContent =
956
+ typeof data.content?.quoteContent === "string" && data.content.quoteContent.trim().length > 0;
939
957
 
940
- const robotCode = resolveRobotCode(dingtalkConfig);
941
- let mediaPath: string | undefined;
942
- let mediaType: string | undefined;
943
- let attachmentContextMsgId = data.msgId;
944
- let attachmentContextCreatedAt = data.createAt;
945
- let attachmentContextMessageType = content.messageType;
946
- let attachmentContextFileName = data.content?.fileName;
947
-
948
- // Use pre-downloaded media if available (from sub-agent outer call)
949
- if (preDownloadedMedia?.mediaPath) {
950
- mediaPath = preDownloadedMedia.mediaPath;
951
- mediaType = preDownloadedMedia.mediaType;
952
- } else if (content.mediaPath && robotCode) {
953
- // Download media only if not pre-downloaded
954
- const media = await downloadMedia(
955
- dingtalkConfig,
956
- content.mediaPath,
957
- log,
958
- attachmentContextFileName,
959
- );
960
- if (media) {
961
- mediaPath = media.path;
962
- mediaType = media.mimeType;
958
+ if (hasLegacyQuoteContent && !quotedRef) {
959
+ log?.debug?.(
960
+ `[DingTalk] Legacy quoteContent present without resolvable quotedRef: ` +
961
+ `conversationType=${data.conversationType} conversationId=${data.conversationId} ` +
962
+ `msgId=${data.msgId} originalMsgId=${data.originalMsgId || "(none)"}`,
963
+ );
964
+ }
965
+ if (quotedRef) {
966
+ log?.debug?.(
967
+ `[DingTalk][QuotedRef] Built inbound quotedRef msgId=${data.msgId} scope=${groupId} ` +
968
+ `quotedRef=${JSON.stringify(quotedRef)}`,
969
+ );
970
+ } else if (
971
+ data.text?.isReplyMsg ||
972
+ data.originalMsgId ||
973
+ data.originalProcessQueryKey ||
974
+ content.quoted
975
+ ) {
976
+ log?.debug?.(
977
+ `[DingTalk][QuotedRef] Reply metadata present without resolvable quotedRef ` +
978
+ `msgId=${data.msgId} scope=${groupId} originalMsgId=${data.originalMsgId || "(none)"} ` +
979
+ `originalProcessQueryKey=${data.originalProcessQueryKey || "(none)"}`,
980
+ );
963
981
  }
964
- }
965
-
966
- // Cache downloadCode (+ spaceId/fileId) for quoted file lookups (DM + group).
967
- if (content.mediaPath && data.msgId) {
968
- upsertInboundMessageContext({
969
- storePath: accountStorePath,
970
- accountId,
971
- conversationId: data.conversationId,
972
- msgId: data.msgId,
973
- createdAt: data.createAt,
974
- messageType: content.messageType,
975
- media: {
976
- downloadCode: content.mediaPath,
977
- spaceId: data.content?.spaceId,
978
- fileId: data.content?.fileId,
979
- },
980
- attachmentFileName: attachmentContextFileName,
981
- ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
982
- topic: null,
983
- });
984
- }
985
982
 
986
- // User-sent DingTalk doc / Drive file card: cache msgId -> {spaceId,fileId}
987
- // during the original message turn, and try downloading immediately in DM.
988
- if (
989
- content.messageType === "interactiveCardFile" &&
990
- data.msgId &&
991
- content.docSpaceId &&
992
- content.docFileId
993
- ) {
994
- upsertInboundMessageContext({
995
- storePath: accountStorePath,
996
- accountId,
997
- conversationId: data.conversationId,
998
- msgId: data.msgId,
999
- createdAt: data.createAt,
1000
- messageType: content.messageType,
1001
- media: {
1002
- spaceId: content.docSpaceId,
1003
- fileId: content.docFileId,
1004
- },
1005
- attachmentFileName: attachmentContextFileName,
1006
- ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
1007
- topic: null,
1008
- });
983
+ try {
984
+ upsertInboundMessageContext({
985
+ storePath: accountStorePath,
986
+ accountId,
987
+ conversationId: groupId,
988
+ msgId: data.msgId,
989
+ messageType: content.messageType,
990
+ text: content.text,
991
+ quotedRef,
992
+ senderId,
993
+ senderName,
994
+ createdAt: data.createAt,
995
+ ttlMs: ttlDaysToMs(journalTTLDays),
996
+ ttlReferenceMs: data.createAt,
997
+ cleanupCreatedAtTtlDays: journalTTLDays,
998
+ topic: null,
999
+ });
1000
+ } catch (err) {
1001
+ log?.warn?.(`[DingTalk] Message context inbound append failed: ${String(err)}`);
1002
+ }
1009
1003
 
1010
- if (!mediaPath && isDirect && data.senderStaffId) {
1011
- try {
1012
- const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
1013
- const docMedia = await downloadGroupFile(
1004
+ const robotCode = resolveRobotCode(dingtalkConfig);
1005
+ let mediaPath: string | undefined;
1006
+ let mediaType: string | undefined;
1007
+ const mediaPaths: string[] = [];
1008
+ const mediaTypes: string[] = [];
1009
+ let attachmentContextMsgId = data.msgId;
1010
+ let attachmentContextCreatedAt = data.createAt;
1011
+ let attachmentContextMessageType = content.messageType;
1012
+ let attachmentContextFileName = data.content?.fileName;
1013
+
1014
+ // Use pre-downloaded media if available (from sub-agent outer call)
1015
+ if (preDownloadedMedia?.mediaPath) {
1016
+ mediaPath = preDownloadedMedia.mediaPath;
1017
+ mediaType = preDownloadedMedia.mediaType;
1018
+ if (preDownloadedMedia.mediaPaths?.length) {
1019
+ mediaPaths.push(...preDownloadedMedia.mediaPaths);
1020
+ for (let i = 0; i < preDownloadedMedia.mediaPaths.length; i++) {
1021
+ mediaTypes.push(
1022
+ preDownloadedMedia.mediaTypes?.[i] || preDownloadedMedia.mediaType || "file",
1023
+ );
1024
+ }
1025
+ } else {
1026
+ mediaPaths.push(mediaPath);
1027
+ mediaTypes.push(mediaType || "file");
1028
+ }
1029
+ } else if (robotCode) {
1030
+ // Download all media attachments (richText may carry multiple images).
1031
+ const downloadCodes =
1032
+ content.mediaPaths && content.mediaPaths.length > 0
1033
+ ? content.mediaPaths
1034
+ : content.mediaPath
1035
+ ? [content.mediaPath]
1036
+ : [];
1037
+ for (const downloadCode of downloadCodes) {
1038
+ const media = await downloadMedia(
1014
1039
  dingtalkConfig,
1015
- content.docSpaceId,
1016
- content.docFileId,
1017
- unionId,
1040
+ downloadCode,
1018
1041
  log,
1019
1042
  attachmentContextFileName,
1020
1043
  );
1021
- if (docMedia) {
1022
- mediaPath = docMedia.path;
1023
- mediaType = docMedia.mimeType;
1044
+ if (media) {
1045
+ if (!mediaPath) {
1046
+ mediaPath = media.path;
1047
+ mediaType = media.mimeType;
1048
+ }
1049
+ mediaPaths.push(media.path);
1050
+ mediaTypes.push(media.mimeType);
1024
1051
  }
1025
- } catch (err: any) {
1026
- log?.warn?.(`[DingTalk] Doc card download failed: ${err.message}`);
1027
1052
  }
1028
1053
  }
1029
- }
1030
1054
 
1031
- const quotedRecord = resolveQuotedRecord({
1032
- storePath: accountStorePath,
1033
- accountId,
1034
- conversationId: data.conversationId,
1035
- quotedRef,
1036
- log,
1037
- });
1038
- const quotedRuntimeContext = filterQuotedRuntimeContext({
1039
- context: resolveQuotedRuntimeContext({
1055
+ // Cache downloadCode(s) (+ spaceId/fileId) for quoted file lookups (DM + group).
1056
+ const allMediaDownloadCodes =
1057
+ content.mediaPaths && content.mediaPaths.length > 0
1058
+ ? content.mediaPaths
1059
+ : content.mediaPath
1060
+ ? [content.mediaPath]
1061
+ : [];
1062
+ if (allMediaDownloadCodes.length > 0 && data.msgId) {
1063
+ upsertInboundMessageContext({
1064
+ storePath: accountStorePath,
1065
+ accountId,
1066
+ conversationId: data.conversationId,
1067
+ msgId: data.msgId,
1068
+ createdAt: data.createAt,
1069
+ messageType: content.messageType,
1070
+ media: {
1071
+ downloadCode: allMediaDownloadCodes[0],
1072
+ downloadCodes: allMediaDownloadCodes.length > 1 ? allMediaDownloadCodes : undefined,
1073
+ spaceId: data.content?.spaceId,
1074
+ fileId: data.content?.fileId,
1075
+ },
1076
+ attachmentFileName: attachmentContextFileName,
1077
+ ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
1078
+ topic: null,
1079
+ });
1080
+ }
1081
+
1082
+ // User-sent DingTalk doc / Drive file card: cache msgId -> {spaceId,fileId}
1083
+ // during the original message turn, and try downloading immediately in DM.
1084
+ if (
1085
+ content.messageType === "interactiveCardFile" &&
1086
+ data.msgId &&
1087
+ content.docSpaceId &&
1088
+ content.docFileId
1089
+ ) {
1090
+ upsertInboundMessageContext({
1091
+ storePath: accountStorePath,
1092
+ accountId,
1093
+ conversationId: data.conversationId,
1094
+ msgId: data.msgId,
1095
+ createdAt: data.createAt,
1096
+ messageType: content.messageType,
1097
+ media: {
1098
+ spaceId: content.docSpaceId,
1099
+ fileId: content.docFileId,
1100
+ },
1101
+ attachmentFileName: attachmentContextFileName,
1102
+ ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
1103
+ topic: null,
1104
+ });
1105
+
1106
+ if (!mediaPath && isDirect && data.senderStaffId) {
1107
+ try {
1108
+ const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
1109
+ const docMedia = await downloadGroupFile(
1110
+ dingtalkConfig,
1111
+ content.docSpaceId,
1112
+ content.docFileId,
1113
+ unionId,
1114
+ log,
1115
+ attachmentContextFileName,
1116
+ );
1117
+ if (docMedia) {
1118
+ mediaPath = docMedia.path;
1119
+ mediaType = docMedia.mimeType;
1120
+ mediaPaths.push(docMedia.path);
1121
+ mediaTypes.push(docMedia.mimeType);
1122
+ }
1123
+ } catch (err: any) {
1124
+ log?.warn?.(`[DingTalk] Doc card download failed: ${err.message}`);
1125
+ }
1126
+ }
1127
+ }
1128
+
1129
+ const quotedRecord = resolveQuotedRecord({
1040
1130
  storePath: accountStorePath,
1041
1131
  accountId,
1042
1132
  conversationId: data.conversationId,
1043
1133
  quotedRef,
1044
- firstRecord: quotedRecord,
1045
- firstPreview:
1046
- content.quoted?.previewText ||
1047
- content.quoted?.previewMessageType
1048
- ? {
1049
- text: content.quoted.previewText,
1050
- messageType: content.quoted.previewMessageType,
1051
- senderId: content.quoted.previewSenderId,
1052
- }
1053
- : undefined,
1054
1134
  log,
1055
- }),
1056
- config: dingtalkConfig,
1057
- isDirect,
1058
- groupId,
1059
- quotedSenderId: quotedRecord?.senderId || content.quoted?.previewSenderId,
1060
- currentSenderId: senderId,
1061
- currentSenderOriginalId: senderOriginalId,
1062
- });
1135
+ });
1136
+ const quotedRuntimeContext = filterQuotedRuntimeContext({
1137
+ context: resolveQuotedRuntimeContext({
1138
+ storePath: accountStorePath,
1139
+ accountId,
1140
+ conversationId: data.conversationId,
1141
+ quotedRef,
1142
+ firstRecord: quotedRecord,
1143
+ firstPreview:
1144
+ content.quoted?.previewText || content.quoted?.previewMessageType
1145
+ ? {
1146
+ text: content.quoted.previewText,
1147
+ messageType: content.quoted.previewMessageType,
1148
+ senderId: content.quoted.previewSenderId,
1149
+ }
1150
+ : undefined,
1151
+ log,
1152
+ }),
1153
+ config: dingtalkConfig,
1154
+ isDirect,
1155
+ groupId,
1156
+ quotedSenderId: quotedRecord?.senderId || content.quoted?.previewSenderId,
1157
+ currentSenderId: senderId,
1158
+ currentSenderOriginalId: senderOriginalId,
1159
+ });
1063
1160
 
1064
- // Try downloading a quoted file from cached downloadCode/spaceId+fileId.
1065
- const tryDownloadFromRecord = async (
1066
- record: {
1067
- msgId?: string;
1068
- media?: {
1069
- downloadCode?: string;
1070
- spaceId?: string;
1071
- fileId?: string;
1072
- };
1073
- } | null,
1074
- originalFilename?: string,
1075
- ): Promise<MediaFile | null> => {
1076
- if (!record?.media) {
1077
- return null;
1078
- }
1079
- let media: MediaFile | null = null;
1080
- if (record.media.downloadCode) {
1081
- media = await downloadMedia(dingtalkConfig, record.media.downloadCode, log, originalFilename);
1082
- if (media) {
1083
- log?.debug?.(
1084
- `[DingTalk][QuotedRef] Recovered quoted media from cached downloadCode ` +
1085
- `recordMsgId=${record.msgId || "(none)"} scope=${data.conversationId}`,
1086
- );
1161
+ // Try downloading a quoted file from cached downloadCode/spaceId+fileId.
1162
+ const tryDownloadFromRecord = async (
1163
+ record: {
1164
+ msgId?: string;
1165
+ media?: {
1166
+ downloadCode?: string;
1167
+ spaceId?: string;
1168
+ fileId?: string;
1169
+ };
1170
+ } | null,
1171
+ originalFilename?: string,
1172
+ ): Promise<MediaFile | null> => {
1173
+ if (!record?.media) {
1174
+ return null;
1087
1175
  }
1088
- }
1089
- if (!media && record.media.spaceId && record.media.fileId && data.senderStaffId) {
1090
- try {
1091
- const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
1092
- media = await downloadGroupFile(
1176
+ let media: MediaFile | null = null;
1177
+ if (record.media.downloadCode) {
1178
+ media = await downloadMedia(
1093
1179
  dingtalkConfig,
1094
- record.media.spaceId,
1095
- record.media.fileId,
1096
- unionId,
1180
+ record.media.downloadCode,
1097
1181
  log,
1098
1182
  originalFilename,
1099
1183
  );
1100
1184
  if (media) {
1101
1185
  log?.debug?.(
1102
- `[DingTalk][QuotedRef] Recovered quoted media from cached spaceId/fileId ` +
1186
+ `[DingTalk][QuotedRef] Recovered quoted media from cached downloadCode ` +
1103
1187
  `recordMsgId=${record.msgId || "(none)"} scope=${data.conversationId}`,
1104
1188
  );
1105
1189
  }
1106
- } catch (err: any) {
1107
- log?.warn?.(`[DingTalk] spaceId+fileId fallback failed: ${err.message}`);
1108
1190
  }
1109
- }
1110
- return media;
1111
- };
1191
+ if (!media && record.media.spaceId && record.media.fileId && data.senderStaffId) {
1192
+ try {
1193
+ const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
1194
+ media = await downloadGroupFile(
1195
+ dingtalkConfig,
1196
+ record.media.spaceId,
1197
+ record.media.fileId,
1198
+ unionId,
1199
+ log,
1200
+ originalFilename,
1201
+ );
1202
+ if (media) {
1203
+ log?.debug?.(
1204
+ `[DingTalk][QuotedRef] Recovered quoted media from cached spaceId/fileId ` +
1205
+ `recordMsgId=${record.msgId || "(none)"} scope=${data.conversationId}`,
1206
+ );
1207
+ }
1208
+ } catch (err: any) {
1209
+ log?.warn?.(`[DingTalk] spaceId+fileId fallback failed: ${err.message}`);
1210
+ }
1211
+ }
1212
+ return media;
1213
+ };
1112
1214
 
1113
- // Quoted picture: download via existing downloadMedia.
1114
- if (!mediaPath && content.quoted?.mediaDownloadCode && robotCode) {
1115
- const quotedOriginalFilename = content.quoted.previewFileName;
1116
- const media =
1117
- (await tryDownloadFromRecord(quotedRecord, quotedOriginalFilename)) ||
1118
- (await downloadMedia(
1119
- dingtalkConfig,
1120
- content.quoted.mediaDownloadCode,
1121
- log,
1122
- quotedOriginalFilename,
1123
- ));
1124
- if (media) {
1125
- if (!quotedRecord) {
1215
+ // Quoted multi-image richText: recover all cached downloadCodes.
1216
+ // Placed before the single-image/ file/ doc-card paths so that the
1217
+ // !mediaPath guard prevents those paths from downloading only the first image.
1218
+ if (
1219
+ !mediaPath &&
1220
+ quotedRecord?.media?.downloadCodes &&
1221
+ quotedRecord.media.downloadCodes.length > 1
1222
+ ) {
1223
+ const recovered: MediaFile[] = [];
1224
+ for (const code of quotedRecord.media.downloadCodes) {
1225
+ const result = await downloadMedia(dingtalkConfig, code, log);
1226
+ if (result) {
1227
+ recovered.push(result);
1228
+ }
1229
+ }
1230
+ if (recovered.length > 0) {
1231
+ mediaPath = recovered[0].path;
1232
+ mediaType = recovered[0].mimeType;
1233
+ for (const m of recovered) {
1234
+ mediaPaths.push(m.path);
1235
+ mediaTypes.push(m.mimeType);
1236
+ }
1237
+ attachmentContextMsgId = quotedRecord.msgId || data.msgId;
1238
+ attachmentContextCreatedAt = quotedRecord.createdAt || data.createAt;
1239
+ attachmentContextMessageType = quotedRecord.messageType || "richText";
1126
1240
  log?.debug?.(
1127
- `[DingTalk][QuotedRef] Recovered quoted image from inbound downloadCode fallback scope=${data.conversationId}`,
1241
+ `[DingTalk][QuotedRef] Recovered ${recovered.length} images from cached multi-image richText ` +
1242
+ `recordMsgId=${quotedRecord.msgId || "(none)"} scope=${data.conversationId}`,
1128
1243
  );
1129
1244
  }
1130
- mediaPath = media.path;
1131
- mediaType = media.mimeType;
1132
- attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
1133
- attachmentContextCreatedAt = quotedRecord?.createdAt || data.createAt;
1134
- attachmentContextMessageType = quotedRecord?.messageType || content.quoted.previewMessageType || "picture";
1135
- attachmentContextFileName = content.quoted.previewFileName;
1136
- } else {
1137
- content.text = `[引用了一张图片,但下载失败]\n\n${content.text}`;
1138
1245
  }
1139
- }
1140
1246
 
1141
- // Quoted file/audio/video (file/audio/video msgType) or unknownMsgType:
1142
- // Step 0 tries direct downloadCode; Steps 1-2 fall back to cache and group file API.
1143
- if (!mediaPath && content.quoted?.isQuotedFile) {
1144
- let fileResolved = false;
1145
-
1146
- // Step 0: Direct download via downloadCode from quoted payload (file/audio/video msgType).
1147
- if (!fileResolved && content.quoted.fileDownloadCode && robotCode) {
1148
- const media = await downloadMedia(
1149
- dingtalkConfig,
1150
- content.quoted.fileDownloadCode,
1151
- log,
1152
- content.quoted.previewFileName,
1153
- );
1247
+ // Quoted picture: download via existing downloadMedia.
1248
+ if (!mediaPath && content.quoted?.mediaDownloadCode && robotCode) {
1249
+ const quotedOriginalFilename = content.quoted.previewFileName;
1250
+ const media =
1251
+ (await tryDownloadFromRecord(quotedRecord, quotedOriginalFilename)) ||
1252
+ (await downloadMedia(
1253
+ dingtalkConfig,
1254
+ content.quoted.mediaDownloadCode,
1255
+ log,
1256
+ quotedOriginalFilename,
1257
+ ));
1154
1258
  if (media) {
1259
+ if (!quotedRecord) {
1260
+ log?.debug?.(
1261
+ `[DingTalk][QuotedRef] Recovered quoted image from inbound downloadCode fallback scope=${data.conversationId}`,
1262
+ );
1263
+ }
1155
1264
  mediaPath = media.path;
1156
1265
  mediaType = media.mimeType;
1157
- attachmentContextMsgId = content.quoted.msgId || data.msgId;
1158
- attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
1159
- attachmentContextMessageType = content.quoted.previewMessageType || "file";
1266
+ mediaPaths.push(media.path);
1267
+ mediaTypes.push(media.mimeType);
1268
+ attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
1269
+ attachmentContextCreatedAt = quotedRecord?.createdAt || data.createAt;
1270
+ attachmentContextMessageType =
1271
+ quotedRecord?.messageType || content.quoted.previewMessageType || "picture";
1160
1272
  attachmentContextFileName = content.quoted.previewFileName;
1161
- fileResolved = true;
1162
- log?.debug?.(
1163
- `[DingTalk][QuotedRef] Downloaded quoted file via direct downloadCode scope=${data.conversationId}`,
1273
+ } else {
1274
+ content.text = `[引用了一张图片,但下载失败]\n\n${content.text}`;
1275
+ }
1276
+ }
1277
+
1278
+ // Quoted file/audio/video (file/audio/video msgType) or unknownMsgType:
1279
+ // Step 0 tries direct downloadCode; Steps 1-2 fall back to cache and group file API.
1280
+ if (!mediaPath && content.quoted?.isQuotedFile) {
1281
+ let fileResolved = false;
1282
+
1283
+ // Step 0: Direct download via downloadCode from quoted payload (file/audio/video msgType).
1284
+ if (!fileResolved && content.quoted.fileDownloadCode && robotCode) {
1285
+ const media = await downloadMedia(
1286
+ dingtalkConfig,
1287
+ content.quoted.fileDownloadCode,
1288
+ log,
1289
+ content.quoted.previewFileName,
1290
+ );
1291
+ if (media) {
1292
+ mediaPath = media.path;
1293
+ mediaType = media.mimeType;
1294
+ mediaPaths.push(media.path);
1295
+ mediaTypes.push(media.mimeType);
1296
+ attachmentContextMsgId = content.quoted.msgId || data.msgId;
1297
+ attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
1298
+ attachmentContextMessageType = content.quoted.previewMessageType || "file";
1299
+ attachmentContextFileName = content.quoted.previewFileName;
1300
+ fileResolved = true;
1301
+ log?.debug?.(
1302
+ `[DingTalk][QuotedRef] Downloaded quoted file via direct downloadCode scope=${data.conversationId}`,
1303
+ );
1304
+ }
1305
+ }
1306
+
1307
+ // Step 1: Prefer quotedRef-backed record lookup, then msgId-based cache.
1308
+ if (!fileResolved) {
1309
+ const cachedMedia = await tryDownloadFromRecord(
1310
+ quotedRecord,
1311
+ quotedRecord?.attachmentFileName || content.quoted.previewFileName,
1312
+ );
1313
+ if (cachedMedia) {
1314
+ mediaPath = cachedMedia.path;
1315
+ mediaType = cachedMedia.mimeType;
1316
+ mediaPaths.push(cachedMedia.path);
1317
+ mediaTypes.push(cachedMedia.mimeType);
1318
+ attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
1319
+ attachmentContextCreatedAt =
1320
+ quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
1321
+ attachmentContextMessageType = quotedRecord?.messageType || "file";
1322
+ attachmentContextFileName =
1323
+ quotedRecord?.attachmentFileName || content.quoted.previewFileName;
1324
+ fileResolved = true;
1325
+ }
1326
+ }
1327
+
1328
+ // Step 2 (group only): Cache miss → fall back to group file API time-based matching.
1329
+ if (!fileResolved && !isDirect) {
1330
+ const resolved = await resolveQuotedFile(
1331
+ dingtalkConfig,
1332
+ {
1333
+ openConversationId: data.conversationId,
1334
+ senderStaffId: data.senderStaffId,
1335
+ fileCreatedAt: content.quoted.fileCreatedAt,
1336
+ },
1337
+ log,
1338
+ );
1339
+ if (resolved) {
1340
+ mediaPath = resolved.media.path;
1341
+ mediaType = resolved.media.mimeType;
1342
+ mediaPaths.push(resolved.media.path);
1343
+ mediaTypes.push(resolved.media.mimeType);
1344
+ attachmentContextMsgId = content.quoted.msgId || data.msgId;
1345
+ attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
1346
+ attachmentContextMessageType = "file";
1347
+ attachmentContextFileName = resolved.name || content.quoted.previewFileName;
1348
+ fileResolved = true;
1349
+ log?.debug?.(
1350
+ `[DingTalk][QuotedRef] Recovered quoted file from group file fallback ` +
1351
+ `scope=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1352
+ );
1353
+ if (content.quoted.msgId) {
1354
+ upsertInboundMessageContext({
1355
+ storePath: accountStorePath,
1356
+ accountId,
1357
+ conversationId: data.conversationId,
1358
+ msgId: content.quoted.msgId,
1359
+ createdAt: content.quoted.fileCreatedAt || Date.now(),
1360
+ messageType: "file",
1361
+ media: {
1362
+ spaceId: resolved.spaceId,
1363
+ fileId: resolved.fileId,
1364
+ },
1365
+ attachmentFileName: resolved.name,
1366
+ ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
1367
+ topic: null,
1368
+ });
1369
+ }
1370
+ }
1371
+ }
1372
+
1373
+ if (!fileResolved) {
1374
+ log?.warn?.(
1375
+ `[DingTalk] Quoted file unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1164
1376
  );
1377
+ const hint = isDirect
1378
+ ? "[引用了一个文件,内容无法自动获取,请直接发送该文件]\n\n"
1379
+ : "[引用了一个文件,但无法获取内容]\n\n";
1380
+ content.text = `${hint}${content.text}`;
1165
1381
  }
1166
1382
  }
1167
1383
 
1168
- // Step 1: Prefer quotedRef-backed record lookup, then msgId-based cache.
1169
- if (!fileResolved) {
1170
- const cachedMedia = await tryDownloadFromRecord(
1384
+ // Quoted DingTalk doc / Drive file card:
1385
+ // 1) Prefer msgId-based cached metadata captured when the original doc card
1386
+ // message was seen.
1387
+ // 2) In group chats, if the bot never saw the original doc card message,
1388
+ // reuse the same group-file fallback chain as ordinary quoted files.
1389
+ if (!mediaPath && content.quoted?.isQuotedDocCard) {
1390
+ let docResolved = false;
1391
+
1392
+ const cachedDocMedia = await tryDownloadFromRecord(
1171
1393
  quotedRecord,
1172
- quotedRecord?.attachmentFileName || content.quoted.previewFileName,
1394
+ quotedRecord?.attachmentFileName || content.quoted?.previewFileName,
1173
1395
  );
1174
- if (cachedMedia) {
1175
- mediaPath = cachedMedia.path;
1176
- mediaType = cachedMedia.mimeType;
1396
+ if (cachedDocMedia) {
1397
+ mediaPath = cachedDocMedia.path;
1398
+ mediaType = cachedDocMedia.mimeType;
1399
+ mediaPaths.push(cachedDocMedia.path);
1400
+ mediaTypes.push(cachedDocMedia.mimeType);
1177
1401
  attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
1178
- attachmentContextCreatedAt = quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
1179
- attachmentContextMessageType = quotedRecord?.messageType || "file";
1180
- attachmentContextFileName = quotedRecord?.attachmentFileName || content.quoted.previewFileName;
1181
- fileResolved = true;
1402
+ attachmentContextCreatedAt =
1403
+ quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
1404
+ attachmentContextMessageType =
1405
+ quotedRecord?.messageType || content.quoted.previewMessageType || "interactiveCardFile";
1406
+ attachmentContextFileName =
1407
+ quotedRecord?.attachmentFileName || content.quoted.previewFileName;
1408
+ docResolved = true;
1182
1409
  }
1183
- }
1184
1410
 
1185
- // Step 2 (group only): Cache miss fall back to group file API time-based matching.
1186
- if (!fileResolved && !isDirect) {
1187
- const resolved = await resolveQuotedFile(
1188
- dingtalkConfig,
1189
- {
1190
- openConversationId: data.conversationId,
1191
- senderStaffId: data.senderStaffId,
1192
- fileCreatedAt: content.quoted.fileCreatedAt,
1193
- },
1194
- log,
1195
- );
1196
- if (resolved) {
1197
- mediaPath = resolved.media.path;
1198
- mediaType = resolved.media.mimeType;
1199
- attachmentContextMsgId = content.quoted.msgId || data.msgId;
1200
- attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
1201
- attachmentContextMessageType = "file";
1202
- attachmentContextFileName = resolved.name || content.quoted.previewFileName;
1203
- fileResolved = true;
1204
- log?.debug?.(
1205
- `[DingTalk][QuotedRef] Recovered quoted file from group file fallback ` +
1206
- `scope=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1411
+ if (!docResolved && !isDirect && content.quoted.fileCreatedAt) {
1412
+ const resolved = await resolveQuotedFile(
1413
+ dingtalkConfig,
1414
+ {
1415
+ openConversationId: data.conversationId,
1416
+ senderStaffId: data.senderStaffId,
1417
+ fileCreatedAt: content.quoted.fileCreatedAt,
1418
+ },
1419
+ log,
1420
+ );
1421
+ if (resolved) {
1422
+ mediaPath = resolved.media.path;
1423
+ mediaType = resolved.media.mimeType;
1424
+ mediaPaths.push(resolved.media.path);
1425
+ mediaTypes.push(resolved.media.mimeType);
1426
+ attachmentContextMsgId = content.quoted.msgId || data.msgId;
1427
+ attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
1428
+ attachmentContextMessageType = "interactiveCardFile";
1429
+ attachmentContextFileName = resolved.name || content.quoted.previewFileName;
1430
+ docResolved = true;
1431
+ log?.debug?.(
1432
+ `[DingTalk][QuotedRef] Recovered quoted doc card from group file fallback ` +
1433
+ `scope=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1434
+ );
1435
+ if (content.quoted.msgId) {
1436
+ upsertInboundMessageContext({
1437
+ storePath: accountStorePath,
1438
+ accountId,
1439
+ conversationId: data.conversationId,
1440
+ msgId: content.quoted.msgId,
1441
+ createdAt: content.quoted.fileCreatedAt || Date.now(),
1442
+ messageType: "interactiveCardFile",
1443
+ media: {
1444
+ spaceId: resolved.spaceId,
1445
+ fileId: resolved.fileId,
1446
+ },
1447
+ attachmentFileName: resolved.name,
1448
+ ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
1449
+ topic: null,
1450
+ });
1451
+ }
1452
+ }
1453
+ }
1454
+
1455
+ if (!docResolved) {
1456
+ log?.warn?.(
1457
+ `[DingTalk] Quoted doc card unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1207
1458
  );
1208
- if (content.quoted.msgId) {
1459
+ const hint = isDirect
1460
+ ? "[引用了钉钉文档,内容无法自动获取,请直接发送该文档]\n\n"
1461
+ : "[引用了钉钉文档,但无法获取内容]\n\n";
1462
+ content.text = `${hint}${content.text}`;
1463
+ }
1464
+ }
1465
+
1466
+ let attachmentExtractedText: string | undefined;
1467
+ if (mediaPath) {
1468
+ try {
1469
+ const extracted = await extractAttachmentText({
1470
+ path: mediaPath,
1471
+ mimeType: mediaType,
1472
+ fileName: attachmentContextFileName || data.content?.fileName,
1473
+ });
1474
+ if (extracted?.text) {
1209
1475
  upsertInboundMessageContext({
1210
1476
  storePath: accountStorePath,
1211
1477
  accountId,
1212
1478
  conversationId: data.conversationId,
1213
- msgId: content.quoted.msgId,
1214
- createdAt: content.quoted.fileCreatedAt || Date.now(),
1215
- messageType: "file",
1216
- media: {
1217
- spaceId: resolved.spaceId,
1218
- fileId: resolved.fileId,
1219
- },
1220
- attachmentFileName: resolved.name,
1221
- ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
1479
+ msgId: attachmentContextMsgId,
1480
+ createdAt: attachmentContextCreatedAt,
1481
+ messageType: attachmentContextMessageType,
1482
+ attachmentText: extracted.text,
1483
+ attachmentTextSource: extracted.sourceType,
1484
+ attachmentTextTruncated: extracted.truncated,
1485
+ attachmentFileName: attachmentContextFileName,
1486
+ ttlMs: ttlDaysToMs(journalTTLDays),
1222
1487
  topic: null,
1223
1488
  });
1489
+ attachmentExtractedText = `${ATTACHMENT_TEXT_PREFIX}\n${extracted.text}`;
1224
1490
  }
1491
+ } catch (err: any) {
1492
+ log?.warn?.(`[DingTalk] Failed to extract attachment text: ${err.message}`);
1225
1493
  }
1226
1494
  }
1227
1495
 
1228
- if (!fileResolved) {
1229
- log?.warn?.(
1230
- `[DingTalk] Quoted file unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1231
- );
1232
- const hint = isDirect
1233
- ? "[引用了一个文件,内容无法自动获取,请直接发送该文件]\n\n"
1234
- : "[引用了一个文件,但无法获取内容]\n\n";
1235
- content.text = `${hint}${content.text}`;
1236
- }
1237
- }
1496
+ const inboundBody = content.text;
1497
+ const inboundText = attachmentExtractedText
1498
+ ? `${inboundBody.trimEnd()}\n\n${attachmentExtractedText}`
1499
+ : inboundBody;
1500
+ const learningEnabled = isLearningEnabled(dingtalkConfig);
1501
+ const learningContextBlock = buildLearningContextBlock({
1502
+ enabled: learningEnabled,
1503
+ storePath: accountStorePath,
1504
+ accountId,
1505
+ targetId: data.conversationId,
1506
+ content,
1507
+ });
1508
+ const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg);
1509
+ const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
1510
+ storePath,
1511
+ sessionKey: route.sessionKey,
1512
+ });
1238
1513
 
1239
- // Quoted DingTalk doc / Drive file card:
1240
- // 1) Prefer msgId-based cached metadata captured when the original doc card
1241
- // message was seen.
1242
- // 2) In group chats, if the bot never saw the original doc card message,
1243
- // reuse the same group-file fallback chain as ordinary quoted files.
1244
- if (!mediaPath && content.quoted?.isQuotedDocCard) {
1245
- let docResolved = false;
1246
-
1247
- const cachedDocMedia = await tryDownloadFromRecord(
1248
- quotedRecord,
1249
- quotedRecord?.attachmentFileName || content.quoted?.previewFileName,
1250
- );
1251
- if (cachedDocMedia) {
1252
- mediaPath = cachedDocMedia.path;
1253
- mediaType = cachedDocMedia.mimeType;
1254
- attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
1255
- attachmentContextCreatedAt = quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
1256
- attachmentContextMessageType =
1257
- quotedRecord?.messageType || content.quoted.previewMessageType || "interactiveCardFile";
1258
- attachmentContextFileName = quotedRecord?.attachmentFileName || content.quoted.previewFileName;
1259
- docResolved = true;
1514
+ const groupConfig = !isDirect ? resolveGroupConfig(dingtalkConfig, groupId) : undefined;
1515
+ // GroupSystemPrompt is injected every turn (not only first-turn intro).
1516
+ const groupSystemPromptParts = !isDirect
1517
+ ? [
1518
+ buildGroupTurnContextPrompt({
1519
+ conversationId: groupId,
1520
+ senderDingtalkId: senderId,
1521
+ senderName,
1522
+ }),
1523
+ groupConfig?.systemPrompt?.trim(),
1524
+ ]
1525
+ : [];
1526
+ const extraSystemPrompt =
1527
+ [...groupSystemPromptParts, learningContextBlock].filter(Boolean).join("\n\n") || undefined;
1528
+
1529
+ if (!isDirect) {
1530
+ noteGroupMember(storePath, groupId, senderId, senderName);
1260
1531
  }
1532
+ const groupMembers = !isDirect ? formatGroupMembers(storePath, groupId) : undefined;
1533
+
1534
+ const fromLabel = isDirect ? `${senderName} (${senderId})` : `${groupName} - ${senderName}`;
1535
+ const body = rt.channel.reply.formatInboundEnvelope({
1536
+ channel: "DingTalk",
1537
+ from: fromLabel,
1538
+ timestamp: data.createAt,
1539
+ body: inboundText,
1540
+ chatType: isDirect ? "direct" : "group",
1541
+ sender: { name: senderName, id: senderId },
1542
+ previousTimestamp,
1543
+ envelope: envelopeOptions,
1544
+ });
1261
1545
 
1262
- if (!docResolved && !isDirect && content.quoted.fileCreatedAt) {
1263
- const resolved = await resolveQuotedFile(
1264
- dingtalkConfig,
1265
- {
1266
- openConversationId: data.conversationId,
1267
- senderStaffId: data.senderStaffId,
1268
- fileCreatedAt: content.quoted.fileCreatedAt,
1269
- },
1270
- log,
1271
- );
1272
- if (resolved) {
1273
- mediaPath = resolved.media.path;
1274
- mediaType = resolved.media.mimeType;
1275
- attachmentContextMsgId = content.quoted.msgId || data.msgId;
1276
- attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
1277
- attachmentContextMessageType = "interactiveCardFile";
1278
- attachmentContextFileName = resolved.name || content.quoted.previewFileName;
1279
- docResolved = true;
1280
- log?.debug?.(
1281
- `[DingTalk][QuotedRef] Recovered quoted doc card from group file fallback ` +
1282
- `scope=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1283
- );
1284
- if (content.quoted.msgId) {
1285
- upsertInboundMessageContext({
1286
- storePath: accountStorePath,
1287
- accountId,
1288
- conversationId: data.conversationId,
1289
- msgId: content.quoted.msgId,
1290
- createdAt: content.quoted.fileCreatedAt || Date.now(),
1291
- messageType: "interactiveCardFile",
1292
- media: {
1293
- spaceId: resolved.spaceId,
1294
- fileId: resolved.fileId,
1295
- },
1296
- attachmentFileName: resolved.name,
1297
- ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
1298
- topic: null,
1299
- });
1546
+ const ctx = rt.channel.reply.finalizeInboundContext({
1547
+ Body: body,
1548
+ RawBody: inboundText,
1549
+ CommandBody: inboundText,
1550
+ QuotedRef: quotedRef,
1551
+ QuotedRefJson: quotedRef ? JSON.stringify(quotedRef) : undefined,
1552
+ ReplyToId: quotedRuntimeContext?.replyToId,
1553
+ ReplyToBody: quotedRuntimeContext?.replyToBody,
1554
+ ReplyToSender: quotedRuntimeContext?.replyToSender,
1555
+ ReplyToIsQuote: quotedRuntimeContext?.replyToIsQuote,
1556
+ UntrustedContext: quotedRuntimeContext?.untrustedContext
1557
+ ? [quotedRuntimeContext.untrustedContext]
1558
+ : undefined,
1559
+ From: to,
1560
+ To: to,
1561
+ SessionKey: route.sessionKey,
1562
+ AccountId: accountId,
1563
+ ChatType: isDirect ? "direct" : "group",
1564
+ ConversationLabel: fromLabel,
1565
+ GroupSubject: isDirect ? undefined : groupName,
1566
+ SenderName: senderName,
1567
+ SenderId: senderId,
1568
+ Provider: "dingtalk",
1569
+ Surface: "dingtalk",
1570
+ MessageSid: data.msgId,
1571
+ Timestamp: data.createAt,
1572
+ MediaPath: mediaPath,
1573
+ MediaType: mediaType,
1574
+ MediaUrl: mediaPath,
1575
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
1576
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
1577
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
1578
+ GroupMembers: groupMembers,
1579
+ GroupSystemPrompt: extraSystemPrompt,
1580
+ GroupChannel: isDirect ? undefined : route.sessionKey,
1581
+ CommandAuthorized: commandAuthorized,
1582
+ OriginatingChannel: "dingtalk",
1583
+ OriginatingTo: to,
1584
+ });
1585
+
1586
+ await rt.channel.session.recordInboundSession({
1587
+ storePath,
1588
+ sessionKey: ctx.SessionKey || route.sessionKey,
1589
+ ctx,
1590
+ updateLastRoute: (() => {
1591
+ if (!isDirect) {
1592
+ return undefined;
1300
1593
  }
1301
- }
1302
- }
1594
+ const pinnedMainDmOwner = resolvePinnedMainDmOwner({
1595
+ dmScope: cfg.session?.dmScope,
1596
+ allowFrom: dingtalkConfig.allowFrom,
1597
+ });
1598
+ const senderRecipient = (senderOriginalId || senderId || "").trim().toLowerCase();
1599
+ if (
1600
+ pinnedMainDmOwner &&
1601
+ senderRecipient &&
1602
+ pinnedMainDmOwner.trim().toLowerCase() !== senderRecipient
1603
+ ) {
1604
+ log?.debug?.(
1605
+ `[DingTalk] Skipping main-session last route update for ${senderRecipient} (pinned owner ${pinnedMainDmOwner})`,
1606
+ );
1607
+ return undefined;
1608
+ }
1609
+ return { sessionKey: route.mainSessionKey, channel: "dingtalk", to, accountId };
1610
+ })(),
1611
+ onRecordError: (err: unknown) => {
1612
+ log?.error?.(`[DingTalk] Failed to record inbound session: ${String(err)}`);
1613
+ },
1614
+ });
1303
1615
 
1304
- if (!docResolved) {
1305
- log?.warn?.(
1306
- `[DingTalk] Quoted doc card unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1616
+ log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
1617
+
1618
+ // ---- Pre-lock abort: bypass session lock for stop requests ----
1619
+ // isAbortRequestText matches "/stop", "停止", "stop", "esc", etc.
1620
+ // Calling dispatchReplyWithBufferedBlockDispatcher without holding the lock lets
1621
+ // tryFastAbortFromMessage (inside the SDK) kill any in-flight generation immediately,
1622
+ // rather than waiting for it to finish before the stop message is processed.
1623
+ //
1624
+ // Strip leading @mention tokens before the abort check so that messages like
1625
+ // "@Agent /stop" are correctly recognised as abort requests in both DM and group
1626
+ // chats. In groups DingTalk usually strips @BotName at the protocol level, but
1627
+ // in DMs with multi-agent routing the @mention prefix survives all the way here.
1628
+ const textForAbortCheck = stripLeadingMentions(inboundText).trim();
1629
+ if (isAbortRequestText(textForAbortCheck)) {
1630
+ log?.info?.(
1631
+ `[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
1307
1632
  );
1308
- const hint = isDirect
1309
- ? "[引用了钉钉文档,内容无法自动获取,请直接发送该文档]\n\n"
1310
- : "[引用了钉钉文档,但无法获取内容]\n\n";
1311
- content.text = `${hint}${content.text}`;
1312
- }
1313
- }
1314
-
1315
- let attachmentExtractedText: string | undefined;
1316
- if (mediaPath) {
1317
- try {
1318
- const extracted = await extractAttachmentText({
1319
- path: mediaPath,
1320
- mimeType: mediaType,
1321
- fileName: attachmentContextFileName || data.content?.fileName,
1322
- });
1323
- if (extracted?.text) {
1324
- upsertInboundMessageContext({
1325
- storePath: accountStorePath,
1326
- accountId,
1327
- conversationId: data.conversationId,
1328
- msgId: attachmentContextMsgId,
1329
- createdAt: attachmentContextCreatedAt,
1330
- messageType: attachmentContextMessageType,
1331
- attachmentText: extracted.text,
1332
- attachmentTextSource: extracted.sourceType,
1333
- attachmentTextTruncated: extracted.truncated,
1334
- attachmentFileName: attachmentContextFileName,
1335
- ttlMs: ttlDaysToMs(journalTTLDays),
1336
- topic: null,
1633
+ // In card mode: capture the abort confirmation text so we can write it into
1634
+ // the card (instead of sending a separate plain text message).
1635
+ let abortConfirmationText: string | undefined;
1636
+ try {
1637
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1638
+ ctx,
1639
+ cfg,
1640
+ dispatcherOptions: {
1641
+ responsePrefix: "",
1642
+ deliver: async (payload) => {
1643
+ if (!payload.text) {
1644
+ log?.debug?.(`[DingTalk] Abort deliver received non-text payload, skipping`);
1645
+ return;
1646
+ }
1647
+ if (currentAICard) {
1648
+ // Card mode: capture text — will be written to card after dispatch.
1649
+ abortConfirmationText = payload.text;
1650
+ } else {
1651
+ try {
1652
+ if (sessionWebhook) {
1653
+ await sendBySession(dingtalkConfig, sessionWebhook, payload.text, {
1654
+ log,
1655
+ accountId,
1656
+ storePath: accountStorePath,
1657
+ });
1658
+ } else {
1659
+ await sendMessage(dingtalkConfig, to, payload.text, {
1660
+ log,
1661
+ accountId,
1662
+ storePath: accountStorePath,
1663
+ conversationId: groupId,
1664
+ });
1665
+ }
1666
+ } catch (deliverErr) {
1667
+ log?.warn?.(
1668
+ `[DingTalk] Abort reply delivery failed: ${getErrorMessage(deliverErr)}`,
1669
+ );
1670
+ }
1671
+ }
1672
+ },
1673
+ },
1337
1674
  });
1338
- attachmentExtractedText = `${ATTACHMENT_TEXT_PREFIX}\n${extracted.text}`;
1675
+ } catch (abortErr) {
1676
+ log?.warn?.(`[DingTalk] Abort dispatch failed: ${getErrorMessage(abortErr)}`);
1339
1677
  }
1340
- } catch (err: any) {
1341
- log?.warn?.(`[DingTalk] Failed to extract attachment text: ${err.message}`);
1342
- }
1343
- }
1344
-
1345
- const inboundBody = content.text;
1346
- const inboundText = attachmentExtractedText
1347
- ? `${inboundBody.trimEnd()}\n\n${attachmentExtractedText}`
1348
- : inboundBody;
1349
- const learningEnabled = isLearningEnabled(dingtalkConfig);
1350
- const learningContextBlock = buildLearningContextBlock({
1351
- enabled: learningEnabled,
1352
- storePath: accountStorePath,
1353
- accountId,
1354
- targetId: data.conversationId,
1355
- content,
1356
- });
1357
- const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg);
1358
- const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
1359
- storePath,
1360
- sessionKey: route.sessionKey,
1361
- });
1362
-
1363
- const groupConfig = !isDirect ? resolveGroupConfig(dingtalkConfig, groupId) : undefined;
1364
- // GroupSystemPrompt is injected every turn (not only first-turn intro).
1365
- const groupSystemPromptParts = !isDirect
1366
- ? [
1367
- buildGroupTurnContextPrompt({
1368
- conversationId: groupId,
1369
- senderDingtalkId: senderId,
1370
- senderName,
1371
- }),
1372
- groupConfig?.systemPrompt?.trim(),
1373
- ]
1374
- : [];
1375
- const extraSystemPrompt =
1376
- [...groupSystemPromptParts, learningContextBlock].filter(Boolean).join("\n\n") || undefined;
1377
-
1378
- if (!isDirect) {
1379
- noteGroupMember(storePath, groupId, senderId, senderName);
1380
- }
1381
- const groupMembers = !isDirect ? formatGroupMembers(storePath, groupId) : undefined;
1382
-
1383
- const fromLabel = isDirect ? `${senderName} (${senderId})` : `${groupName} - ${senderName}`;
1384
- const body = rt.channel.reply.formatInboundEnvelope({
1385
- channel: "DingTalk",
1386
- from: fromLabel,
1387
- timestamp: data.createAt,
1388
- body: inboundText,
1389
- chatType: isDirect ? "direct" : "group",
1390
- sender: { name: senderName, id: senderId },
1391
- previousTimestamp,
1392
- envelope: envelopeOptions,
1393
- });
1678
+ // Finalize the card that was created for this message before the abort check.
1679
+ // Without this, the card stays in PROCESSING ("处理中...") indefinitely.
1680
+ // Use V2 finalize (commitAICardBlocks) for consistent state transition.
1681
+ if (currentAICard && !isCardInTerminalState(currentAICard.state)) {
1682
+ try {
1683
+ const abortText = abortConfirmationText ?? "已停止";
1684
+ const abortBlockList = [{ type: 0, markdown: abortText }];
1685
+ const blockListJson = JSON.stringify(abortBlockList);
1394
1686
 
1395
- const ctx = rt.channel.reply.finalizeInboundContext({
1396
- Body: body,
1397
- RawBody: inboundText,
1398
- CommandBody: inboundText,
1399
- QuotedRef: quotedRef,
1400
- QuotedRefJson: quotedRef ? JSON.stringify(quotedRef) : undefined,
1401
- ReplyToId: quotedRuntimeContext?.replyToId,
1402
- ReplyToBody: quotedRuntimeContext?.replyToBody,
1403
- ReplyToSender: quotedRuntimeContext?.replyToSender,
1404
- ReplyToIsQuote: quotedRuntimeContext?.replyToIsQuote,
1405
- UntrustedContext: quotedRuntimeContext?.untrustedContext
1406
- ? [quotedRuntimeContext.untrustedContext]
1407
- : undefined,
1408
- From: to,
1409
- To: to,
1410
- SessionKey: route.sessionKey,
1411
- AccountId: accountId,
1412
- ChatType: isDirect ? "direct" : "group",
1413
- ConversationLabel: fromLabel,
1414
- GroupSubject: isDirect ? undefined : groupName,
1415
- SenderName: senderName,
1416
- SenderId: senderId,
1417
- Provider: "dingtalk",
1418
- Surface: "dingtalk",
1419
- MessageSid: data.msgId,
1420
- Timestamp: data.createAt,
1421
- MediaPath: mediaPath,
1422
- MediaType: mediaType,
1423
- MediaUrl: mediaPath,
1424
- GroupMembers: groupMembers,
1425
- GroupSystemPrompt: extraSystemPrompt,
1426
- GroupChannel: isDirect ? undefined : route.sessionKey,
1427
- CommandAuthorized: commandAuthorized,
1428
- OriginatingChannel: "dingtalk",
1429
- OriginatingTo: to,
1430
- });
1687
+ await commitAICardBlocks(
1688
+ currentAICard,
1689
+ {
1690
+ blockListJson,
1691
+ content: abortText,
1692
+ },
1693
+ log,
1694
+ );
1431
1695
 
1432
- await rt.channel.session.recordInboundSession({
1433
- storePath,
1434
- sessionKey: ctx.SessionKey || route.sessionKey,
1435
- ctx,
1436
- updateLastRoute: (() => {
1437
- if (!isDirect) {
1438
- return undefined;
1439
- }
1440
- const pinnedMainDmOwner = resolvePinnedMainDmOwner({
1441
- dmScope: cfg.session?.dmScope,
1442
- allowFrom: dingtalkConfig.allowFrom,
1443
- });
1444
- const senderRecipient = (senderOriginalId || senderId || "").trim().toLowerCase();
1445
- if (
1446
- pinnedMainDmOwner
1447
- && senderRecipient
1448
- && pinnedMainDmOwner.trim().toLowerCase() !== senderRecipient
1449
- ) {
1450
- log?.debug?.(
1451
- `[DingTalk] Skipping main-session last route update for ${senderRecipient} (pinned owner ${pinnedMainDmOwner})`,
1452
- );
1453
- return undefined;
1696
+ log?.debug?.(
1697
+ `[DingTalk] Abort card finalized via V2 API: card=${currentAICard.cardInstanceId}`,
1698
+ );
1699
+ } catch (cardErr) {
1700
+ log?.warn?.(`[DingTalk] Abort card finalize failed: ${getErrorMessage(cardErr)}`);
1701
+ currentAICard.state = AICardStatus.FAILED;
1702
+ }
1454
1703
  }
1455
- return { sessionKey: route.mainSessionKey, channel: "dingtalk", to, accountId };
1456
- })(),
1457
- onRecordError: (err: unknown) => {
1458
- log?.error?.(`[DingTalk] Failed to record inbound session: ${String(err)}`);
1459
- },
1460
- });
1704
+ return;
1705
+ }
1461
1706
 
1462
- log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
1463
-
1464
- // ---- Pre-lock abort: bypass session lock for stop requests ----
1465
- // isAbortRequestText matches "/stop", "停止", "stop", "esc", etc.
1466
- // Calling dispatchReplyWithBufferedBlockDispatcher without holding the lock lets
1467
- // tryFastAbortFromMessage (inside the SDK) kill any in-flight generation immediately,
1468
- // rather than waiting for it to finish before the stop message is processed.
1469
- //
1470
- // Strip leading @mention tokens before the abort check so that messages like
1471
- // "@Agent /stop" are correctly recognised as abort requests in both DM and group
1472
- // chats. In groups DingTalk usually strips @BotName at the protocol level, but
1473
- // in DMs with multi-agent routing the @mention prefix survives all the way here.
1474
- const textForAbortCheck = stripLeadingMentions(inboundText).trim();
1475
- if (isAbortRequestText(textForAbortCheck)) {
1476
- log?.info?.(
1477
- `[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
1478
- );
1479
- // In card mode: capture the abort confirmation text so we can write it into
1480
- // the card (instead of sending a separate plain text message).
1481
- let abortConfirmationText: string | undefined;
1482
- try {
1483
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1484
- ctx,
1485
- cfg,
1486
- dispatcherOptions: {
1487
- responsePrefix: "",
1488
- deliver: async (payload) => {
1489
- if (!payload.text) {
1490
- log?.debug?.(`[DingTalk] Abort deliver received non-text payload, skipping`);
1491
- return;
1492
- }
1493
- if (currentAICard) {
1494
- // Card mode: capture text — will be written to card after dispatch.
1495
- abortConfirmationText = payload.text;
1496
- } else {
1497
- try {
1498
- if (sessionWebhook) {
1499
- await sendBySession(dingtalkConfig, sessionWebhook, payload.text, {
1500
- log,
1501
- accountId,
1502
- storePath: accountStorePath,
1503
- });
1504
- } else {
1505
- await sendMessage(dingtalkConfig, to, payload.text, {
1506
- log,
1507
- accountId,
1508
- storePath: accountStorePath,
1509
- conversationId: groupId,
1510
- });
1511
- }
1512
- } catch (deliverErr) {
1513
- log?.warn?.(
1514
- `[DingTalk] Abort reply delivery failed: ${getErrorMessage(deliverErr)}`,
1515
- );
1707
+ // ---- Pre-lock BTW: bypass session lock for /btw side questions ----
1708
+ // /btw runs an isolated, tool-less side query in openclaw without polluting
1709
+ // the main run's transcript. The dispatch must NOT acquire the session lock,
1710
+ // otherwise it would queue behind the in-flight main task and lose its "side
1711
+ // question" semantics.
1712
+ //
1713
+ // The `isBtwBypass` flag is computed once early (just after `content` is
1714
+ // resolved, just before `createAICard`). The same constant gates `createAICard` above and
1715
+ // drives this branch single decision, two consequences. See the comment
1716
+ // at the flag's definition for why /btw uses pre-OCR `content.text` while
1717
+ // abort uses `inboundText`.
1718
+ if (isBtwBypass) {
1719
+ log?.info?.(
1720
+ `[DingTalk] BTW request detected, bypassing session lock for session=${route.sessionKey}`,
1721
+ );
1722
+ // Empty fallback (NOT "Unknown" like the file's main `senderName` variable):
1723
+ // when the nickname is missing we want the blockquote to render as
1724
+ // `> /btw <question>` rather than `> Unknown: /btw <question>`. Read locally
1725
+ // here so the existing `senderName` semantics elsewhere in the file are not
1726
+ // affected.
1727
+ const btwSenderName = data.senderNick || "";
1728
+ try {
1729
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1730
+ ctx,
1731
+ cfg,
1732
+ dispatcherOptions: {
1733
+ responsePrefix: "",
1734
+ deliver: async (payload) => {
1735
+ if (!payload.text) {
1736
+ log?.debug?.(`[DingTalk] BTW deliver received non-text payload, skipping`);
1737
+ return;
1516
1738
  }
1517
- }
1739
+ await deliverBtwReply({
1740
+ config: dingtalkConfig,
1741
+ sessionWebhook,
1742
+ conversationId: groupId,
1743
+ to,
1744
+ senderName: btwSenderName,
1745
+ // Use pre-OCR content.text (consistent with isBtwBypass detection)
1746
+ // so the blockquote shows the user's typed body, not body + OCR.
1747
+ rawQuestion: content.text,
1748
+ replyText: payload.text,
1749
+ log,
1750
+ accountId,
1751
+ storePath: accountStorePath,
1752
+ });
1753
+ },
1518
1754
  },
1519
- },
1520
- });
1521
- } catch (abortErr) {
1522
- log?.warn?.(`[DingTalk] Abort dispatch failed: ${getErrorMessage(abortErr)}`);
1523
- }
1524
- // Finalize the card that was created for this message before the abort check.
1525
- // Without this, the card stays in PROCESSING ("处理中...") indefinitely.
1526
- // Use V2 finalize (commitAICardBlocks) for consistent state transition.
1527
- if (currentAICard && !isCardInTerminalState(currentAICard.state)) {
1528
- try {
1529
- const abortText = abortConfirmationText ?? "已停止";
1530
- const abortBlockList = [{ type: 0, markdown: abortText }];
1531
- const blockListJson = JSON.stringify(abortBlockList);
1532
-
1533
- await commitAICardBlocks(currentAICard, {
1534
- blockListJson,
1535
- content: abortText,
1536
- }, log);
1537
-
1538
- log?.debug?.(`[DingTalk] Abort card finalized via V2 API: card=${currentAICard.cardInstanceId}`);
1539
- } catch (cardErr) {
1540
- log?.warn?.(`[DingTalk] Abort card finalize failed: ${getErrorMessage(cardErr)}`);
1541
- currentAICard.state = AICardStatus.FAILED;
1755
+ });
1756
+ } catch (btwErr) {
1757
+ log?.warn?.(`[DingTalk] BTW dispatch failed: ${getErrorMessage(btwErr)}`);
1542
1758
  }
1759
+ return;
1543
1760
  }
1544
- return;
1545
- }
1546
1761
 
1547
- // ---- Pre-lock BTW: bypass session lock for /btw side questions ----
1548
- // /btw runs an isolated, tool-less side query in openclaw without polluting
1549
- // the main run's transcript. The dispatch must NOT acquire the session lock,
1550
- // otherwise it would queue behind the in-flight main task and lose its "side
1551
- // question" semantics.
1552
- //
1553
- // The `isBtwBypass` flag is computed once early (just after `content` is
1554
- // resolved, just before `createAICard`). The same constant gates `createAICard` above and
1555
- // drives this branch single decision, two consequences. See the comment
1556
- // at the flag's definition for why /btw uses pre-OCR `content.text` while
1557
- // abort uses `inboundText`.
1558
- if (isBtwBypass) {
1559
- log?.info?.(
1560
- `[DingTalk] BTW request detected, bypassing session lock for session=${route.sessionKey}`,
1561
- );
1562
- // Empty fallback (NOT "Unknown" like the file's main `senderName` variable):
1563
- // when the nickname is missing we want the blockquote to render as
1564
- // `> /btw <question>` rather than `> Unknown: /btw <question>`. Read locally
1565
- // here so the existing `senderName` semantics elsewhere in the file are not
1566
- // affected.
1567
- const btwSenderName = data.senderNick || "";
1568
- try {
1569
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1570
- ctx,
1571
- cfg,
1572
- dispatcherOptions: {
1573
- responsePrefix: "",
1574
- deliver: async (payload) => {
1575
- if (!payload.text) {
1576
- log?.debug?.(`[DingTalk] BTW deliver received non-text payload, skipping`);
1577
- return;
1578
- }
1579
- await deliverBtwReply({
1580
- config: dingtalkConfig,
1581
- sessionWebhook,
1582
- conversationId: groupId,
1583
- to,
1584
- senderName: btwSenderName,
1585
- // Use pre-OCR content.text (consistent with isBtwBypass detection)
1586
- // so the blockquote shows the user's typed body, not body + OCR.
1587
- rawQuestion: content.text,
1588
- replyText: payload.text,
1589
- log,
1590
- accountId,
1591
- storePath: accountStorePath,
1592
- });
1593
- },
1762
+ const ackReaction =
1763
+ typeof dingtalkConfig.ackReaction === "string"
1764
+ ? dingtalkConfig.ackReaction.trim()
1765
+ : resolveAckReactionSetting({
1766
+ cfg,
1767
+ accountId,
1768
+ agentId: route.agentId,
1769
+ });
1770
+ const normalizedAckReaction = ackReaction === "off" ? "" : ackReaction;
1771
+ const resolvedAckReaction =
1772
+ normalizedAckReaction === "kaomoji"
1773
+ ? classifyAckReactionEmoji(content.text).emoji
1774
+ : normalizedAckReaction === "emoji"
1775
+ ? "🤔思考中"
1776
+ : normalizedAckReaction;
1777
+ const shouldAttachAckReaction = Boolean(resolvedAckReaction);
1778
+ let ackReactionAttached = false;
1779
+ let ackReactionAttachedAt = 0;
1780
+
1781
+ if (shouldAttachAckReaction) {
1782
+ ackReactionAttached = await attachNativeAckReaction(
1783
+ dingtalkConfig,
1784
+ {
1785
+ msgId: data.msgId,
1786
+ conversationId: groupId,
1787
+ reactionName: resolvedAckReaction,
1594
1788
  },
1595
- });
1596
- } catch (btwErr) {
1597
- log?.warn?.(`[DingTalk] BTW dispatch failed: ${getErrorMessage(btwErr)}`);
1598
- }
1599
- return;
1600
- }
1601
-
1602
- const ackReaction =
1603
- typeof dingtalkConfig.ackReaction === "string"
1604
- ? dingtalkConfig.ackReaction.trim()
1605
- : resolveAckReactionSetting({
1606
- cfg,
1607
- accountId,
1608
- agentId: route.agentId,
1609
- });
1610
- const normalizedAckReaction = ackReaction === "off" ? "" : ackReaction;
1611
- const resolvedAckReaction =
1612
- normalizedAckReaction === "kaomoji"
1613
- ? classifyAckReactionEmoji(content.text).emoji
1614
- : normalizedAckReaction === "emoji"
1615
- ? "🤔思考中"
1616
- : normalizedAckReaction;
1617
- const shouldAttachAckReaction = Boolean(resolvedAckReaction);
1618
- let ackReactionAttached = false;
1619
- let ackReactionAttachedAt = 0;
1620
-
1621
- if (shouldAttachAckReaction) {
1622
- ackReactionAttached = await attachNativeAckReaction(
1623
- dingtalkConfig,
1624
- {
1625
- msgId: data.msgId,
1626
- conversationId: groupId,
1627
- reactionName: resolvedAckReaction,
1628
- },
1629
- log,
1630
- );
1631
- if (ackReactionAttached) {
1632
- ackReactionAttachedAt = Date.now();
1633
- log?.debug?.(
1634
- `[DingTalk] Initial ack reaction attached mode=${normalizedAckReaction || "off"} reaction=${resolvedAckReaction}`,
1789
+ log,
1635
1790
  );
1791
+ if (ackReactionAttached) {
1792
+ ackReactionAttachedAt = Date.now();
1793
+ log?.debug?.(
1794
+ `[DingTalk] Initial ack reaction attached mode=${normalizedAckReaction || "off"} reaction=${resolvedAckReaction}`,
1795
+ );
1796
+ }
1636
1797
  }
1637
- }
1638
1798
 
1639
- function parseInlineReplyPayloadText(text: unknown): {
1640
- text?: string;
1641
- mediaUrls: string[];
1642
- audioAsVoice: boolean;
1643
- } {
1644
- if (typeof text !== "string") {
1645
- return { text: undefined, mediaUrls: [], audioAsVoice: false };
1646
- }
1799
+ function parseInlineReplyPayloadText(text: unknown): {
1800
+ text?: string;
1801
+ mediaUrls: string[];
1802
+ audioAsVoice: boolean;
1803
+ } {
1804
+ if (typeof text !== "string") {
1805
+ return { text: undefined, mediaUrls: [], audioAsVoice: false };
1806
+ }
1647
1807
 
1648
- const normalizedText = text.replace(/\r\n/g, "\n");
1649
- const parsedInline = normalizedText.includes("[[")
1650
- ? parseInlineDirectives(normalizedText, {
1651
- stripAudioTag: true,
1652
- stripReplyTags: false,
1653
- })
1654
- : {
1655
- text: normalizedText,
1656
- audioAsVoice: false,
1657
- replyToCurrent: false,
1658
- hasAudioTag: false,
1659
- hasReplyTag: false,
1660
- };
1661
- const mediaUrls: string[] = [];
1662
- const contentLines: string[] = [];
1663
-
1664
- for (const line of parsedInline.text.split("\n")) {
1665
- const trimmed = line.trim();
1666
- const mediaCandidate = trimmed.replace(/^(?:\[\[[^[\]]+\]\]\s*)+/, "");
1667
- if (mediaCandidate.startsWith(MEDIA_DIRECTIVE_PREFIX)) {
1668
- const mediaSource = mediaCandidate.slice(MEDIA_DIRECTIVE_PREFIX.length).trim();
1669
- if (mediaSource) {
1670
- mediaUrls.push(mediaSource);
1671
- continue;
1808
+ const normalizedText = text.replace(/\r\n/g, "\n");
1809
+ const parsedInline = normalizedText.includes("[[")
1810
+ ? parseInlineDirectives(normalizedText, {
1811
+ stripAudioTag: true,
1812
+ stripReplyTags: false,
1813
+ })
1814
+ : {
1815
+ text: normalizedText,
1816
+ audioAsVoice: false,
1817
+ replyToCurrent: false,
1818
+ hasAudioTag: false,
1819
+ hasReplyTag: false,
1820
+ };
1821
+ const mediaUrls: string[] = [];
1822
+ const contentLines: string[] = [];
1823
+
1824
+ for (const line of parsedInline.text.split("\n")) {
1825
+ const trimmed = line.trim();
1826
+ const mediaCandidate = trimmed.replace(/^(?:\[\[[^[\]]+\]\]\s*)+/, "");
1827
+ if (mediaCandidate.startsWith(MEDIA_DIRECTIVE_PREFIX)) {
1828
+ const mediaSource = mediaCandidate.slice(MEDIA_DIRECTIVE_PREFIX.length).trim();
1829
+ if (mediaSource) {
1830
+ mediaUrls.push(mediaSource);
1831
+ continue;
1832
+ }
1672
1833
  }
1834
+ contentLines.push(line);
1673
1835
  }
1674
- contentLines.push(line);
1675
- }
1676
1836
 
1677
- const cleanedText = contentLines.join("\n").trim();
1678
- const inlineTextWasTransformed = parsedInline.text !== normalizedText;
1679
- if (mediaUrls.length === 0) {
1680
- const standaloneMediaSource = extractStandaloneMediaSource(cleanedText);
1681
- if (standaloneMediaSource) {
1682
- return {
1683
- text: undefined,
1684
- mediaUrls: [standaloneMediaSource],
1685
- audioAsVoice: parsedInline.audioAsVoice,
1686
- };
1837
+ const cleanedText = contentLines.join("\n").trim();
1838
+ const inlineTextWasTransformed = parsedInline.text !== normalizedText;
1839
+ if (mediaUrls.length === 0) {
1840
+ const standaloneMediaSource = extractStandaloneMediaSource(cleanedText);
1841
+ if (standaloneMediaSource) {
1842
+ return {
1843
+ text: undefined,
1844
+ mediaUrls: [standaloneMediaSource],
1845
+ audioAsVoice: parsedInline.audioAsVoice,
1846
+ };
1847
+ }
1687
1848
  }
1688
- }
1689
1849
 
1690
- return {
1691
- // Keep ordinary text formatting stable except for newline normalization.
1692
- // Once inline parsing actually strips directives/media lines, return the
1693
- // cleaned body text instead of the original raw payload.
1694
- text: mediaUrls.length > 0 || inlineTextWasTransformed ? cleanedText || undefined : normalizedText,
1695
- mediaUrls,
1696
- audioAsVoice: parsedInline.audioAsVoice,
1697
- };
1698
- }
1699
-
1700
- function extractStandaloneMediaSource(text: string): string | undefined {
1701
- const trimmed = text.trim();
1702
- if (!trimmed || trimmed.includes("\n") || /\s/.test(trimmed)) {
1703
- return undefined;
1850
+ return {
1851
+ // Keep ordinary text formatting stable except for newline normalization.
1852
+ // Once inline parsing actually strips directives/media lines, return the
1853
+ // cleaned body text instead of the original raw payload.
1854
+ text:
1855
+ mediaUrls.length > 0 || inlineTextWasTransformed
1856
+ ? cleanedText || undefined
1857
+ : normalizedText,
1858
+ mediaUrls,
1859
+ audioAsVoice: parsedInline.audioAsVoice,
1860
+ };
1704
1861
  }
1705
1862
 
1706
- const hasPathLikeShape =
1707
- /^https?:\/\/\S+$/i.test(trimmed) ||
1708
- trimmed.startsWith("~/") ||
1709
- trimmed.startsWith("~\\") ||
1710
- trimmed.startsWith("./") ||
1711
- trimmed.startsWith(".\\") ||
1712
- trimmed.startsWith("../") ||
1713
- trimmed.startsWith("..\\") ||
1714
- trimmed.startsWith("/") ||
1715
- trimmed.startsWith("\\") ||
1716
- /^[a-zA-Z]:[\\/]/.test(trimmed) ||
1717
- trimmed.includes("/") ||
1718
- trimmed.includes("\\");
1719
- if (!hasPathLikeShape) {
1720
- return undefined;
1721
- }
1863
+ function extractStandaloneMediaSource(text: string): string | undefined {
1864
+ const trimmed = text.trim();
1865
+ if (!trimmed || trimmed.includes("\n") || /\s/.test(trimmed)) {
1866
+ return undefined;
1867
+ }
1722
1868
 
1723
- return STANDALONE_MEDIA_PATH_EXTENSIONS.has(path.extname(trimmed).toLowerCase())
1724
- ? trimmed
1725
- : undefined;
1726
- }
1869
+ const hasPathLikeShape =
1870
+ /^https?:\/\/\S+$/i.test(trimmed) ||
1871
+ trimmed.startsWith("~/") ||
1872
+ trimmed.startsWith("~\\") ||
1873
+ trimmed.startsWith("./") ||
1874
+ trimmed.startsWith(".\\") ||
1875
+ trimmed.startsWith("../") ||
1876
+ trimmed.startsWith("..\\") ||
1877
+ trimmed.startsWith("/") ||
1878
+ trimmed.startsWith("\\") ||
1879
+ /^[a-zA-Z]:[\\/]/.test(trimmed) ||
1880
+ trimmed.includes("/") ||
1881
+ trimmed.includes("\\");
1882
+ if (!hasPathLikeShape) {
1883
+ return undefined;
1884
+ }
1727
1885
 
1728
- // ---- Shared media delivery helper ----
1729
- function extractSharedAudioAsVoice(
1730
- payload: ReplyStreamPayload,
1731
- inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
1732
- ): boolean {
1733
- // Normalize all reply-time voice hints into the shared `audioAsVoice`
1734
- // semantic that strategies and media delivery use downstream.
1735
- const richPayload = payload as ReplyStreamPayload & {
1736
- audioAsVoice?: unknown;
1737
- asVoice?: unknown;
1738
- };
1739
- const sharedValue = parseBooleanLike(richPayload.audioAsVoice);
1740
- if (sharedValue !== undefined) {
1741
- return sharedValue;
1886
+ return STANDALONE_MEDIA_PATH_EXTENSIONS.has(path.extname(trimmed).toLowerCase())
1887
+ ? trimmed
1888
+ : undefined;
1742
1889
  }
1743
- if (parseBooleanLike(richPayload.asVoice) === true) {
1744
- return true;
1890
+
1891
+ // ---- Shared media delivery helper ----
1892
+ function extractSharedAudioAsVoice(
1893
+ payload: ReplyStreamPayload,
1894
+ inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
1895
+ ): boolean {
1896
+ // Normalize all reply-time voice hints into the shared `audioAsVoice`
1897
+ // semantic that strategies and media delivery use downstream.
1898
+ const richPayload = payload as ReplyStreamPayload & {
1899
+ audioAsVoice?: unknown;
1900
+ asVoice?: unknown;
1901
+ };
1902
+ const sharedValue = parseBooleanLike(richPayload.audioAsVoice);
1903
+ if (sharedValue !== undefined) {
1904
+ return sharedValue;
1905
+ }
1906
+ if (parseBooleanLike(richPayload.asVoice) === true) {
1907
+ return true;
1908
+ }
1909
+ return inlineReplyPayload?.audioAsVoice === true;
1745
1910
  }
1746
- return inlineReplyPayload?.audioAsVoice === true;
1747
- }
1748
1911
 
1749
- async function deliverMediaAttachments(urls: string[], options?: { audioAsVoice?: boolean }) {
1750
- for (const rawMediaUrl of urls) {
1751
- const preparedMedia = await prepareMediaInput(
1752
- rawMediaUrl,
1753
- log,
1754
- dingtalkConfig.mediaUrlAllowlist,
1755
- );
1756
- try {
1757
- const actualMediaPath = preparedMedia.cleanup
1758
- ? preparedMedia.path
1759
- : resolveRelativePath(preparedMedia.path);
1760
- const outMediaType = resolveOutboundMediaType({
1761
- mediaPath: actualMediaPath,
1762
- asVoice: options?.audioAsVoice === true,
1763
- });
1764
- if (sessionWebhook) {
1765
- const sendResult = await sendMessage(dingtalkConfig, to, "", {
1766
- sessionWebhook,
1912
+ async function deliverMediaAttachments(urls: string[], options?: { audioAsVoice?: boolean }) {
1913
+ for (const rawMediaUrl of urls) {
1914
+ const preparedMedia = await prepareMediaInput(
1915
+ rawMediaUrl,
1916
+ log,
1917
+ dingtalkConfig.mediaUrlAllowlist,
1918
+ );
1919
+ try {
1920
+ const actualMediaPath = preparedMedia.cleanup
1921
+ ? preparedMedia.path
1922
+ : resolveRelativePath(preparedMedia.path);
1923
+ const outMediaType = resolveOutboundMediaType({
1767
1924
  mediaPath: actualMediaPath,
1768
- mediaType: outMediaType,
1769
- log,
1770
- accountId,
1771
- storePath: accountStorePath,
1772
- conversationId: groupId,
1773
- quotedRef: replyQuotedRef,
1925
+ asVoice: options?.audioAsVoice === true,
1774
1926
  });
1775
- if (!sendResult.ok) {
1776
- throw new Error(sendResult.error || "Media reply send failed");
1777
- }
1778
- } else {
1779
- const sendResult = await sendProactiveMedia(
1780
- dingtalkConfig,
1781
- to,
1782
- actualMediaPath,
1783
- outMediaType,
1784
- {
1785
- accountId,
1927
+ if (sessionWebhook) {
1928
+ const sendResult = await sendMessage(dingtalkConfig, to, "", {
1929
+ sessionWebhook,
1930
+ mediaPath: actualMediaPath,
1931
+ mediaType: outMediaType,
1786
1932
  log,
1933
+ accountId,
1787
1934
  storePath: accountStorePath,
1788
1935
  conversationId: groupId,
1789
1936
  quotedRef: replyQuotedRef,
1790
- },
1791
- );
1792
- if (!sendResult.ok) {
1793
- throw new Error(sendResult.error || "Media reply send failed");
1937
+ });
1938
+ if (!sendResult.ok) {
1939
+ throw new Error(sendResult.error || "Media reply send failed");
1940
+ }
1941
+ } else {
1942
+ const sendResult = await sendProactiveMedia(
1943
+ dingtalkConfig,
1944
+ to,
1945
+ actualMediaPath,
1946
+ outMediaType,
1947
+ {
1948
+ accountId,
1949
+ log,
1950
+ storePath: accountStorePath,
1951
+ conversationId: groupId,
1952
+ quotedRef: replyQuotedRef,
1953
+ },
1954
+ );
1955
+ if (!sendResult.ok) {
1956
+ throw new Error(sendResult.error || "Media reply send failed");
1957
+ }
1794
1958
  }
1959
+ } finally {
1960
+ await preparedMedia.cleanup?.();
1795
1961
  }
1796
- } finally {
1797
- await preparedMedia.cleanup?.();
1798
1962
  }
1799
1963
  }
1800
- }
1801
1964
 
1802
- // ---- Extract mediaUrls from runtime payload ----
1803
- function extractMediaUrls(
1804
- payload: ReplyStreamPayload,
1805
- inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
1806
- ): string[] {
1807
- const richPayload = payload as typeof payload & {
1808
- mediaUrl?: string;
1809
- mediaUrls?: string[];
1810
- };
1811
- const explicitMediaUrls = Array.isArray(richPayload.mediaUrls)
1812
- ? richPayload.mediaUrls.filter((entry: unknown) => typeof entry === "string" && entry.trim())
1813
- : richPayload.mediaUrl &&
1814
- typeof richPayload.mediaUrl === "string" &&
1815
- richPayload.mediaUrl.trim()
1816
- ? [richPayload.mediaUrl]
1817
- : [];
1818
- return explicitMediaUrls.length > 0
1819
- ? explicitMediaUrls
1820
- : inlineReplyPayload?.mediaUrls ?? [];
1821
- }
1822
-
1823
- // Serialize dispatchReply + card finalize per session to prevent the runtime
1824
- // from receiving concurrent dispatch calls on the same session key, which
1825
- // causes empty replies for all but the first caller.
1826
- // Each sub-agent call acquires its own lock since sub-agent sessions have
1827
- // different session keys (different agentId), so no deadlock risk.
1828
- const currentOutTrackId = currentAICard?.outTrackId;
1829
- const shouldTrackDynamicAckReaction =
1830
- (normalizedAckReaction === "emoji" || normalizedAckReaction === "kaomoji")
1831
- && shouldAttachAckReaction;
1832
- const runtimeEvents = (rt as typeof rt & {
1833
- events?: {
1834
- onAgentEvent?: (listener: (event: unknown) => void) => (() => void);
1835
- };
1836
- }).events;
1837
- const releaseSessionLock = await acquireSessionLock(route.sessionKey);
1838
- const dynamicAckReactionController = createDynamicAckReactionController({
1839
- enabled: shouldTrackDynamicAckReaction,
1840
- initialReaction: resolvedAckReaction || "",
1841
- initialAttached: ackReactionAttached,
1842
- initialAttachedAt: ackReactionAttachedAt,
1843
- dingtalkConfig,
1844
- msgId: data.msgId,
1845
- conversationId: groupId,
1846
- sessionKey: route.sessionKey,
1847
- log,
1848
- runtimeEvents,
1849
- onReactionDisposed: () => {
1850
- ackReactionAttached = false;
1851
- },
1852
- });
1853
- try {
1854
- if (!ackReactionAttached && shouldAttachAckReaction) {
1855
- log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
1856
- }
1857
- const isCurrentCardStopRequested = () =>
1858
- Boolean(
1859
- currentAICard
1860
- && (
1861
- currentAICard.state === AICardStatus.STOPPED
1862
- || (currentOutTrackId && isCardRunStopRequested(currentOutTrackId))
1863
- ),
1864
- );
1865
-
1866
- if (isCurrentCardStopRequested()) {
1867
- log?.info?.("[DingTalk][CardStop] Skip dispatch because card was already stopped before session lock was acquired");
1868
- return;
1965
+ // ---- Extract mediaUrls from runtime payload ----
1966
+ function extractMediaUrls(
1967
+ payload: ReplyStreamPayload,
1968
+ inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
1969
+ ): string[] {
1970
+ const richPayload = payload as typeof payload & {
1971
+ mediaUrl?: string;
1972
+ mediaUrls?: string[];
1973
+ };
1974
+ const explicitMediaUrls = Array.isArray(richPayload.mediaUrls)
1975
+ ? richPayload.mediaUrls.filter(
1976
+ (entry: unknown) => typeof entry === "string" && entry.trim(),
1977
+ )
1978
+ : richPayload.mediaUrl &&
1979
+ typeof richPayload.mediaUrl === "string" &&
1980
+ richPayload.mediaUrl.trim()
1981
+ ? [richPayload.mediaUrl]
1982
+ : [];
1983
+ return explicitMediaUrls.length > 0
1984
+ ? explicitMediaUrls
1985
+ : (inlineReplyPayload?.mediaUrls ?? []);
1869
1986
  }
1870
1987
 
1871
- // ---- Create reply strategy (card or markdown) ----
1872
- const replyMode: ReplyMode = useCardMode && !!currentAICard ? "card" : "markdown";
1873
- const sessionReasoningLevel = readSessionReasoningLevel({
1874
- storePath,
1988
+ // Serialize dispatchReply + card finalize per session to prevent the runtime
1989
+ // from receiving concurrent dispatch calls on the same session key, which
1990
+ // causes empty replies for all but the first caller.
1991
+ // Each sub-agent call acquires its own lock since sub-agent sessions have
1992
+ // different session keys (different agentId), so no deadlock risk.
1993
+ const currentOutTrackId = currentAICard?.outTrackId;
1994
+ const shouldTrackDynamicAckReaction =
1995
+ (normalizedAckReaction === "emoji" || normalizedAckReaction === "kaomoji") &&
1996
+ shouldAttachAckReaction;
1997
+ const runtimeEvents = (
1998
+ rt as typeof rt & {
1999
+ events?: {
2000
+ onAgentEvent?: (listener: (event: unknown) => void) => () => void;
2001
+ };
2002
+ }
2003
+ ).events;
2004
+ const releaseSessionLock = await acquireSessionLock(route.sessionKey);
2005
+ const dynamicAckReactionController = createDynamicAckReactionController({
2006
+ enabled: shouldTrackDynamicAckReaction,
2007
+ initialReaction: resolvedAckReaction || "",
2008
+ initialAttached: ackReactionAttached,
2009
+ initialAttachedAt: ackReactionAttachedAt,
2010
+ dingtalkConfig,
2011
+ msgId: data.msgId,
2012
+ conversationId: groupId,
1875
2013
  sessionKey: route.sessionKey,
1876
- sessionUpdatedAt: previousTimestamp,
1877
2014
  log,
2015
+ runtimeEvents,
2016
+ onReactionDisposed: () => {
2017
+ ackReactionAttached = false;
2018
+ },
1878
2019
  });
1879
- const legacyCardStreamReasoning = resolveLegacyCardStreamReasoningForInternalUse({
1880
- cfg,
1881
- accountId,
1882
- });
1883
- const strategyConfig =
1884
- legacyCardStreamReasoning === undefined
1885
- ? dingtalkConfig
1886
- : { ...dingtalkConfig, cardStreamReasoning: legacyCardStreamReasoning };
1887
- const sessionTaskState = getSessionState(accountId, taskInfoConversationId);
1888
- const taskMeta = {
1889
- model: sessionTaskState?.model,
1890
- effort: sessionTaskState?.effort,
1891
- elapsedMs: typeof sessionTaskState?.taskStartTime === "number"
1892
- ? Math.max(0, Date.now() - sessionTaskState.taskStartTime)
1893
- : undefined,
1894
- agent: getAgentDisplayName({
1895
- subAgentOptions,
1896
- agentId: route.agentId,
1897
- agentsList: cfg.agents?.list,
1898
- }),
1899
- };
2020
+ try {
2021
+ if (!ackReactionAttached && shouldAttachAckReaction) {
2022
+ log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
2023
+ }
2024
+ const isCurrentCardStopRequested = () =>
2025
+ Boolean(
2026
+ currentAICard &&
2027
+ (currentAICard.state === AICardStatus.STOPPED ||
2028
+ (currentOutTrackId && isCardRunStopRequested(currentOutTrackId))),
2029
+ );
1900
2030
 
1901
- const strategy = createReplyStrategy({
1902
- config: strategyConfig,
1903
- card: currentAICard,
1904
- useCardMode: replyMode === "card",
1905
- to,
1906
- sessionWebhook,
1907
- senderId,
1908
- isDirect,
1909
- accountId,
1910
- storePath: accountStorePath,
1911
- sessionKey: route.sessionKey,
1912
- sessionAgentId: route.agentId,
1913
- disableBlockStreaming: shouldDisableBlockStreamingForReplyMode({
1914
- replyMode,
2031
+ if (isCurrentCardStopRequested()) {
2032
+ log?.info?.(
2033
+ "[DingTalk][CardStop] Skip dispatch because card was already stopped before session lock was acquired",
2034
+ );
2035
+ return;
2036
+ }
2037
+
2038
+ // ---- Create reply strategy (card or markdown) ----
2039
+ const replyMode: ReplyMode = useCardMode && !!currentAICard ? "card" : "markdown";
2040
+ const sessionReasoningLevel = readSessionReasoningLevel({
2041
+ storePath,
1915
2042
  sessionKey: route.sessionKey,
1916
- reasoningLevel: sessionReasoningLevel,
2043
+ sessionUpdatedAt: previousTimestamp,
1917
2044
  log,
1918
- }),
1919
- groupId,
1920
- log,
1921
- replyQuotedRef,
1922
- deliverMedia: deliverMediaAttachments,
1923
- isStopRequested: isCurrentCardStopRequested,
1924
- inboundText: rawInboundText,
1925
- taskMeta,
1926
- });
1927
-
1928
- try {
1929
- let deliveredFinalCount = 0;
1930
- const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1931
- ctx,
2045
+ });
2046
+ const legacyCardStreamReasoning = resolveLegacyCardStreamReasoningForInternalUse({
1932
2047
  cfg,
1933
- dispatcherOptions: {
1934
- responsePrefix: subAgentOptions?.responsePrefix || "",
1935
- deliver: async (payload: ReplyStreamPayload, info?: ReplyChunkInfo) => {
1936
- if (isCurrentCardStopRequested()) {
1937
- log?.debug?.("[DingTalk][CardStop] Ignoring reply delivery because stop was already requested");
1938
- return;
1939
- }
1940
- try {
1941
- if (info?.kind === "final") {
1942
- deliveredFinalCount += 1;
1943
- }
1944
- const inlineReplyPayload = parseInlineReplyPayloadText(payload.text);
1945
- const mediaUrls = extractMediaUrls(payload, inlineReplyPayload);
1946
- const richPayload = payload as ReplyStreamPayload & { isReasoning?: boolean };
1947
- await strategy.deliver({
1948
- text: inlineReplyPayload.text,
1949
- mediaUrls,
1950
- audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
1951
- kind: (info?.kind as DeliverPayload["kind"]) || "block",
1952
- isReasoning: richPayload.isReasoning === true,
1953
- });
1954
- } catch (err: unknown) {
1955
- log?.error?.(`[DingTalk] Reply failed: ${getErrorMessage(err)}`);
1956
- const responseData = getErrorResponseData(err);
1957
- if (responseData !== undefined) {
1958
- log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", responseData));
1959
- }
1960
- throw err;
1961
- }
1962
- },
1963
- },
1964
- replyOptions: strategy.getReplyOptions(),
2048
+ accountId,
1965
2049
  });
2050
+ const strategyConfig =
2051
+ legacyCardStreamReasoning === undefined
2052
+ ? dingtalkConfig
2053
+ : { ...dingtalkConfig, cardStreamReasoning: legacyCardStreamReasoning };
2054
+ const sessionTaskState = getSessionState(accountId, taskInfoConversationId);
2055
+ const taskMeta = {
2056
+ model: sessionTaskState?.model,
2057
+ effort: sessionTaskState?.effort,
2058
+ elapsedMs:
2059
+ typeof sessionTaskState?.taskStartTime === "number"
2060
+ ? Math.max(0, Date.now() - sessionTaskState.taskStartTime)
2061
+ : undefined,
2062
+ agent: getAgentDisplayName({
2063
+ subAgentOptions,
2064
+ agentId: route.agentId,
2065
+ agentsList: cfg.agents?.list,
2066
+ }),
2067
+ };
1966
2068
 
1967
- const bufferedFinal =
1968
- dispatchResult && typeof dispatchResult === "object" && "queuedFinal" in dispatchResult
1969
- ? (dispatchResult as { queuedFinal?: unknown }).queuedFinal
1970
- : undefined;
1971
- const finalCount =
1972
- dispatchResult && typeof dispatchResult === "object" && "counts" in dispatchResult
1973
- ? (dispatchResult as { counts?: { final?: unknown } }).counts?.final
1974
- : undefined;
2069
+ const strategy = createReplyStrategy({
2070
+ config: strategyConfig,
2071
+ card: currentAICard,
2072
+ useCardMode: replyMode === "card",
2073
+ to,
2074
+ sessionWebhook,
2075
+ senderId,
2076
+ isDirect,
2077
+ accountId,
2078
+ storePath: accountStorePath,
2079
+ sessionKey: route.sessionKey,
2080
+ sessionAgentId: route.agentId,
2081
+ disableBlockStreaming: shouldDisableBlockStreamingForReplyMode({
2082
+ replyMode,
2083
+ sessionKey: route.sessionKey,
2084
+ reasoningLevel: sessionReasoningLevel,
2085
+ log,
2086
+ }),
2087
+ groupId,
2088
+ log,
2089
+ replyQuotedRef,
2090
+ deliverMedia: deliverMediaAttachments,
2091
+ isStopRequested: isCurrentCardStopRequested,
2092
+ inboundText: rawInboundText,
2093
+ taskMeta,
2094
+ });
1975
2095
 
1976
- log?.info?.(
1977
- `[DingTalk][Dispatch] completed — deliveredFinalCount=${deliveredFinalCount} ` +
1978
- `counts.final=${typeof finalCount === "number" ? finalCount : "n/a"} ` +
1979
- `queuedFinalType=${typeof bufferedFinal}`,
1980
- );
2096
+ try {
2097
+ let deliveredFinalCount = 0;
2098
+ const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
2099
+ ctx,
2100
+ cfg,
2101
+ dispatcherOptions: {
2102
+ responsePrefix: subAgentOptions?.responsePrefix || "",
2103
+ deliver: async (payload: ReplyStreamPayload, info?: ReplyChunkInfo) => {
2104
+ if (isCurrentCardStopRequested()) {
2105
+ log?.debug?.(
2106
+ "[DingTalk][CardStop] Ignoring reply delivery because stop was already requested",
2107
+ );
2108
+ return;
2109
+ }
2110
+ try {
2111
+ if (info?.kind === "final") {
2112
+ deliveredFinalCount += 1;
2113
+ }
2114
+ const inlineReplyPayload = parseInlineReplyPayloadText(payload.text);
2115
+ const mediaUrls = extractMediaUrls(payload, inlineReplyPayload);
2116
+ const richPayload = payload as ReplyStreamPayload & { isReasoning?: boolean };
2117
+ await strategy.deliver({
2118
+ text: inlineReplyPayload.text,
2119
+ mediaUrls,
2120
+ audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
2121
+ kind: (info?.kind as DeliverPayload["kind"]) || "block",
2122
+ isReasoning: richPayload.isReasoning === true,
2123
+ });
2124
+ } catch (err: unknown) {
2125
+ log?.error?.(`[DingTalk] Reply failed: ${getErrorMessage(err)}`);
2126
+ const responseData = getErrorResponseData(err);
2127
+ if (responseData !== undefined) {
2128
+ log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", responseData));
2129
+ }
2130
+ throw err;
2131
+ }
2132
+ },
2133
+ },
2134
+ replyOptions: strategy.getReplyOptions(),
2135
+ });
1981
2136
 
1982
- const bufferedFinalPayload =
1983
- typeof bufferedFinal === "string"
1984
- ? ({ text: bufferedFinal } satisfies ReplyStreamPayload)
1985
- : bufferedFinal && typeof bufferedFinal === "object"
1986
- ? (bufferedFinal as ReplyStreamPayload)
2137
+ const bufferedFinal =
2138
+ dispatchResult && typeof dispatchResult === "object" && "queuedFinal" in dispatchResult
2139
+ ? (dispatchResult as { queuedFinal?: unknown }).queuedFinal
2140
+ : undefined;
2141
+ const finalCount =
2142
+ dispatchResult && typeof dispatchResult === "object" && "counts" in dispatchResult
2143
+ ? (dispatchResult as { counts?: { final?: unknown } }).counts?.final
1987
2144
  : undefined;
1988
2145
 
1989
- if (deliveredFinalCount === 0 && bufferedFinalPayload) {
1990
- const inlineReplyPayload = parseInlineReplyPayloadText(bufferedFinalPayload.text);
1991
- const mediaUrls = extractMediaUrls(bufferedFinalPayload, inlineReplyPayload);
1992
- const hasBufferedText = typeof bufferedFinalPayload.text === "string" && bufferedFinalPayload.text.trim().length > 0;
1993
- if (hasBufferedText || mediaUrls.length > 0) {
1994
- await strategy.deliver({
1995
- text: inlineReplyPayload.text,
1996
- mediaUrls,
1997
- audioAsVoice: extractSharedAudioAsVoice(bufferedFinalPayload, inlineReplyPayload),
1998
- kind: "final",
1999
- isReasoning: bufferedFinalPayload.isReasoning === true,
2000
- });
2146
+ log?.info?.(
2147
+ `[DingTalk][Dispatch] completed deliveredFinalCount=${deliveredFinalCount} ` +
2148
+ `counts.final=${typeof finalCount === "number" ? finalCount : "n/a"} ` +
2149
+ `queuedFinalType=${typeof bufferedFinal}`,
2150
+ );
2151
+
2152
+ const bufferedFinalPayload =
2153
+ typeof bufferedFinal === "string"
2154
+ ? ({ text: bufferedFinal } satisfies ReplyStreamPayload)
2155
+ : bufferedFinal && typeof bufferedFinal === "object"
2156
+ ? (bufferedFinal as ReplyStreamPayload)
2157
+ : undefined;
2158
+
2159
+ if (deliveredFinalCount === 0 && bufferedFinalPayload) {
2160
+ const inlineReplyPayload = parseInlineReplyPayloadText(bufferedFinalPayload.text);
2161
+ const mediaUrls = extractMediaUrls(bufferedFinalPayload, inlineReplyPayload);
2162
+ const hasBufferedText =
2163
+ typeof bufferedFinalPayload.text === "string" &&
2164
+ bufferedFinalPayload.text.trim().length > 0;
2165
+ if (hasBufferedText || mediaUrls.length > 0) {
2166
+ await strategy.deliver({
2167
+ text: inlineReplyPayload.text,
2168
+ mediaUrls,
2169
+ audioAsVoice: extractSharedAudioAsVoice(bufferedFinalPayload, inlineReplyPayload),
2170
+ kind: "final",
2171
+ isReasoning: bufferedFinalPayload.isReasoning === true,
2172
+ });
2173
+ }
2001
2174
  }
2175
+ } catch (dispatchErr: unknown) {
2176
+ const error =
2177
+ dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
2178
+ await strategy.abort(error);
2179
+ throw dispatchErr;
2002
2180
  }
2003
- } catch (dispatchErr: unknown) {
2004
- const error = dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
2005
- await strategy.abort(error);
2006
- throw dispatchErr;
2007
- }
2008
2181
 
2009
- await strategy.finalize();
2182
+ await strategy.finalize();
2183
+ } finally {
2184
+ // Only remove the registry entry if no stop was requested. When a stop is
2185
+ // in progress, card-stop-handler may still be running async operations
2186
+ // (finalize card, hide button, gateway abort) that read the record.
2187
+ // In that case, let the 30-minute TTL sweep handle cleanup.
2188
+ if (currentOutTrackId && !isCardRunStopRequested(currentOutTrackId)) {
2189
+ removeCardRun(currentOutTrackId);
2190
+ }
2191
+ await waitForDynamicAckDispose({
2192
+ dispose: () => dynamicAckReactionController.dispose(MIN_THINKING_REACTION_VISIBLE_MS),
2193
+ log,
2194
+ sessionKey: route.sessionKey,
2195
+ });
2196
+ releaseSessionLock();
2197
+ }
2010
2198
  } finally {
2011
- // Only remove the registry entry if no stop was requested. When a stop is
2012
- // in progress, card-stop-handler may still be running async operations
2013
- // (finalize card, hide button, gateway abort) that read the record.
2014
- // In that case, let the 30-minute TTL sweep handle cleanup.
2015
- if (currentOutTrackId && !isCardRunStopRequested(currentOutTrackId)) {
2016
- removeCardRun(currentOutTrackId);
2199
+ if (cardFlightKey) {
2200
+ cardCreationInFlight.delete(cardFlightKey);
2017
2201
  }
2018
- await waitForDynamicAckDispose({
2019
- dispose: () => dynamicAckReactionController.dispose(MIN_THINKING_REACTION_VISIBLE_MS),
2020
- log,
2021
- sessionKey: route.sessionKey,
2022
- });
2023
- releaseSessionLock();
2024
2202
  }
2025
2203
  }