@soimy/dingtalk 3.5.3 → 3.6.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 (39) hide show
  1. package/README.md +4 -1
  2. package/index.ts +7 -0
  3. package/openclaw.plugin.json +153 -5
  4. package/package.json +1 -1
  5. package/src/auth.ts +5 -2
  6. package/src/card/card-markdown-image-reroute.ts +106 -0
  7. package/src/card/card-run-registry.ts +54 -1
  8. package/src/card/card-stop-handler.ts +10 -20
  9. package/src/card/card-template.ts +14 -3
  10. package/src/card/statusline-renderer.ts +94 -0
  11. package/src/card-draft-controller.ts +245 -52
  12. package/src/card-service.ts +408 -23
  13. package/src/channel.ts +24 -1083
  14. package/src/config-schema.ts +21 -1
  15. package/src/config.ts +139 -66
  16. package/src/device-registration.ts +245 -0
  17. package/src/gateway/channel-gateway.ts +637 -0
  18. package/src/inbound-handler.ts +1276 -975
  19. package/src/media-utils.ts +6 -0
  20. package/src/message-context-store.ts +183 -85
  21. package/src/message-utils.ts +124 -16
  22. package/src/messaging/btw-deliver.ts +85 -0
  23. package/src/messaging/channel-actions.ts +174 -0
  24. package/src/messaging/channel-outbound.ts +158 -0
  25. package/src/onboarding.ts +333 -235
  26. package/src/path-utils.ts +49 -0
  27. package/src/platform/channel-status.ts +81 -0
  28. package/src/reply-strategy-card.ts +373 -64
  29. package/src/reply-strategy-markdown.ts +1 -1
  30. package/src/reply-strategy-types.ts +93 -0
  31. package/src/reply-strategy-with-reaction.ts +1 -1
  32. package/src/reply-strategy.ts +14 -72
  33. package/src/run-usage-store.ts +59 -0
  34. package/src/secret-input.ts +216 -0
  35. package/src/send-service.ts +115 -3
  36. package/src/session-state.ts +62 -0
  37. package/src/targeting/agent-name-matcher.ts +28 -0
  38. package/src/targeting/agent-routing.ts +30 -5
  39. package/src/types.ts +48 -157
@@ -1,27 +1,24 @@
1
1
  import fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import axios from "./http-client";
