@soimy/dingtalk 3.5.2 → 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.
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import axios from "./http-client";
3
4
  import { normalizeAllowFrom, isSenderAllowed, resolveGroupAccess } from "./access-control";
4
5
  import { buildAgentSessionKey, resolveSubAgentRoute, dispatchSubAgents } from "./targeting/agent-routing";
@@ -9,7 +10,7 @@ import { extractAttachmentText } from "./messaging/attachment-text-extractor";
9
10
  import { getAccessToken } from "./auth";
10
11
  import { createAICard, finishAICard, isCardInTerminalState } from "./card-service";
11
12
  import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
12
- import { resolveAckReactionSetting, resolveGroupConfig, resolveRobotCode } from "./config";
13
+ import { resolveAckReactionSetting, resolveGroupConfig, resolveRelativePath, resolveRobotCode } from "./config";
13
14
  import { AICardStatus } from "./types";
14
15
  import {
15
16
  isCardRunStopRequested,
@@ -57,13 +58,38 @@ import {
57
58
  upsertObservedUserTarget,
58
59
  } from "./targeting/target-directory-store";
59
60
  import type { DingTalkConfig, HandleDingTalkMessageParams, Logger, MediaFile } from "./types";
60
- import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData } from "./utils";
61
+ import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData, parseBooleanLike } from "./utils";
61
62
  import { isAbortRequestText } from "openclaw/plugin-sdk/reply-runtime";
63
+ import { parseInlineDirectives } from "openclaw/plugin-sdk/text-runtime";
62
64
 
63
65
  const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
64
66
  const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
65
67
  const MAX_DYNAMIC_ACK_DISPOSE_WAIT_MS = 500;
66
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
+ ]);
67
93
  const proactiveHintLastSentAt = new Map<string, number>();
68
94
  const sessionReasoningLevelCache = new Map<string, {
69
95
  updatedAt?: number;
@@ -71,6 +97,71 @@ const sessionReasoningLevelCache = new Map<string, {
71
97
  }>();
72
98
  type ReplyMode = "card" | "markdown";
73
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
+
74
165
  function readSessionReasoningLevel(params: {
75
166
  storePath?: string;
76
167
  sessionKey: string;
@@ -132,6 +223,29 @@ function shouldDisableBlockStreamingForReplyMode(params: {
132
223
  return shouldDisable;
133
224
  }
134
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
+ }
248
+
135
249
  function resolvePinnedMainDmOwner(params: {
136
250
  dmScope?: string;
137
251
  allowFrom?: string[];
@@ -290,6 +404,7 @@ export async function downloadMedia(
290
404
  config: DingTalkConfig,
291
405
  downloadCode: string,
292
406
  log?: any,
407
+ originalFilename?: string,
293
408
  ): Promise<MediaFile | null> {
294
409
  const rt = getDingTalkRuntime();
295
410
  let downloadUrl: string | undefined;
@@ -363,9 +478,13 @@ export async function downloadMedia(
363
478
 
364
479
  const maxBytes =
365
480
  config.mediaMaxMb && config.mediaMaxMb > 0 ? config.mediaMaxMb * 1024 * 1024 : undefined;
366
- const saved = maxBytes
367
- ? await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound", maxBytes)
368
- : 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
+ );
369
488
  log?.debug?.(`[DingTalk] Media saved: ${saved.path}`);
370
489
  return { path: saved.path, mimeType: saved.contentType ?? contentType };
371
490
  } catch (err: any) {
@@ -771,6 +890,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
771
890
  messageType: content.messageType,
772
891
  text: content.text,
773
892
  quotedRef,
893
+ senderId,
894
+ senderName,
774
895
  createdAt: data.createAt,
775
896
  ttlMs: ttlDaysToMs(journalTTLDays),
776
897
  ttlReferenceMs: data.createAt,
@@ -795,7 +916,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
795
916
  mediaType = preDownloadedMedia.mediaType;
796
917
  } else if (content.mediaPath && robotCode) {
797
918
  // Download media only if not pre-downloaded
798
- const media = await downloadMedia(dingtalkConfig, content.mediaPath, log);
919
+ const media = await downloadMedia(
920
+ dingtalkConfig,
921
+ content.mediaPath,
922
+ log,
923
+ attachmentContextFileName,
924
+ );
799
925
  if (media) {
800
926
  mediaPath = media.path;
801
927
  mediaType = media.mimeType;
@@ -816,6 +942,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
816
942
  spaceId: data.content?.spaceId,
817
943
  fileId: data.content?.fileId,
818
944
  },
945
+ attachmentFileName: attachmentContextFileName,
819
946
  ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
820
947
  topic: null,
821
948
  });
@@ -840,6 +967,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
840
967
  spaceId: content.docSpaceId,
841
968
  fileId: content.docFileId,
842
969
  },
970
+ attachmentFileName: attachmentContextFileName,
843
971
  ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
844
972
  topic: null,
845
973
  });
@@ -853,6 +981,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
853
981
  content.docFileId,
854
982
  unionId,
855
983
  log,
984
+ attachmentContextFileName,
856
985
  );
