@soimy/dingtalk 3.5.1 → 3.5.3

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 (36) hide show
  1. package/README.md +13 -24
  2. package/openclaw.plugin.json +695 -0
  3. package/package.json +12 -7
  4. package/src/ack-reaction-service.ts +1 -1
  5. package/src/auth.ts +1 -1
  6. package/src/card/card-action-handler.ts +1 -1
  7. package/src/card/card-stop-handler.ts +1 -1
  8. package/src/card/card-streaming-mode.ts +30 -0
  9. package/src/card/reasoning-answer-split.ts +162 -0
  10. package/src/card/reasoning-block-assembler.ts +157 -0
  11. package/src/card-callback-service.ts +1 -1
  12. package/src/card-draft-controller.ts +117 -6
  13. package/src/card-service.ts +112 -1
  14. package/src/channel.ts +131 -96
  15. package/src/command/card-stop-command.ts +4 -22
  16. package/src/command/inbound-command-dispatch-service.ts +464 -0
  17. package/src/config-schema.ts +62 -38
  18. package/src/config.ts +25 -3
  19. package/src/docs-service.ts +5 -5
  20. package/src/http-client.ts +20 -0
  21. package/src/inbound-handler.ts +475 -501
  22. package/src/logger-context.ts +16 -2
  23. package/src/media-utils.ts +166 -10
  24. package/src/message-utils.ts +33 -5
  25. package/src/{attachment-text-extractor.ts → messaging/attachment-text-extractor.ts} +1 -1
  26. package/src/{quoted-file-service.ts → messaging/quoted-file-service.ts} +14 -9
  27. package/src/onboarding.ts +29 -0
  28. package/src/plugin-sdk-channel-actions-augment.ts +11 -0
  29. package/src/reply-strategy-card.ts +294 -28
  30. package/src/reply-strategy-markdown.ts +124 -19
  31. package/src/reply-strategy.ts +22 -2
  32. package/src/send-service.ts +178 -7
  33. package/src/targeting/agent-routing.ts +55 -32
  34. package/src/{group-members-store.ts → targeting/group-members-store.ts} +1 -1
  35. package/src/types.ts +60 -4
  36. package/src/utils.ts +190 -0
@@ -1,13 +1,16 @@
1
- import axios from "axios";
1
+ import fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import axios from "./http-client";
2
4
  import { normalizeAllowFrom, isSenderAllowed, resolveGroupAccess } from "./access-control";
3
5
  import { buildAgentSessionKey, resolveSubAgentRoute, dispatchSubAgents } from "./targeting/agent-routing";
4
6
  import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
5
7
  import { attachNativeAckReaction } from "./ack-reaction-service";
6
8
  import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
7
- import { extractAttachmentText } from "./attachment-text-extractor";
9
+ import { extractAttachmentText } from "./messaging/attachment-text-extractor";
8
10
  import { getAccessToken } from "./auth";
9
11
  import { createAICard, finishAICard, isCardInTerminalState } from "./card-service";
10
- import { resolveAckReactionSetting, resolveGroupConfig, resolveRobotCode } from "./config";
12
+ import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
13
+ import { resolveAckReactionSetting, resolveGroupConfig, resolveRelativePath, resolveRobotCode } from "./config";
11
14
  import { AICardStatus } from "./types";