3
+ import { isAbortRequestText, isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
4
+ import { parseInlineDirectives } from "openclaw/plugin-sdk/text-runtime";
4
5
  import { normalizeAllowFrom, isSenderAllowed, resolveGroupAccess } from "./access-control";
5
- import { buildAgentSessionKey, resolveSubAgentRoute, dispatchSubAgents } from "./targeting/agent-routing";
6
6
  import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
7
7
  import { attachNativeAckReaction } from "./ack-reaction-service";
8
8
  import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
9
- import { extractAttachmentText } from "./messaging/attachment-text-extractor";
10
9
  import { getAccessToken } from "./auth";
11
- import { createAICard, finishAICard, isCardInTerminalState } from "./card-service";
10
+ import { createAICard, commitAICardBlocks, isCardInTerminalState } from "./card-service";
11
+ import { isCardRunStopRequested, registerCardRun, removeCardRun } from "./card/card-run-registry";
12
+ import { renderStatusLine } from "./card/statusline-renderer";
12
13
  import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
13
- import { resolveAckReactionSetting, resolveGroupConfig, resolveRelativePath, resolveRobotCode } from "./config";
14
- import { AICardStatus } from "./types";
15
14
  import {
16
- isCardRunStopRequested,
17
- registerCardRun,
18
- removeCardRun,
19
- } from "./card/card-run-registry";
20
- import {
21
- buildLearningContextBlock,
22
- isLearningEnabled,
23
- } from "./feedback-learning-service";
24
- 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";
25
22
  import { setCurrentLogger } from "./logger-context";
26
23
  import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
27
24
  import {
@@ -30,7 +27,14 @@ import {
30
27
  upsertInboundMessageContext,
31
28
  } from "./message-context-store";
32
29
  import { extractMessageContent } from "./message-utils";
30
+ import { extractAttachmentText } from "./messaging/attachment-text-extractor";
31
+ import { deliverBtwReply, stripLeadingMentions } from "./messaging/btw-deliver";
33
32
  import { resolveQuotedRuntimeContext } from "./messaging/quoted-context";
33
+ import {
34
+ downloadGroupFile,
35
+ getUnionIdByStaffId,
36
+ resolveQuotedFile,
37
+ } from "./messaging/quoted-file-service";
34
38
  import {
35
39
  buildInboundQuotedRef,
36
40
  createReplyQuotedRef,
@@ -41,9 +45,8 @@ import {
41
45
  clearProactiveRiskObservationsForTest,
42
46
  getProactiveRiskObservationForAny,
43
47
  } from "./proactive-risk-registry";
44
- import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./messaging/quoted-file-service";
45
48
  import { createReplyStrategy } from "./reply-strategy";
46
- import type { DeliverPayload } from "./reply-strategy";
49
+ import type { DeliverPayload } from "./reply-strategy-types";
47
50
  import { getDingTalkRuntime } from "./runtime";
48
51
  import { sendBySession, sendMessage, sendProactiveMedia } from "./send-service";
49
52
  import { acquireSessionLock } from "./session-lock";
@@ -53,14 +56,27 @@ import {
53
56
  setSessionPeerOverride,
54
57
  } from "./session-peer-store";
55
58
  import { resolveDingTalkSessionPeer } from "./session-routing";
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";
56
67
  import {
57
68
  upsertObservedGroupTarget,
58
69
  upsertObservedUserTarget,
59
70
  } from "./targeting/target-directory-store";
71
+ import { AICardStatus } from "./types";
60
72
  import type { DingTalkConfig, HandleDingTalkMessageParams, Logger, MediaFile } from "./types";
61
- import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData, parseBooleanLike } from "./utils";
62
- import { isAbortRequestText } from "openclaw/plugin-sdk/reply-runtime";
63
- import { parseInlineDirectives } from "openclaw/plugin-sdk/text-runtime";
73
+ import {
74
+ formatDingTalkErrorPayloadLog,
75
+ getErrorMessage,
76
+ getErrorResponseData,
77
+ maskSensitiveData,
78
+ parseBooleanLike,
79
+ } from "./utils";
64
80
 
65
81
  const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
66
82
  const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
@@ -91,10 +107,19 @@ const STANDALONE_MEDIA_PATH_EXTENSIONS = new Set([
91
107
  ".rar",
92
108
  ]);
93
109
  const proactiveHintLastSentAt = new Map<string, number>();
94
- const sessionReasoningLevelCache = new Map<string, {
95
- updatedAt?: number;
96
- reasoningLevel?: string;
97
- }>();
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>();
98
123
  type ReplyMode = "card" | "markdown";
99
124
 
100
125
  function resolveQuotedContextAllowFrom(
@@ -129,7 +154,15 @@ function filterQuotedRuntimeContext(params: {
129
154
  currentSenderId: string;
130
155
  currentSenderOriginalId: string;
131
156
  }): ReturnType<typeof resolveQuotedRuntimeContext> {
132
- 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;
133
166
  if (!context || isDirect) {
134
167
  return context;
135
168
  }
@@ -146,9 +179,7 @@ function filterQuotedRuntimeContext(params: {
146
179
  currentSenderOriginalId,
147
180
  });
148
181
  const senderAllowed =
149
- allow.hasEntries && !!senderId
150
- ? isSenderAllowed({ allow, senderId })
151
- : false;
182
+ allow.hasEntries && !!senderId ? isSenderAllowed({ allow, senderId }) : false;
152
183
 
153
184
  if (senderAllowed) {
154
185
  return context;
@@ -174,9 +205,9 @@ function readSessionReasoningLevel(params: {
174
205
  const cacheKey = `${params.storePath}:${params.sessionKey}`;
175
206
  const cached = sessionReasoningLevelCache.get(cacheKey);
176
207
  if (
177
- cached
178
- && params.sessionUpdatedAt !== undefined
179
- && cached.updatedAt === params.sessionUpdatedAt
208
+ cached &&
209
+ params.sessionUpdatedAt !== undefined &&
210
+ cached.updatedAt === params.sessionUpdatedAt
180
211
  ) {
181
212
  return cached.reasoningLevel;
182
213
  }
@@ -387,6 +418,8 @@ function buildGroupTurnContextPrompt(params: {
387
418
  type ReplyStreamPayload = {
388
419
  text?: string;
389
420
  isReasoning?: boolean;
421
+ mediaUrl?: string;
422
+ mediaUrls?: string[];
390
423
  };
391
424
 
392
425
  type ReplyChunkInfo = {
@@ -514,7 +547,16 @@ export async function downloadMedia(
514
547
  }
515
548
 
516
549
  export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
517
- 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;
518
560
  const rt = getDingTalkRuntime();
519
561
 
520
562
  // Save logger globally so shared services can log consistently without threading log everywhere.
@@ -534,6 +576,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
534
576
  return;
535
577
  }
536
578
 
579
+ // Preserve raw inbound text before any rewriting (e.g., sub-agent context hint)
580
+ // for use in card quoteContent which should show the user's original message.
581
+ const rawInboundText = extractedContent.text.trim();
582
+
537
583
  // Add context hint for sub-agent mode, stripping quoted prefix to avoid protocol noise in agent context.
538
584
  if (subAgentOptions) {
539
585
  const cleanText = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
@@ -639,31 +685,32 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
639
685
  if (groupAccess.legacyFallback) {
640
686
  log?.info?.(
641
687
  `[DingTalk] DEPRECATED: groupPolicy=allowlist is using "allowFrom" for group access control. ` +
642
- `Please migrate to "groups" (group ID allowlist) or "groupAllowFrom" (sender allowlist).`,
688
+ `Please migrate to "groups" (group ID allowlist) or "groupAllowFrom" (sender allowlist).`,
643
689
  );
644
690
  }
645
691
 
646
692
  if (!groupAccess.allowed) {
647
693
  if (groupAccess.reason === "disabled") {
648
- 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
+ );
649
697
  return;
650
698
  }
651
699
 
652
- const denyMessage = groupAccess.reason === "sender_not_allowed"
653
- ? `⛔ 访问受限\n\n您的用户ID:\`${senderId}\`\n\n请联系管理员将此ID添加到群聊允许列表中。`
654
- : `⛔ 访问受限\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添加到允许列表中。`;
655
704
 
656
705
  log?.debug?.(
657
706
  `[DingTalk] Group blocked: conversationId=${groupId} senderId=${senderId} reason=${groupAccess.reason}`,
658
707
  );
659
708
 
660
709
  try {
661
- await sendBySession(
662
- dingtalkConfig,
663
- sessionWebhook,
664
- denyMessage,
665
- { log, atUserId: senderId },
666
- );
710
+ await sendBySession(dingtalkConfig, sessionWebhook, denyMessage, {
711
+ log,
712
+ atUserId: senderId,
713
+ });
667
714
  } catch (err: any) {
668
715
  log?.debug?.(`[DingTalk] Failed to send group access denied message: ${err.message}`);
669
716
  if (err?.response?.data !== undefined) {
@@ -676,9 +723,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
676
723
  return;
677
724
  }
678
725
 
679
- log?.debug?.(
680
- `[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId}`,
681
- );
726
+ log?.debug?.(`[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId}`);
682
727
  }
683
728
 
684
729
  // Calculate account store path and session peer (for session alias feature)
@@ -808,21 +853,69 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
808
853
  if (commandHandled) {
809
854
  return;
810
855
  }
856
+
857
+ const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_MESSAGE_CONTEXT_TTL_DAYS;
858
+ const quotedRef = buildInboundQuotedRef(data, extractedContent);
859
+ const replyQuotedRef = createReplyQuotedRef(data.msgId);
860
+ const content = extractedContent;
861
+ const isBtwBypass = isBtwRequestText(stripLeadingMentions(content.text).trim());
862
+ const taskInfoConversationId = groupId || to;
863
+ const sessionTaskState = initSessionState(accountId, taskInfoConversationId);
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;
877
+
811
878
  // 3) Select response mode (card vs markdown).
812
879
  // Card creation runs BEFORE media download so the user sees immediate visual
813
880
  // feedback while large files are still being downloaded.
881
+ // /btw is gated out here (`!isBtwBypass`) because it must never create a card —
882
+ // it has its own bypass dispatch later that returns before reaching the main
883
+ // run. Abort (/stop) is intentionally NOT gated: in card mode the existing
884
+ // abort branch finalizes the card with the abort confirmation text instead of
885
+ // sending a separate plain-text message.
814
886
  let useCardMode = dingtalkConfig.messageType === "card";
815
887
  let currentAICard: import("./types").AICardInstance | undefined;
816
888
 
817
- if (useCardMode) {
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
+
903
+ if (useCardMode && !isBtwBypass) {
818
904
  try {
819
905
  log?.debug?.(
820
906
  `[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${to}`,
821
907
  );
908
+ // quoteContent always shows the inbound message text so the user can
909
+ // identify which of their messages this card is replying to.
910
+ // Use rawInboundText ( preserved before sub-agent rewriting) to avoid
911
+ // showing internal routing context like "[你被 @ 为...]" in the card UI.
912
+ const inboundQuoteText = rawInboundText.slice(0, 200);
822
913
  const aiCard = await createAICard(dingtalkConfig, to, log, {
823
914
  accountId,
824
915
  storePath: accountStorePath,
825
916
  contextConversationId: groupId,
917
+ quoteContent: inboundQuoteText,
918
+ statusLine: initialStatusLine,
826
919
  });
827
920
  if (aiCard) {
828
921
  currentAICard = aiCard;
@@ -837,1066 +930,1274 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
837
930
  }
838
931
  } else {
839
932
  useCardMode = false;
933
+ if (cardFlightKey) {
934
+ cardCreationInFlight.delete(cardFlightKey);
935
+ cardFlightKey = undefined;
936
+ }
840
937
  log?.warn?.(
841
938
  "[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.",
842
939
  );
843
940
  }
844
941
  } catch (err: any) {
845
942
  useCardMode = false;
943
+ if (cardFlightKey) {
944
+ cardCreationInFlight.delete(cardFlightKey);
945
+ cardFlightKey = undefined;
946
+ }
846
947
  log?.warn?.(
847
948
  `[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`,
848
949
  );
849
950
  }
850
951
  }
851
-
852
- const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_MESSAGE_CONTEXT_TTL_DAYS;
853
- const quotedRef = buildInboundQuotedRef(data, extractedContent);
854
- const replyQuotedRef = createReplyQuotedRef(data.msgId);
855
- const content = extractedContent;
856
- const hasLegacyQuoteContent =
857
- typeof data.content?.quoteContent === "string" && data.content.quoteContent.trim().length > 0;
858
-
859
- if (hasLegacyQuoteContent && !quotedRef) {
860
- log?.debug?.(
861
- `[DingTalk] Legacy quoteContent present without resolvable quotedRef: ` +
862
- `conversationType=${data.conversationType} conversationId=${data.conversationId} ` +
863
- `msgId=${data.msgId} originalMsgId=${data.originalMsgId || "(none)"}`,
864
- );
865
- }
866
- if (quotedRef) {
867
- log?.debug?.(
868
- `[DingTalk][QuotedRef] Built inbound quotedRef msgId=${data.msgId} scope=${groupId} ` +
869
- `quotedRef=${JSON.stringify(quotedRef)}`,
870
- );
871
- } else if (
872
- data.text?.isReplyMsg ||
873
- data.originalMsgId ||
874
- data.originalProcessQueryKey ||
875
- content.quoted
876
- ) {
877
- log?.debug?.(
878
- `[DingTalk][QuotedRef] Reply metadata present without resolvable quotedRef ` +
879
- `msgId=${data.msgId} scope=${groupId} originalMsgId=${data.originalMsgId || "(none)"} ` +
880
- `originalProcessQueryKey=${data.originalProcessQueryKey || "(none)"}`,
881
- );
882
- }
883
-
952
+ // Outer try/finally guarantees cardFlightKey cleanup even when the handler
953
+ // returns or throws between card creation and session-lock release.
884
954
  try {
885
- upsertInboundMessageContext({
886
- storePath: accountStorePath,
887
- accountId,
888
- conversationId: groupId,
889
- msgId: data.msgId,
890
- messageType: content.messageType,
891
- text: content.text,
892
- quotedRef,
893
- senderId,
894
- senderName,
895
- createdAt: data.createAt,
896
- ttlMs: ttlDaysToMs(journalTTLDays),
897
- ttlReferenceMs: data.createAt,
898
- cleanupCreatedAtTtlDays: journalTTLDays,
899
- topic: null,
900
- });
901
- } catch (err) {
902
- log?.warn?.(`[DingTalk] Message context inbound append failed: ${String(err)}`);
903
- }
955
+ const hasLegacyQuoteContent =
956
+ typeof data.content?.quoteContent === "string" && data.content.quoteContent.trim().length > 0;
904
957
 
905
- const robotCode = resolveRobotCode(dingtalkConfig);
906
- let mediaPath: string | undefined;
907
- let mediaType: string | undefined;
908
- let attachmentContextMsgId = data.msgId;
909
- let attachmentContextCreatedAt = data.createAt;
910
- let attachmentContextMessageType = content.messageType;
911
- let attachmentContextFileName = data.content?.fileName;
912
-
913
- // Use pre-downloaded media if available (from sub-agent outer call)
914
- if (preDownloadedMedia?.mediaPath) {
915
- mediaPath = preDownloadedMedia.mediaPath;
916
- mediaType = preDownloadedMedia.mediaType;
917
- } else if (content.mediaPath && robotCode) {
918
- // Download media only if not pre-downloaded
919
- const media = await downloadMedia(
920
- dingtalkConfig,
921
- content.mediaPath,
922
- log,
923
- attachmentContextFileName,
924
- );
925
- if (media) {
926
- mediaPath = media.path;
927
- 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
+ );
928
981
  }
929
- }
930
-
931
- // Cache downloadCode (+ spaceId/fileId) for quoted file lookups (DM + group).
932
- if (content.mediaPath && data.msgId) {
933
- upsertInboundMessageContext({
934
- storePath: accountStorePath,
935
- accountId,
936
- conversationId: data.conversationId,
937
- msgId: data.msgId,
938
- createdAt: data.createAt,
939
- messageType: content.messageType,
940
- media: {
941
- downloadCode: content.mediaPath,
942
- spaceId: data.content?.spaceId,
943
- fileId: data.content?.fileId,
944
- },
945
- attachmentFileName: attachmentContextFileName,
946
- ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
947
- topic: null,
948
- });
949
- }
950
982
 
951
- // User-sent DingTalk doc / Drive file card: cache msgId -> {spaceId,fileId}
952
- // during the original message turn, and try downloading immediately in DM.
953
- if (
954
- content.messageType === "interactiveCardFile" &&
955
- data.msgId &&
956
- content.docSpaceId &&
957
- content.docFileId
958
- ) {
959
- upsertInboundMessageContext({
960
- storePath: accountStorePath,
961
- accountId,
962
- conversationId: data.conversationId,
963
- msgId: data.msgId,
964
- createdAt: data.createAt,
965
- messageType: content.messageType,
966
- media: {
967
- spaceId: content.docSpaceId,
968
- fileId: content.docFileId,
969
- },
970
- attachmentFileName: attachmentContextFileName,
971
- ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
972
- topic: null,
973
- });
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
+ }
974
1003
 
975
- if (!mediaPath && isDirect && data.senderStaffId) {
976
- try {
977
- const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
978
- 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(
979
1039
  dingtalkConfig,
980
- content.docSpaceId,
981
- content.docFileId,
982
- unionId,
1040
+ downloadCode,
983
1041
  log,
984
1042
  attachmentContextFileName,
985
1043
  );
986
- if (docMedia) {
987
- mediaPath = docMedia.path;
988
- 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);
989
1051
  }
990
- } catch (err: any) {
991
- log?.warn?.(`[DingTalk] Doc card download failed: ${err.message}`);
992
1052
  }
993
1053
  }
994
- }
995
1054
 
996
- const quotedRecord = resolveQuotedRecord({
997
- storePath: accountStorePath,
998
- accountId,
999
- conversationId: data.conversationId,
1000
- quotedRef,
1001
- log,
1002
- });
1003
- const quotedRuntimeContext = filterQuotedRuntimeContext({
1004
- 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({
1005
1130
  storePath: accountStorePath,
1006
1131
  accountId,
1007
1132
  conversationId: data.conversationId,
1008
1133
  quotedRef,
1009
- firstRecord: quotedRecord,
1010
- firstPreview:
1011
- content.quoted?.previewText ||
1012
- content.quoted?.previewMessageType
1013
- ? {
1014
- text: content.quoted.previewText,
1015
- messageType: content.quoted.previewMessageType,
1016
- senderId: content.quoted.previewSenderId,
1017
- }
1018
- : undefined,
1019
1134
  log,
1020
- }),
1021
- config: dingtalkConfig,
1022
- isDirect,
1023
- groupId,
1024
- quotedSenderId: quotedRecord?.senderId || content.quoted?.previewSenderId,
1025
- currentSenderId: senderId,
1026
- currentSenderOriginalId: senderOriginalId,
1027
- });
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
+ });
1028
1160
 
1029
- // Try downloading a quoted file from cached downloadCode/spaceId+fileId.
1030
- const tryDownloadFromRecord = async (
1031
- record: {
1032
- msgId?: string;
1033
- media?: {
1034
- downloadCode?: string;
1035
- spaceId?: string;
1036
- fileId?: string;
1037
- };
1038
- } | null,
1039
- originalFilename?: string,
1040
- ): Promise<MediaFile | null> => {
1041
- if (!record?.media) {
1042
- return null;
1043
- }
1044
- let media: MediaFile | null = null;
1045
- if (record.media.downloadCode) {
1046
- media = await downloadMedia(dingtalkConfig, record.media.downloadCode, log, originalFilename);
1047
- if (media) {
1048
- log?.debug?.(
1049
- `[DingTalk][QuotedRef] Recovered quoted media from cached downloadCode ` +
1050
- `recordMsgId=${record.msgId || "(none)"} scope=${data.conversationId}`,
1051
- );
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;
1052
1175
  }
1053
- }
1054
- if (!media && record.media.spaceId && record.media.fileId && data.senderStaffId) {
1055
- try {
1056
- const unionId = await getUnionIdByStaffId(dingtalkConfig, data.senderStaffId, log);
1057
- media = await downloadGroupFile(
1176
+ let media: MediaFile | null = null;
1177
+ if (record.media.downloadCode) {
1178
+ media = await downloadMedia(
1058
1179
  dingtalkConfig,
1059
- record.media.spaceId,
1060
- record.media.fileId,
1061
- unionId,
1180
+ record.media.downloadCode,
1062
1181
  log,
1063
1182
  originalFilename,
1064
1183
  );
1065
1184
  if (media) {
1066
1185
  log?.debug?.(
1067
- `[DingTalk][QuotedRef] Recovered quoted media from cached spaceId/fileId ` +
1186
+ `[DingTalk][QuotedRef] Recovered quoted media from cached downloadCode ` +
1068
1187
  `recordMsgId=${record.msgId || "(none)"} scope=${data.conversationId}`,
1069
1188
  );
1070
1189
  }
1071
- } catch (err: any) {
1072
- log?.warn?.(`[DingTalk] spaceId+fileId fallback failed: ${err.message}`);
1073
1190
  }
1074
- }
1075
- return media;
1076
- };
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
+ };
1077
1214
 
1078
- // Quoted picture: download via existing downloadMedia.
1079
- if (!mediaPath && content.quoted?.mediaDownloadCode && robotCode) {
1080
- const quotedOriginalFilename = content.quoted.previewFileName;
1081
- const media =
1082
- (await tryDownloadFromRecord(quotedRecord, quotedOriginalFilename)) ||
1083
- (await downloadMedia(
1084
- dingtalkConfig,
1085
- content.quoted.mediaDownloadCode,
1086
- log,
1087
- quotedOriginalFilename,
1088
- ));
1089
- if (media) {
1090
- 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";
1091
1240
  log?.debug?.(
1092
- `[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}`,
1093
1243
  );
1094
1244
  }
1095
- mediaPath = media.path;
1096
- mediaType = media.mimeType;
1097
- attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
1098
- attachmentContextCreatedAt = quotedRecord?.createdAt || data.createAt;
1099
- attachmentContextMessageType = quotedRecord?.messageType || content.quoted.previewMessageType || "picture";
1100
- attachmentContextFileName = content.quoted.previewFileName;
1101
- } else {
1102
- content.text = `[引用了一张图片,但下载失败]\n\n${content.text}`;
1103
1245
  }
1104
- }
1105
-
1106
- // Quoted file/audio/video (file/audio/video msgType) or unknownMsgType:
1107
- // Step 0 tries direct downloadCode; Steps 1-2 fall back to cache and group file API.
1108
- if (!mediaPath && content.quoted?.isQuotedFile) {
1109
- let fileResolved = false;
1110
1246
 
1111
- // Step 0: Direct download via downloadCode from quoted payload (file/audio/video msgType).
1112
- if (!fileResolved && content.quoted.fileDownloadCode && robotCode) {
1113
- const media = await downloadMedia(
1114
- dingtalkConfig,
1115
- content.quoted.fileDownloadCode,
1116
- log,
1117
- content.quoted.previewFileName,
1118
- );
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
+ ));
1119
1258
  if (media) {
1259
+ if (!quotedRecord) {
1260
+ log?.debug?.(
1261
+ `[DingTalk][QuotedRef] Recovered quoted image from inbound downloadCode fallback scope=${data.conversationId}`,
1262
+ );
1263
+ }
1120
1264
  mediaPath = media.path;
1121
1265
  mediaType = media.mimeType;
1122
- attachmentContextMsgId = content.quoted.msgId || data.msgId;
1123
- attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
1124
- 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";
1125
1272
  attachmentContextFileName = content.quoted.previewFileName;
1126
- fileResolved = true;
1127
- log?.debug?.(
1128
- `[DingTalk][QuotedRef] Downloaded quoted file via direct downloadCode scope=${data.conversationId}`,
1129
- );
1273
+ } else {
1274
+ content.text = `[引用了一张图片,但下载失败]\n\n${content.text}`;
1130
1275
  }
1131
1276
  }
1132
1277
 
1133
- // Step 1: Prefer quotedRef-backed record lookup, then msgId-based cache.
1134
- if (!fileResolved) {
1135
- const cachedMedia = await tryDownloadFromRecord(
1136
- quotedRecord,
1137
- quotedRecord?.attachmentFileName || content.quoted.previewFileName,
1138
- );
1139
- if (cachedMedia) {
1140
- mediaPath = cachedMedia.path;
1141
- mediaType = cachedMedia.mimeType;
1142
- attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
1143
- attachmentContextCreatedAt = quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
1144
- attachmentContextMessageType = quotedRecord?.messageType || "file";
1145
- attachmentContextFileName = quotedRecord?.attachmentFileName || content.quoted.previewFileName;
1146
- fileResolved = true;
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
+ }
1147
1305
  }
1148
- }
1149
1306
 
1150
- // Step 2 (group only): Cache miss fall back to group file API time-based matching.
1151
- if (!fileResolved && !isDirect) {
1152
- const resolved = await resolveQuotedFile(
1153
- dingtalkConfig,
1154
- {
1155
- openConversationId: data.conversationId,
1156
- senderStaffId: data.senderStaffId,
1157
- fileCreatedAt: content.quoted.fileCreatedAt,
1158
- },
1159
- log,
1160
- );
1161
- if (resolved) {
1162
- mediaPath = resolved.media.path;
1163
- mediaType = resolved.media.mimeType;
1164
- attachmentContextMsgId = content.quoted.msgId || data.msgId;
1165
- attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
1166
- attachmentContextMessageType = "file";
1167
- attachmentContextFileName = resolved.name || content.quoted.previewFileName;
1168
- fileResolved = true;
1169
- log?.debug?.(
1170
- `[DingTalk][QuotedRef] Recovered quoted file from group file fallback ` +
1171
- `scope=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
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,
1172
1312
  );
1173
- if (content.quoted.msgId) {
1174
- upsertInboundMessageContext({
1175
- storePath: accountStorePath,
1176
- accountId,
1177
- conversationId: data.conversationId,
1178
- msgId: content.quoted.msgId,
1179
- createdAt: content.quoted.fileCreatedAt || Date.now(),
1180
- messageType: "file",
1181
- media: {
1182
- spaceId: resolved.spaceId,
1183
- fileId: resolved.fileId,
1184
- },
1185
- attachmentFileName: resolved.name,
1186
- ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
1187
- topic: null,
1188
- });
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;
1189
1325
  }
1190
1326
  }
1191
- }
1192
1327
 
1193
- if (!fileResolved) {
1194
- log?.warn?.(
1195
- `[DingTalk] Quoted file unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1196
- );
1197
- const hint = isDirect
1198
- ? "[引用了一个文件,内容无法自动获取,请直接发送该文件]\n\n"
1199
- : "[引用了一个文件,但无法获取内容]\n\n";
1200
- content.text = `${hint}${content.text}`;
1201
- }
1202
- }
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
+ }
1203
1372
 
1204
- // Quoted DingTalk doc / Drive file card:
1205
- // 1) Prefer msgId-based cached metadata captured when the original doc card
1206
- // message was seen.
1207
- // 2) In group chats, if the bot never saw the original doc card message,
1208
- // reuse the same group-file fallback chain as ordinary quoted files.
1209
- if (!mediaPath && content.quoted?.isQuotedDocCard) {
1210
- let docResolved = false;
1211
-
1212
- const cachedDocMedia = await tryDownloadFromRecord(
1213
- quotedRecord,
1214
- quotedRecord?.attachmentFileName || content.quoted?.previewFileName,
1215
- );
1216
- if (cachedDocMedia) {
1217
- mediaPath = cachedDocMedia.path;
1218
- mediaType = cachedDocMedia.mimeType;
1219
- attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
1220
- attachmentContextCreatedAt = quotedRecord?.createdAt || content.quoted.fileCreatedAt || data.createAt;
1221
- attachmentContextMessageType =
1222
- quotedRecord?.messageType || content.quoted.previewMessageType || "interactiveCardFile";
1223
- attachmentContextFileName = quotedRecord?.attachmentFileName || content.quoted.previewFileName;
1224
- docResolved = true;
1373
+ if (!fileResolved) {
1374
+ log?.warn?.(
1375
+ `[DingTalk] Quoted file unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1376
+ );
1377
+ const hint = isDirect
1378
+ ? "[引用了一个文件,内容无法自动获取,请直接发送该文件]\n\n"
1379
+ : "[引用了一个文件,但无法获取内容]\n\n";
1380
+ content.text = `${hint}${content.text}`;
1381
+ }
1225
1382
  }
1226
1383
 
1227
- if (!docResolved && !isDirect && content.quoted.fileCreatedAt) {
1228
- const resolved = await resolveQuotedFile(
1229
- dingtalkConfig,
1230
- {
1231
- openConversationId: data.conversationId,
1232
- senderStaffId: data.senderStaffId,
1233
- fileCreatedAt: content.quoted.fileCreatedAt,
1234
- },
1235
- log,
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(
1393
+ quotedRecord,
1394
+ quotedRecord?.attachmentFileName || content.quoted?.previewFileName,
1236
1395
  );
1237
- if (resolved) {
1238
- mediaPath = resolved.media.path;
1239
- mediaType = resolved.media.mimeType;
1240
- attachmentContextMsgId = content.quoted.msgId || data.msgId;
1241
- attachmentContextCreatedAt = content.quoted.fileCreatedAt || data.createAt;
1242
- attachmentContextMessageType = "interactiveCardFile";
1243
- attachmentContextFileName = resolved.name || content.quoted.previewFileName;
1396
+ if (cachedDocMedia) {
1397
+ mediaPath = cachedDocMedia.path;
1398
+ mediaType = cachedDocMedia.mimeType;
1399
+ mediaPaths.push(cachedDocMedia.path);
1400
+ mediaTypes.push(cachedDocMedia.mimeType);
1401
+ attachmentContextMsgId = quotedRecord?.msgId || content.quoted.msgId || data.msgId;
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;
1244
1408
  docResolved = true;
1245
- log?.debug?.(
1246
- `[DingTalk][QuotedRef] Recovered quoted doc card from group file fallback ` +
1247
- `scope=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1248
- );
1249
- if (content.quoted.msgId) {
1409
+ }
1410
+
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)"}`,
1458
+ );
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) {
1250
1475
  upsertInboundMessageContext({
1251
1476
  storePath: accountStorePath,
1252
1477
  accountId,
1253
1478
  conversationId: data.conversationId,
1254
- msgId: content.quoted.msgId,
1255
- createdAt: content.quoted.fileCreatedAt || Date.now(),
1256
- messageType: "interactiveCardFile",
1257
- media: {
1258
- spaceId: resolved.spaceId,
1259
- fileId: resolved.fileId,
1260
- },
1261
- attachmentFileName: resolved.name,
1262
- 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),
1263
1487
  topic: null,
1264
1488
  });
1489
+ attachmentExtractedText = `${ATTACHMENT_TEXT_PREFIX}\n${extracted.text}`;
1265
1490
  }
1491
+ } catch (err: any) {
1492
+ log?.warn?.(`[DingTalk] Failed to extract attachment text: ${err.message}`);
1266
1493
  }
