@soimy/dingtalk 3.6.2-beta.1 → 3.6.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.
@@ -102,7 +102,6 @@ export function createCardReplyStrategy(
102
102
  };
103
103
  const { mode, usedDeprecatedCardRealTimeStream } = resolveCardStreamingMode(config);
104
104
  const streamAnswerLive = mode === "answer" || mode === "all";
105
- const renderAnswerBlocksLive = mode === "all";
106
105
  const streamThinkingLive = mode === "all";
107
106
  let lifecycleState: CardReplyLifecycleState = "open";
108
107
  const shouldAcceptAnswerSnapshot = () => lifecycleState === "open";
@@ -261,7 +260,10 @@ export function createCardReplyStrategy(
261
260
  finalTextForFallback = normalized.answerText;
262
261
  return;
263
262
  }
264
- await controller.updateAnswer(normalized.answerText);
263
+ await controller.updateAnswer(normalized.answerText, {
264
+ stream: streamAnswerLive,
265
+ renderBlocks: !streamAnswerLive,
266
+ });
265
267
  }
266
268
  };
267
269
 
@@ -304,7 +306,8 @@ export function createCardReplyStrategy(
304
306
 
305
307
  await controller.updateAnswer(answerSnapshot, {
306
308
  stream: streamAnswerLive,
307
- renderBlocks: renderAnswerBlocksLive,
309
+ // Active answer previews live in the content field; blockList is committed at boundaries/finalize.
310
+ renderBlocks: false,
308
311
  });
309
312
  };
310
313
 