12
15
  import {
13
16
  isCardRunStopRequested,
@@ -15,35 +18,10 @@ import {
15
18
  removeCardRun,
16
19
  } from "./card/card-run-registry";
17
20
  import {
18
- applyManualTargetLearningRule,
19
- applyManualTargetsLearningRule,
20
- applyManualGlobalLearningRule,
21
- applyManualSessionLearningNote,
22
- applyTargetSetLearningRule,
23
21
  buildLearningContextBlock,
24
- createOrUpdateTargetSet,
25
- deleteManualRule,
26
- disableManualRule,
27
22
  isLearningEnabled,
28
- listLearningTargetSets,
29
- listScopedLearningRules,
30
- resolveManualForcedReply,
31
23
  } from "./feedback-learning-service";
32
- import { formatGroupMembers, noteGroupMember } from "./group-members-store";
33
- import {
34
- formatLearnAppliedReply,
35
- formatLearnCommandHelp,
36
- formatLearnDeletedReply,
37
- formatLearnDisabledReply,
38
- formatLearnListReply,
39
- formatOwnerOnlyDeniedReply,
40
- formatOwnerStatusReply,
41
- formatTargetSetSavedReply,
42
- formatWhereAmIReply,
43
- formatWhoAmIReply,
44
- isLearningOwner,
45
- parseLearnCommand,
46
- } from "./learning-command-service";
24
+ import { formatGroupMembers, noteGroupMember } from "./targeting/group-members-store";
47
25
  import { setCurrentLogger } from "./logger-context";
48
26
  import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
49
27
  import {
@@ -63,21 +41,11 @@ import {
63
41
  clearProactiveRiskObservationsForTest,
64
42
  getProactiveRiskObservationForAny,
65
43
  } from "./proactive-risk-registry";
66
- import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./quoted-file-service";
44
+ import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./messaging/quoted-file-service";
67
45
  import { createReplyStrategy } from "./reply-strategy";
68
46
  import type { DeliverPayload } from "./reply-strategy";
69
47
  import { getDingTalkRuntime } from "./runtime";
70
48
  import { sendBySession, sendMessage, sendProactiveMedia } from "./send-service";
71
- import {
72
- formatSessionAliasBoundReply,
73
- formatSessionAliasClearedReply,
74
- formatSessionAliasReply,
75
- formatSessionAliasSetReply,
76
- formatSessionAliasUnboundReply,
77
- formatSessionAliasValidationErrorReply,
78
- parseSessionCommand,
79
- validateSessionAlias,
80
- } from "./session-command-service";
81
49
  import { acquireSessionLock } from "./session-lock";
82
50
  import {
83
51
  clearSessionPeerOverride,
@@ -89,15 +57,194 @@ import {
89
57
  upsertObservedGroupTarget,
90
58
  upsertObservedUserTarget,
91
59
  } from "./targeting/target-directory-store";
92
- import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
93
- import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData } from "./utils";
60
+ import type { DingTalkConfig, HandleDingTalkMessageParams, Logger, MediaFile } from "./types";
61
+ import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData, parseBooleanLike } from "./utils";
94
62
  import { isAbortRequestText } from "openclaw/plugin-sdk/reply-runtime";
63
+ import { parseInlineDirectives } from "openclaw/plugin-sdk/text-runtime";
95
64
 
96
65
  const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
97
66
  const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
98
67
  const MAX_DYNAMIC_ACK_DISPOSE_WAIT_MS = 500;
99
68
  const ATTACHMENT_TEXT_PREFIX = "[附件内容摘录]";
69
+ const MEDIA_DIRECTIVE_PREFIX = "MEDIA:";
70
+ const STANDALONE_MEDIA_PATH_EXTENSIONS = new Set([
71
+ ".jpg",
72
+ ".jpeg",
73
+ ".png",
74
+ ".gif",
75
+ ".bmp",
76
+ ".ogg",
77
+ ".amr",
78
+ ".mp3",
79
+ ".wav",
80
+ ".mp4",
81
+ ".avi",
82
+ ".mov",
83
+ ".doc",
84
+ ".docx",
85
+ ".xls",
86
+ ".xlsx",
87
+ ".ppt",
88
+ ".pptx",
89
+ ".zip",
90
+ ".pdf",
91
+ ".rar",
92
+ ]);
100
93
  const proactiveHintLastSentAt = new Map<string, number>();
94
+ const sessionReasoningLevelCache = new Map<string, {
95
+ updatedAt?: number;
96
+ reasoningLevel?: string;
97
+ }>();
98
+ type ReplyMode = "card" | "markdown";
99
+
100
+ function resolveQuotedContextAllowFrom(
101
+ config: DingTalkConfig,
102
+ groupId: string,
103
+ ): string[] | undefined {
104
+ const groupConfig = resolveGroupConfig(config, groupId);
105
+ return groupConfig?.groupAllowFrom ?? config.groupAllowFrom;
106
+ }
107
+
108
+ function resolveQuotedVisibilitySenderId(params: {
109
+ quotedSenderId?: string;
110
+ currentSenderId: string;
111
+ currentSenderOriginalId: string;
112
+ }): string | undefined {
113
+ const quotedSenderId = (params.quotedSenderId || "").trim();
114
+ if (!quotedSenderId) {
115
+ return undefined;
116
+ }
117
+ if (quotedSenderId === params.currentSenderOriginalId) {
118
+ return params.currentSenderId;
119
+ }
120
+ return quotedSenderId;
121
+ }
122
+
123
+ function filterQuotedRuntimeContext(params: {
124
+ context: ReturnType<typeof resolveQuotedRuntimeContext>;
125
+ config: DingTalkConfig;
126
+ isDirect: boolean;
127
+ groupId: string;
128
+ quotedSenderId?: string;
129
+ currentSenderId: string;
130
+ currentSenderOriginalId: string;
131
+ }): ReturnType<typeof resolveQuotedRuntimeContext> {
132
+ const { context, config, isDirect, groupId, quotedSenderId, currentSenderId, currentSenderOriginalId } = params;
133
+ if (!context || isDirect) {
134
+ return context;
135
+ }
136
+
137
+ const mode = config.contextVisibility || "all";
138
+ if (mode === "all") {
139
+ return context;
140
+ }
141
+
142
+ const allow = normalizeAllowFrom(resolveQuotedContextAllowFrom(config, groupId));
143
+ const senderId = resolveQuotedVisibilitySenderId({
144
+ quotedSenderId,
145
+ currentSenderId,
146
+ currentSenderOriginalId,
147
+ });
148
+ const senderAllowed =
149
+ allow.hasEntries && !!senderId
150
+ ? isSenderAllowed({ allow, senderId })
151
+ : false;
152
+
153
+ if (senderAllowed) {
154
+ return context;
155
+ }
156
+ if (mode === "allowlist_quote") {
157
+ return {
158
+ ...context,
159
+ untrustedContext: undefined,
160
+ };
161
+ }
162
+ return null;
163
+ }
164
+
165
+ function readSessionReasoningLevel(params: {
166
+ storePath?: string;
167
+ sessionKey: string;
168
+ sessionUpdatedAt?: number;
169
+ log?: Logger;
170
+ }): string | undefined {
171
+ if (!params.storePath || !params.sessionKey) {
172
+ return undefined;
173
+ }
174
+ const cacheKey = `${params.storePath}:${params.sessionKey}`;
175
+ const cached = sessionReasoningLevelCache.get(cacheKey);
176
+ if (
177
+ cached
178
+ && params.sessionUpdatedAt !== undefined
179
+ && cached.updatedAt === params.sessionUpdatedAt
180
+ ) {
181
+ return cached.reasoningLevel;
182
+ }
183
+ try {
184
+ const raw = fs.readFileSync(params.storePath, "utf8");
185
+ const parsed = JSON.parse(raw) as Record<string, { reasoningLevel?: unknown }>;
186
+ const value = parsed?.[params.sessionKey]?.reasoningLevel;
187
+ const reasoningLevel = typeof value === "string" ? value.trim().toLowerCase() : undefined;
188
+ sessionReasoningLevelCache.set(cacheKey, {
189
+ updatedAt: params.sessionUpdatedAt,
190
+ reasoningLevel,
191
+ });
192
+ return reasoningLevel;
193
+ } catch (err: unknown) {
194
+ params.log?.debug?.(
195
+ `[DingTalk][Session] Failed to read session reasoning level from ${params.storePath}: ${getErrorMessage(err)}`,
196
+ );
197
+ return undefined;
198
+ }
199
+ }
200
+
201
+ function shouldDisableBlockStreamingForReplyMode(params: {
202
+ replyMode: ReplyMode;
203
+ reasoningLevel?: string;
204
+ sessionKey: string;
205
+ log?: Logger;
206
+ }): boolean {
207
+ if (params.replyMode === "markdown") {
208
+ const shouldDisable = params.reasoningLevel === "on" || params.reasoningLevel === "stream";
209
+ if (shouldDisable) {
210
+ params.log?.debug?.(
211
+ `[DingTalk][Markdown] Disable block streaming for reasoningLevel=${params.reasoningLevel} sessionKey=${params.sessionKey}`,
212
+ );
213
+ }
214
+ return shouldDisable;
215
+ }
216
+
217
+ const shouldDisable = params.reasoningLevel !== "on";
218
+ if (!shouldDisable) {
219
+ params.log?.debug?.(
220
+ `[DingTalk][Card] Enable block streaming for reasoningLevel=${params.reasoningLevel} sessionKey=${params.sessionKey}`,
221
+ );
222
+ }
223
+ return shouldDisable;
224
+ }
225
+
226
+ function resolveLegacyCardStreamReasoningForInternalUse(params: {
227
+ cfg: HandleDingTalkMessageParams["cfg"];
228
+ accountId: string;
229
+ }): boolean | undefined {
230
+ const dingtalk = (params.cfg?.channels?.dingtalk ?? null) as
231
+ | (Record<string, unknown> & {
232
+ cardStreamReasoning?: unknown;
233
+ accounts?: Record<string, Record<string, unknown> | undefined>;
234
+ })
235
+ | null;
236
+ if (!dingtalk) {
237
+ return undefined;
238
+ }
239
+ const accountConfig = params.accountId ? dingtalk.accounts?.[params.accountId] : undefined;
240
+ if (typeof accountConfig?.cardStreamReasoning === "boolean") {
241
+ return accountConfig.cardStreamReasoning;
242
+ }
243
+ if (typeof dingtalk.cardStreamReasoning === "boolean") {
244
+ return dingtalk.cardStreamReasoning;
245
+ }
246
+ return undefined;
247
+ }
101
248
 
102
249
  function resolvePinnedMainDmOwner(params: {
103
250
  dmScope?: string;
@@ -239,6 +386,7 @@ function buildGroupTurnContextPrompt(params: {
239
386
 
240
387
  type ReplyStreamPayload = {
241
388
  text?: string;
389
+ isReasoning?: boolean;
242
390
  };
243
391
 
244
392
  type ReplyChunkInfo = {
@@ -256,6 +404,7 @@ export async function downloadMedia(
256
404
  config: DingTalkConfig,
257
405
  downloadCode: string,
258
406
  log?: any,
407
+ originalFilename?: string,
259
408
  ): Promise<MediaFile | null> {
260
409
  const rt = getDingTalkRuntime();
261
410
  let downloadUrl: string | undefined;
@@ -329,9 +478,13 @@ export async function downloadMedia(
329
478
 
330
479
  const maxBytes =
331
480
  config.mediaMaxMb && config.mediaMaxMb > 0 ? config.mediaMaxMb * 1024 * 1024 : undefined;
332
- const saved = maxBytes
333
- ? await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound", maxBytes)
334
- : await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound");
481
+ const saved = await rt.channel.media.saveMediaBuffer(
482
+ buffer,
483
+ contentType,
484
+ "inbound",
485
+ maxBytes,
486
+ originalFilename,
487
+ );
335
488
  log?.debug?.(`[DingTalk] Media saved: ${saved.path}`);
336
489
  return { path: saved.path, mimeType: saved.contentType ?? contentType };
337
490
  } catch (err: any) {
@@ -365,7 +518,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
365
518
  const rt = getDingTalkRuntime();
366
519
 
367
520
  // Save logger globally so shared services can log consistently without threading log everywhere.
368
- setCurrentLogger(log);
521
+ setCurrentLogger(log, accountId);
369
522
 
370
523
  log?.debug?.("[DingTalk] Full Inbound Data: " + JSON.stringify(maskSensitiveData(data)));
371
524
 
@@ -628,423 +781,31 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
628
781
  });
629
782
 
630
783
  const to = isDirect ? senderId : groupId;
631
- const parsedLearnCommand = parseLearnCommand(extractedContent.text);
632
- const parsedSessionCommand = parseSessionCommand(extractedContent.text);
633
- const isOwner = isLearningOwner({
784
+ const commandHandled = await handleInboundCommandDispatch({
634
785
  cfg,
635
- config: dingtalkConfig,
636
- senderId,
637
- rawSenderId: data.senderId,
638
- });
639
- if (isDirect && parsedLearnCommand.scope === "whoami") {
640
- await sendBySession(
641
- dingtalkConfig,
642
- sessionWebhook,
643
- formatWhoAmIReply({
644
- senderId,
645
- rawSenderId: data.senderId,
646
- senderStaffId: data.senderStaffId,
647
- isOwner,
648
- }),
649
- { log },
650
- );
651
- return;
652
- }
653
- if (parsedLearnCommand.scope === "whereami") {
654
- await sendBySession(
655
- dingtalkConfig,
656
- sessionWebhook,
657
- formatWhereAmIReply({
658
- conversationId: data.conversationId,
659
- conversationType: isDirect ? "dm" : "group",
660
- peerId: sessionPeer.peerId,
661
- }),
662
- { log },
663
- );
664
- return;
665
- }
666
- if (isDirect && parsedLearnCommand.scope === "owner-status") {
667
- await sendBySession(
668
- dingtalkConfig,
669
- sessionWebhook,
670
- formatOwnerStatusReply({
671
- senderId,
672
- rawSenderId: data.senderId,
673
- isOwner,
674
- }),
675
- { log },
676
- );
677
- return;
678
- }
679
- if (parsedLearnCommand.scope === "help") {
680
- await sendBySession(dingtalkConfig, sessionWebhook, formatLearnCommandHelp(), { log });
681
- return;
682
- }
683
- if (
684
- (parsedLearnCommand.scope === "global" ||
685
- parsedLearnCommand.scope === "session" ||
686
- parsedLearnCommand.scope === "here" ||
687
- parsedLearnCommand.scope === "target" ||
688
- parsedLearnCommand.scope === "targets" ||
689
- parsedLearnCommand.scope === "list" ||
690
- parsedLearnCommand.scope === "disable" ||
691
- parsedLearnCommand.scope === "delete" ||
692
- parsedLearnCommand.scope === "target-set-create" ||
693
- parsedLearnCommand.scope === "target-set-apply" ||
694
- parsedSessionCommand.scope === "session-alias-show" ||
695
- parsedSessionCommand.scope === "session-alias-set" ||
696
- parsedSessionCommand.scope === "session-alias-clear" ||
697
- parsedSessionCommand.scope === "session-alias-bind" ||
698
- parsedSessionCommand.scope === "session-alias-unbind") &&
699
- !isOwner
700
- ) {
701
- await sendBySession(dingtalkConfig, sessionWebhook, formatOwnerOnlyDeniedReply(), { log });
702
- return;
703
- }
704
- if (isOwner) {
705
- if (parsedSessionCommand.scope === "session-alias-show") {
706
- await sendBySession(
707
- dingtalkConfig,
708
- sessionWebhook,
709
- formatSessionAliasReply({
710
- sourceKind: currentSessionSourceKind,
711
- sourceId: currentSessionSourceId,
712
- peerId: sessionPeer.peerId,
713
- aliasSource: peerIdOverride ? "override" : "default",
714
- }),
715
- { log },
716
- );
717
- return;
718
- }
719
- if (parsedSessionCommand.scope === "session-alias-set" && parsedSessionCommand.peerId) {
720
- const aliasValidationError = validateSessionAlias(parsedSessionCommand.peerId);
721
- if (aliasValidationError) {
722
- await sendBySession(
723
- dingtalkConfig,
724
- sessionWebhook,
725
- formatSessionAliasValidationErrorReply(aliasValidationError),
726
- { log },
727
- );
728
- return;
729
- }
730
- setSessionPeerOverride({
731
- storePath: accountStorePath,
732
- accountId,
733
- sourceKind: currentSessionSourceKind,
734
- sourceId: currentSessionSourceId,
735
- peerId: parsedSessionCommand.peerId,
736
- });
737
- await sendBySession(
738
- dingtalkConfig,
739
- sessionWebhook,
740
- formatSessionAliasSetReply({
741
- sourceKind: currentSessionSourceKind,
742
- sourceId: currentSessionSourceId,
743
- peerId: parsedSessionCommand.peerId,
744
- }),
745
- { log },
746
- );
747
- return;
748
- }
749
- if (parsedSessionCommand.scope === "session-alias-clear") {
750
- clearSessionPeerOverride({
751
- storePath: accountStorePath,
752
- accountId,
753
- sourceKind: currentSessionSourceKind,
754
- sourceId: currentSessionSourceId,
755
- });
756
- await sendBySession(
757
- dingtalkConfig,
758
- sessionWebhook,
759
- formatSessionAliasClearedReply({
760
- sourceKind: currentSessionSourceKind,
761
- sourceId: currentSessionSourceId,
762
- }),
763
- { log },
764
- );
765
- return;
766
- }
767
- if (
768
- parsedSessionCommand.scope === "session-alias-bind" &&
769
- parsedSessionCommand.sourceKind &&
770
- parsedSessionCommand.sourceId &&
771
- parsedSessionCommand.peerId
772
- ) {
773
- const aliasValidationError = validateSessionAlias(parsedSessionCommand.peerId);
774
- if (aliasValidationError) {
775
- await sendBySession(
776
- dingtalkConfig,
777
- sessionWebhook,
778
- formatSessionAliasValidationErrorReply(aliasValidationError),
779
- { log },
780
- );
781
- return;
782
- }
783
- setSessionPeerOverride({
784
- storePath: accountStorePath,
785
- accountId,
786
- sourceKind: parsedSessionCommand.sourceKind,
787
- sourceId: parsedSessionCommand.sourceId,
788
- peerId: parsedSessionCommand.peerId,
789
- });
790
- await sendBySession(
791
- dingtalkConfig,
792
- sessionWebhook,
793
- formatSessionAliasBoundReply({
794
- sourceKind: parsedSessionCommand.sourceKind,
795
- sourceId: parsedSessionCommand.sourceId,
796
- peerId: parsedSessionCommand.peerId,
797
- }),
798
- { log },
799
- );
800
- return;
801
- }
802
- if (
803
- parsedSessionCommand.scope === "session-alias-unbind" &&
804
- parsedSessionCommand.sourceKind &&
805
- parsedSessionCommand.sourceId
806
- ) {
807
- const existed = clearSessionPeerOverride({
808
- storePath: accountStorePath,
809
- accountId,
810
- sourceKind: parsedSessionCommand.sourceKind,
811
- sourceId: parsedSessionCommand.sourceId,
812
- });
813
- await sendBySession(
814
- dingtalkConfig,
815
- sessionWebhook,
816
- formatSessionAliasUnboundReply({
817
- sourceKind: parsedSessionCommand.sourceKind,
818
- sourceId: parsedSessionCommand.sourceId,
819
- existed,
820
- }),
821
- { log },
822
- );
823
- return;
824
- }
825
- if (parsedLearnCommand.scope === "global" && parsedLearnCommand.instruction) {
826
- const applied = applyManualGlobalLearningRule({
827
- storePath: accountStorePath,
828
- accountId,
829
- instruction: parsedLearnCommand.instruction,
830
- });
831
- await sendBySession(
832
- dingtalkConfig,
833
- sessionWebhook,
834
- formatLearnAppliedReply({
835
- scope: "global",
836
- instruction: parsedLearnCommand.instruction,
837
- ruleId: applied?.ruleId,
838
- }),
839
- { log },
840
- );
841
- return;
842
- }
843
- if (parsedLearnCommand.scope === "session" && parsedLearnCommand.instruction) {
844
- applyManualSessionLearningNote({
845
- storePath: accountStorePath,
846
- accountId,
847
- targetId: data.conversationId,
848
- instruction: parsedLearnCommand.instruction,
849
- });
850
- await sendBySession(
851
- dingtalkConfig,
852
- sessionWebhook,
853
- formatLearnAppliedReply({
854
- scope: "session",
855
- instruction: parsedLearnCommand.instruction,
856
- }),
857
- { log },
858
- );
859
- return;
860
- }
861
- if (parsedLearnCommand.scope === "here" && parsedLearnCommand.instruction) {
862
- const applied = applyManualTargetLearningRule({
863
- storePath: accountStorePath,
864
- accountId,
865
- targetId: data.conversationId,
866
- instruction: parsedLearnCommand.instruction,
867
- });
868
- await sendBySession(
869
- dingtalkConfig,
870
- sessionWebhook,
871
- formatLearnAppliedReply({
872
- scope: "target",
873
- targetId: data.conversationId,
874
- instruction: parsedLearnCommand.instruction,
875
- ruleId: applied?.ruleId,
876
- }),
877
- { log },
878
- );
879
- return;
880
- }
881
- if (
882
- parsedLearnCommand.scope === "target" &&
883
- parsedLearnCommand.targetId &&
884
- parsedLearnCommand.instruction
885
- ) {
886
- const applied = applyManualTargetLearningRule({
887
- storePath: accountStorePath,
888
- accountId,
889
- targetId: parsedLearnCommand.targetId,
890
- instruction: parsedLearnCommand.instruction,
891
- });
892
- await sendBySession(
893
- dingtalkConfig,
894
- sessionWebhook,
895
- formatLearnAppliedReply({
896
- scope: "target",
897
- targetId: parsedLearnCommand.targetId,
898
- instruction: parsedLearnCommand.instruction,
899
- ruleId: applied?.ruleId,
900
- }),
901
- { log },
902
- );
903
- return;
904
- }
905
- if (
906
- parsedLearnCommand.scope === "targets" &&
907
- parsedLearnCommand.targetIds?.length &&
908
- parsedLearnCommand.instruction
909
- ) {
910
- const applied = applyManualTargetsLearningRule({
911
- storePath: accountStorePath,
912
- accountId,
913
- targetIds: parsedLearnCommand.targetIds,
914
- instruction: parsedLearnCommand.instruction,
915
- });
916
- await sendBySession(
917
- dingtalkConfig,
918
- sessionWebhook,
919
- formatLearnAppliedReply({
920
- scope: "targets",
921
- targetIds: parsedLearnCommand.targetIds,
922
- instruction: parsedLearnCommand.instruction,
923
- ruleId: applied[0]?.ruleId,
924
- }),
925
- { log },
926
- );
927
- return;
928
- }
929
- if (
930
- parsedLearnCommand.scope === "target-set-create" &&
931
- parsedLearnCommand.setName &&
932
- parsedLearnCommand.targetIds?.length
933
- ) {
934
- const saved = createOrUpdateTargetSet({
935
- storePath: accountStorePath,
936
- accountId,
937
- name: parsedLearnCommand.setName,
938
- targetIds: parsedLearnCommand.targetIds,
939
- });
940
- await sendBySession(
941
- dingtalkConfig,
942
- sessionWebhook,
943
- saved
944
- ? formatTargetSetSavedReply({
945
- setName: parsedLearnCommand.setName,
946
- targetIds: parsedLearnCommand.targetIds,
947
- })
948
- : "目标组保存失败,请检查名称和目标列表。",
949
- { log },
950
- );
951
- return;
952
- }
953
- if (
954
- parsedLearnCommand.scope === "target-set-apply" &&
955
- parsedLearnCommand.setName &&
956
- parsedLearnCommand.instruction
957
- ) {
958
- const applied = applyTargetSetLearningRule({
959
- storePath: accountStorePath,
960
- accountId,
961
- name: parsedLearnCommand.setName,
962
- instruction: parsedLearnCommand.instruction,
963
- });
964
- await sendBySession(
965
- dingtalkConfig,
966
- sessionWebhook,
967
- applied.length > 0
968
- ? formatLearnAppliedReply({
969
- scope: "target-set",
970
- setName: parsedLearnCommand.setName,
971
- targetIds: applied.map((item) => item.targetId),
972
- instruction: parsedLearnCommand.instruction,
973
- ruleId: applied[0]?.ruleId,
974
- })
975
- : `未找到目标组 \`${parsedLearnCommand.setName}\`,或该目标组为空。`,
976
- { log },
977
- );
978
- return;
979
- }
980
- if (parsedLearnCommand.scope === "list") {
981
- const rules = listScopedLearningRules({ storePath: accountStorePath, accountId })
982
- .slice(0, 20)
983
- .map((rule) => {
984
- const scope = rule.scope === "target" ? `target(${rule.targetId})` : "global";
985
- const status = rule.enabled ? "enabled" : "disabled";
986
- return `- [${scope}] ${rule.ruleId} (${status}) => ${rule.instruction}`;
987
- });
988
- const targetSets = listLearningTargetSets({ storePath: accountStorePath, accountId })
989
- .slice(0, 10)
990
- .map(
991
- (targetSet) => `- [target-set] ${targetSet.name} => ${targetSet.targetIds.join(", ")}`,
992
- );
993
- await sendBySession(
994
- dingtalkConfig,
995
- sessionWebhook,
996
- formatLearnListReply([...rules, ...targetSets]),
997
- { log },
998
- );
999
- return;
1000
- }
1001
- if (parsedLearnCommand.scope === "disable" && parsedLearnCommand.ruleId) {
1002
- const result = disableManualRule({
1003
- storePath: accountStorePath,
1004
- accountId,
1005
- ruleId: parsedLearnCommand.ruleId,
1006
- });
1007
- await sendBySession(
1008
- dingtalkConfig,
1009
- sessionWebhook,
1010
- formatLearnDisabledReply({
1011
- ruleId: parsedLearnCommand.ruleId,
1012
- existed: result.existed,
1013
- scope: result.scope,
1014
- targetId: result.targetId,
1015
- }),
1016
- { log },
1017
- );
1018
- return;
1019
- }
1020
- if (parsedLearnCommand.scope === "delete" && parsedLearnCommand.ruleId) {
1021
- const result = deleteManualRule({
1022
- storePath: accountStorePath,
1023
- accountId,
1024
- ruleId: parsedLearnCommand.ruleId,
1025
- });
1026
- await sendBySession(
1027
- dingtalkConfig,
1028
- sessionWebhook,
1029
- formatLearnDeletedReply({
1030
- ruleId: parsedLearnCommand.ruleId,
1031
- existed: result.existed,
1032
- scope: result.scope,
1033
- targetId: result.targetId,
1034
- }),
1035
- { log },
1036
- );
1037
- return;
1038
- }
1039
- }
1040
- const manualForcedReply = resolveManualForcedReply({
1041
- storePath: accountStorePath,
1042
786
  accountId,
1043
- targetId: data.conversationId,
1044
- content: extractedContent,
787
+ dingtalkConfig,
788
+ senderId,
789
+ isDirect,
790
+ extractedText: extractedContent.text,
791
+ messageType: extractedContent.messageType,
792
+ data: {
793
+ conversationId: data.conversationId,
794
+ senderId: data.senderId,
795
+ senderStaffId: data.senderStaffId,
796
+ },
797
+ accountStorePath,
798
+ currentSessionSourceKind,
799
+ currentSessionSourceId,
800
+ peerIdOverride,
801
+ sessionPeer,
802
+ sendReply: async (text: string) => {
803
+ await sendBySession(dingtalkConfig, sessionWebhook, text, { log });
804
+ },
805
+ clearSessionPeerOverride,
806
+ setSessionPeerOverride,
1045
807
  });
1046
- if (manualForcedReply) {
1047
- await sendBySession(dingtalkConfig, sessionWebhook, manualForcedReply, { log });
808
+ if (commandHandled) {
1048
809
  return;
1049
810
  }
1050
811
  // 3) Select response mode (card vs markdown).
@@ -1129,6 +890,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1129
890
  messageType: content.messageType,
1130
891
  text: content.text,
1131
892
  quotedRef,
893
+ senderId,
894
+ senderName,
1132
895
  createdAt: data.createAt,
1133
896
  ttlMs: ttlDaysToMs(journalTTLDays),
1134
897
  ttlReferenceMs: data.createAt,
@@ -1153,7 +916,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1153
916
  mediaType = preDownloadedMedia.mediaType;
1154
917
  } else if (content.mediaPath && robotCode) {
1155
918
  // Download media only if not pre-downloaded
1156
- const media = await downloadMedia(dingtalkConfig, content.mediaPath, log);
919
+ const media = await downloadMedia(
920
+ dingtalkConfig,
921
+ content.mediaPath,
922
+ log,
923
+ attachmentContextFileName,
924
+ );
1157
925
  if (media) {
1158
926
  mediaPath = media.path;
1159
927
  mediaType = media.mimeType;
@@ -1174,6 +942,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1174
942
  spaceId: data.content?.spaceId,
1175
943
  fileId: data.content?.fileId,
1176
944
  },
945
+ attachmentFileName: attachmentContextFileName,
1177
946
  ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
1178
947
  topic: null,
1179
948
  });
@@ -1198,6 +967,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1198
967
  spaceId: content.docSpaceId,
1199
968
  fileId: content.docFileId,
1200
969
  },
970
+ attachmentFileName: attachmentContextFileName,
1201
971
  ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
1202
972
  topic: null,
1203
973
  });
@@ -1211,6 +981,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1211
981
  content.docFileId,
1212
982
  unionId,
1213
983
  log,
984
+ attachmentContextFileName,
1214
985
  );
1215
986
  if (docMedia) {
1216
987
  mediaPath = docMedia.path;
@@ -1229,22 +1000,30 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1229
1000
  quotedRef,
1230
1001
  log,
1231
1002
  });
1232
- const quotedRuntimeContext = resolveQuotedRuntimeContext({
1233
- storePath: accountStorePath,
1234
- accountId,
1235
- conversationId: data.conversationId,
1236
- quotedRef,
1237
- firstRecord: quotedRecord,
1238
- firstPreview:
1239
- content.quoted?.previewText ||
1240
- content.quoted?.previewMessageType
1241
- ? {
1242
- text: content.quoted.previewText,
1243
- messageType: content.quoted.previewMessageType,
1244
- senderId: content.quoted.previewSenderId,
1245
- }
1246
- : undefined,
1247
- log,
1003
+ const quotedRuntimeContext = filterQuotedRuntimeContext({
1004
+ context: resolveQuotedRuntimeContext({
1005
+ storePath: accountStorePath,
1006
+ accountId,
1007
+ conversationId: data.conversationId,
1008
+ 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
+ log,
1020
+ }),
1021
+ config: dingtalkConfig,
1022
+ isDirect,
1023
+ groupId,
1024
+ quotedSenderId: quotedRecord?.senderId || content.quoted?.previewSenderId,
1025
+ currentSenderId: senderId,
1026
+ currentSenderOriginalId: senderOriginalId,
1248
1027
  });
1249
1028
 
1250
1029
  // Try downloading a quoted file from cached downloadCode/spaceId+fileId.
@@ -1257,13 +1036,14 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1257
1036
  fileId?: string;
1258
1037
  };
1259
1038
  } | null,
1039
+ originalFilename?: string,
1260
1040
  ): Promise<MediaFile | null> => {
1261
1041
  if (!record?.media) {
1262
1042
  return null;
1263
1043
  }
1264
1044
  let media: MediaFile | null = null;
1265
1045
  if (record.media.downloadCode) {
1266
- media = await downloadMedia(dingtalkConfig, record.media.downloadCode, log);
1046
+ media = await downloadMedia(dingtalkConfig, record.media.downloadCode, log, originalFilename);
1267
1047
  if (media) {
1268
1048
  log?.debug?.(
1269
1049
  `[DingTalk][QuotedRef] Recovered quoted media from cached downloadCode ` +
@@ -1280,6 +1060,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1280
1060
  record.media.fileId,
1281
1061
  unionId,
1282
1062
  log,
1063
+ originalFilename,
1283
1064
  );
1284
1065
  if (media) {
1285
1066
  log?.debug?.(
@@ -1296,9 +1077,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1296
1077
 
1297
1078
  // Quoted picture: download via existing downloadMedia.
1298
1079
  if (!mediaPath && content.quoted?.mediaDownloadCode && robotCode) {
1080
+ const quotedOriginalFilename = content.quoted.previewFileName;
1299
1081
  const media =
1300
- (await tryDownloadFromRecord(quotedRecord)) ||
1301
- (await downloadMedia(dingtalkConfig, content.quoted.mediaDownloadCode, log));
1082
+ (await tryDownloadFromRecord(quotedRecord, quotedOriginalFilename)) ||
1083
+ (await downloadMedia(
1084
+ dingtalkConfig,
1085
+ content.quoted.mediaDownloadCode,
1086
+ log,
1087
+ quotedOriginalFilename,
1088
+ ));
1302
1089
  if (media) {
1303
1090
  if (!quotedRecord) {
1304
1091
  log?.debug?.(
@@ -1323,7 +1110,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1323
1110
 
1324
1111
  // Step 0: Direct download via downloadCode from quoted payload (file/audio/video msgType).
1325
1112
  if (!fileResolved && content.quoted.fileDownloadCode && robotCode) {
1326
- const media = await downloadMedia(dingtalkConfig, content.quoted.fileDownloadCode, log);
1113
+ const media = await downloadMedia(
1114
+ dingtalkConfig,
1115
+ content.quoted.fileDownloadCode,
1116
+ log,
1117
+ content.quoted.previewFileName,
1118
+ );
1327
1119
  if (media) {
1328
1120
  mediaPath = media.path;
1329
1121
  mediaType = media.mimeType;
@@ -1340,7 +1132,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1340
1132
 
1341
1133
  // Step 1: Prefer quotedRef-backed record lookup, then msgId-based cache.
1342
1134
  if (!fileResolved) {
1343
- const cachedMedia = await tryDownloadFromRecord(quotedRecord);
1135
+ const cachedMedia = await tryDownloadFromRecord(
1136
+ quotedRecord,
1137
+ quotedRecord?.attachmentFileName || content.quoted.previewFileName,
1138
+ );
1344
1139
  if (cachedMedia) {
1345
1140
  mediaPath = cachedMedia.path;
1346
1141
  mediaType = cachedMedia.mimeType;
@@ -1414,7 +1209,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1414
1209
  if (!mediaPath && content.quoted?.isQuotedDocCard) {
1415
1210
  let docResolved = false;
1416
1211
 
1417
- const cachedDocMedia = await tryDownloadFromRecord(quotedRecord);
1212
+ const cachedDocMedia = await tryDownloadFromRecord(
1213
+ quotedRecord,
1214
+ quotedRecord?.attachmentFileName || content.quoted?.previewFileName,
1215
+ );
1418
1216
  if (cachedDocMedia) {
1419
1217
  mediaPath = cachedDocMedia.path;
1420
1218
  mediaType = cachedDocMedia.mimeType;
@@ -1634,13 +1432,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1634
1432
  // tryFastAbortFromMessage (inside the SDK) kill any in-flight generation immediately,
1635
1433
  // rather than waiting for it to finish before the stop message is processed.
1636
1434
  //
1637
- // In group chats, DingTalk typically strips @BotName from text.content at the
1638
- // protocol level before delivery, but as a defensive measure we also strip leading
1639
- // @mention tokens here (e.g. "@Bot 停止" "停止") to match the SDK's own behavior
1640
- // in tryFastAbortFromMessage (which calls stripMentions for group messages).
1641
- const textForAbortCheck = !isDirect
1642
- ? inboundText.replace(/^(?:@\S+\s+)*/u, "").trim()
1643
- : inboundText;
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();
1644
1440
  if (isAbortRequestText(textForAbortCheck)) {
1645
1441
  log?.info?.(
1646
1442
  `[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
@@ -1740,8 +1536,117 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1740
1536
  }
1741
1537
  }
1742
1538
 
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 };
1546
+ }
1547
+
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
+ }
1573
+ }
1574
+ contentLines.push(line);
1575
+ }
1576
+
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
+ };
1587
+ }
1588
+ }
1589
+
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
+ }
1599
+
1600
+ function extractStandaloneMediaSource(text: string): string | undefined {
1601
+ const trimmed = text.trim();
1602
+ if (!trimmed || trimmed.includes("\n") || /\s/.test(trimmed)) {
1603
+ return undefined;
1604
+ }
1605
+
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
+ }
1622
+
1623
+ return STANDALONE_MEDIA_PATH_EXTENSIONS.has(path.extname(trimmed).toLowerCase())
1624
+ ? trimmed
1625
+ : undefined;
1626
+ }
1627
+
1743
1628
  // ---- Shared media delivery helper ----
1744
- async function deliverMediaAttachments(urls: string[]) {
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;
1642
+ }
1643
+ if (parseBooleanLike(richPayload.asVoice) === true) {
1644
+ return true;
1645
+ }
1646
+ return inlineReplyPayload?.audioAsVoice === true;
1647
+ }
1648
+
1649
+ async function deliverMediaAttachments(urls: string[], options?: { audioAsVoice?: boolean }) {
1745
1650
  for (const rawMediaUrl of urls) {
1746
1651
  const preparedMedia = await prepareMediaInput(
1747
1652
  rawMediaUrl,
@@ -1749,10 +1654,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1749
1654
  dingtalkConfig.mediaUrlAllowlist,
1750
1655
  );
1751
1656
  try {
1752
- const actualMediaPath = preparedMedia.path;
1657
+ const actualMediaPath = preparedMedia.cleanup
1658
+ ? preparedMedia.path
1659
+ : resolveRelativePath(preparedMedia.path);
1753
1660
  const outMediaType = resolveOutboundMediaType({
1754
1661
  mediaPath: actualMediaPath,
1755
- asVoice: false,
1662
+ asVoice: options?.audioAsVoice === true,
1756
1663
  });
1757
1664
  if (sessionWebhook) {
1758
1665
  const sendResult = await sendMessage(dingtalkConfig, to, "", {
@@ -1793,18 +1700,24 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1793
1700
  }
1794
1701
 
1795
1702
  // ---- Extract mediaUrls from runtime payload ----
1796
- function extractMediaUrls(payload: ReplyStreamPayload): string[] {
1703
+ function extractMediaUrls(
1704
+ payload: ReplyStreamPayload,
1705
+ inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
1706
+ ): string[] {
1797
1707
  const richPayload = payload as typeof payload & {
1798
1708
  mediaUrl?: string;
1799
1709
  mediaUrls?: string[];
1800
1710
  };
1801
- return Array.isArray(richPayload.mediaUrls)
1711
+ const explicitMediaUrls = Array.isArray(richPayload.mediaUrls)
1802
1712
  ? richPayload.mediaUrls.filter((entry: unknown) => typeof entry === "string" && entry.trim())
1803
1713
  : richPayload.mediaUrl &&
1804
1714
  typeof richPayload.mediaUrl === "string" &&
1805
1715
  richPayload.mediaUrl.trim()
1806
1716
  ? [richPayload.mediaUrl]
1807
1717
  : [];
1718
+ return explicitMediaUrls.length > 0
1719
+ ? explicitMediaUrls
1720
+ : inlineReplyPayload?.mediaUrls ?? [];
1808
1721
  }
1809
1722
 
1810
1723
  // Serialize dispatchReply + card finalize per session to prevent the runtime
@@ -1856,16 +1769,39 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1856
1769
  }
1857
1770
 
1858
1771
  // ---- Create reply strategy (card or markdown) ----
1772
+ const replyMode: ReplyMode = useCardMode && !!currentAICard ? "card" : "markdown";
1773
+ const sessionReasoningLevel = readSessionReasoningLevel({
1774
+ storePath,
1775
+ sessionKey: route.sessionKey,
1776
+ sessionUpdatedAt: previousTimestamp,
1777
+ log,
1778
+ });
1779
+ const legacyCardStreamReasoning = resolveLegacyCardStreamReasoningForInternalUse({
1780
+ cfg,
1781
+ accountId,
1782
+ });
1783
+ const strategyConfig =
1784
+ legacyCardStreamReasoning === undefined
1785
+ ? dingtalkConfig
1786
+ : { ...dingtalkConfig, cardStreamReasoning: legacyCardStreamReasoning };
1859
1787
  const strategy = createReplyStrategy({
1860
- config: dingtalkConfig,
1788
+ config: strategyConfig,
1861
1789
  card: currentAICard,
1862
- useCardMode: useCardMode && !!currentAICard,
1790
+ useCardMode: replyMode === "card",
1863
1791
  to,
1864
1792
  sessionWebhook,
1865
1793
  senderId,
1866
1794
  isDirect,
1867
1795
  accountId,
1868
1796
  storePath: accountStorePath,
1797
+ sessionKey: route.sessionKey,
1798
+ sessionAgentId: route.agentId,
1799
+ disableBlockStreaming: shouldDisableBlockStreamingForReplyMode({
1800
+ replyMode,
1801
+ sessionKey: route.sessionKey,
1802
+ reasoningLevel: sessionReasoningLevel,
1803
+ log,
1804
+ }),
1869
1805
  groupId,
1870
1806
  log,
1871
1807
  replyQuotedRef,
@@ -1874,7 +1810,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1874
1810
  });
1875
1811
 
1876
1812
  try {
1877
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1813
+ let deliveredFinalCount = 0;
1814
+ const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1878
1815
  ctx,
1879
1816
  cfg,
1880
1817
  dispatcherOptions: {
@@ -1883,13 +1820,20 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1883
1820
  if (isCurrentCardStopRequested()) {
1884
1821
  log?.debug?.("[DingTalk][CardStop] Ignoring reply delivery because stop was already requested");
1885
1822
  return;
1886
- }
1823
+ }
1887
1824
  try {
1888
- const mediaUrls = extractMediaUrls(payload);
1825
+ if (info?.kind === "final") {
1826
+ deliveredFinalCount += 1;
1827
+ }
1828
+ const inlineReplyPayload = parseInlineReplyPayloadText(payload.text);
1829
+ const mediaUrls = extractMediaUrls(payload, inlineReplyPayload);
1830
+ const richPayload = payload as ReplyStreamPayload & { isReasoning?: boolean };
1889
1831
  await strategy.deliver({
1890
- text: payload.text,
1832
+ text: inlineReplyPayload.text,
1891
1833
  mediaUrls,
1834
+ audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
1892
1835
  kind: (info?.kind as DeliverPayload["kind"]) || "block",
1836
+ isReasoning: richPayload.isReasoning === true,
1893
1837
  });
1894
1838
  } catch (err: unknown) {
1895
1839
  log?.error?.(`[DingTalk] Reply failed: ${getErrorMessage(err)}`);
@@ -1903,6 +1847,36 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1903
1847
  },
1904
1848
  replyOptions: strategy.getReplyOptions(),
1905
1849
  });
1850
+
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;
1859
+
1860
+ log?.info?.(
1861
+ `[DingTalk][Dispatch] completed — deliveredFinalCount=${deliveredFinalCount} ` +
1862
+ `counts.final=${typeof finalCount === "number" ? finalCount : "n/a"} ` +
1863
+ `queuedFinalType=${typeof bufferedFinal}`,
1864
+ );
1865
+
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
+ });
1879
+ }
1906
1880
  } catch (dispatchErr: unknown) {
1907
1881
  const error = dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
1908
1882
  await strategy.abort(error);