1267
1494
  }
1268
1495
 
1269
- if (!docResolved) {
1270
- log?.warn?.(
1271
- `[DingTalk] Quoted doc card unresolved: conversationType=${data.conversationType} conversationId=${data.conversationId} quotedMsgId=${content.quoted.msgId || "(none)"}`,
1272
- );
1273
- const hint = isDirect
1274
- ? "[引用了钉钉文档,内容无法自动获取,请直接发送该文档]\n\n"
1275
- : "[引用了钉钉文档,但无法获取内容]\n\n";
1276
- content.text = `${hint}${content.text}`;
1277
- }
1278
- }
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
+ });
1279
1513
 
1280
- let attachmentExtractedText: string | undefined;
1281
- if (mediaPath) {
1282
- try {
1283
- const extracted = await extractAttachmentText({
1284
- path: mediaPath,
1285
- mimeType: mediaType,
1286
- fileName: attachmentContextFileName || data.content?.fileName,
1287
- });
1288
- if (extracted?.text) {
1289
- upsertInboundMessageContext({
1290
- storePath: accountStorePath,
1291
- accountId,
1292
- conversationId: data.conversationId,
1293
- msgId: attachmentContextMsgId,
1294
- createdAt: attachmentContextCreatedAt,
1295
- messageType: attachmentContextMessageType,
1296
- attachmentText: extracted.text,
1297
- attachmentTextSource: extracted.sourceType,
1298
- attachmentTextTruncated: extracted.truncated,
1299
- attachmentFileName: attachmentContextFileName,
1300
- ttlMs: ttlDaysToMs(journalTTLDays),
1301
- topic: null,
1302
- });
1303
- attachmentExtractedText = `${ATTACHMENT_TEXT_PREFIX}\n${extracted.text}`;
1304
- }
1305
- } catch (err: any) {
1306
- log?.warn?.(`[DingTalk] Failed to extract attachment text: ${err.message}`);
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);
1307
1531
  }
