@soimy/dingtalk 3.5.2 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +6 -23
  2. package/index.ts +7 -0
  3. package/openclaw.plugin.json +799 -0
  4. package/package.json +5 -5
  5. package/src/card/card-markdown-image-reroute.ts +106 -0
  6. package/src/card/card-run-registry.ts +54 -1
  7. package/src/card/card-stop-handler.ts +10 -20
  8. package/src/card/card-streaming-mode.ts +30 -0
  9. package/src/card/card-template.ts +14 -3
  10. package/src/card/reasoning-answer-split.ts +162 -0
  11. package/src/card/statusline-renderer.ts +94 -0
  12. package/src/card-draft-controller.ts +326 -54
  13. package/src/card-service.ts +479 -8
  14. package/src/channel.ts +19 -1062
  15. package/src/config-schema.ts +81 -38
  16. package/src/config.ts +142 -4
  17. package/src/device-registration.ts +245 -0
  18. package/src/gateway/channel-gateway.ts +636 -0
  19. package/src/inbound-handler.ts +489 -49
  20. package/src/media-utils.ts +169 -7
  21. package/src/message-utils.ts +153 -17
  22. package/src/messaging/btw-deliver.ts +85 -0
  23. package/src/messaging/channel-actions.ts +173 -0
  24. package/src/messaging/channel-outbound.ts +158 -0
  25. package/src/messaging/quoted-file-service.ts +9 -4
  26. package/src/onboarding.ts +323 -205
  27. package/src/platform/channel-status.ts +81 -0
  28. package/src/plugin-sdk-channel-actions-augment.ts +11 -0
  29. package/src/reply-strategy-card.ts +568 -44
  30. package/src/reply-strategy-markdown.ts +2 -2
  31. package/src/reply-strategy-types.ts +93 -0
  32. package/src/reply-strategy-with-reaction.ts +1 -1
  33. package/src/reply-strategy.ts +14 -56
  34. package/src/run-usage-store.ts +59 -0
  35. package/src/send-service.ts +225 -7
  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 +44 -28
  39. package/src/types.ts +49 -117
  40. package/src/utils.ts +25 -0
