@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.
@@ -9,8 +9,14 @@
9
9
  import {
10
10
  finishAICard,
11
11
  isCardInTerminalState,
12
+ recallAICardMessage,
12
13
  } from "./card-service";
14
+ import { splitCardReasoningAnswerText } from "./card/reasoning-answer-split";
13
15
  import { createReasoningBlockAssembler } from "./card/reasoning-block-assembler";
16
+ import {
17
+ resolveCardStreamingMode,
18
+ shouldWarnDeprecatedCardRealTimeStreamOnce,
19
+ } from "./card/card-streaming-mode";
14
20
  import { createCardDraftController } from "./card-draft-controller";
15
21
  import { attachCardRunController } from "./card/card-run-registry";
16
22
  import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
@@ -20,19 +26,43 @@ import { AICardStatus } from "./types";
20
26
  import { formatDingTalkErrorPayloadLog } from "./utils";
21
27
 
22
28
  const EMPTY_FINAL_REPLY = "✅ Done";
29
+ type CardReplyLifecycleState = "open" | "final_seen" | "sealed";
23
30
 
24
31
  export function createCardReplyStrategy(
25
32
  ctx: ReplyStrategyContext & { card: AICardInstance; isStopRequested?: () => boolean },
26
33
  ): ReplyStrategy {
27
34
  const { card, config, log, isStopRequested } = ctx;
35
+ const { mode, usedDeprecatedCardRealTimeStream } = resolveCardStreamingMode(config);
36
+ const streamAnswerLive = mode === "answer" || mode === "all";
37
+ const streamThinkingLive = mode === "all";
38
+ let lifecycleState: CardReplyLifecycleState = "open";
39
+ const shouldAcceptAnswerSnapshot = () => lifecycleState === "open";
40
+ const isLifecycleSealed = () => lifecycleState === "sealed";
41
+
42
+ if (usedDeprecatedCardRealTimeStream) {
43
+ const warningKey = `dingtalk-card-streaming:${ctx.accountId || config.clientId || "default"}`;
44
+ if (shouldWarnDeprecatedCardRealTimeStreamOnce(warningKey)) {
45
+ log?.warn?.(
46
+ "[DingTalk][Config] `cardRealTimeStream` is deprecated. Use `cardStreamingMode` with `off` | `answer` | `all`.",
47
+ );
48
+ }
49
+ }
28
50
 
29
- const controller = createCardDraftController({ card, log });
51
+ const controller = createCardDraftController({
52
+ card,
53
+ log,
54
+ throttleMs: config.cardStreamInterval ?? 1000,
55
+ });
30
56
  const reasoningAssembler = createReasoningBlockAssembler();
31
57
  if (card.outTrackId) {
32
58
  attachCardRunController(card.outTrackId, controller);
33
59
  }
34
60
  let finalTextForFallback: string | undefined;
35
61
  let sawFinalDelivery = false;
62
+ let successfulMediaDeliveries = 0;
63
+ let failedMediaDeliveries = 0;
64
+ /** Tracks the latest reasoning snapshot text for non-streaming boundary flush. */
65
+ let latestReasoningSnapshot = "";
36
66
 
37
67
  const getRenderedTimeline = (options: { preferFinalAnswer?: boolean } = {}): string => {
38
68
  const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
@@ -42,6 +72,12 @@ export function createCardReplyStrategy(
42
72
  });
43
73
  };
44
74
 