1308
- }
1309
-
1310
- const inboundBody = content.text;
1311
- const inboundText = attachmentExtractedText
1312
- ? `${inboundBody.trimEnd()}\n\n${attachmentExtractedText}`
1313
- : inboundBody;
1314
- const learningEnabled = isLearningEnabled(dingtalkConfig);
1315
- const learningContextBlock = buildLearningContextBlock({
1316
- enabled: learningEnabled,
1317
- storePath: accountStorePath,
1318
- accountId,
1319
- targetId: data.conversationId,
1320
- content,
1321
- });
1322
- const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg);
1323
- const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
1324
- storePath,
1325
- sessionKey: route.sessionKey,
1326
- });
1327
-
1328
- const groupConfig = !isDirect ? resolveGroupConfig(dingtalkConfig, groupId) : undefined;
1329
- // GroupSystemPrompt is injected every turn (not only first-turn intro).
1330
- const groupSystemPromptParts = !isDirect
1331
- ? [
1332
- buildGroupTurnContextPrompt({
1333
- conversationId: groupId,
1334
- senderDingtalkId: senderId,
1335
- senderName,
1336
- }),
1337
- groupConfig?.systemPrompt?.trim(),
1338
- ]
1339
- : [];
1340
- const extraSystemPrompt =
1341
- [...groupSystemPromptParts, learningContextBlock].filter(Boolean).join("\n\n") || undefined;
1342
-
1343
- if (!isDirect) {
1344
- noteGroupMember(storePath, groupId, senderId, senderName);
1345
- }
1346
- const groupMembers = !isDirect ? formatGroupMembers(storePath, groupId) : undefined;
1347
-
1348
- const fromLabel = isDirect ? `${senderName} (${senderId})` : `${groupName} - ${senderName}`;
1349
- const body = rt.channel.reply.formatInboundEnvelope({
1350
- channel: "DingTalk",
1351
- from: fromLabel,
1352
- timestamp: data.createAt,
1353
- body: inboundText,
1354
- chatType: isDirect ? "direct" : "group",
1355
- sender: { name: senderName, id: senderId },
1356
- previousTimestamp,
1357
- envelope: envelopeOptions,
1358
- });
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
+ });
1359
1545
 