@@ -1,15 +1,19 @@
1
1
  import fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { isAbortRequestText, isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
2
4
  import axios from "./http-client";
3
5
  import { normalizeAllowFrom, isSenderAllowed, resolveGroupAccess } from "./access-control";
4
6
  import { buildAgentSessionKey, resolveSubAgentRoute, dispatchSubAgents } from "./targeting/agent-routing";
7
+ import { getAgentDisplayName } from "./targeting/agent-name-matcher";
5
8
  import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
6
9
  import { attachNativeAckReaction } from "./ack-reaction-service";
7
10
  import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
8
11
  import { extractAttachmentText } from "./messaging/attachment-text-extractor";
9
12
  import { getAccessToken } from "./auth";
10
- import { createAICard, finishAICard, isCardInTerminalState } from "./card-service";
13
+ import { createAICard, commitAICardBlocks, isCardInTerminalState } from "./card-service";
14
+ import { renderStatusLine } from "./card/statusline-renderer";
11
15
  import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
12
- import { resolveAckReactionSetting, resolveGroupConfig, resolveRobotCode } from "./config";
16
+ import { resolveAckReactionSetting, resolveGroupConfig, resolveRelativePath, resolveRobotCode } from "./config";
13
17
  import { AICardStatus } from "./types";
14
18
  import {
15
19
  isCardRunStopRequested,
@@ -29,6 +33,7 @@ import {
29
33
  upsertInboundMessageContext,
30
34
  } from "./message-context-store";
31
35
  import { extractMessageContent } from "./message-utils";
36
+ import { deliverBtwReply, stripLeadingMentions } from "./messaging/btw-deliver";
32
37
  import { resolveQuotedRuntimeContext } from "./messaging/quoted-context";
33
38
  import {
34
39
  buildInboundQuotedRef,
@@ -42,7 +47,7 @@ import {
42
47
  } from "./proactive-risk-registry";
43
48
  import { downloadGroupFile, getUnionIdByStaffId, resolveQuotedFile } from "./messaging/quoted-file-service";
44
49
  import { createReplyStrategy } from "./reply-strategy";
45
- import type { DeliverPayload } from "./reply-strategy";
50
+ import type { DeliverPayload } from "./reply-strategy-types";
46
51
  import { getDingTalkRuntime } from "./runtime";
47
52
  import { sendBySession, sendMessage, sendProactiveMedia } from "./send-service";
48
53
  import { acquireSessionLock } from "./session-lock";
@@ -52,18 +57,43 @@ import {
52
57
  setSessionPeerOverride,
53
58
  } from "./session-peer-store";
54
59
  import { resolveDingTalkSessionPeer } from "./session-routing";
60
+ import { getSessionState, initSessionState } from "./session-state";
55
61
  import {
56
62
  upsertObservedGroupTarget,
57
63
  upsertObservedUserTarget,
58
64
  } from "./targeting/target-directory-store";
59
65
  import type { DingTalkConfig, HandleDingTalkMessageParams, Logger, MediaFile } from "./types";
60
- import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData } from "./utils";
61
- import { isAbortRequestText } from "openclaw/plugin-sdk/reply-runtime";
66
+ import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData, parseBooleanLike } from "./utils";
67
+ import { parseInlineDirectives } from "openclaw/plugin-sdk/text-runtime";
62
68
 
63
69
  const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
64
70
  const MIN_THINKING_REACTION_VISIBLE_MS = 1200;
65
71
  const MAX_DYNAMIC_ACK_DISPOSE_WAIT_MS = 500;
66
72
  const ATTACHMENT_TEXT_PREFIX = "[附件内容摘录]";
73
+ const MEDIA_DIRECTIVE_PREFIX = "MEDIA:";
74
+ const STANDALONE_MEDIA_PATH_EXTENSIONS = new Set([
75
+ ".jpg",
76
+ ".jpeg",
77
+ ".png",
78
+ ".gif",
79
+ ".bmp",
80
+ ".ogg",
81
+ ".amr",
82
+ ".mp3",
83
+ ".wav",
84
+ ".mp4",
85
+ ".avi",
86
+ ".mov",
87
+ ".doc",
88
+ ".docx",
89
+ ".xls",
90
+ ".xlsx",
91
+ ".ppt",
92
+ ".pptx",
93
+ ".zip",
94
+ ".pdf",
95
+ ".rar",
96
+ ]);
67
97
  const proactiveHintLastSentAt = new Map<string, number>();
68
98
  const sessionReasoningLevelCache = new Map<string, {
69
99
  updatedAt?: number;
@@ -71,6 +101,71 @@ const sessionReasoningLevelCache = new Map<string, {
71
101
  }>();
72
102
  type ReplyMode = "card" | "markdown";
73
103
 
104
+ function resolveQuotedContextAllowFrom(
105
+ config: DingTalkConfig,
106
+ groupId: string,
107
+ ): string[] | undefined {
108
+ const groupConfig = resolveGroupConfig(config, groupId);
109
+ return groupConfig?.groupAllowFrom ?? config.groupAllowFrom;
110
+ }
111
+
112
+ function resolveQuotedVisibilitySenderId(params: {
113
+ quotedSenderId?: string;
114
+ currentSenderId: string;
115
+ currentSenderOriginalId: string;
116
+ }): string | undefined {
117
+ const quotedSenderId = (params.quotedSenderId || "").trim();
118
+ if (!quotedSenderId) {
119
+ return undefined;
120
+ }
121
+ if (quotedSenderId === params.currentSenderOriginalId) {
122
+ return params.currentSenderId;
123
+ }
124
+ return quotedSenderId;
125
+ }
126
+
127
+ function filterQuotedRuntimeContext(params: {
128
+ context: ReturnType<typeof resolveQuotedRuntimeContext>;
129
+ config: DingTalkConfig;
130
+ isDirect: boolean;
131
+ groupId: string;
132
+ quotedSenderId?: string;
133
+ currentSenderId: string;
134
+ currentSenderOriginalId: string;
135
+ }): ReturnType<typeof resolveQuotedRuntimeContext> {
136
+ const { context, config, isDirect, groupId, quotedSenderId, currentSenderId, currentSenderOriginalId } = params;
137
+ if (!context || isDirect) {
138
+ return context;
139
+ }
140
+
141
+ const mode = config.contextVisibility || "all";
142
+ if (mode === "all") {
143
+ return context;
144
+ }
145
+
146
+ const allow = normalizeAllowFrom(resolveQuotedContextAllowFrom(config, groupId));
147
+ const senderId = resolveQuotedVisibilitySenderId({
148
+ quotedSenderId,
149
+ currentSenderId,
150
+ currentSenderOriginalId,
151
+ });
152
+ const senderAllowed =
153
+ allow.hasEntries && !!senderId
154
+ ? isSenderAllowed({ allow, senderId })
155
+ : false;
156
+
157
+ if (senderAllowed) {
158
+ return context;
159
+ }
160
+ if (mode === "allowlist_quote") {
161
+ return {
162
+ ...context,
163
+ untrustedContext: undefined,
164
+ };
165
+ }
166
+ return null;
167
+ }
168
+
74
169
  function readSessionReasoningLevel(params: {
75
170
  storePath?: string;
76
171
  sessionKey: string;
@@ -132,6 +227,29 @@ function shouldDisableBlockStreamingForReplyMode(params: {
132
227
  return shouldDisable;
133
228
  }
134
229
 
230
+ function resolveLegacyCardStreamReasoningForInternalUse(params: {
231
+ cfg: HandleDingTalkMessageParams["cfg"];
232
+ accountId: string;
233
+ }): boolean | undefined {
234
+ const dingtalk = (params.cfg?.channels?.dingtalk ?? null) as
235
+ | (Record<string, unknown> & {
236
+ cardStreamReasoning?: unknown;
237
+ accounts?: Record<string, Record<string, unknown> | undefined>;
238
+ })
239
+ | null;
240
+ if (!dingtalk) {
241
+ return undefined;
242
+ }
243
+ const accountConfig = params.accountId ? dingtalk.accounts?.[params.accountId] : undefined;
244
+ if (typeof accountConfig?.cardStreamReasoning === "boolean") {
245
+ return accountConfig.cardStreamReasoning;
246
+ }
247
+ if (typeof dingtalk.cardStreamReasoning === "boolean") {
248
+ return dingtalk.cardStreamReasoning;
249
+ }
250
+ return undefined;
251
+ }
252
+
135
253
  function resolvePinnedMainDmOwner(params: {
136
254
  dmScope?: string;
137
255
  allowFrom?: string[];
@@ -273,6 +391,8 @@ function buildGroupTurnContextPrompt(params: {
273
391
  type ReplyStreamPayload = {
274
392
  text?: string;
275
393
  isReasoning?: boolean;
394
+ mediaUrl?: string;
395
+ mediaUrls?: string[];
276
396
  };
277
397
 
278
398
  type ReplyChunkInfo = {
@@ -290,6 +410,7 @@ export async function downloadMedia(
290
410
  config: DingTalkConfig,
291
411
  downloadCode: string,
292
412
  log?: any,
413
+ originalFilename?: string,
293
414
  ): Promise<MediaFile | null> {
294
415
  const rt = getDingTalkRuntime();
295
416
  let downloadUrl: string | undefined;
@@ -363,9 +484,13 @@ export async function downloadMedia(
363
484
 
364
485
  const maxBytes =
365
486
  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");
487
+ const saved = await rt.channel.media.saveMediaBuffer(
488
+ buffer,
489
+ contentType,
490
+ "inbound",
491
+ maxBytes,
492
+ originalFilename,
493
+ );
369
494
  log?.debug?.(`[DingTalk] Media saved: ${saved.path}`);
370
495
  return { path: saved.path, mimeType: saved.contentType ?? contentType };
371
496
  } catch (err: any) {
@@ -415,6 +540,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
415
540
  return;
416
541
  }
417
542
 
543
+ // Preserve raw inbound text before any rewriting (e.g., sub-agent context hint)
544
+ // for use in card quoteContent which should show the user's original message.
545
+ const rawInboundText = extractedContent.text.trim();
546
+
418
547
  // Add context hint for sub-agent mode, stripping quoted prefix to avoid protocol noise in agent context.
419
548
  if (subAgentOptions) {
420
549
  const cleanText = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
@@ -689,21 +818,51 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
689
818
  if (commandHandled) {
690
819
  return;
691
820
  }
821
+
822
+ const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_MESSAGE_CONTEXT_TTL_DAYS;
823
+ const quotedRef = buildInboundQuotedRef(data, extractedContent);
824
+ const replyQuotedRef = createReplyQuotedRef(data.msgId);
825
+ const content = extractedContent;
826
+ const isBtwBypass = isBtwRequestText(stripLeadingMentions(content.text).trim());
827
+ const taskInfoConversationId = groupId || to;
828
+ const sessionTaskState = initSessionState(accountId, taskInfoConversationId);
829
+ const initialStatusLine = renderStatusLine({
830
+ model: sessionTaskState.model,
831
+ effort: sessionTaskState.effort,
832
+ agent: getAgentDisplayName({
833
+ subAgentOptions,
834
+ agentId: route.agentId,
835
+ agentsList: cfg.agents?.list,
836
+ }),
837
+ }, dingtalkConfig) || undefined;
838
+
692
839
  // 3) Select response mode (card vs markdown).
693
840
  // Card creation runs BEFORE media download so the user sees immediate visual
694
841
  // feedback while large files are still being downloaded.
842
+ // /btw is gated out here (`!isBtwBypass`) because it must never create a card —
843
+ // it has its own bypass dispatch later that returns before reaching the main
844
+ // run. Abort (/stop) is intentionally NOT gated: in card mode the existing
845
+ // abort branch finalizes the card with the abort confirmation text instead of
846
+ // sending a separate plain-text message.
695
847
  let useCardMode = dingtalkConfig.messageType === "card";
696
848
  let currentAICard: import("./types").AICardInstance | undefined;
697
849
 
698
- if (useCardMode) {
850
+ if (useCardMode && !isBtwBypass) {
699
851
  try {
700
852
  log?.debug?.(
701
853
  `[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${to}`,
702
854
  );
855
+ // quoteContent always shows the inbound message text so the user can
856
+ // identify which of their messages this card is replying to.
857
+ // Use rawInboundText ( preserved before sub-agent rewriting) to avoid
858
+ // showing internal routing context like "[你被 @ 为...]" in the card UI.
859
+ const inboundQuoteText = rawInboundText.slice(0, 200);
703
860
  const aiCard = await createAICard(dingtalkConfig, to, log, {
704
861
  accountId,
705
862
  storePath: accountStorePath,
706
863
  contextConversationId: groupId,
864
+ quoteContent: inboundQuoteText,
865
+ statusLine: initialStatusLine,
707
866
  });
708
867
  if (aiCard) {
709
868
  currentAICard = aiCard;
@@ -729,11 +888,6 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
729
888
  );
730
889
  }
731
890
  }
732
-
733
- const journalTTLDays = dingtalkConfig.journalTTLDays ?? DEFAULT_MESSAGE_CONTEXT_TTL_DAYS;
734
- const quotedRef = buildInboundQuotedRef(data, extractedContent);
735
- const replyQuotedRef = createReplyQuotedRef(data.msgId);
736
- const content = extractedContent;
737
891
  const hasLegacyQuoteContent =
738
892
  typeof data.content?.quoteContent === "string" && data.content.quoteContent.trim().length > 0;
739
893
 
@@ -771,6 +925,8 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
771
925
  messageType: content.messageType,
772
926
  text: content.text,
773
927
  quotedRef,
928
+ senderId,
929
+ senderName,
774
930
  createdAt: data.createAt,
775
931
  ttlMs: ttlDaysToMs(journalTTLDays),
776
932
  ttlReferenceMs: data.createAt,
@@ -795,7 +951,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
795
951
  mediaType = preDownloadedMedia.mediaType;
796
952
  } else if (content.mediaPath && robotCode) {
797
953
  // Download media only if not pre-downloaded
798
- const media = await downloadMedia(dingtalkConfig, content.mediaPath, log);
954
+ const media = await downloadMedia(
955
+ dingtalkConfig,
956
+ content.mediaPath,
957
+ log,
958
+ attachmentContextFileName,
959
+ );
799
960
  if (media) {
800
961
  mediaPath = media.path;
801
962
  mediaType = media.mimeType;
@@ -816,6 +977,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
816
977
  spaceId: data.content?.spaceId,
817
978
  fileId: data.content?.fileId,
818
979
  },
980
+ attachmentFileName: attachmentContextFileName,
819
981
  ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
820
982
  topic: null,
821
983
  });
@@ -840,6 +1002,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
840
1002
  spaceId: content.docSpaceId,
841
1003
  fileId: content.docFileId,
842
1004
  },
1005
+ attachmentFileName: attachmentContextFileName,
843
1006
  ttlMs: DEFAULT_MEDIA_CONTEXT_TTL_MS,
844
1007
  topic: null,
845
1008
  });
@@ -853,6 +1016,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
853
1016
  content.docFileId,
854
1017
  unionId,
855
1018
  log,
1019
+ attachmentContextFileName,
856
1020
  );
857
1021
  if (docMedia) {
858
1022
  mediaPath = docMedia.path;
@@ -871,22 +1035,30 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
871
1035
  quotedRef,
872
1036
  log,
873
1037
  });
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,
1038
+ const quotedRuntimeContext = filterQuotedRuntimeContext({
1039
+ context: resolveQuotedRuntimeContext({
1040
+ storePath: accountStorePath,
1041
+ accountId,
1042
+ conversationId: data.conversationId,
1043
+ quotedRef,
1044
+ firstRecord: quotedRecord,
1045
+ firstPreview:
1046
+ content.quoted?.previewText ||
1047
+ content.quoted?.previewMessageType
1048
+ ? {
1049
+ text: content.quoted.previewText,
1050
+ messageType: content.quoted.previewMessageType,
1051
+ senderId: content.quoted.previewSenderId,
1052
+ }
1053
+ : undefined,
1054
+ log,
1055
+ }),
1056
+ config: dingtalkConfig,
1057
+ isDirect,
1058
+ groupId,
1059
+ quotedSenderId: quotedRecord?.senderId || content.quoted?.previewSenderId,
1060
+ currentSenderId: senderId,
1061
+ currentSenderOriginalId: senderOriginalId,
890
1062
  });
891
1063
 
892
1064
  // Try downloading a quoted file from cached downloadCode/spaceId+fileId.
@@ -899,13 +1071,14 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
899
1071
  fileId?: string;
900
1072
  };
901
1073
  } | null,
1074
+ originalFilename?: string,
902
1075
  ): Promise<MediaFile | null> => {
903
1076
  if (!record?.media) {
904
1077
  return null;
905
1078
  }
906
1079
  let media: MediaFile | null = null;
907
1080
  if (record.media.downloadCode) {
908
- media = await downloadMedia(dingtalkConfig, record.media.downloadCode, log);
1081
+ media = await downloadMedia(dingtalkConfig, record.media.downloadCode, log, originalFilename);
909
1082
  if (media) {
910
1083
  log?.debug?.(
911
1084
  `[DingTalk][QuotedRef] Recovered quoted media from cached downloadCode ` +
@@ -922,6 +1095,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
922
1095
  record.media.fileId,
923
1096
  unionId,
924
1097
  log,
1098
+ originalFilename,
925
1099
  );
926
1100
  if (media) {
927
1101
  log?.debug?.(
@@ -938,9 +1112,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
938
1112
 
939
1113
  // Quoted picture: download via existing downloadMedia.
940
1114
  if (!mediaPath && content.quoted?.mediaDownloadCode && robotCode) {
1115
+ const quotedOriginalFilename = content.quoted.previewFileName;
941
1116
  const media =
942
- (await tryDownloadFromRecord(quotedRecord)) ||
943
- (await downloadMedia(dingtalkConfig, content.quoted.mediaDownloadCode, log));
1117
+ (await tryDownloadFromRecord(quotedRecord, quotedOriginalFilename)) ||
1118
+ (await downloadMedia(
1119
+ dingtalkConfig,
1120
+ content.quoted.mediaDownloadCode,
1121
+ log,
1122
+ quotedOriginalFilename,
1123
+ ));
944
1124
  if (media) {
945
1125
  if (!quotedRecord) {
946
1126
  log?.debug?.(
@@ -965,7 +1145,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
965
1145
 
966
1146
  // Step 0: Direct download via downloadCode from quoted payload (file/audio/video msgType).
967
1147
  if (!fileResolved && content.quoted.fileDownloadCode && robotCode) {
968
- const media = await downloadMedia(dingtalkConfig, content.quoted.fileDownloadCode, log);
1148
+ const media = await downloadMedia(
1149
+ dingtalkConfig,
1150
+ content.quoted.fileDownloadCode,
1151
+ log,
1152
+ content.quoted.previewFileName,
1153
+ );
969
1154
  if (media) {
970
1155
  mediaPath = media.path;
971
1156
  mediaType = media.mimeType;
@@ -982,7 +1167,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
982
1167
 
983
1168
  // Step 1: Prefer quotedRef-backed record lookup, then msgId-based cache.
984
1169
  if (!fileResolved) {
985
- const cachedMedia = await tryDownloadFromRecord(quotedRecord);
1170
+ const cachedMedia = await tryDownloadFromRecord(
1171
+ quotedRecord,
1172
+ quotedRecord?.attachmentFileName || content.quoted.previewFileName,
1173
+ );
986
1174
  if (cachedMedia) {
987
1175
  mediaPath = cachedMedia.path;
988
1176
  mediaType = cachedMedia.mimeType;
@@ -1056,7 +1244,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1056
1244
  if (!mediaPath && content.quoted?.isQuotedDocCard) {
1057
1245
  let docResolved = false;
1058
1246
 
1059
- const cachedDocMedia = await tryDownloadFromRecord(quotedRecord);
1247
+ const cachedDocMedia = await tryDownloadFromRecord(
1248
+ quotedRecord,
1249
+ quotedRecord?.attachmentFileName || content.quoted?.previewFileName,
1250
+ );
1060
1251
  if (cachedDocMedia) {
1061
1252
  mediaPath = cachedDocMedia.path;
1062
1253
  mediaType = cachedDocMedia.mimeType;
@@ -1280,7 +1471,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1280
1471
  // "@Agent /stop" are correctly recognised as abort requests in both DM and group
1281
1472
  // chats. In groups DingTalk usually strips @BotName at the protocol level, but
1282
1473
  // in DMs with multi-agent routing the @mention prefix survives all the way here.
1283
- const textForAbortCheck = inboundText.replace(/^(?:@\S+\s+)*/u, "").trim();
1474
+ const textForAbortCheck = stripLeadingMentions(inboundText).trim();
1284
1475
  if (isAbortRequestText(textForAbortCheck)) {
1285
1476
  log?.info?.(
1286
1477
  `[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
@@ -1332,9 +1523,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1332
1523
  }
1333
1524
  // Finalize the card that was created for this message before the abort check.
1334
1525
  // Without this, the card stays in PROCESSING ("处理中...") indefinitely.
1526
+ // Use V2 finalize (commitAICardBlocks) for consistent state transition.
1335
1527
  if (currentAICard && !isCardInTerminalState(currentAICard.state)) {
1336
1528
  try {
1337
- await finishAICard(currentAICard, abortConfirmationText ?? "已停止", log);
1529
+ const abortText = abortConfirmationText ?? "已停止";
1530
+ const abortBlockList = [{ type: 0, markdown: abortText }];
1531
+ const blockListJson = JSON.stringify(abortBlockList);
1532
+
1533
+ await commitAICardBlocks(currentAICard, {
1534
+ blockListJson,
1535
+ content: abortText,
1536
+ }, log);
1537
+
1538
+ log?.debug?.(`[DingTalk] Abort card finalized via V2 API: card=${currentAICard.cardInstanceId}`);
1338
1539
  } catch (cardErr) {
1339
1540
  log?.warn?.(`[DingTalk] Abort card finalize failed: ${getErrorMessage(cardErr)}`);
1340
1541
  currentAICard.state = AICardStatus.FAILED;
@@ -1343,6 +1544,61 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1343
1544
  return;
1344
1545
  }
1345
1546
 
1547
+ // ---- Pre-lock BTW: bypass session lock for /btw side questions ----
1548
+ // /btw runs an isolated, tool-less side query in openclaw without polluting
1549
+ // the main run's transcript. The dispatch must NOT acquire the session lock,
1550
+ // otherwise it would queue behind the in-flight main task and lose its "side
1551
+ // question" semantics.
1552
+ //
1553
+ // The `isBtwBypass` flag is computed once early (just after `content` is
1554
+ // resolved, just before `createAICard`). The same constant gates `createAICard` above and
1555
+ // drives this branch — single decision, two consequences. See the comment
1556
+ // at the flag's definition for why /btw uses pre-OCR `content.text` while
1557
+ // abort uses `inboundText`.
1558
+ if (isBtwBypass) {
1559
+ log?.info?.(
1560
+ `[DingTalk] BTW request detected, bypassing session lock for session=${route.sessionKey}`,
1561
+ );
1562
+ // Empty fallback (NOT "Unknown" like the file's main `senderName` variable):
1563
+ // when the nickname is missing we want the blockquote to render as
1564
+ // `> /btw <question>` rather than `> Unknown: /btw <question>`. Read locally
1565
+ // here so the existing `senderName` semantics elsewhere in the file are not
1566
+ // affected.
1567
+ const btwSenderName = data.senderNick || "";
1568
+ try {
1569
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1570
+ ctx,
1571
+ cfg,
1572
+ dispatcherOptions: {
1573
+ responsePrefix: "",
1574
+ deliver: async (payload) => {
1575
+ if (!payload.text) {
1576
+ log?.debug?.(`[DingTalk] BTW deliver received non-text payload, skipping`);
1577
+ return;
1578
+ }
1579
+ await deliverBtwReply({
1580
+ config: dingtalkConfig,
1581
+ sessionWebhook,
1582
+ conversationId: groupId,
1583
+ to,
1584
+ senderName: btwSenderName,
1585
+ // Use pre-OCR content.text (consistent with isBtwBypass detection)
1586
+ // so the blockquote shows the user's typed body, not body + OCR.
1587
+ rawQuestion: content.text,
1588
+ replyText: payload.text,
1589
+ log,
1590
+ accountId,
1591
+ storePath: accountStorePath,
1592
+ });
1593
+ },
1594
+ },
1595
+ });
1596
+ } catch (btwErr) {
1597
+ log?.warn?.(`[DingTalk] BTW dispatch failed: ${getErrorMessage(btwErr)}`);
1598
+ }
1599
+ return;
1600
+ }
1601
+
1346
1602
  const ackReaction =
1347
1603
  typeof dingtalkConfig.ackReaction === "string"
1348
1604
  ? dingtalkConfig.ackReaction.trim()
@@ -1380,8 +1636,117 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1380
1636
  }
1381
1637
  }
1382
1638
 
1639
+ function parseInlineReplyPayloadText(text: unknown): {
1640
+ text?: string;
1641
+ mediaUrls: string[];
1642
+ audioAsVoice: boolean;
1643
+ } {
1644
+ if (typeof text !== "string") {
1645
+ return { text: undefined, mediaUrls: [], audioAsVoice: false };
1646
+ }
1647
+
1648
+ const normalizedText = text.replace(/\r\n/g, "\n");
1649
+ const parsedInline = normalizedText.includes("[[")
1650
+ ? parseInlineDirectives(normalizedText, {
1651
+ stripAudioTag: true,
1652
+ stripReplyTags: false,
1653
+ })
1654
+ : {
1655
+ text: normalizedText,
1656
+ audioAsVoice: false,
1657
+ replyToCurrent: false,
1658
+ hasAudioTag: false,
1659
+ hasReplyTag: false,
1660
+ };
1661
+ const mediaUrls: string[] = [];
1662
+ const contentLines: string[] = [];
1663
+
1664
+ for (const line of parsedInline.text.split("\n")) {
1665
+ const trimmed = line.trim();
1666
+ const mediaCandidate = trimmed.replace(/^(?:\[\[[^[\]]+\]\]\s*)+/, "");
1667
+ if (mediaCandidate.startsWith(MEDIA_DIRECTIVE_PREFIX)) {
1668
+ const mediaSource = mediaCandidate.slice(MEDIA_DIRECTIVE_PREFIX.length).trim();
1669
+ if (mediaSource) {
1670
+ mediaUrls.push(mediaSource);
1671
+ continue;
1672
+ }
1673
+ }
1674
+ contentLines.push(line);
1675
+ }
1676
+
1677
+ const cleanedText = contentLines.join("\n").trim();
1678
+ const inlineTextWasTransformed = parsedInline.text !== normalizedText;
1679
+ if (mediaUrls.length === 0) {
1680
+ const standaloneMediaSource = extractStandaloneMediaSource(cleanedText);
1681
+ if (standaloneMediaSource) {
1682
+ return {
1683
+ text: undefined,
1684
+ mediaUrls: [standaloneMediaSource],
1685
+ audioAsVoice: parsedInline.audioAsVoice,
1686
+ };
1687
+ }
1688
+ }
1689
+
1690
+ return {
1691
+ // Keep ordinary text formatting stable except for newline normalization.
1692
+ // Once inline parsing actually strips directives/media lines, return the
1693
+ // cleaned body text instead of the original raw payload.
1694
+ text: mediaUrls.length > 0 || inlineTextWasTransformed ? cleanedText || undefined : normalizedText,
1695
+ mediaUrls,
1696
+ audioAsVoice: parsedInline.audioAsVoice,
1697
+ };
1698
+ }
1699
+
1700
+ function extractStandaloneMediaSource(text: string): string | undefined {
1701
+ const trimmed = text.trim();
1702
+ if (!trimmed || trimmed.includes("\n") || /\s/.test(trimmed)) {
1703
+ return undefined;
1704
+ }
1705
+
1706
+ const hasPathLikeShape =
1707
+ /^https?:\/\/\S+$/i.test(trimmed) ||
1708
+ trimmed.startsWith("~/") ||
1709
+ trimmed.startsWith("~\\") ||
1710
+ trimmed.startsWith("./") ||
1711
+ trimmed.startsWith(".\\") ||
1712
+ trimmed.startsWith("../") ||
1713
+ trimmed.startsWith("..\\") ||
1714
+ trimmed.startsWith("/") ||
1715
+ trimmed.startsWith("\\") ||
1716
+ /^[a-zA-Z]:[\\/]/.test(trimmed) ||
1717
+ trimmed.includes("/") ||
1718
+ trimmed.includes("\\");
1719
+ if (!hasPathLikeShape) {
1720
+ return undefined;
1721
+ }
1722
+
1723
+ return STANDALONE_MEDIA_PATH_EXTENSIONS.has(path.extname(trimmed).toLowerCase())
1724
+ ? trimmed
1725
+ : undefined;
1726
+ }
1727
+
1383
1728
  // ---- Shared media delivery helper ----
1384
- async function deliverMediaAttachments(urls: string[]) {
1729
+ function extractSharedAudioAsVoice(
1730
+ payload: ReplyStreamPayload,
1731
+ inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
1732
+ ): boolean {
1733
+ // Normalize all reply-time voice hints into the shared `audioAsVoice`
1734
+ // semantic that strategies and media delivery use downstream.
1735
+ const richPayload = payload as ReplyStreamPayload & {
1736
+ audioAsVoice?: unknown;
1737
+ asVoice?: unknown;
1738
+ };
1739
+ const sharedValue = parseBooleanLike(richPayload.audioAsVoice);
1740
+ if (sharedValue !== undefined) {
1741
+ return sharedValue;
1742
+ }
1743
+ if (parseBooleanLike(richPayload.asVoice) === true) {
1744
+ return true;
1745
+ }
1746
+ return inlineReplyPayload?.audioAsVoice === true;
1747
+ }
1748
+
1749
+ async function deliverMediaAttachments(urls: string[], options?: { audioAsVoice?: boolean }) {
1385
1750
  for (const rawMediaUrl of urls) {
1386
1751
  const preparedMedia = await prepareMediaInput(
1387
1752
  rawMediaUrl,
@@ -1389,10 +1754,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1389
1754
  dingtalkConfig.mediaUrlAllowlist,
1390
1755
  );
1391
1756
  try {
1392
- const actualMediaPath = preparedMedia.path;
1757
+ const actualMediaPath = preparedMedia.cleanup
1758
+ ? preparedMedia.path
1759
+ : resolveRelativePath(preparedMedia.path);
1393
1760
  const outMediaType = resolveOutboundMediaType({
1394
1761
  mediaPath: actualMediaPath,
1395
- asVoice: false,
1762
+ asVoice: options?.audioAsVoice === true,
1396
1763
  });
1397
1764
  if (sessionWebhook) {
1398
1765
  const sendResult = await sendMessage(dingtalkConfig, to, "", {
@@ -1433,18 +1800,24 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1433
1800
  }
1434
1801
 
1435
1802
  // ---- Extract mediaUrls from runtime payload ----
1436
- function extractMediaUrls(payload: ReplyStreamPayload): string[] {
1803
+ function extractMediaUrls(
1804
+ payload: ReplyStreamPayload,
1805
+ inlineReplyPayload?: ReturnType<typeof parseInlineReplyPayloadText>,
1806
+ ): string[] {
1437
1807
  const richPayload = payload as typeof payload & {
1438
1808
  mediaUrl?: string;
1439
1809
  mediaUrls?: string[];
1440
1810
  };
1441
- return Array.isArray(richPayload.mediaUrls)
1811
+ const explicitMediaUrls = Array.isArray(richPayload.mediaUrls)
1442
1812
  ? richPayload.mediaUrls.filter((entry: unknown) => typeof entry === "string" && entry.trim())
1443
1813
  : richPayload.mediaUrl &&
1444
1814
  typeof richPayload.mediaUrl === "string" &&
1445
1815
  richPayload.mediaUrl.trim()
1446
1816
  ? [richPayload.mediaUrl]
1447
1817
  : [];
1818
+ return explicitMediaUrls.length > 0
1819
+ ? explicitMediaUrls
1820
+ : inlineReplyPayload?.mediaUrls ?? [];
1448
1821
  }
1449
1822
 
1450
1823
  // Serialize dispatchReply + card finalize per session to prevent the runtime
@@ -1503,8 +1876,30 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1503
1876
  sessionUpdatedAt: previousTimestamp,
1504
1877
  log,
1505
1878
  });
1879
+ const legacyCardStreamReasoning = resolveLegacyCardStreamReasoningForInternalUse({
1880
+ cfg,
1881
+ accountId,
1882
+ });
1883
+ const strategyConfig =
1884
+ legacyCardStreamReasoning === undefined
1885
+ ? dingtalkConfig
1886
+ : { ...dingtalkConfig, cardStreamReasoning: legacyCardStreamReasoning };
1887
+ const sessionTaskState = getSessionState(accountId, taskInfoConversationId);
1888
+ const taskMeta = {
1889
+ model: sessionTaskState?.model,
1890
+ effort: sessionTaskState?.effort,
1891
+ elapsedMs: typeof sessionTaskState?.taskStartTime === "number"
1892
+ ? Math.max(0, Date.now() - sessionTaskState.taskStartTime)
1893
+ : undefined,
1894
+ agent: getAgentDisplayName({
1895
+ subAgentOptions,
1896
+ agentId: route.agentId,
1897
+ agentsList: cfg.agents?.list,
1898
+ }),
1899
+ };
1900
+
1506
1901
  const strategy = createReplyStrategy({
1507
- config: dingtalkConfig,
1902
+ config: strategyConfig,
1508
1903
  card: currentAICard,
1509
1904
  useCardMode: replyMode === "card",
1510
1905
  to,
@@ -1526,10 +1921,13 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1526
1921
  replyQuotedRef,
1527
1922
  deliverMedia: deliverMediaAttachments,
1528
1923
  isStopRequested: isCurrentCardStopRequested,
1924
+ inboundText: rawInboundText,
1925
+ taskMeta,
1529
1926
  });
1530
1927
 
1531
1928
  try {
1532
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1929
+ let deliveredFinalCount = 0;
1930
+ const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1533
1931
  ctx,
1534
1932
  cfg,
1535
1933
  dispatcherOptions: {
@@ -1538,13 +1936,18 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1538
1936
  if (isCurrentCardStopRequested()) {
1539
1937
  log?.debug?.("[DingTalk][CardStop] Ignoring reply delivery because stop was already requested");
1540
1938
  return;
1541
- }
1939
+ }
1542
1940
  try {
1543
- const mediaUrls = extractMediaUrls(payload);
1941
+ if (info?.kind === "final") {
1942
+ deliveredFinalCount += 1;
1943
+ }
1944
+ const inlineReplyPayload = parseInlineReplyPayloadText(payload.text);
1945
+ const mediaUrls = extractMediaUrls(payload, inlineReplyPayload);
1544
1946
  const richPayload = payload as ReplyStreamPayload & { isReasoning?: boolean };
1545
1947
  await strategy.deliver({
1546
- text: payload.text,
1948
+ text: inlineReplyPayload.text,
1547
1949
  mediaUrls,
1950
+ audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
1548
1951
  kind: (info?.kind as DeliverPayload["kind"]) || "block",
1549
1952
  isReasoning: richPayload.isReasoning === true,
1550
1953
  });
@@ -1560,6 +1963,43 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1560
1963
  },
1561
1964
  replyOptions: strategy.getReplyOptions(),
1562
1965
  });
1966
+
1967
+ const bufferedFinal =
1968
+ dispatchResult && typeof dispatchResult === "object" && "queuedFinal" in dispatchResult
1969
+ ? (dispatchResult as { queuedFinal?: unknown }).queuedFinal
1970
+ : undefined;
1971
+ const finalCount =
1972
+ dispatchResult && typeof dispatchResult === "object" && "counts" in dispatchResult
1973
+ ? (dispatchResult as { counts?: { final?: unknown } }).counts?.final
1974
+ : undefined;
1975
+
1976
+ log?.info?.(
1977
+ `[DingTalk][Dispatch] completed — deliveredFinalCount=${deliveredFinalCount} ` +
1978
+ `counts.final=${typeof finalCount === "number" ? finalCount : "n/a"} ` +
1979
+ `queuedFinalType=${typeof bufferedFinal}`,
1980
+ );
1981
+
1982
+ const bufferedFinalPayload =
1983
+ typeof bufferedFinal === "string"
1984
+ ? ({ text: bufferedFinal } satisfies ReplyStreamPayload)
1985
+ : bufferedFinal && typeof bufferedFinal === "object"
1986
+ ? (bufferedFinal as ReplyStreamPayload)
1987
+ : undefined;
1988
+
1989
+ if (deliveredFinalCount === 0 && bufferedFinalPayload) {
1990
+ const inlineReplyPayload = parseInlineReplyPayloadText(bufferedFinalPayload.text);
1991
+ const mediaUrls = extractMediaUrls(bufferedFinalPayload, inlineReplyPayload);
1992
+ const hasBufferedText = typeof bufferedFinalPayload.text === "string" && bufferedFinalPayload.text.trim().length > 0;
1993
+ if (hasBufferedText || mediaUrls.length > 0) {
1994
+ await strategy.deliver({
1995
+ text: inlineReplyPayload.text,
1996
+ mediaUrls,
1997
+ audioAsVoice: extractSharedAudioAsVoice(bufferedFinalPayload, inlineReplyPayload),
1998
+ kind: "final",
1999
+ isReasoning: bufferedFinalPayload.isReasoning === true,
2000
+ });
2001
+ }
2002
+ }
1563
2003
  } catch (dispatchErr: unknown) {
1564
2004
  const error = dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
1565
2005
  await strategy.abort(error);