857
986
  if (docMedia) {
858
987
  mediaPath = docMedia.path;
@@ -871,22 +1000,30 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
871
1000
  quotedRef,
872
1001
  log,
873
1002
  });
874
- const quotedRuntimeContext = resolveQuotedRuntimeContext({
875
- storePath: accountStorePath,
876
- accountId,
877
- conversationId: data.conversationId,
878
- quotedRef,
879
- firstRecord: quotedRecord,
880
- firstPreview:
881
- content.quoted?.previewText ||
882
- content.quoted?.previewMessageType
883
- ? {
884
- text: content.quoted.previewText,
885
- messageType: content.quoted.previewMessageType,
886
- senderId: content.quoted.previewSenderId,
887
- }
888
- : undefined,
889
- 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,
890
1027
  });
891
1028
 
892
1029
  // Try downloading a quoted file from cached downloadCode/spaceId+fileId.
@@ -899,13 +1036,14 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
899
1036
  fileId?: string;
900
1037
  };
901
1038
  } | null,
1039
+ originalFilename?: string,
902
1040
  ): Promise<MediaFile | null> => {
903
1041
  if (!record?.media) {
904
1042
  return null;
905
1043
  }
906
1044
  let media: MediaFile | null = null;
907
1045
  if (record.media.downloadCode) {
908
- media = await downloadMedia(dingtalkConfig, record.media.downloadCode, log);
1046
+ media = await downloadMedia(dingtalkConfig, record.media.downloadCode, log, originalFilename);
909
1047
  if (media) {
910
1048
  log?.debug?.(
911
1049
  `[DingTalk][QuotedRef] Recovered quoted media from cached downloadCode ` +
@@ -922,6 +1060,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
922
1060
  record.media.fileId,
923
1061
  unionId,
924
1062
  log,
1063
+ originalFilename,
925
1064
  );
926
1065
  if (media) {
927
1066
  log?.debug?.(
@@ -938,9 +1077,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
938
1077
 
939
1078
  // Quoted picture: download via existing downloadMedia.
940
1079
  if (!mediaPath && content.quoted?.mediaDownloadCode && robotCode) {
1080
+ const quotedOriginalFilename = content.quoted.previewFileName;
941
1081
  const media =
942
- (await tryDownloadFromRecord(quotedRecord)) ||
943
- (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
+ ));
944
1089
  if (media) {
945
1090
  if (!quotedRecord) {
946
1091
  log?.debug?.(
@@ -965,7 +1110,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
965
1110
 
966
1111
  // Step 0: Direct download via downloadCode from quoted payload (file/audio/video msgType).
967
1112
  if (!fileResolved && content.quoted.fileDownloadCode && robotCode) {
968
- 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
+ );
969
1119
  if (media) {
970
1120
  mediaPath = media.path;
971
1121
  mediaType = media.mimeType;
@@ -982,7 +1132,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
982
1132
 
983
1133
  // Step 1: Prefer quotedRef-backed record lookup, then msgId-based cache.
984
1134
  if (!fileResolved) {
985
- const cachedMedia = await tryDownloadFromRecord(quotedRecord);
1135
+ const cachedMedia = await tryDownloadFromRecord(
1136
+ quotedRecord,
1137
+ quotedRecord?.attachmentFileName || content.quoted.previewFileName,
1138
+ );
986
1139
  if (cachedMedia) {
987
1140
  mediaPath = cachedMedia.path;
988
1141
  mediaType = cachedMedia.mimeType;
@@ -1056,7 +1209,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1056
1209
  if (!mediaPath && content.quoted?.isQuotedDocCard) {
1057
1210
  let docResolved = false;
1058
1211
 
1059
- const cachedDocMedia = await tryDownloadFromRecord(quotedRecord);
1212
+ const cachedDocMedia = await tryDownloadFromRecord(
1213
+ quotedRecord,
1214
+ quotedRecord?.attachmentFileName || content.quoted?.previewFileName,
1215
+ );
1060
1216
  if (cachedDocMedia) {
1061
1217
  mediaPath = cachedDocMedia.path;
1062
1218
  mediaType = cachedDocMedia.mimeType;
@@ -1380,8 +1536,117 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1380
1536
  }
1381
1537
  }
1382
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
+
1383
1628
  // ---- Shared media delivery helper ----
1384
- 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 }) {
1385
1650
  for (const rawMediaUrl of urls) {
1386
1651
  const preparedMedia = await prepareMediaInput(
1387
1652
  rawMediaUrl,
@@ -1389,10 +1654,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1389
1654
  dingtalkConfig.mediaUrlAllowlist,
1390
1655
  );
1391
1656
  try {
1392
- const actualMediaPath = preparedMedia.path;
1657
+ const actualMediaPath = preparedMedia.cleanup
1658
+ ? preparedMedia.path
1659
+ : resolveRelativePath(preparedMedia.path);
1393
1660
  const outMediaType = resolveOutboundMediaType({
1394
1661
  mediaPath: actualMediaPath,
1395
- asVoice: false,
1662
+ asVoice: options?.audioAsVoice === true,
1396
1663
  });
1397
1664
  if (sessionWebhook) {
1398
1665
  const sendResult = await sendMessage(dingtalkConfig, to, "", {
@@ -1433,18 +1700,24 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1433
1700
  }
1434
1701
 
1435
1702
  // ---- Extract mediaUrls from runtime payload ----
1436
- function extractMediaUrls(payload: ReplyStreamPayload): string[] {
1703
+ function extractMediaUrls(
1704
+ payload: ReplyStreamPayload,
1705
+ inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
1706
+ ): string[] {
1437
1707
  const richPayload = payload as typeof payload & {
1438
1708
  mediaUrl?: string;
1439
1709
  mediaUrls?: string[];
1440
1710
  };
1441
- return Array.isArray(richPayload.mediaUrls)
1711
+ const explicitMediaUrls = Array.isArray(richPayload.mediaUrls)
1442
1712
  ? richPayload.mediaUrls.filter((entry: unknown) => typeof entry === "string" && entry.trim())
1443
1713
  : richPayload.mediaUrl &&
1444
1714
  typeof richPayload.mediaUrl === "string" &&
1445
1715
  richPayload.mediaUrl.trim()
1446
1716
  ? [richPayload.mediaUrl]
1447
1717
  : [];
1718
+ return explicitMediaUrls.length > 0
1719
+ ? explicitMediaUrls
1720
+ : inlineReplyPayload?.mediaUrls ?? [];
1448
1721
  }
1449
1722
 
1450
1723
  // Serialize dispatchReply + card finalize per session to prevent the runtime
@@ -1503,8 +1776,16 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1503
1776
  sessionUpdatedAt: previousTimestamp,
1504
1777
  log,
1505
1778
  });
1779
+ const legacyCardStreamReasoning = resolveLegacyCardStreamReasoningForInternalUse({
1780
+ cfg,
1781
+ accountId,
1782
+ });
1783
+ const strategyConfig =
1784
+ legacyCardStreamReasoning === undefined
1785
+ ? dingtalkConfig
1786
+ : { ...dingtalkConfig, cardStreamReasoning: legacyCardStreamReasoning };
1506
1787
  const strategy = createReplyStrategy({
1507
- config: dingtalkConfig,
1788
+ config: strategyConfig,
1508
1789
  card: currentAICard,
1509
1790
  useCardMode: replyMode === "card",
1510
1791
  to,
@@ -1529,7 +1810,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1529
1810
  });
1530
1811
 
1531
1812
  try {
1532
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1813
+ let deliveredFinalCount = 0;
1814
+ const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1533
1815
  ctx,
1534
1816
  cfg,
1535
1817
  dispatcherOptions: {
@@ -1538,13 +1820,18 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1538
1820
  if (isCurrentCardStopRequested()) {
1539
1821
  log?.debug?.("[DingTalk][CardStop] Ignoring reply delivery because stop was already requested");
1540
1822
  return;
1541
- }
1823
+ }
1542
1824
  try {
1543
- 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);
1544
1830
  const richPayload = payload as ReplyStreamPayload & { isReasoning?: boolean };
1545
1831
  await strategy.deliver({
1546
- text: payload.text,
1832
+ text: inlineReplyPayload.text,
1547
1833
  mediaUrls,
1834
+ audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
1548
1835
  kind: (info?.kind as DeliverPayload["kind"]) || "block",
1549
1836
  isReasoning: richPayload.isReasoning === true,
1550
1837
  });
@@ -1560,6 +1847,36 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1560
1847
  },
1561
1848
  replyOptions: strategy.getReplyOptions(),
1562
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
+ }
1563
1880
  } catch (dispatchErr: unknown) {
1564
1881
  const error = dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
1565
1882
  await strategy.abort(error);