1360
- const ctx = rt.channel.reply.finalizeInboundContext({
1361
- Body: body,
1362
- RawBody: inboundText,
1363
- CommandBody: inboundText,
1364
- QuotedRef: quotedRef,
1365
- QuotedRefJson: quotedRef ? JSON.stringify(quotedRef) : undefined,
1366
- ReplyToId: quotedRuntimeContext?.replyToId,
1367
- ReplyToBody: quotedRuntimeContext?.replyToBody,
1368
- ReplyToSender: quotedRuntimeContext?.replyToSender,
1369
- ReplyToIsQuote: quotedRuntimeContext?.replyToIsQuote,
1370
- UntrustedContext: quotedRuntimeContext?.untrustedContext
1371
- ? [quotedRuntimeContext.untrustedContext]
1372
- : undefined,
1373
- From: to,
1374
- To: to,
1375
- SessionKey: route.sessionKey,
1376
- AccountId: accountId,
1377
- ChatType: isDirect ? "direct" : "group",
1378
- ConversationLabel: fromLabel,
1379
- GroupSubject: isDirect ? undefined : groupName,
1380
- SenderName: senderName,
1381
- SenderId: senderId,
1382
- Provider: "dingtalk",
1383
- Surface: "dingtalk",
1384
- MessageSid: data.msgId,
1385
- Timestamp: data.createAt,
1386
- MediaPath: mediaPath,
1387
- MediaType: mediaType,
1388
- MediaUrl: mediaPath,
1389
- GroupMembers: groupMembers,
1390
- GroupSystemPrompt: extraSystemPrompt,
1391
- GroupChannel: isDirect ? undefined : route.sessionKey,
1392
- CommandAuthorized: commandAuthorized,
1393
- OriginatingChannel: "dingtalk",
1394
- OriginatingTo: to,
1395
- });
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
+ });
1396
1585
 
