@soimy/dingtalk 3.6.2 → 3.6.4

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.
@@ -398,7 +398,10 @@ function isMarkdownTableSeparator(line: string): boolean {
398
398
  .replace(/\|$/, "")
399
399
  .split("|")
400
400
  .map((cell) => cell.trim());
401
- return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
401
+ // DingTalk markdown requires :-: separator format (1 dash minimum).
402
+ // Note: single `-` cells in data rows would also match; this is an accepted trade-off
403
+ // for compatibility with DingTalk's recommended :-: separator format.
404
+ return cells.length > 0 && cells.every((cell) => /^:?-{1,}:?$/.test(cell));
402
405
  }
403
406
 
404
407
  function isMarkdownTableRow(line: string): boolean {
@@ -415,11 +418,96 @@ function parseMarkdownTableRow(line: string): string[] {
415
418
  .map((cell) => cell.trim());
416
419
  }
417
420
 
418
- function renderMarkdownTable(lines: string[]): string {
419
- const rows = lines.map(parseMarkdownTableRow).filter((cells) => cells.length > 0);
420
- return rows.map((cells) => cells.join(" | ")).join(" \n");
421
+ /**
422
+ * Parse alignment from a separator cell.
423
+ * Returns 'left', 'center', 'right', or 'center' (default).
424
+ */
425
+ function parseSeparatorAlignment(cell: string): "left" | "center" | "right" {
426
+ const trimmed = cell.trim();
427
+ const hasLeftColon = trimmed.startsWith(":");
428
+ const hasRightColon = trimmed.endsWith(":");
429
+ if (hasLeftColon && hasRightColon) {
430
+ return "center";
431
+ }
432
+ if (hasRightColon) {
433
+ return "right";
434
+ }
435
+ if (hasLeftColon) {
436
+ return "left";
437
+ }
438
+ // Default to center for bare dashes (standard markdown)
439
+ return "center";
440
+ }
441
+
442
+ /**
443
+ * Build separator row from alignment array.
444
+ */
445
+ function buildSeparatorRow(alignments: ("left" | "center" | "right")[]): string {
446
+ const cells = alignments.map((align) => {
447
+ if (align === "left") {
448
+ return ":---";
449
+ }
450
+ if (align === "right") {
451
+ return "---:";
452
+ }
453
+ return ":---:";
454
+ });
455
+ return "|" + cells.join("|") + "|";
456
+ }
457
+
458
+ /**
459
+ * Render markdown table rows preserving original alignment.
460
+ * Extracts alignment from the separator row and applies it to output.
461
+ */
462
+ function renderMarkdownTable(headerLine: string, separatorLine: string, dataLines: string[]): string {
463
+ const headerCells = parseMarkdownTableRow(headerLine);
464
+ const separatorCells = parseMarkdownTableRow(separatorLine);
465
+ const dataRows = dataLines.map(parseMarkdownTableRow).filter((cells) => cells.length > 0);
466
+
467
+ if (headerCells.length === 0) {
468
+ return "";
469
+ }
470
+
471
+ const colCount = Math.max(
472
+ headerCells.length,
473
+ separatorCells.length,
474
+ ...dataRows.map((cells) => cells.length),
475
+ );
476
+
477
+ // Extract alignments from separator
478
+ const alignments: ("left" | "center" | "right")[] = [];
479
+ for (let i = 0; i < colCount; i++) {
480
+ const sepCell = separatorCells[i] || "";
481
+ alignments.push(parseSeparatorAlignment(sepCell));
482
+ }
483
+
484
+ const separator = buildSeparatorRow(alignments);
485
+
486
+ const allRows = [headerCells, ...dataRows];
487
+ const rendered = allRows
488
+ .map((cells) => {
489
+ const padded = cells.length < colCount
490
+ ? [...cells, ...Array(colCount - cells.length).fill("")]
491
+ : cells;
492
+ return "|" + padded.join("|") + "|";
493
+ });
494
+
495
+ // Insert separator after header row (works for header-only tables too)
496
+ rendered.splice(1, 0, separator);
497
+
498
+ return rendered.join("\n");
421
499
  }
422
500
 
501
+ /**
502
+ * Convert markdown tables to DingTalk-compatible format.
503
+ * DingTalk's markdown renderer supports left/center/right alignment (:---, :---:, ---:).
504
+ * This function preserves the original alignment from the separator row.
505
+ * Tables inside code fences are preserved unchanged.
506
+ *
507
+ * Note: Despite the function name, this now produces DingTalk-compatible
508
+ * markdown tables rather than plain text. The name is preserved for API
509
+ * compatibility with the `convertMarkdownTables` config option.
510
+ */
423
511
  export function convertMarkdownTablesToPlainText(text: string): string {
424
512
  const lines = text.split("\n");
425
513
  const output: string[] = [];
@@ -441,13 +529,21 @@ export function convertMarkdownTablesToPlainText(text: string): string {
441
529
  isMarkdownTableRow(line) &&
442
530
  isMarkdownTableSeparator(lines[index + 1] || "")
443
531
  ) {
444
- const tableLines = [line];
532
+ const headerLine = line;
533
+ const separatorLine = lines[index + 1] || "";
534
+ const dataLines: string[] = [];
445
535
  index += 2;
446
536
  while (index < lines.length && isMarkdownTableRow(lines[index] || "")) {
447
- tableLines.push(lines[index] || "");
537
+ dataLines.push(lines[index] || "");
448
538
  index += 1;
449
539
  }
450
- output.push(renderMarkdownTable(tableLines));
540
+ const renderedTable = renderMarkdownTable(headerLine, separatorLine, dataLines);
541
+ // Ensure blank line before table for DingTalk markdown rendering
542
+ const lastOutput = output[output.length - 1];
543
+ if (lastOutput !== undefined && lastOutput.trim() !== "") {
544
+ output.push("");
545
+ }
546
+ output.push(renderedTable);
451
547
  continue;
452
548
  }
453
549
 
@@ -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";
@@ -134,6 +133,8 @@ export function createCardReplyStrategy(
134
133
  let latestReasoningSnapshot = "";
135
134
  /** Non-image media attachments deferred for out-of-card delivery. */
136
135
  let pendingNonImageMedia: DeferredMedia[] = [];
136
+ /** URLs already processed in this card session — prevents duplicate upload/send when the same mediaUrl appears in both a non-final and final deliver. */
137
+ const processedMediaUrls = new Set<string>();
137
138
 
138
139
  const getRenderedTimeline = (options: { preferFinalAnswer?: boolean } = {}): string => {
139
140
  const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
@@ -261,7 +262,10 @@ export function createCardReplyStrategy(
261
262
  finalTextForFallback = normalized.answerText;
262
263
  return;
263
264
  }
264
- await controller.updateAnswer(normalized.answerText);
265
+ await controller.updateAnswer(normalized.answerText, {
266
+ stream: streamAnswerLive,
267
+ renderBlocks: !streamAnswerLive,
268
+ });
265
269
  }
266
270
  };
267
271
 
@@ -304,7 +308,8 @@ export function createCardReplyStrategy(
304
308
 
305
309
  await controller.updateAnswer(answerSnapshot, {
306
310
  stream: streamAnswerLive,
307
- renderBlocks: renderAnswerBlocksLive,
311
+ // Active answer previews live in the content field; blockList is committed at boundaries/finalize.
312
+ renderBlocks: false,
308
313
  });
309
314
  };
310
315
 
@@ -342,6 +347,12 @@ export function createCardReplyStrategy(
342
347
  continue;
343
348
  }
344
349
 
350
+ if (processedMediaUrls.has(candidate.url.trim())) {
351
+ const placeholder = buildImagePlaceholderText({ alt: candidate.alt, url: candidate.url });
352
+ nextText = `${nextText.slice(0, candidate.start)}${placeholder}${nextText.slice(candidate.end)}`;
353
+ continue;
354
+ }
355
+
345
356
  let prepared: Awaited<ReturnType<typeof prepareMediaInput>> | undefined;
346
357
  try {
347
358
  prepared = await prepareMediaInput(candidate.url, log, config.mediaUrlAllowlist);
@@ -358,6 +369,7 @@ export function createCardReplyStrategy(
358
369
  continue;
359
370
  }
360
371
 
372
+ processedMediaUrls.add(candidate.url.trim());
361
373
  const placeholder = buildImagePlaceholderText({ alt: candidate.alt, url: candidate.url });
362
374
  const blockText = candidate.alt.trim() || placeholder.replace(/^见下图/, "").trim() || "图片";
363
375
  successfulReroutes.push({
@@ -388,6 +400,11 @@ export function createCardReplyStrategy(
388
400
  // Card mode keeps runtime block streaming disabled, but still consumes
389
401
  // reasoning blocks through explicit callbacks and delivery metadata.
390
402
  disableBlockStreaming: ctx.disableBlockStreaming ?? true,
403
+ // DingTalk card mode owns the visible reply surface. In group chats,
404
+ // OpenClaw defaults source replies to message-tool-only; override that
405
+ // so final replies are delivered into this card instead of spawning a
406
+ // separate visible message/card via the message tool.
407
+ sourceReplyDeliveryMode: "automatic",
391
408
 
392
409
  onAssistantMessageStart: async () => {
393
410
  if (isLifecycleSealed() || isStopRequested?.()) {
@@ -469,6 +486,10 @@ export function createCardReplyStrategy(
469
486
  // Inline media upload → image blocks in card; defer non-image attachments
470
487
  if (payload.mediaUrls.length > 0) {
471
488
  for (const url of payload.mediaUrls) {
489
+ const normalizedUrl = url.trim();
490
+ if (processedMediaUrls.has(normalizedUrl)) {
491
+ continue;
492
+ }
472
493
  try {
473
494
  const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
474
495
  const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });
@@ -484,6 +505,7 @@ export function createCardReplyStrategy(
484
505
  const result = await uploadMedia(config, prepared.path, "image", log);
485
506
  await prepared.cleanup?.();
486
507
  if (result?.mediaId) {
508
+ processedMediaUrls.add(normalizedUrl);
487
509
  await controller.appendImageBlock(result.mediaId);
488
510
  }
489
511
  } catch (err: unknown) {
@@ -547,6 +569,10 @@ export function createCardReplyStrategy(
547
569
  // ---- block: only handle reasoning/media (other text blocks are unused) ----
548
570
  if (payload.mediaUrls.length > 0) {
549
571
  for (const url of payload.mediaUrls) {
572
+ const normalizedUrl = url.trim();
573
+ if (processedMediaUrls.has(normalizedUrl)) {
574
+ continue;
575
+ }
550
576
  try {
551
577
  const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
552
578
  const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });
@@ -559,6 +585,7 @@ export function createCardReplyStrategy(
559
585
  const result = await uploadMedia(config, prepared.path, "image", log);
560
586
  await prepared.cleanup?.();
561
587
  if (result?.mediaId) {
588
+ processedMediaUrls.add(normalizedUrl);
562
589
  await controller.appendImageBlock(result.mediaId);
563
590
  }
564
591
  } catch (err: unknown) {
@@ -671,6 +698,7 @@ export function createCardReplyStrategy(
671
698
  try {
672
699
  await flushPendingReasoning();
673
700
 
701
+ await controller.clearStreamingContent?.();
674
702
  await controller.flush();
675
703
  await controller.waitForInFlight();
676
704
 
@@ -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);