@@ -388,6 +391,11 @@ export function createCardReplyStrategy(
388
391
  // Card mode keeps runtime block streaming disabled, but still consumes
389
392
  // reasoning blocks through explicit callbacks and delivery metadata.
390
393
  disableBlockStreaming: ctx.disableBlockStreaming ?? true,
394
+ // DingTalk card mode owns the visible reply surface. In group chats,
395
+ // OpenClaw defaults source replies to message-tool-only; override that
396
+ // so final replies are delivered into this card instead of spawning a
397
+ // separate visible message/card via the message tool.
398
+ sourceReplyDeliveryMode: "automatic",
391
399
 
392
400
  onAssistantMessageStart: async () => {
393
401
  if (isLifecycleSealed() || isStopRequested?.()) {
@@ -671,6 +679,7 @@ export function createCardReplyStrategy(
671
679
  try {
672
680
  await flushPendingReasoning();
673
681
 
682
+ await controller.clearStreamingContent?.();
674
683
  await controller.flush();
675
684
  await controller.waitForInFlight();
676
685
 
@@ -6,7 +6,15 @@
6
6
  * Reasoning display is intentionally unsupported on DingTalk markdown.
7
7
  */
8
8
 
9
- import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy-types";
9
+ import path from "node:path";
10
+ import { resolveRelativePath } from "./config";
11
+ import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
12
+ import type {
13
+ DeliverPayload,
14
+ ReplyOptions,
15
+ ReplyStrategy,
16
+ ReplyStrategyContext,
17
+ } from "./reply-strategy-types";
10
18
  import { sendMessage } from "./send-service";
11
19
 
12
20
  const EMPTY_FINAL_FALLBACK_TEXT = "✅ Done";
@@ -14,7 +22,7 @@ const EMPTY_FINAL_FALLBACK_TEXT = "✅ Done";
14
22
  function renderQuotedSegment(text: string): string {
15
23
  return text
16
24
  .split("\n")
17
- .map((line) => line.length > 0 ? `> ${line}` : ">")
25
+ .map((line) => (line.length > 0 ? `> ${line}` : ">"))
18
26
  .join("\n");
19
27
  }
20
28
 
@@ -52,9 +60,12 @@ function computeSharedPrefixTail(previous: string, next: string): string {
52
60
  return suffix.trim() ? suffix : "";
53
61
  }
54
62
 
55
- export function createMarkdownReplyStrategy(
56
- ctx: ReplyStrategyContext,
57
- ): ReplyStrategy {
63
+ function renderMarkdownImage(mediaPath: string): string {
64
+ const filename = path.basename(mediaPath) || "image";
65
+ return `![${filename}](${mediaPath})`;
66
+ }
67
+
68
+ export function createMarkdownReplyStrategy(ctx: ReplyStrategyContext): ReplyStrategy {
58
69
  let finalText: string | undefined;
59
70
  let activeAnswerText = "";
60
71
  let lastSentAnswerText = "";
@@ -79,7 +90,10 @@ export function createMarkdownReplyStrategy(
79
90
  sentVisibleContent = true;
80
91
  };
81
92
 
82
- const emitAnswerSuffix = async (text: string | undefined): Promise<void> => {
93
+ const prepareAnswerSuffix = (text: string | undefined): {
94
+ text: string;
95
+ markSent: () => void;
96
+ } | null => {
83
97
  const current = typeof text === "string" ? text : "";
84
98
  if (current.length > 0) {
85
99
  activeAnswerText = current;
@@ -88,42 +102,146 @@ export function createMarkdownReplyStrategy(
88
102
 
89
103
  const suffix = computeIncrementalSuffix(lastSentAnswerText, current);
90
104
  if (suffix) {
91
- await sendMarkdownSegment(suffix);
92
- lastSentAnswerText = current;
93
- return;
105
+ return {
106
+ text: suffix,
107
+ markSent: () => {
108
+ lastSentAnswerText = current;
109
+ },
110
+ };
94
111
  }
95
112
 
96
113
  if (current.trim() && lastSentAnswerText && !current.startsWith(lastSentAnswerText)) {
97
114
  const suffix = computeSharedPrefixTail(lastSentAnswerText, current);
98
115
  ctx.log?.warn?.(
99
116
  `[DingTalk][Markdown] answer prefix drift detected; falling back to shared-prefix tail ` +
100
- `prevLen=${lastSentAnswerText.length} currentLen=${current.length}`,
117
+ `prevLen=${lastSentAnswerText.length} currentLen=${current.length}`,
101
118
  );
102
- lastSentAnswerText = "";
103
119
  if (suffix) {
104
- await sendMarkdownSegment(suffix);
105
- lastSentAnswerText = current;
106
- return;
120
+ return {
121
+ text: suffix,
122
+ markSent: () => {
123
+ lastSentAnswerText = current;
124
+ },
125
+ };
107
126
  }
108
- await sendMarkdownSegment(current);
109
- lastSentAnswerText = current;
127
+ return {
128
+ text: current,
129
+ markSent: () => {
130
+ lastSentAnswerText = current;
131
+ },
132
+ };
133
+ }
134
+
135
+ return null;
136
+ };
137
+
138
+ const emitAnswerSuffix = async (text: string | undefined): Promise<void> => {
139
+ const suffix = prepareAnswerSuffix(text);
140
+ if (suffix) {
141
+ await sendMarkdownSegment(suffix.text);
142
+ suffix.markSent();
110
143
  }
111
144
  };
112
145
 
146
+ const prepareMarkdownImageAttachments = async (
147
+ mediaUrls: string[],
148
+ ): Promise<{
149
+ imageMarkdown: string[];
150
+ passthroughMediaUrls: string[];
151
+ cleanups: Array<() => Promise<void>>;
152
+ }> => {
153
+ const imageMarkdown: string[] = [];
154
+ const passthroughMediaUrls: string[] = [];
155
+ const cleanups: Array<() => Promise<void>> = [];
156
+
157
+ for (const rawMediaUrl of mediaUrls) {
158
+ const preparedMedia = await prepareMediaInput(
159
+ rawMediaUrl,
160
+ ctx.log,
161
+ ctx.config.mediaUrlAllowlist,
162
+ );
163
+ const actualMediaPath = preparedMedia.cleanup
164
+ ? preparedMedia.path
165
+ : resolveRelativePath(preparedMedia.path);
166
+ const mediaType = resolveOutboundMediaType({
167
+ mediaPath: actualMediaPath,
168
+ asVoice: false,
169
+ });
170
+
171
+ if (mediaType === "image") {
172
+ imageMarkdown.push(renderMarkdownImage(actualMediaPath));
173
+ if (preparedMedia.cleanup) {
174
+ cleanups.push(preparedMedia.cleanup);
175
+ }
176
+ } else {
177
+ await preparedMedia.cleanup?.();
178
+ passthroughMediaUrls.push(rawMediaUrl);
179
+ }
180
+ }
181
+
182
+ return { imageMarkdown, passthroughMediaUrls, cleanups };
183
+ };
184
+
113
185
  return {
114
186
  getReplyOptions(): ReplyOptions {
115
187
  return {
116
188
  disableBlockStreaming: ctx.disableBlockStreaming === true,
189
+ // DingTalk markdown/sessionWebhook mode owns the visible reply surface.
190
+ // Keep runtime final replies on this strategy even when group chats
191
+ // default source replies to message-tool-only.
192
+ sourceReplyDeliveryMode: "automatic",
117
193
  };
118
194
  },
119
195
 
120
196
  async deliver(payload: DeliverPayload): Promise<void> {
197
+ let answerTextSentWithImages = false;
198
+ let toolTextSentWithImages = false;
199
+
121
200
  if (payload.mediaUrls.length > 0) {
122
- await ctx.deliverMedia(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
123
- sentVisibleContent = true;
201
+ const prepared =
202
+ payload.audioAsVoice === true
203
+ ? {
204
+ imageMarkdown: [],
205
+ passthroughMediaUrls: payload.mediaUrls,
206
+ cleanups: [],
207
+ }
208
+ : await prepareMarkdownImageAttachments(payload.mediaUrls);
209
+ try {
210
+ if (prepared.passthroughMediaUrls.length > 0) {
211
+ await ctx.deliverMedia(prepared.passthroughMediaUrls, {
212
+ audioAsVoice: payload.audioAsVoice,
213
+ });
214
+ sentVisibleContent = true;
215
+ }
216
+
217
+ if (prepared.imageMarkdown.length > 0) {
218
+ const answerSuffix =
219
+ payload.kind === "block" || payload.kind === "final"
220
+ ? prepareAnswerSuffix(payload.text)
221
+ : typeof payload.text === "string"
222
+ ? { text: renderQuotedSegment(payload.text), markSent: () => {} }
223
+ : null;
224
+ const markdownParts = [answerSuffix?.text || "", ...prepared.imageMarkdown].filter(
225
+ (part) => part.trim().length > 0,
226
+ );
227
+ if (markdownParts.length > 0) {
228
+ await sendMarkdownSegment(markdownParts.join("\n\n"));
229
+ answerSuffix?.markSent();
230
+ }
231
+ answerTextSentWithImages = payload.kind === "block" || payload.kind === "final";
232
+ toolTextSentWithImages = payload.kind === "tool";
233
+ }
234
+ } finally {
235
+ for (const cleanup of prepared.cleanups) {
236
+ await cleanup();
237
+ }
238
+ }
124
239
  }
125
240
 
126
241
  if (payload.kind === "tool") {
242
+ if (toolTextSentWithImages) {
243
+ return;
244
+ }
127
245
  const text = typeof payload.text === "string" ? payload.text : "";
128
246
  if (!text.trim()) {
129
247
  return;
@@ -133,8 +251,9 @@ export function createMarkdownReplyStrategy(
133
251
  }
134
252
 
135
253
  if (
136
- (payload.kind === "block" || payload.kind === "final")
137
- && typeof payload.text === "string"
254
+ (payload.kind === "block" || payload.kind === "final") &&
255
+ typeof payload.text === "string" &&
256
+ !answerTextSentWithImages
138
257
  ) {
139
258
  await emitAnswerSuffix(payload.text);
140
259
  }
@@ -16,6 +16,8 @@ export type InternalReplyStrategyConfig = DingTalkConfig & {
16
16
  cardStreamReasoning?: boolean;
17
17
  };
18
18
 
19
+ export type SourceReplyDeliveryMode = "automatic" | "message_tool_only";
20
+
19
21
  // ---- Public interfaces ----
20
22
 
21
23
  export interface DeliverPayload {
@@ -33,6 +35,7 @@ export interface DeliverPayload {
33
35
 
34
36
  export interface ReplyOptions {
35
37
  disableBlockStreaming: boolean;
38
+ sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
36
39
  onPartialReply?: (payload: { text?: string }) => void | Promise<void>;
37
40
  onReasoningStream?: (payload: { text?: string }) => void | Promise<void>;
38
41
  onAssistantMessageStart?: () => void | Promise<void>;
@@ -182,6 +182,10 @@ function buildPersistedOutboundText(text: string, options: SendMessageOptions):
182
182
  return text;
183
183
  }
184
184
 
185
+ function shouldRouteSessionMediaViaProactive(mediaType?: string | null): mediaType is "voice" | "video" | "file" {
186
+ return mediaType === "voice" || mediaType === "video" || mediaType === "file";
187
+ }
188
+
185
189
  const DINGTALK_TEXT_CHUNK_LIMIT = 3800;
186
190
  const CARD_MEDIA_CONTROLLER_ATTACH_WAIT_MS = 150;
187
191
  const CARD_MEDIA_CONTROLLER_ATTACH_POLL_MS = 25;
@@ -726,55 +730,30 @@ export async function sendBySession(
726
730
  const token = await getAccessToken(config, options.log);
727
731
  const log = options.log || getLogger();
728
732
 
729
- // Session webhook supports native media messages; prefer that when media info is available.
733
+ // Keep session webhooks on text/markdown. Images can render through markdown
734
+ // media references; other media types are routed by sendMessage via OpenAPI.
730
735
  if (options.mediaPath && options.mediaType) {
731
- const uploadResult = await uploadMedia(config, options.mediaPath, options.mediaType, log, {
732
- mediaLocalRoots: options.mediaLocalRoots,
733
- });
734
- if (uploadResult) {
735
- const { mediaId, buffer, durationMs: uploadedDurationMs } = uploadResult;
736
- let body: any;
737
-
738
- if (options.mediaType === "image") {
739
- body = { msgtype: "image", image: { media_id: mediaId } };
740
- } else if (options.mediaType === "voice") {
741
- const durationMs = uploadedDurationMs
742
- ?? await getVoiceDurationMs(options.mediaPath, options.mediaType, log, { preReadBuffer: buffer });
743
- body = { msgtype: "voice", voice: { media_id: mediaId, duration: String(durationMs) } };
744
- log?.debug?.(
745
- `[DingTalk] Sending session voice message mediaId=${mediaId} durationMs=${durationMs}`,
746
- );
747
- } else if (options.mediaType === "video") {
748
- body = { msgtype: "video", video: { media_id: mediaId } };
749
- } else if (options.mediaType === "file") {
750
- body = { msgtype: "file", file: { media_id: mediaId } };
751
- }
752
-
753
- if (body) {
754
- const result = await axios({
755
- url: sessionWebhook,
756
- method: "POST",
757
- data: body,
758
- headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
759
- ...getProxyBypassOption(config),
760
- });
736
+ if (options.mediaType === "image") {
737
+ const uploadResult = await uploadMedia(config, options.mediaPath, options.mediaType, log, {
738
+ mediaLocalRoots: options.mediaLocalRoots,
739
+ });
740
+ if (uploadResult) {
741
+ const imageMarkdown = `![${path.basename(options.mediaPath)}](${uploadResult.mediaId})`;
742
+ text = text ? `${text}\n\n${imageMarkdown}` : imageMarkdown;
761
743
  log?.debug?.(
762
- `[DingTalk] Session webhook response msgtype=${body.msgtype} ${summarizeSessionWebhookResponse(result.data)}`,
744
+ `[DingTalk] Session webhook image will be delivered as markdown media reference mediaId=${uploadResult.mediaId}`,
763
745
  );
764
- ensureSessionWebhookBusinessSuccess(result.data, { msgtype: body.msgtype });
765
- const delivery = extractOutboundDeliveryMetadata(result.data);
766
- if (!delivery.messageId && !delivery.processQueryKey && !delivery.outTrackId) {
767
- log?.warn?.(
768
- `[DingTalk] Session webhook ${body.msgtype} response missing delivery metadata; ` +
769
- summarizeSessionWebhookResponse(result.data),
770
- );
771
- }
772
- return result.data;
746
+ } else {
747
+ const mediaHint = options.mediaUrl || options.mediaPath || options.filePath || "(媒体发送失败)";
748
+ text = `${text}\n\n📎 媒体发送失败,兜底链接/路径:${mediaHint}`.trim();
749
+ log?.warn?.("[DingTalk] Media upload failed, falling back to text description");
773
750
  }
774
751
  } else {
775
752
  const mediaHint = options.mediaUrl || options.mediaPath || options.filePath || "(媒体发送失败)";
776
- text = `${text}\n\n📎 媒体发送失败,兜底链接/路径:${mediaHint}`.trim();
777
- log?.warn?.("[DingTalk] Media upload failed, falling back to text description");
753
+ text = `${text}\n\n📎 当前会话无法直接发送 ${options.mediaType},兜底链接/路径:${mediaHint}`.trim();
754
+ log?.warn?.(
755
+ `[DingTalk] Session webhook does not support native ${options.mediaType} replies; falling back to text description`,
756
+ );
778
757
  }
779
758
  }
780
759
 
@@ -835,6 +814,51 @@ export async function sendMessage(
835
814
  const messageType = config.messageType || "markdown";
836
815
  const log = options.log || getLogger();
837
816
 
817
+ if (options.sessionWebhook && options.mediaPath && shouldRouteSessionMediaViaProactive(options.mediaType)) {
818
+ log?.debug?.(
819
+ `[DingTalk] Session webhook does not support ${options.mediaType} replies reliably; ` +
820
+ "using proactive media API instead",
821
+ );
822
+ const proactiveMediaResult = await sendProactiveMedia(
823
+ config,
824
+ conversationId,
825
+ options.mediaPath,
826
+ options.mediaType,
827
+ options,
828
+ );
829
+ if (!proactiveMediaResult.ok) {
830
+ log?.warn?.(
831
+ `[DingTalk] Proactive ${options.mediaType} reply failed; falling back to session markdown: ` +
832
+ (proactiveMediaResult.error || "unknown"),
833
+ );
834
+ const data = await sendBySession(config, options.sessionWebhook, text, options);
835
+ const delivery = extractOutboundDeliveryMetadata(data);
836
+ const messageId = delivery.messageId || delivery.processQueryKey || delivery.outTrackId;
837
+ const persistedText = buildPersistedOutboundText(text, options);
838
+ persistOutboundMessageContext({
839
+ storePath: options.storePath,
840
+ accountId: options.accountId,
841
+ conversationId: options.conversationId || conversationId,
842
+ text: persistedText,
843
+ messageType: "outbound-media",
844
+ quotedRef: options.quotedRef,
845
+ log,
846
+ ...DEFAULT_OUTBOUND_SENDER,
847
+ chatType: inferConversationChatType(options.conversationId || conversationId),
848
+ delivery: {
849
+ ...delivery,
850
+ kind: "session",
851
+ },
852
+ });
853
+ return { ok: true, data, messageId };
854
+ }
855
+ return {
856
+ ok: true,
857
+ data: proactiveMediaResult.data,
858
+ messageId: proactiveMediaResult.messageId,
859
+ };
860
+ }
861
+
838
862
  if (messageType === "card" && options.card && !options.forceMarkdown) {
839
863
  const card = options.card;
840
864
  if (isCardInTerminalState(card.state)) {
@@ -858,28 +882,6 @@ export async function sendMessage(
858
882
  }
859
883
  }
860
884
 
861
- if (options.sessionWebhook && options.mediaPath && options.mediaType === "voice") {
862
- log?.debug?.(
863
- "[DingTalk] Session webhook does not support voice replies reliably; " +
864
- "using proactive media API for this voice response",
865
- );
866
- const proactiveVoiceResult = await sendProactiveMedia(
867
- config,
868
- conversationId,
869
- options.mediaPath,
870
- options.mediaType,
871
- options,
872
- );
873
- if (!proactiveVoiceResult.ok) {
874
- return { ok: false, error: proactiveVoiceResult.error || "Voice reply send failed" };
875
- }
876
- return {
877
- ok: true,
878
- data: proactiveVoiceResult.data,
879
- messageId: proactiveVoiceResult.messageId,
880
- };
881
- }
882
-
883
885
  if (options.sessionWebhook) {
884
886
  const data = await sendBySession(config, options.sessionWebhook, text, options);
885
887
  const delivery = extractOutboundDeliveryMetadata(data);