1397
- await rt.channel.session.recordInboundSession({
1398
- storePath,
1399
- sessionKey: ctx.SessionKey || route.sessionKey,
1400
- ctx,
1401
- updateLastRoute: (() => {
1402
- if (!isDirect) {
1403
- return undefined;
1404
- }
1405
- const pinnedMainDmOwner = resolvePinnedMainDmOwner({
1406
- dmScope: cfg.session?.dmScope,
1407
- allowFrom: dingtalkConfig.allowFrom,
1408
- });
1409
- const senderRecipient = (senderOriginalId || senderId || "").trim().toLowerCase();
1410
- if (
1411
- pinnedMainDmOwner
1412
- && senderRecipient
1413
- && pinnedMainDmOwner.trim().toLowerCase() !== senderRecipient
1414
- ) {
1415
- log?.debug?.(
1416
- `[DingTalk] Skipping main-session last route update for ${senderRecipient} (pinned owner ${pinnedMainDmOwner})`,
1417
- );
1418
- return undefined;
1419
- }
1420
- return { sessionKey: route.mainSessionKey, channel: "dingtalk", to, accountId };
1421
- })(),
1422
- onRecordError: (err: unknown) => {
1423
- log?.error?.(`[DingTalk] Failed to record inbound session: ${String(err)}`);
1424
- },
1425
- });
1586
+ await rt.channel.session.recordInboundSession({
1587
+ storePath,
1588
+ sessionKey: ctx.SessionKey || route.sessionKey,
1589
+ ctx,
1590
+ updateLastRoute: (() => {
1591
+ if (!isDirect) {
1592
+ return undefined;
1593
+ }
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
+ });
1426
1615
 
1427
- log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
1428
-
1429
- // ---- Pre-lock abort: bypass session lock for stop requests ----
1430
- // isAbortRequestText matches "/stop", "停止", "stop", "esc", etc.
1431
- // Calling dispatchReplyWithBufferedBlockDispatcher without holding the lock lets
1432
- // tryFastAbortFromMessage (inside the SDK) kill any in-flight generation immediately,
1433
- // rather than waiting for it to finish before the stop message is processed.
1434
- //
1435
- // Strip leading @mention tokens before the abort check so that messages like
1436
- // "@Agent /stop" are correctly recognised as abort requests in both DM and group
1437
- // chats. In groups DingTalk usually strips @BotName at the protocol level, but
1438
- // in DMs with multi-agent routing the @mention prefix survives all the way here.
1439
- const textForAbortCheck = inboundText.replace(/^(?:@\S+\s+)*/u, "").trim();
1440
- if (isAbortRequestText(textForAbortCheck)) {
1441
- log?.info?.(
1442
- `[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
1443
- );
1444
- // In card mode: capture the abort confirmation text so we can write it into
1445
- // the card (instead of sending a separate plain text message).
1446
- let abortConfirmationText: string | undefined;
1447
- try {
1448
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1449
- ctx,
1450
- cfg,
1451
- dispatcherOptions: {
1452
- responsePrefix: "",
1453
- deliver: async (payload) => {
1454
- if (!payload.text) {
1455
- log?.debug?.(`[DingTalk] Abort deliver received non-text payload, skipping`);
1456
- return;
1457
- }
1458
- if (currentAICard) {
1459
- // Card mode: capture text — will be written to card after dispatch.
1460
- abortConfirmationText = payload.text;
1461
- } else {
1462
- try {
1463
- if (sessionWebhook) {
1464
- await sendBySession(dingtalkConfig, sessionWebhook, payload.text, {
1465
- log,
1466
- accountId,
1467
- storePath: accountStorePath,
1468
- });
1469
- } else {
1470
- await sendMessage(dingtalkConfig, to, payload.text, {
1471
- log,
1472
- accountId,
1473
- storePath: accountStorePath,
1474
- conversationId: groupId,
1475
- });
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}`,
1632
+ );
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
+ );
1476
1670
  }
1477
- } catch (deliverErr) {
1478
- log?.warn?.(
1479
- `[DingTalk] Abort reply delivery failed: ${getErrorMessage(deliverErr)}`,
1480
- );
1481
1671
  }
1482
- }
1672
+ },
1483
1673
  },
1484
- },
1485
- });
1486
- } catch (abortErr) {
1487
- log?.warn?.(`[DingTalk] Abort dispatch failed: ${getErrorMessage(abortErr)}`);
1488
- }
1489
- // Finalize the card that was created for this message before the abort check.
1490
- // Without this, the card stays in PROCESSING ("处理中...") indefinitely.
1491
- if (currentAICard && !isCardInTerminalState(currentAICard.state)) {
1492
- try {
1493
- await finishAICard(currentAICard, abortConfirmationText ?? "已停止", log);
1494
- } catch (cardErr) {
1495
- log?.warn?.(`[DingTalk] Abort card finalize failed: ${getErrorMessage(cardErr)}`);
1496
- currentAICard.state = AICardStatus.FAILED;
1674
+ });
1675
+ } catch (abortErr) {
1676
+ log?.warn?.(`[DingTalk] Abort dispatch failed: ${getErrorMessage(abortErr)}`);
1497
1677
  }
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);
1686
+
1687
+ await commitAICardBlocks(
1688
+ currentAICard,
1689
+ {
1690
+ blockListJson,
1691
+ content: abortText,
1692
+ },
1693
+ log,
1694
+ );
1695
+
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
+ }
1703
+ }
1704
+ return;
1498
1705
  }
1499
- return;
1500
- }
1501
1706
 
1502
- const ackReaction =
1503
- typeof dingtalkConfig.ackReaction === "string"
1504
- ? dingtalkConfig.ackReaction.trim()
1505
- : resolveAckReactionSetting({
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,
1506
1731
  cfg,
1507
- accountId,
1508
- agentId: route.agentId,
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;
1738
+ }
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
+ },
1754
+ },
1509
1755
  });
1510
- const normalizedAckReaction = ackReaction === "off" ? "" : ackReaction;
1511
- const resolvedAckReaction =
1512
- normalizedAckReaction === "kaomoji"
1513
- ? classifyAckReactionEmoji(content.text).emoji
1514
- : normalizedAckReaction === "emoji"
1515
- ? "🤔思考中"
1516
- : normalizedAckReaction;
1517
- const shouldAttachAckReaction = Boolean(resolvedAckReaction);
1518
- let ackReactionAttached = false;
1519
- let ackReactionAttachedAt = 0;
1520
-
1521
- if (shouldAttachAckReaction) {
1522
- ackReactionAttached = await attachNativeAckReaction(
1523
- dingtalkConfig,
1524
- {
1525
- msgId: data.msgId,
1526
- conversationId: groupId,
1527
- reactionName: resolvedAckReaction,
1528
- },
1529
- log,
1530
- );
1531
- if (ackReactionAttached) {
1532
- ackReactionAttachedAt = Date.now();
1533
- log?.debug?.(
1534
- `[DingTalk] Initial ack reaction attached mode=${normalizedAckReaction || "off"} reaction=${resolvedAckReaction}`,
1535
- );
1756
+ } catch (btwErr) {
1757
+ log?.warn?.(`[DingTalk] BTW dispatch failed: ${getErrorMessage(btwErr)}`);
1758
+ }
1759
+ return;
1536
1760
  }
1537
- }
1538
1761
 
1539
- function parseInlineReplyPayloadText(text: unknown): {
1540
- text?: string;
1541
- mediaUrls: string[];
1542
- audioAsVoice: boolean;
1543
- } {
1544
- if (typeof text !== "string") {
1545
- return { text: undefined, mediaUrls: [], audioAsVoice: false };
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,
1788
+ },
1789
+ log,
1790
+ );
1791
+ if (ackReactionAttached) {
1792
+ ackReactionAttachedAt = Date.now();
1793
+ log?.debug?.(
1794
+ `[DingTalk] Initial ack reaction attached mode=${normalizedAckReaction || "off"} reaction=${resolvedAckReaction}`,
1795
+ );
1796
+ }
1546
1797
  }
1547
1798
 
1548
- const normalizedText = text.replace(/\r\n/g, "\n");
1549
- const parsedInline = normalizedText.includes("[[")
1550
- ? parseInlineDirectives(normalizedText, {
1551
- stripAudioTag: true,
1552
- stripReplyTags: false,
1553
- })
1554
- : {
1555
- text: normalizedText,
1556
- audioAsVoice: false,
1557
- replyToCurrent: false,
1558
- hasAudioTag: false,
1559
- hasReplyTag: false,
1560
- };
1561
- const mediaUrls: string[] = [];
1562
- const contentLines: string[] = [];
1563
-
1564
- for (const line of parsedInline.text.split("\n")) {
1565
- const trimmed = line.trim();
1566
- const mediaCandidate = trimmed.replace(/^(?:\[\[[^[\]]+\]\]\s*)+/, "");
1567
- if (mediaCandidate.startsWith(MEDIA_DIRECTIVE_PREFIX)) {
1568
- const mediaSource = mediaCandidate.slice(MEDIA_DIRECTIVE_PREFIX.length).trim();
1569
- if (mediaSource) {
1570
- mediaUrls.push(mediaSource);
1571
- continue;
1572
- }
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 };
1573
1806
  }
1574
- contentLines.push(line);
1575
- }
1576
1807
 