75
+ const getRawRenderedTimeline = (): string =>
76
+ controller.getRenderedContent({
77
+ fallbackAnswer: undefined,
78
+ overrideAnswer: undefined,
79
+ });
80
+
45
81
  const appendAssembledThinkingBlocks = async (blocks: string[]): Promise<void> => {
46
82
  for (const block of blocks) {
47
83
  if (!block.trim() || isStopRequested?.()) {
@@ -51,25 +87,157 @@ export function createCardReplyStrategy(
51
87
  }
52
88
  };
53
89
 
54
- const ingestReasoningSnapshot = async (text: string | undefined): Promise<void> => {
90
+ const applyModeAwareDeliveredReasoning = async (text: string | undefined): Promise<void> => {
91
+ if (typeof text !== "string" || !text.trim() || isStopRequested?.()) {
92
+ return;
93
+ }
94
+ if (streamThinkingLive) {
95
+ await controller.appendThinkingBlock(text);
96
+ return;
97
+ }
98
+ await applyModeAwareReasoningSnapshot(text);
99
+ };
100
+
101
+ const applyModeAwareReasoningSnapshot = async (text: string | undefined): Promise<void> => {
102
+ if (typeof text !== "string" || !text.trim() || isStopRequested?.()) {
103
+ return;
104
+ }
105
+ if (streamThinkingLive) {
106
+ latestReasoningSnapshot = text;
107
+ await controller.updateReasoning(text);
108
+ return;
109
+ }
55
110
  const blocks = reasoningAssembler.ingestSnapshot(text);
111
+ const trimmed = text.trimStart();
56
112
  if (
57
113
  blocks.length === 0
58
- && typeof text === "string"
59
- && text.trim()
60
- && !text.trimStart().startsWith("Reasoning:")
114
+ && !trimmed.startsWith("Reasoning:")
61
115
  ) {
62
- await appendAssembledThinkingBlocks([text.trim()]);
116
+ if (trimmed.startsWith("Reason:")) {
117
+ latestReasoningSnapshot = "";
118
+ return;
119
+ }
120
+ latestReasoningSnapshot = text.trim();
63
121
  return;
64
122
  }
123
+ latestReasoningSnapshot = "";
65
124
  await appendAssembledThinkingBlocks(blocks);
66
125
  };
67
126
 
68
127
  const flushPendingReasoning = async (): Promise<void> => {
128
+ if (streamThinkingLive) {
129
+ await controller.sealActiveThinking();
130
+ latestReasoningSnapshot = "";
131
+ return;
132
+ }
69
133
  const blocks = reasoningAssembler.flushPendingAtBoundary();
134
+ if (latestReasoningSnapshot) {
135
+ blocks.push(latestReasoningSnapshot);
136
+ latestReasoningSnapshot = "";
137
+ }
70
138
  await appendAssembledThinkingBlocks(blocks);
71
139
  };
72
140
 
141
+ const handleAssistantBoundary = async (): Promise<void> => {
142
+ if (streamThinkingLive) {
143
+ await controller.sealActiveThinking();
144
+ latestReasoningSnapshot = "";
145
+ reasoningAssembler.reset();
146
+ await controller.notifyNewAssistantTurn();
147
+ return;
148
+ }
149
+ const pendingReasoningBlocks = reasoningAssembler.flushPendingAtBoundary();
150
+ if (latestReasoningSnapshot) {
151
+ pendingReasoningBlocks.push(latestReasoningSnapshot);
152
+ latestReasoningSnapshot = "";
153
+ }
154
+ reasoningAssembler.reset();
155
+ const turnBoundary = controller.notifyNewAssistantTurn();
156
+ if (pendingReasoningBlocks.length > 0) {
157
+ await turnBoundary;
158
+ await appendAssembledThinkingBlocks(pendingReasoningBlocks);
159
+ return;
160
+ }
161
+ await turnBoundary;
162
+ };
163
+
164
+ const normalizeDeliveredText = (
165
+ text: string,
166
+ options: { isReasoning: boolean },
167
+ ): { reasoningText?: string; answerText?: string } => {
168
+ if (options.isReasoning) {
169
+ const split = splitCardReasoningAnswerText(text);
170
+ return { reasoningText: split.reasoningText || text };
171
+ }
172
+ const split = splitCardReasoningAnswerText(text);
173
+ return {
174
+ reasoningText: split.reasoningText,
175
+ answerText: split.answerText,
176
+ };
177
+ };
178
+
179
+ const applyDeliveredContent = async (
180
+ normalized: { reasoningText?: string; answerText?: string },
181
+ options: {
182
+ routeReasoningThroughModePolicy: boolean;
183
+ answerHandling?: "update" | "capture" | "ignore";
184
+ },
185
+ ): Promise<void> => {
186
+ if (normalized.reasoningText) {
187
+ if (options.routeReasoningThroughModePolicy) {
188
+ await applyModeAwareDeliveredReasoning(normalized.reasoningText);
189
+ } else {
190
+ // Conservative local split fallback: keep existing behavior for mixed payloads.
191
+ await controller.appendThinkingBlock(normalized.reasoningText);
192
+ }
193
+ }
194
+ if (normalized.answerText && options.answerHandling !== "ignore") {
195
+ if (options.answerHandling === "capture") {
196
+ finalTextForFallback = normalized.answerText;
197
+ return;
198
+ }
199
+ await controller.updateAnswer(normalized.answerText);
200
+ }
201
+ };
202
+
203
+ const handleAnswerSnapshot = async (text: string | undefined): Promise<void> => {
204
+ if (!shouldAcceptAnswerSnapshot() || isStopRequested?.()) {
205
+ return;
206
+ }
207
+ if (!text) {
208
+ return;
209
+ }
210
+ await controller.updateAnswer(text, { stream: streamAnswerLive });
211
+ };
212
+
213
+ const applySplitTextToTimeline = async (
214
+ text: string,
215
+ options: { answerHandling?: "update" | "capture" | "ignore" } = {},
216
+ ) => {
217
+ const normalized = normalizeDeliveredText(text, { isReasoning: false });
218
+ await applyDeliveredContent(normalized, {
219
+ routeReasoningThroughModePolicy: true,
220
+ answerHandling: options.answerHandling ?? "update",
221
+ });
222
+ return normalized;
223
+ };
224
+
225
+ const deliverMediaWithTracking = async (
226
+ mediaUrls: string[],
227
+ options: { audioAsVoice?: boolean },
228
+ ): Promise<void> => {
229
+ if (mediaUrls.length === 0) {
230
+ return;
231
+ }
232
+ try {
233
+ await ctx.deliverMedia(mediaUrls, options);
234
+ successfulMediaDeliveries += mediaUrls.length;
235
+ } catch (err) {
236
+ failedMediaDeliveries += mediaUrls.length;
237
+ throw err;
238
+ }
239
+ };
240
+
73
241
  return {
74
242
  getReplyOptions(): ReplyOptions {
75
243
  return {
@@ -78,37 +246,29 @@ export function createCardReplyStrategy(
78
246
  disableBlockStreaming: ctx.disableBlockStreaming ?? true,
79
247
 
80
248
  onAssistantMessageStart: async () => {
81
- if (isStopRequested?.()) {
249
+ if (isLifecycleSealed() || isStopRequested?.()) {
82
250
  return;
83
251
  }
84
- const pendingReasoningBlocks = reasoningAssembler.flushPendingAtBoundary();
85
- reasoningAssembler.reset();
86
- const turnBoundary = controller.notifyNewAssistantTurn();
87
- if (pendingReasoningBlocks.length > 0) {
88
- await turnBoundary;
89
- await appendAssembledThinkingBlocks(pendingReasoningBlocks);
90
- return;
91
- }
92
- await turnBoundary;
252
+ await handleAssistantBoundary();
93
253
  },
94
254
 
95
- onPartialReply: config.cardRealTimeStream
96
- ? async (payload) => {
97
- if (payload.text && !isStopRequested?.()) {
98
- await controller.updateAnswer(payload.text);
99
- }
100
- }
101
- : undefined,
255
+ onPartialReply: async (payload) => {
256
+ await handleAnswerSnapshot(payload.text);
257
+ },
102
258
 
103
259
  onReasoningStream: async (payload) => {
104
- if (payload.text && !isStopRequested?.()) {
105
- await ingestReasoningSnapshot(payload.text);
260
+ if (isLifecycleSealed() || isStopRequested?.()) {
261
+ return;
106
262
  }
263
+ await applyModeAwareReasoningSnapshot(payload.text);
107
264
  },
108
265
  };
109
266
  },
110
267
 
111
268
  async deliver(payload: DeliverPayload): Promise<void> {
269
+ if (isLifecycleSealed()) {
270
+ return;
271
+ }
112
272
  const textToSend = payload.text;
113
273
 
114
274
  // Empty-payload guard — card final is an exception (e.g. file-only response).
@@ -120,8 +280,12 @@ export function createCardReplyStrategy(
120
280
 
121
281
  // ---- final: defer to finalize, just save text ----
122
282
  if (payload.kind === "final") {
283
+ const isFirstFinalDelivery = !sawFinalDelivery;
284
+ lifecycleState = "final_seen";
123
285
  await flushPendingReasoning();
124
- sawFinalDelivery = true;
286
+ if (isFirstFinalDelivery) {
287
+ sawFinalDelivery = true;
288
+ }
125
289
  log?.info?.(
126
290
  `[DingTalk][Finalize] deliver(final) received — cardState=${card.state} ` +
127
291
  `textLen=${typeof textToSend === "string" ? textToSend.length : "null"} ` +
@@ -130,11 +294,22 @@ export function createCardReplyStrategy(
130
294
  `lastContent="${(controller.getLastContent() ?? "").slice(0, 80)}"`,
131
295
  );
132
296
  if (payload.mediaUrls.length > 0) {
133
- await ctx.deliverMedia(payload.mediaUrls);
297
+ await deliverMediaWithTracking(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
134
298
  }
135
299
  const rawFinalText = typeof textToSend === "string" ? textToSend : "";
136
300
  if (rawFinalText) {
137
- finalTextForFallback = rawFinalText;
301
+ if (payload.isReasoning === true) {
302
+ await applyModeAwareReasoningSnapshot(rawFinalText);
303
+ await flushPendingReasoning();
304
+ } else {
305
+ const normalizedFinal = await applySplitTextToTimeline(rawFinalText, {
306
+ answerHandling: "capture",
307
+ });
308
+ if (isFirstFinalDelivery && !normalizedFinal.answerText && !normalizedFinal.reasoningText) {
309
+ finalTextForFallback = rawFinalText;
310
+ }
311
+ await flushPendingReasoning();
312
+ }
138
313
  }
139
314
  return;
140
315
  }
@@ -149,22 +324,32 @@ export function createCardReplyStrategy(
149
324
  log?.info?.(
150
325
  `[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
151
326
  );
152
- await controller.appendTool(textToSend ?? "");
327
+ if (lifecycleState === "final_seen") {
328
+ await controller.appendToolBeforeCurrentAnswer(textToSend ?? "");
329
+ } else {
330
+ await controller.appendTool(textToSend ?? "");
331
+ }
153
332
  return;
154
333
  }
155
334
 
156
335
  const isReasoningBlock = payload.isReasoning === true;
157
336
  if (typeof textToSend === "string" && textToSend.trim()) {
158
337
  if (isReasoningBlock) {
159
- await ingestReasoningSnapshot(textToSend);
338
+ const normalized = normalizeDeliveredText(textToSend, { isReasoning: true });
339
+ await applyDeliveredContent(normalized, {
340
+ routeReasoningThroughModePolicy: true,
341
+ answerHandling: "ignore",
342
+ });
160
343
  } else {
161
- await controller.updateAnswer(textToSend);
344
+ await applySplitTextToTimeline(textToSend, {
345
+ answerHandling: lifecycleState === "open" ? "update" : "capture",
346
+ });
162
347
  }
163
348
  }
164
349
 
165
350
  // ---- block: only handle reasoning/media (other text blocks are unused) ----
166
351
  if (payload.mediaUrls.length > 0) {
167
- await ctx.deliverMedia(payload.mediaUrls);
352
+ await deliverMediaWithTracking(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
168
353
  }
169
354
  },
170
355
 
@@ -180,16 +365,19 @@ export function createCardReplyStrategy(
180
365
 
181
366
  if (isStopRequested?.()) {
182
367
  log?.info?.("[DingTalk][Finalize] Skipping — card stop was requested");
368
+ lifecycleState = "sealed";
183
369
  return;
184
370
  }
185
371
 
186
372
  if (card.state === AICardStatus.FINISHED) {
187
373
  log?.info?.("[DingTalk][Finalize] Skipping — card already FINISHED");
374
+ lifecycleState = "sealed";
188
375
  return;
189
376
  }
190
377
 
191
378
  if (card.state === AICardStatus.STOPPED) {
192
379
  log?.info?.("[DingTalk][Finalize] Skipping — card already STOPPED");
380
+ lifecycleState = "sealed";
193
381
  return;
194
382
  }
195
383
 
@@ -217,6 +405,7 @@ export function createCardReplyStrategy(
217
405
  } else {
218
406
  log?.debug?.("[DingTalk] Card failed but no content to fallback with");
219
407
  }
408
+ lifecycleState = "sealed";
220
409
  return;
221
410
  }
222
411
 
@@ -226,6 +415,28 @@ export function createCardReplyStrategy(
226
415
  await controller.flush();
227
416
  await controller.waitForInFlight();
228
417
  const renderedTimeline = getRenderedTimeline({ preferFinalAnswer: true });
418
+ const rawRenderedTimeline = getRawRenderedTimeline();
419
+ const hasRenderedCardContent = Boolean(rawRenderedTimeline.trim());
420
+ const hasMeaningfulCardContent = hasRenderedCardContent
421
+ || Boolean((finalTextForFallback || "").trim())
422
+ || Boolean((controller.getFinalAnswerContent() || "").trim())
423
+ || Boolean((controller.getLastAnswerContent() || "").trim())
424
+ || Boolean((controller.getLastContent() || "").trim());
425
+ const shouldRecallEmptyCard =
426
+ successfulMediaDeliveries > 0
427
+ && failedMediaDeliveries === 0
428
+ && !hasMeaningfulCardContent;
429
+ if (shouldRecallEmptyCard) {
430
+ controller.stop();
431
+ log?.info?.(
432
+ `[DingTalk][Finalize] Attempting to recall empty card after successful media delivery ` +
433
+ `mediaCount=${successfulMediaDeliveries} conversationId=${card.conversationId}`,
434
+ );
435
+ if (await recallAICardMessage(card, log)) {
436
+ lifecycleState = "sealed";
437
+ return;
438
+ }
439
+ }
229
440
  const finalText = renderedTimeline || EMPTY_FINAL_REPLY;
230
441
  controller.stop();
231
442
  log?.info?.(
@@ -236,6 +447,7 @@ export function createCardReplyStrategy(
236
447
  await finishAICard(card, finalText, log, {
237
448
  quotedRef: ctx.replyQuotedRef,
238
449
  });
450
+ lifecycleState = "sealed";
239
451
 
240
452
  // In group chats, send a lightweight @mention via session webhook
241
453
  // so the sender gets a notification — card API doesn't support @mention.
@@ -261,10 +473,13 @@ export function createCardReplyStrategy(
261
473
  card.state = AICardStatus.FAILED;
262
474
  card.lastUpdated = Date.now();
263
475
  }
476
+ } finally {
477
+ lifecycleState = "sealed";
264
478
  }
265
479
  },
266
480
 
267
481
  async abort(_error: Error): Promise<void> {
482
+ lifecycleState = "sealed";
268
483
  if (!isCardInTerminalState(card.state)) {
269
484
  controller.stop();
270
485
  await controller.waitForInFlight();
@@ -119,7 +119,7 @@ export function createMarkdownReplyStrategy(
119
119
 
120
120
  async deliver(payload: DeliverPayload): Promise<void> {
121
121
  if (payload.mediaUrls.length > 0) {
122
- await ctx.deliverMedia(payload.mediaUrls);
122
+ await ctx.deliverMedia(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
123
123
  sentVisibleContent = true;
124
124
  }
125
125
 
@@ -12,9 +12,20 @@ import { createMarkdownReplyStrategy } from "./reply-strategy-markdown";
12
12
 
13
13
  // ---- Public types ------------------------------------------------
14
14
 
15
+ type InternalReplyStrategyConfig = DingTalkConfig & {
16
+ /** @deprecated Internal compatibility only. Removed from public config surface. */
17
+ cardStreamReasoning?: boolean;
18
+ };
19
+
15
20
  export interface DeliverPayload {
16
21
  text?: string;
17
22
  mediaUrls: string[];
23
+ /**
24
+ * Shared reply-runtime voice hint. Strategies forward this unchanged into the
25
+ * channel media delivery helper; inbound-handler is responsible for bridging
26
+ * legacy aliases (for example `asVoice`) into this single field.
27
+ */
28
+ audioAsVoice?: boolean;
18
29
  kind: "block" | "final" | "tool";
19
30
  isReasoning?: boolean;
20
31
  }
@@ -45,7 +56,7 @@ export interface ReplyStrategy {
45
56
 
46
57
  /** Shared context passed to every strategy implementation. */
47
58
  export interface ReplyStrategyContext {
48
- config: DingTalkConfig;
59
+ config: InternalReplyStrategyConfig;
49
60
  to: string;
50
61
  sessionWebhook: string;
51
62
  senderId: string;
@@ -58,7 +69,12 @@ export interface ReplyStrategyContext {
58
69
  groupId?: string;
59
70
  log?: Logger;
60
71
  replyQuotedRef?: QuotedRef;
61
- deliverMedia: (urls: string[]) => Promise<void>;
72
+ /**
73
+ * Channel-level media delivery hook. The `audioAsVoice` option is the same
74
+ * shared voice semantic carried on DeliverPayload, not a second independent
75
+ * config knob.
76
+ */
77
+ deliverMedia: (urls: string[], options?: { audioAsVoice?: boolean }) => Promise<void>;
62
78
  isStopRequested?: () => boolean;
63
79
  }
64
80
 
@@ -212,6 +212,14 @@ function extractErrorCodeFromResponseData(data: unknown): string | null {
212
212
  }
213
213
 
214
214
  const payload = data as Record<string, unknown>;
215
+ const errcode = payload.errcode;
216
+ if (typeof errcode === "number" && Number.isFinite(errcode)) {
217
+ return String(errcode);
218
+ }
219
+ if (typeof errcode === "string" && errcode.trim()) {
220
+ return errcode.trim();
221
+ }
222
+
215
223
  const code = payload.code;
216
224
  if (typeof code === "string" && code.trim()) {
217
225
  return code.trim();
@@ -225,6 +233,62 @@ function extractErrorCodeFromResponseData(data: unknown): string | null {
225
233
  return null;
226
234
  }
227
235
 
236
+ function summarizeSessionWebhookResponse(data: unknown): string {
237
+ if (!data || typeof data !== "object") {
238
+ return `type=${typeof data}`;
239
+ }
240
+ const payload = data as Record<string, unknown>;
241
+ const code = extractErrorCodeFromResponseData(payload) || "(none)";
242
+ const message = firstTrimmedString(
243
+ payload.message,
244
+ payload.errmsg,
245
+ payload.msg,
246
+ payload.errorMessage,
247
+ ) || "(none)";
248
+ const success =
249
+ typeof payload.success === "boolean"
250
+ ? String(payload.success)
251
+ : typeof payload.result === "boolean"
252
+ ? String(payload.result)
253
+ : "(none)";
254
+ const delivery = extractOutboundDeliveryMetadata(payload);
255
+ return (
256
+ `success=${success} code=${code} message=${message} ` +
257
+ `messageId=${delivery.messageId || "(none)"} ` +
258
+ `processQueryKey=${delivery.processQueryKey || "(none)"} ` +
259
+ `outTrackId=${delivery.outTrackId || "(none)"}`
260
+ );
261
+ }
262
+
263
+ function ensureSessionWebhookBusinessSuccess(
264
+ data: unknown,
265
+ context: { msgtype: string },
266
+ ): void {
267
+ if (!data || typeof data !== "object") {
268
+ return;
269
+ }
270
+ const payload = data as Record<string, unknown>;
271
+ const code = extractErrorCodeFromResponseData(payload);
272
+ const message = firstTrimmedString(
273
+ payload.message,
274
+ payload.errmsg,
275
+ payload.msg,
276
+ payload.errorMessage,
277
+ ) || "unknown error";
278
+
279
+ const hasFailureSuccessFlag = payload.success === false || payload.result === false;
280
+ const hasFailureCode = typeof code === "string" && code !== "" && code !== "0";
281
+ if (!hasFailureSuccessFlag && !hasFailureCode) {
282
+ return;
283
+ }
284
+
285
+ const reason = [
286
+ code && code !== "0" ? `code=${code}` : "",
287
+ message !== "unknown error" ? `message=${message}` : "",
288
+ ].filter(Boolean).join(" ");
289
+ throw new Error(`Session webhook ${context.msgtype} send failed${reason ? `: ${reason}` : ""}`);
290
+ }
291
+
228
292
  function isProactivePermissionOrScopeError(code: string | null): boolean {
229
293
  if (!code) {
230
294
  return false;
@@ -400,7 +464,7 @@ export async function sendProactiveMedia(
400
464
  if (!uploadResult) {
401
465
  return { ok: false, error: "Failed to upload media" };
402
466
  }
403
- const { mediaId, buffer } = uploadResult;
467
+ const { mediaId, buffer, durationMs: uploadedDurationMs } = uploadResult;
404
468
 
405
469
  const token = await getAccessToken(config, log);
406
470
  const { targetId, isExplicitUser } = stripTargetPrefix(target);
@@ -421,7 +485,8 @@ export async function sendProactiveMedia(
421
485
  msgParam = JSON.stringify({ photoURL: mediaId });
422
486
  } else if (mediaType === "voice") {
423
487
  msgKey = "sampleAudio";
424
- const durationMs = await getVoiceDurationMs(mediaPath, mediaType, log, { preReadBuffer: buffer });
488
+ const durationMs = uploadedDurationMs
489
+ ?? await getVoiceDurationMs(mediaPath, mediaType, log, { preReadBuffer: buffer });
425
490
  msgParam = JSON.stringify({ mediaId, duration: String(durationMs) });
426
491
  } else {
427
492
  // sampleVideo requires picMediaId; fallback to sampleFile for broader compatibility.
@@ -555,14 +620,18 @@ export async function sendBySession(
555
620
  mediaLocalRoots: options.mediaLocalRoots,
556
621
  });
557
622
  if (uploadResult) {
558
- const { mediaId, buffer } = uploadResult;
623
+ const { mediaId, buffer, durationMs: uploadedDurationMs } = uploadResult;
559
624
  let body: any;
560
625
 
561
626
  if (options.mediaType === "image") {
562
627
  body = { msgtype: "image", image: { media_id: mediaId } };
563
628
  } else if (options.mediaType === "voice") {
564
- const durationMs = await getVoiceDurationMs(options.mediaPath, options.mediaType, log, { preReadBuffer: buffer });
629
+ const durationMs = uploadedDurationMs
630
+ ?? await getVoiceDurationMs(options.mediaPath, options.mediaType, log, { preReadBuffer: buffer });
565
631
  body = { msgtype: "voice", voice: { media_id: mediaId, duration: String(durationMs) } };
632
+ log?.debug?.(
633
+ `[DingTalk] Sending session voice message mediaId=${mediaId} durationMs=${durationMs}`,
634
+ );
566
635
  } else if (options.mediaType === "video") {
567
636
  body = { msgtype: "video", video: { media_id: mediaId } };
568
637
  } else if (options.mediaType === "file") {
@@ -577,6 +646,17 @@ export async function sendBySession(
577
646
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
578
647
  ...getProxyBypassOption(config),
579
648
  });
649
+ log?.debug?.(
650
+ `[DingTalk] Session webhook response msgtype=${body.msgtype} ${summarizeSessionWebhookResponse(result.data)}`,
651
+ );
652
+ ensureSessionWebhookBusinessSuccess(result.data, { msgtype: body.msgtype });
653
+ const delivery = extractOutboundDeliveryMetadata(result.data);
654
+ if (!delivery.messageId && !delivery.processQueryKey && !delivery.outTrackId) {
655
+ log?.warn?.(
656
+ `[DingTalk] Session webhook ${body.msgtype} response missing delivery metadata; ` +
657
+ summarizeSessionWebhookResponse(result.data),
658
+ );
659
+ }
580
660
  return result.data;
581
661
  }
582
662
  } else {
@@ -624,6 +704,10 @@ export async function sendBySession(
624
704
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
625
705
  ...getProxyBypassOption(config),
626
706
  });
707
+ log?.debug?.(
708
+ `[DingTalk] Session webhook response msgtype=${body.msgtype} ${summarizeSessionWebhookResponse(result.data)}`,
709
+ );
710
+ ensureSessionWebhookBusinessSuccess(result.data, { msgtype: body.msgtype });
627
711
  lastResult = result.data;
628
712
  }
629
713
  return lastResult;
@@ -662,6 +746,28 @@ export async function sendMessage(
662
746
  }
663
747
  }
664
748
 
749
+ if (options.sessionWebhook && options.mediaPath && options.mediaType === "voice") {
750
+ log?.debug?.(
751
+ "[DingTalk] Session webhook does not support voice replies reliably; " +
752
+ "using proactive media API for this voice response",
753
+ );
754
+ const proactiveVoiceResult = await sendProactiveMedia(
755
+ config,
756
+ conversationId,
757
+ options.mediaPath,
758
+ options.mediaType,
759
+ options,
760
+ );
761
+ if (!proactiveVoiceResult.ok) {
762
+ return { ok: false, error: proactiveVoiceResult.error || "Voice reply send failed" };
763
+ }
764
+ return {
765
+ ok: true,
766
+ data: proactiveVoiceResult.data,
767
+ messageId: proactiveVoiceResult.messageId,
768
+ };
769
+ }
770
+
665
771
  if (options.sessionWebhook) {
666
772
  const data = await sendBySession(config, options.sessionWebhook, text, options);
667
773
  const delivery = extractOutboundDeliveryMetadata(data);