1577
- const cleanedText = contentLines.join("\n").trim();
1578
- const inlineTextWasTransformed = parsedInline.text !== normalizedText;
1579
- if (mediaUrls.length === 0) {
1580
- const standaloneMediaSource = extractStandaloneMediaSource(cleanedText);
1581
- if (standaloneMediaSource) {
1582
- return {
1583
- text: undefined,
1584
- mediaUrls: [standaloneMediaSource],
1585
- audioAsVoice: parsedInline.audioAsVoice,
1586
- };
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
+ }
1833
+ }
1834
+ contentLines.push(line);
1587
1835
  }
1588
- }
1589
1836
 
1590
- return {
1591
- // Keep ordinary text formatting stable except for newline normalization.
1592
- // Once inline parsing actually strips directives/media lines, return the
1593
- // cleaned body text instead of the original raw payload.
1594
- text: mediaUrls.length > 0 || inlineTextWasTransformed ? cleanedText || undefined : normalizedText,
1595
- mediaUrls,
1596
- audioAsVoice: parsedInline.audioAsVoice,
1597
- };
1598
- }
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
+ }
1848
+ }
1599
1849
 
1600
- function extractStandaloneMediaSource(text: string): string | undefined {
1601
- const trimmed = text.trim();
1602
- if (!trimmed || trimmed.includes("\n") || /\s/.test(trimmed)) {
1603
- 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
+ };
1604
1861
  }
1605
1862
 
1606
- const hasPathLikeShape =
1607
- /^https?:\/\/\S+$/i.test(trimmed) ||
1608
- trimmed.startsWith("~/") ||
1609
- trimmed.startsWith("~\\") ||
1610
- trimmed.startsWith("./") ||
1611
- trimmed.startsWith(".\\") ||
1612
- trimmed.startsWith("../") ||
1613
- trimmed.startsWith("..\\") ||
1614
- trimmed.startsWith("/") ||
1615
- trimmed.startsWith("\\") ||
1616
- /^[a-zA-Z]:[\\/]/.test(trimmed) ||
1617
- trimmed.includes("/") ||
1618
- trimmed.includes("\\");
1619
- if (!hasPathLikeShape) {
1620
- return undefined;
1621
- }
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
+ }
1622
1868
 
1623
- return STANDALONE_MEDIA_PATH_EXTENSIONS.has(path.extname(trimmed).toLowerCase())
1624
- ? trimmed
1625
- : undefined;
1626
- }
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
+ }
1627
1885
 
1628
- // ---- Shared media delivery helper ----
1629
- function extractSharedAudioAsVoice(
1630
- payload: ReplyStreamPayload,
1631
- inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
1632
- ): boolean {
1633
- // Normalize all reply-time voice hints into the shared `audioAsVoice`
1634
- // semantic that strategies and media delivery use downstream.
1635
- const richPayload = payload as ReplyStreamPayload & {
1636
- audioAsVoice?: unknown;
1637
- asVoice?: unknown;
1638
- };
1639
- const sharedValue = parseBooleanLike(richPayload.audioAsVoice);
1640
- if (sharedValue !== undefined) {
1641
- return sharedValue;
1886
+ return STANDALONE_MEDIA_PATH_EXTENSIONS.has(path.extname(trimmed).toLowerCase())
1887
+ ? trimmed
1888
+ : undefined;
1642
1889
  }
1643
- if (parseBooleanLike(richPayload.asVoice) === true) {
1644
- 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;
1645
1910
  }
1646
- return inlineReplyPayload?.audioAsVoice === true;
1647
- }
1648
1911
 
1649
- async function deliverMediaAttachments(urls: string[], options?: { audioAsVoice?: boolean }) {
1650
- for (const rawMediaUrl of urls) {
1651
- const preparedMedia = await prepareMediaInput(
1652
- rawMediaUrl,
1653
- log,
1654
- dingtalkConfig.mediaUrlAllowlist,
1655
- );
1656
- try {
1657
- const actualMediaPath = preparedMedia.cleanup
1658
- ? preparedMedia.path
1659
- : resolveRelativePath(preparedMedia.path);
1660
- const outMediaType = resolveOutboundMediaType({
1661
- mediaPath: actualMediaPath,
1662
- asVoice: options?.audioAsVoice === true,
1663
- });
1664
- if (sessionWebhook) {
1665
- const sendResult = await sendMessage(dingtalkConfig, to, "", {
1666
- 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({
1667
1924
  mediaPath: actualMediaPath,
1668
- mediaType: outMediaType,
1669
- log,
1670
- accountId,
1671
- storePath: accountStorePath,
1672
- conversationId: groupId,
1673
- quotedRef: replyQuotedRef,
1925
+ asVoice: options?.audioAsVoice === true,
1674
1926
  });
1675
- if (!sendResult.ok) {
1676
- throw new Error(sendResult.error || "Media reply send failed");
1677
- }
1678
- } else {
1679
- const sendResult = await sendProactiveMedia(
1680
- dingtalkConfig,
1681
- to,
1682
- actualMediaPath,
1683
- outMediaType,
1684
- {
1685
- accountId,
1927
+ if (sessionWebhook) {
1928
+ const sendResult = await sendMessage(dingtalkConfig, to, "", {
1929
+ sessionWebhook,
1930
+ mediaPath: actualMediaPath,
1931
+ mediaType: outMediaType,
1686
1932
  log,
1933
+ accountId,
1687
1934
  storePath: accountStorePath,
1688
1935
  conversationId: groupId,
1689
1936
  quotedRef: replyQuotedRef,
1690
- },
1691
- );
1692
- if (!sendResult.ok) {
1693
- 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
+ }
1694
1958
  }
1959
+ } finally {
1960
+ await preparedMedia.cleanup?.();
1695
1961
  }
1696
- } finally {
1697
- await preparedMedia.cleanup?.();
1698
1962
  }
1699
1963
  }
1700
- }
1701
-
1702
- // ---- Extract mediaUrls from runtime payload ----
1703
- function extractMediaUrls(
1704
- payload: ReplyStreamPayload,
1705
- inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
1706
- ): string[] {
1707
- const richPayload = payload as typeof payload & {
1708
- mediaUrl?: string;
1709
- mediaUrls?: string[];
1710
- };
1711
- const explicitMediaUrls = Array.isArray(richPayload.mediaUrls)
1712
- ? richPayload.mediaUrls.filter((entry: unknown) => typeof entry === "string" && entry.trim())
1713
- : richPayload.mediaUrl &&
1714
- typeof richPayload.mediaUrl === "string" &&
1715
- richPayload.mediaUrl.trim()
1716
- ? [richPayload.mediaUrl]
1717
- : [];
1718
- return explicitMediaUrls.length > 0
1719
- ? explicitMediaUrls
1720
- : inlineReplyPayload?.mediaUrls ?? [];
1721
- }
1722
-
1723
- // Serialize dispatchReply + card finalize per session to prevent the runtime
1724
- // from receiving concurrent dispatch calls on the same session key, which
1725
- // causes empty replies for all but the first caller.
1726
- // Each sub-agent call acquires its own lock since sub-agent sessions have
1727
- // different session keys (different agentId), so no deadlock risk.
1728
- const currentOutTrackId = currentAICard?.outTrackId;
1729
- const shouldTrackDynamicAckReaction =
1730
- (normalizedAckReaction === "emoji" || normalizedAckReaction === "kaomoji")
1731
- && shouldAttachAckReaction;
1732
- const runtimeEvents = (rt as typeof rt & {
1733
- events?: {
1734
- onAgentEvent?: (listener: (event: unknown) => void) => (() => void);
1735
- };
1736
- }).events;
1737
- const releaseSessionLock = await acquireSessionLock(route.sessionKey);
1738
- const dynamicAckReactionController = createDynamicAckReactionController({
1739
- enabled: shouldTrackDynamicAckReaction,
1740
- initialReaction: resolvedAckReaction || "",
1741
- initialAttached: ackReactionAttached,
1742
- initialAttachedAt: ackReactionAttachedAt,
1743
- dingtalkConfig,
1744
- msgId: data.msgId,
1745
- conversationId: groupId,
1746
- sessionKey: route.sessionKey,
1747
- log,
1748
- runtimeEvents,
1749
- onReactionDisposed: () => {
1750
- ackReactionAttached = false;
1751
- },
1752
- });
1753
- try {
1754
- if (!ackReactionAttached && shouldAttachAckReaction) {
1755
- log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
1756
- }
1757
- const isCurrentCardStopRequested = () =>
1758
- Boolean(
1759
- currentAICard
1760
- && (
1761
- currentAICard.state === AICardStatus.STOPPED
1762
- || (currentOutTrackId && isCardRunStopRequested(currentOutTrackId))
1763
- ),
1764
- );
1765
1964
 
1766
- if (isCurrentCardStopRequested()) {
1767
- log?.info?.("[DingTalk][CardStop] Skip dispatch because card was already stopped before session lock was acquired");
1768
- 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 ?? []);
1769
1986
  }
1770
1987
 
1771
- // ---- Create reply strategy (card or markdown) ----
1772
- const replyMode: ReplyMode = useCardMode && !!currentAICard ? "card" : "markdown";
1773
- const sessionReasoningLevel = readSessionReasoningLevel({
1774
- 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,
1775
2013
  sessionKey: route.sessionKey,
1776
- sessionUpdatedAt: previousTimestamp,
1777
2014
  log,
2015
+ runtimeEvents,
2016
+ onReactionDisposed: () => {
2017
+ ackReactionAttached = false;
2018
+ },
1778
2019
  });
1779
- const legacyCardStreamReasoning = resolveLegacyCardStreamReasoningForInternalUse({
1780
- cfg,
1781
- accountId,
1782
- });
1783
- const strategyConfig =
1784
- legacyCardStreamReasoning === undefined
1785
- ? dingtalkConfig
1786
- : { ...dingtalkConfig, cardStreamReasoning: legacyCardStreamReasoning };
1787
- const strategy = createReplyStrategy({
1788
- config: strategyConfig,
1789
- card: currentAICard,
1790
- useCardMode: replyMode === "card",
1791
- to,
1792
- sessionWebhook,
1793
- senderId,
1794
- isDirect,
1795
- accountId,
1796
- storePath: accountStorePath,
1797
- sessionKey: route.sessionKey,
1798
- sessionAgentId: route.agentId,
1799
- disableBlockStreaming: shouldDisableBlockStreamingForReplyMode({
1800
- replyMode,
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
+ );
2030
+
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,
1801
2042
  sessionKey: route.sessionKey,
1802
- reasoningLevel: sessionReasoningLevel,
2043
+ sessionUpdatedAt: previousTimestamp,
1803
2044
  log,
1804
- }),
1805
- groupId,
1806
- log,
1807
- replyQuotedRef,
1808
- deliverMedia: deliverMediaAttachments,
1809
- isStopRequested: isCurrentCardStopRequested,
1810
- });
1811
-
1812
- try {
1813
- let deliveredFinalCount = 0;
1814
- const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1815
- ctx,
2045
+ });
2046
+ const legacyCardStreamReasoning = resolveLegacyCardStreamReasoningForInternalUse({
1816
2047
  cfg,
1817
- dispatcherOptions: {
1818
- responsePrefix: subAgentOptions?.responsePrefix || "",
1819
- deliver: async (payload: ReplyStreamPayload, info?: ReplyChunkInfo) => {
1820
- if (isCurrentCardStopRequested()) {
1821
- log?.debug?.("[DingTalk][CardStop] Ignoring reply delivery because stop was already requested");
1822
- return;
1823
- }
1824
- try {
1825
- if (info?.kind === "final") {
1826
- deliveredFinalCount += 1;
2048
+ accountId,
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
+ };
2068
+
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
+ });
2095
+
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;
1827
2109
  }
1828
- const inlineReplyPayload = parseInlineReplyPayloadText(payload.text);
1829
- const mediaUrls = extractMediaUrls(payload, inlineReplyPayload);
1830
- const richPayload = payload as ReplyStreamPayload & { isReasoning?: boolean };
1831
- await strategy.deliver({
1832
- text: inlineReplyPayload.text,
1833
- mediaUrls,
1834
- audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
1835
- kind: (info?.kind as DeliverPayload["kind"]) || "block",
1836
- isReasoning: richPayload.isReasoning === true,
1837
- });
1838
- } catch (err: unknown) {
1839
- log?.error?.(`[DingTalk] Reply failed: ${getErrorMessage(err)}`);
1840
- const responseData = getErrorResponseData(err);
1841
- if (responseData !== undefined) {
1842
- log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", responseData));
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;
1843
2131
  }
1844
- throw err;
1845
- }
2132
+ },
1846
2133
  },
1847
- },
1848
- replyOptions: strategy.getReplyOptions(),
1849
- });
2134
+ replyOptions: strategy.getReplyOptions(),
2135
+ });
1850
2136
 
1851
- const bufferedFinal =
1852
- dispatchResult && typeof dispatchResult === "object" && "queuedFinal" in dispatchResult
1853
- ? (dispatchResult as { queuedFinal?: unknown }).queuedFinal
1854
- : undefined;
1855
- const finalCount =
1856
- dispatchResult && typeof dispatchResult === "object" && "counts" in dispatchResult
1857
- ? (dispatchResult as { counts?: { final?: unknown } }).counts?.final
1858
- : undefined;
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
2144
+ : undefined;
2145
+
2146
+ log?.info?.(
2147
+ `[DingTalk][Dispatch] completed — deliveredFinalCount=${deliveredFinalCount} ` +
2148
+ `counts.final=${typeof finalCount === "number" ? finalCount : "n/a"} ` +
2149
+ `queuedFinalType=${typeof bufferedFinal}`,
2150
+ );
1859
2151
 
1860
- log?.info?.(
1861
- `[DingTalk][Dispatch] completed deliveredFinalCount=${deliveredFinalCount} ` +
1862
- `counts.final=${typeof finalCount === "number" ? finalCount : "n/a"} ` +
1863
- `queuedFinalType=${typeof bufferedFinal}`,
1864
- );
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
+ }
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;
2180
+ }
1865
2181
 
1866
- if (deliveredFinalCount === 0 && typeof bufferedFinal === "string" && bufferedFinal.trim()) {
1867
- const inlineReplyPayload = parseInlineReplyPayloadText(bufferedFinal);
1868
- const richBufferedPayload = {
1869
- text: bufferedFinal,
1870
- mediaUrls: [],
1871
- } as ReplyStreamPayload;
1872
- await strategy.deliver({
1873
- text: inlineReplyPayload.text,
1874
- mediaUrls: extractMediaUrls(richBufferedPayload, inlineReplyPayload),
1875
- audioAsVoice: extractSharedAudioAsVoice(richBufferedPayload, inlineReplyPayload),
1876
- kind: "final",
1877
- isReasoning: false,
1878
- });
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);
1879
2190
  }
1880
- } catch (dispatchErr: unknown) {
1881
- const error = dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
1882
- await strategy.abort(error);
1883
- throw dispatchErr;
2191
+ await waitForDynamicAckDispose({
2192
+ dispose: () => dynamicAckReactionController.dispose(MIN_THINKING_REACTION_VISIBLE_MS),
2193
+ log,
2194
+ sessionKey: route.sessionKey,
2195
+ });
2196
+ releaseSessionLock();
1884
2197
  }
1885
-
1886
- await strategy.finalize();
1887
2198
  } finally {
1888
- // Only remove the registry entry if no stop was requested. When a stop is
1889
- // in progress, card-stop-handler may still be running async operations
1890
- // (finalize card, hide button, gateway abort) that read the record.
1891
- // In that case, let the 30-minute TTL sweep handle cleanup.
1892
- if (currentOutTrackId && !isCardRunStopRequested(currentOutTrackId)) {
1893
- removeCardRun(currentOutTrackId);
2199
+ if (cardFlightKey) {
2200
+ cardCreationInFlight.delete(cardFlightKey);
1894
2201
  }
1895
- await waitForDynamicAckDispose({
1896
- dispose: () => dynamicAckReactionController.dispose(MIN_THINKING_REACTION_VISIBLE_MS),
1897
- log,
1898
- sessionKey: route.sessionKey,
1899
- });
1900
- releaseSessionLock();
1901
2202
  }
1902
2203
  }