@soimy/dingtalk 3.5.3 → 3.6.1

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 (39) hide show
  1. package/README.md +4 -1
  2. package/index.ts +7 -0
  3. package/openclaw.plugin.json +153 -5
  4. package/package.json +1 -1
  5. package/src/auth.ts +5 -2
  6. package/src/card/card-markdown-image-reroute.ts +106 -0
  7. package/src/card/card-run-registry.ts +54 -1
  8. package/src/card/card-stop-handler.ts +10 -20
  9. package/src/card/card-template.ts +14 -3
  10. package/src/card/statusline-renderer.ts +94 -0
  11. package/src/card-draft-controller.ts +245 -52
  12. package/src/card-service.ts +408 -23
  13. package/src/channel.ts +24 -1083
  14. package/src/config-schema.ts +21 -1
  15. package/src/config.ts +139 -66
  16. package/src/device-registration.ts +245 -0
  17. package/src/gateway/channel-gateway.ts +637 -0
  18. package/src/inbound-handler.ts +1276 -975
  19. package/src/media-utils.ts +6 -0
  20. package/src/message-context-store.ts +183 -85
  21. package/src/message-utils.ts +124 -16
  22. package/src/messaging/btw-deliver.ts +85 -0
  23. package/src/messaging/channel-actions.ts +174 -0
  24. package/src/messaging/channel-outbound.ts +158 -0
  25. package/src/onboarding.ts +333 -235
  26. package/src/path-utils.ts +49 -0
  27. package/src/platform/channel-status.ts +81 -0
  28. package/src/reply-strategy-card.ts +373 -64
  29. package/src/reply-strategy-markdown.ts +1 -1
  30. package/src/reply-strategy-types.ts +93 -0
  31. package/src/reply-strategy-with-reaction.ts +1 -1
  32. package/src/reply-strategy.ts +14 -72
  33. package/src/run-usage-store.ts +59 -0
  34. package/src/secret-input.ts +216 -0
  35. package/src/send-service.ts +115 -3
  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 +30 -5
  39. package/src/types.ts +48 -157
@@ -7,9 +7,9 @@
7
7
  */
8
8
 
9
9
  import {
10
- finishAICard,
10
+ commitAICardBlocks,
11
11
  isCardInTerminalState,
12
- recallAICardMessage,
12
+ updateAICardStatusLine,
13
13
  } from "./card-service";
14
14
  import { splitCardReasoningAnswerText } from "./card/reasoning-answer-split";
15
15
  import { createReasoningBlockAssembler } from "./card/reasoning-block-assembler";
@@ -17,23 +17,92 @@ import {
17
17
  resolveCardStreamingMode,
18
18
  shouldWarnDeprecatedCardRealTimeStreamOnce,
19
19
  } from "./card/card-streaming-mode";
20
+ import {
21
+ buildImagePlaceholderText,
22
+ extractMarkdownImageCandidates,
23
+ } from "./card/card-markdown-image-reroute";
20
24
  import { createCardDraftController } from "./card-draft-controller";
21
25
  import { attachCardRunController } from "./card/card-run-registry";
22
- import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
23
- import { sendBySession, sendMessage } from "./send-service";
26
+ import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy-types";
27
+ import { resolveRelativePath } from "./config";
28
+ import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
29
+ import { getTaskTimeSeconds, updateSessionState } from "./session-state";
30
+ import { renderStatusLine } from "./card/statusline-renderer";
31
+ import type { StatusLineData } from "./card/statusline-renderer";
32
+ import { recordRunStart, getAggregatedUsage, clearRuns } from "./run-usage-store";
33
+ import { sendBySession, sendMessage, sendProactiveMedia, uploadMedia } from "./send-service";
24
34
  import type { AICardInstance } from "./types";
25
35
  import { AICardStatus } from "./types";
26
36
  import { formatDingTalkErrorPayloadLog } from "./utils";
27
37
 
28
38
  const EMPTY_FINAL_REPLY = "✅ Done";
39
+ const DEFAULT_CARD_FAILED_MESSAGE = "回复生成失败,请重试";
29
40
  type CardReplyLifecycleState = "open" | "final_seen" | "sealed";
30
41
 
42
+ /** Deferred media attachment for out-of-card delivery */
43
+ interface DeferredMedia {
44
+ url: string;
45
+ type: "voice" | "video" | "file";
46
+ }
47
+
31
48
  export function createCardReplyStrategy(
32
49
  ctx: ReplyStrategyContext & { card: AICardInstance; isStopRequested?: () => boolean },
33
50
  ): ReplyStrategy {
34
51
  const { card, config, log, isStopRequested } = ctx;
52
+
53
+ const buildStatusLine = (): string | undefined => {
54
+ if (!ctx.taskMeta) {
55
+ return undefined;
56
+ }
57
+ const resolvedUsage =
58
+ typeof card.dapiUsage === "number"
59
+ ? card.dapiUsage
60
+ : typeof ctx.taskMeta.usage === "number"
61
+ ? ctx.taskMeta.usage
62
+ : undefined;
63
+
64
+ const sessionTaskTimeSeconds = card.accountId && card.conversationId
65
+ ? getTaskTimeSeconds(card.accountId, card.contextConversationId || card.conversationId)
66
+ : undefined;
67
+ const cardElapsedMs = Math.max(0, Date.now() - card.createdAt);
68
+ const sessionElapsedMs = typeof sessionTaskTimeSeconds === "number"
69
+ ? sessionTaskTimeSeconds * 1000
70
+ : undefined;
71
+ const metaElapsedMs = typeof ctx.taskMeta.elapsedMs === "number" && ctx.taskMeta.elapsedMs > 0
72
+ ? ctx.taskMeta.elapsedMs
73
+ : undefined;
74
+ ctx.taskMeta.elapsedMs = Math.max(
75
+ cardElapsedMs,
76
+ sessionElapsedMs ?? 0,
77
+ metaElapsedMs ?? 0,
78
+ );
79
+
80
+ let inputTokens: number | undefined;
81
+ let outputTokens: number | undefined;
82
+ let cacheRead: number | undefined;
83
+ if (ctx.taskMeta.runIds && ctx.taskMeta.runIds.size > 0) {
84
+ const tokenUsage = getAggregatedUsage(ctx.taskMeta.runIds);
85
+ if (typeof tokenUsage.input === "number") { inputTokens = tokenUsage.input; }
86
+ if (typeof tokenUsage.output === "number") { outputTokens = tokenUsage.output; }
87
+ if (typeof tokenUsage.cacheRead === "number") { cacheRead = tokenUsage.cacheRead; }
88
+ }
89
+
90
+ const statusLineData: StatusLineData = {
91
+ model: ctx.taskMeta.model,
92
+ effort: ctx.taskMeta.effort,
93
+ agent: ctx.taskMeta.agent,
94
+ taskTime: typeof ctx.taskMeta.elapsedMs === "number" ? Math.round(ctx.taskMeta.elapsedMs / 1000) : undefined,
95
+ inputTokens,
96
+ outputTokens,
97
+ cacheRead,
98
+ dapi_usage: resolvedUsage,
99
+ };
100
+ const statusLine = renderStatusLine(statusLineData, config);
101
+ return statusLine || undefined;
102
+ };
35
103
  const { mode, usedDeprecatedCardRealTimeStream } = resolveCardStreamingMode(config);
36
104
  const streamAnswerLive = mode === "answer" || mode === "all";
105
+ const renderAnswerBlocksLive = mode === "all";
37
106
  const streamThinkingLive = mode === "all";
38
107
  let lifecycleState: CardReplyLifecycleState = "open";
39
108
  const shouldAcceptAnswerSnapshot = () => lifecycleState === "open";
@@ -51,7 +120,9 @@ export function createCardReplyStrategy(
51
120
  const controller = createCardDraftController({
52
121
  card,
53
122
  log,
123
+ realTimeStreamEnabled: streamAnswerLive,
54
124
  throttleMs: config.cardStreamInterval ?? 1000,
125
+ getStatusLine: buildStatusLine,
55
126
  });
56
127
  const reasoningAssembler = createReasoningBlockAssembler();
57
128
  if (card.outTrackId) {
@@ -59,10 +130,10 @@ export function createCardReplyStrategy(
59
130
  }
60
131
  let finalTextForFallback: string | undefined;
61
132
  let sawFinalDelivery = false;
62
- let successfulMediaDeliveries = 0;
63
- let failedMediaDeliveries = 0;
64
133
  /** Tracks the latest reasoning snapshot text for non-streaming boundary flush. */
65
134
  let latestReasoningSnapshot = "";
135
+ /** Non-image media attachments deferred for out-of-card delivery. */
136
+ let pendingNonImageMedia: DeferredMedia[] = [];
66
137
 
67
138
  const getRenderedTimeline = (options: { preferFinalAnswer?: boolean } = {}): string => {
68
139
  const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
@@ -72,12 +143,6 @@ export function createCardReplyStrategy(
72
143
  });
73
144
  };
74
145
 
75
- const getRawRenderedTimeline = (): string =>
76
- controller.getRenderedContent({
77
- fallbackAnswer: undefined,
78
- overrideAnswer: undefined,
79
- });
80
-
81
146
  const appendAssembledThinkingBlocks = async (blocks: string[]): Promise<void> => {
82
147
  for (const block of blocks) {
83
148
  if (!block.trim() || isStopRequested?.()) {
@@ -200,6 +265,23 @@ export function createCardReplyStrategy(
200
265
  }
201
266
  };
202
267
 
268
+ const rewriteLocalMarkdownImagesToPlaceholders = (text: string): string => {
269
+ const candidates = extractMarkdownImageCandidates(text);
270
+ if (candidates.length === 0) {
271
+ return text;
272
+ }
273
+
274
+ let nextText = text;
275
+ for (const candidate of candidates.toReversed()) {
276
+ if (candidate.classification !== "local") {
277
+ continue;
278
+ }
279
+ const placeholder = buildImagePlaceholderText({ alt: candidate.alt, url: candidate.url });
280
+ nextText = `${nextText.slice(0, candidate.start)}${placeholder}${nextText.slice(candidate.end)}`;
281
+ }
282
+ return nextText;
283
+ };
284
+
203
285
  const handleAnswerSnapshot = async (text: string | undefined): Promise<void> => {
204
286
  if (!shouldAcceptAnswerSnapshot() || isStopRequested?.()) {
205
287
  return;
@@ -207,7 +289,23 @@ export function createCardReplyStrategy(
207
289
  if (!text) {
208
290
  return;
209
291
  }
210
- await controller.updateAnswer(text, { stream: streamAnswerLive });
292
+ const rewrittenSnapshot = rewriteLocalMarkdownImagesToPlaceholders(text);
293
+ const normalizedSnapshot = normalizeDeliveredText(rewrittenSnapshot, { isReasoning: false });
294
+
295
+ if (normalizedSnapshot.reasoningText) {
296
+ await applyModeAwareReasoningSnapshot(normalizedSnapshot.reasoningText);
297
+ }
298
+
299
+ const answerSnapshot = normalizedSnapshot.answerText
300
+ ?? (!normalizedSnapshot.reasoningText ? rewrittenSnapshot : undefined);
301
+ if (!answerSnapshot) {
302
+ return;
303
+ }
304
+
305
+ await controller.updateAnswer(answerSnapshot, {
306
+ stream: streamAnswerLive,
307
+ renderBlocks: renderAnswerBlocksLive,
308
+ });
211
309
  };
212
310
 
213
311
  const applySplitTextToTimeline = async (
@@ -222,20 +320,66 @@ export function createCardReplyStrategy(
222
320
  return normalized;
223
321
  };
224
322
 
225
- const deliverMediaWithTracking = async (
226
- mediaUrls: string[],
227
- options: { audioAsVoice?: boolean },
228
- ): Promise<void> => {
229
- if (mediaUrls.length === 0) {
230
- return;
323
+ const rerouteMarkdownImagesFromAnswer = async (text: string): Promise<string> => {
324
+ const candidates = extractMarkdownImageCandidates(text);
325
+ if (candidates.length === 0) {
326
+ return text;
231
327
  }
232
- try {
233
- await ctx.deliverMedia(mediaUrls, options);
234
- successfulMediaDeliveries += mediaUrls.length;
235
- } catch (err) {
236
- failedMediaDeliveries += mediaUrls.length;
237
- throw err;
328
+
329
+ type SuccessfulReroute = {
330
+ start: number;
331
+ end: number;
332
+ placeholder: string;
333
+ mediaId: string;
334
+ blockText: string;
335
+ };
336
+
337
+ let nextText = text;
338
+ const successfulReroutes: SuccessfulReroute[] = [];
339
+
340
+ for (const candidate of candidates.toReversed()) {
341
+ if (candidate.classification !== "local") {
342
+ continue;
343
+ }
344
+
345
+ let prepared: Awaited<ReturnType<typeof prepareMediaInput>> | undefined;
346
+ try {
347
+ prepared = await prepareMediaInput(candidate.url, log, config.mediaUrlAllowlist);
348
+ const mediaPath = prepared.cleanup
349
+ ? prepared.path
350
+ : resolveRelativePath(prepared.path);
351
+ const mediaType = resolveOutboundMediaType({ mediaPath, asVoice: false });
352
+ if (mediaType !== "image") {
353
+ continue;
354
+ }
355
+
356
+ const result = await uploadMedia(config, mediaPath, "image", log);
357
+ if (!result?.mediaId) {
358
+ continue;
359
+ }
360
+
361
+ const placeholder = buildImagePlaceholderText({ alt: candidate.alt, url: candidate.url });
362
+ const blockText = candidate.alt.trim() || placeholder.replace(/^见下图/, "").trim() || "图片";
363
+ successfulReroutes.push({
364
+ start: candidate.start,
365
+ end: candidate.end,
366
+ placeholder,
367
+ mediaId: result.mediaId,
368
+ blockText,
369
+ });
370
+ nextText = `${nextText.slice(0, candidate.start)}${placeholder}${nextText.slice(candidate.end)}`;
371
+ } catch {
372
+ // Failure fallback: keep the original markdown unchanged.
373
+ } finally {
374
+ await prepared?.cleanup?.();
375
+ }
238
376
  }
377
+
378
+ for (const reroute of successfulReroutes.toSorted((left, right) => left.start - right.start)) {
379
+ await controller.appendImageBlock(reroute.mediaId, reroute.blockText);
380
+ }
381
+
382
+ return nextText;
239
383
  };
240
384
 
241
385
  return {
@@ -252,6 +396,17 @@ export function createCardReplyStrategy(
252
396
  await handleAssistantBoundary();
253
397
  },
254
398
 
399
+ onAgentRunStart: (runId: string) => {
400
+ if (isLifecycleSealed()) {
401
+ return;
402
+ }
403
+ recordRunStart(runId);
404
+ if (ctx.taskMeta) {
405
+ if (!ctx.taskMeta.runIds) { ctx.taskMeta.runIds = new Set(); }
406
+ ctx.taskMeta.runIds.add(runId);
407
+ }
408
+ },
409
+
255
410
  onPartialReply: async (payload) => {
256
411
  await handleAnswerSnapshot(payload.text);
257
412
  },
@@ -262,6 +417,24 @@ export function createCardReplyStrategy(
262
417
  }
263
418
  await applyModeAwareReasoningSnapshot(payload.text);
264
419
  },
420
+
421
+ onModelSelected: (selected) => {
422
+ if (!card.accountId || !card.conversationId) {
423
+ return;
424
+ }
425
+ updateSessionState(card.accountId, card.contextConversationId || card.conversationId, {
426
+ model: selected.model,
427
+ effort: selected.thinkLevel,
428
+ });
429
+ if (ctx.taskMeta) {
430
+ ctx.taskMeta.model = selected.model;
431
+ ctx.taskMeta.effort = selected.thinkLevel;
432
+ }
433
+ const statusLine = buildStatusLine();
434
+ if (statusLine) {
435
+ void updateAICardStatusLine(card, statusLine, log);
436
+ }
437
+ },
265
438
  };
266
439
  },
267
440
 
@@ -293,8 +466,31 @@ export function createCardReplyStrategy(
293
466
  `lastAnswer="${(controller.getLastAnswerContent() ?? "").slice(0, 80)}" ` +
294
467
  `lastContent="${(controller.getLastContent() ?? "").slice(0, 80)}"`,
295
468
  );
469
+ // Inline media upload → image blocks in card; defer non-image attachments
296
470
  if (payload.mediaUrls.length > 0) {
297
- await deliverMediaWithTracking(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
471
+ for (const url of payload.mediaUrls) {
472
+ try {
473
+ const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
474
+ const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });
475
+ if (mediaType !== "image") {
476
+ log?.debug?.(`[DingTalk][Card] Deferring non-image media (${mediaType}) for out-of-card delivery: ${url}`);
477
+ // Collect non-image attachments for later delivery
478
+ if (mediaType === "voice" || mediaType === "video" || mediaType === "file") {
479
+ pendingNonImageMedia.push({ url, type: mediaType });
480
+ }
481
+ await prepared.cleanup?.();
482
+ continue;
483
+ }
484
+ const result = await uploadMedia(config, prepared.path, "image", log);
485
+ await prepared.cleanup?.();
486
+ if (result?.mediaId) {
487
+ await controller.appendImageBlock(result.mediaId);
488
+ }
489
+ } catch (err: unknown) {
490
+ const msg = err instanceof Error ? err.message : String(err);
491
+ log?.debug?.(`[DingTalk][Card] Failed to upload media as image block: ${msg}`);
492
+ }
493
+ }
298
494
  }
299
495
  const rawFinalText = typeof textToSend === "string" ? textToSend : "";
300
496
  if (rawFinalText) {
@@ -302,11 +498,12 @@ export function createCardReplyStrategy(
302
498
  await applyModeAwareReasoningSnapshot(rawFinalText);
303
499
  await flushPendingReasoning();
304
500
  } else {
305
- const normalizedFinal = await applySplitTextToTimeline(rawFinalText, {
501
+ const rewrittenFinalText = await rerouteMarkdownImagesFromAnswer(rawFinalText);
502
+ const normalizedFinal = await applySplitTextToTimeline(rewrittenFinalText, {
306
503
  answerHandling: "capture",
307
504
  });
308
505
  if (isFirstFinalDelivery && !normalizedFinal.answerText && !normalizedFinal.reasoningText) {
309
- finalTextForFallback = rawFinalText;
506
+ finalTextForFallback = rewrittenFinalText;
310
507
  }
311
508
  await flushPendingReasoning();
312
509
  }
@@ -337,11 +534,11 @@ export function createCardReplyStrategy(
337
534
  if (isReasoningBlock) {
338
535
  const normalized = normalizeDeliveredText(textToSend, { isReasoning: true });
339
536
  await applyDeliveredContent(normalized, {
340
- routeReasoningThroughModePolicy: true,
537
+ routeReasoningThroughModePolicy: false,
341
538
  answerHandling: "ignore",
342
539
  });
343
540
  } else {
344
- await applySplitTextToTimeline(textToSend, {
541
+ await applySplitTextToTimeline(rewriteLocalMarkdownImagesToPlaceholders(textToSend), {
345
542
  answerHandling: lifecycleState === "open" ? "update" : "capture",
346
543
  });
347
544
  }
@@ -349,7 +546,26 @@ export function createCardReplyStrategy(
349
546
 
350
547
  // ---- block: only handle reasoning/media (other text blocks are unused) ----
351
548
  if (payload.mediaUrls.length > 0) {
352
- await deliverMediaWithTracking(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
549
+ for (const url of payload.mediaUrls) {
550
+ try {
551
+ const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
552
+ const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });
553
+ if (mediaType !== "image") {
554
+ log?.debug?.(`[DingTalk][Card] Deferring non-image media (${mediaType}) for out-of-card delivery: ${url}`);
555
+ pendingNonImageMedia.push({ url, type: mediaType });
556
+ await prepared.cleanup?.();
557
+ continue;
558
+ }
559
+ const result = await uploadMedia(config, prepared.path, "image", log);
560
+ await prepared.cleanup?.();
561
+ if (result?.mediaId) {
562
+ await controller.appendImageBlock(result.mediaId);
563
+ }
564
+ } catch (err: unknown) {
565
+ const msg = err instanceof Error ? err.message : String(err);
566
+ log?.debug?.(`[DingTalk][Card] Failed to upload media as image block: ${msg}`);
567
+ }
568
+ }
353
569
  }
354
570
  },
355
571
 
@@ -366,18 +582,58 @@ export function createCardReplyStrategy(
366
582
  if (isStopRequested?.()) {
367
583
  log?.info?.("[DingTalk][Finalize] Skipping — card stop was requested");
368
584
  lifecycleState = "sealed";
585
+ if (card.accountId && card.conversationId) {
586
+ clearRuns(ctx.taskMeta?.runIds);
587
+ }
369
588
  return;
370
589
  }
371
590
 
372
591
  if (card.state === AICardStatus.FINISHED) {
373
- log?.info?.("[DingTalk][Finalize] Skipping card already FINISHED");
592
+ // Card was already finalized (e.g. first embedded run timed out).
593
+ // If session-recovery triggered a second run that produced new content,
594
+ // deliver it as a markdown fallback so the user sees the final result.
595
+ // The user may see partial overlap with the frozen card's content, but
596
+ // delivering the full answer is preferred over silence.
597
+ const recoveryText = getRenderedTimeline({ preferFinalAnswer: true })
598
+ || finalTextForFallback
599
+ || controller.getLastAnswerContent()
600
+ || controller.getLastContent();
601
+ if (recoveryText) {
602
+ log?.info?.(
603
+ `[DingTalk][Finalize] Card already FINISHED — sending markdown fallback for session-recovery content ` +
604
+ `len=${recoveryText.length} preview="${recoveryText.slice(0, 80)}"`,
605
+ );
606
+ const sendResult = await sendMessage(ctx.config, ctx.to, recoveryText, {
607
+ sessionWebhook: ctx.sessionWebhook,
608
+ atUserId: !ctx.isDirect ? ctx.senderId : null,
609
+ log,
610
+ accountId: ctx.accountId,
611
+ storePath: ctx.storePath,
612
+ conversationId: ctx.groupId,
613
+ quotedRef: ctx.replyQuotedRef,
614
+ forceMarkdown: true,
615
+ });
616
+ if (!sendResult.ok) {
617
+ log?.warn?.(
618
+ `[DingTalk][Finalize] Markdown fallback after FINISHED card failed: ${sendResult.error}`,
619
+ );
620
+ }
621
+ } else {
622
+ log?.info?.("[DingTalk][Finalize] Skipping — card already FINISHED and no new content");
623
+ }
374
624
  lifecycleState = "sealed";
625
+ if (card.accountId && card.conversationId) {
626
+ clearRuns(ctx.taskMeta?.runIds);
627
+ }
375
628
  return;
376
629
  }
377
630
 
378
631
  if (card.state === AICardStatus.STOPPED) {
379
632
  log?.info?.("[DingTalk][Finalize] Skipping — card already STOPPED");
380
633
  lifecycleState = "sealed";
634
+ if (card.accountId && card.conversationId) {
635
+ clearRuns(ctx.taskMeta?.runIds);
636
+ }
381
637
  return;
382
638
  }
383
639
 
@@ -385,8 +641,7 @@ export function createCardReplyStrategy(
385
641
  if (card.state === AICardStatus.FAILED || controller.isFailed()) {
386
642
  const fallbackText = getRenderedTimeline({ preferFinalAnswer: true })
387
643
  || controller.getLastAnswerContent()
388
- || controller.getLastContent()
389
- || card.lastStreamedContent;
644
+ || DEFAULT_CARD_FAILED_MESSAGE;
390
645
  if (fallbackText) {
391
646
  log?.debug?.("[DingTalk] Card failed during streaming, sending markdown fallback");
392
647
  const sendResult = await sendMessage(ctx.config, ctx.to, fallbackText, {
@@ -406,47 +661,90 @@ export function createCardReplyStrategy(
406
661
  log?.debug?.("[DingTalk] Card failed but no content to fallback with");
407
662
  }
408
663
  lifecycleState = "sealed";
664
+ if (card.accountId && card.conversationId) {
665
+ clearRuns(ctx.taskMeta?.runIds);
666
+ }
409
667
  return;
410
668
  }
411
669
 
412
- // Normal finalize.
670
+ // Normal finalize (V2 template path: single instances API call).
413
671
  try {
414
672
  await flushPendingReasoning();
673
+
415
674
  await controller.flush();
416
675
  await controller.waitForInFlight();
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
- }
440
- const finalText = renderedTimeline || EMPTY_FINAL_REPLY;
676
+
677
+ // Prepare finalize options for single instances API call
678
+ const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
679
+ const blockListJson = controller.getRenderedBlocks({
680
+ fallbackAnswer,
681
+ overrideAnswer: finalTextForFallback,
682
+ });
683
+ const content = controller.getRenderedContent({
684
+ fallbackAnswer,
685
+ overrideAnswer: finalTextForFallback,
686
+ }) || fallbackAnswer || EMPTY_FINAL_REPLY;
687
+
441
688
  controller.stop();
442
689
  log?.info?.(
443
- `[DingTalk][Finalize] Calling finishAICardfinalTextLen=${finalText.length} ` +
690
+ `[DingTalk][Finalize] Calling commitAICardBlocks — ` +
691
+ `blockListLen=${blockListJson.length} contentLen=${content.length} ` +
444
692
  `source=${finalTextForFallback ? "final.payload" : controller.getFinalAnswerContent() ? "timeline.answer" : sawFinalDelivery ? "timeline.fileOnly" : "fallbackDone"} ` +
445
- `preview="${finalText.slice(0, 120)}"`,
693
+ `preview="${content.slice(0, 120)}"`,
446
694
  );
447
- await finishAICard(card, finalText, log, {
695
+
696
+ // Build statusLine for card template
697
+ const statusLine = buildStatusLine();
698
+
699
+ await commitAICardBlocks(card, {
700
+ blockListJson,
701
+ content,
702
+ statusLine,
448
703
  quotedRef: ctx.replyQuotedRef,
449
- });
704
+ }, log);
705
+
706
+ // Send deferred non-image attachments after card finalize
707
+ // Use sessionWebhook for reply-session semantics; fallback to proactive if unavailable.
708
+ if (pendingNonImageMedia.length > 0) {
709
+ log?.debug?.(`[DingTalk][Card] Sending ${pendingNonImageMedia.length} deferred non-image attachments`);
710
+ for (const { url, type } of pendingNonImageMedia) {
711
+ try {
712
+ const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
713
+ const actualMediaPath = prepared.path;
714
+
715
+ // Prefer sessionWebhook for reply-session permission semantics
716
+ if (ctx.sessionWebhook) {
717
+ const sendResult = await sendMessage(config, ctx.to, "", {
718
+ sessionWebhook: ctx.sessionWebhook,
719
+ mediaPath: actualMediaPath,
720
+ mediaType: type,
721
+ log,
722
+ accountId: ctx.accountId,
723
+ storePath: ctx.storePath,
724
+ });
725
+ if (!sendResult.ok) {
726
+ log?.warn?.(`[DingTalk][Card] Deferred media session send failed: ${sendResult.error || "unknown"}`);
727
+ }
728
+ } else {
729
+ // Fallback: proactive send when no reply session available
730
+ const result = await sendProactiveMedia(config, ctx.to, actualMediaPath, type, {
731
+ log,
732
+ accountId: ctx.accountId,
733
+ });
734
+ if (!result.ok) {
735
+ log?.warn?.(`[DingTalk][Card] Deferred media proactive send failed: ${result.error || "unknown"}`);
736
+ }
737
+ }
738
+
739
+ await prepared.cleanup?.();
740
+ } catch (err: unknown) {
741
+ const msg = err instanceof Error ? err.message : String(err);
742
+ log?.warn?.(`[DingTalk][Card] Failed to send deferred media: ${msg}`);
743
+ }
744
+ }
745
+ pendingNonImageMedia = []; // Clear after sending
746
+ }
747
+
450
748
  lifecycleState = "sealed";
451
749
 
452
750
  // In group chats, send a lightweight @mention via session webhook
@@ -475,16 +773,27 @@ export function createCardReplyStrategy(
475
773
  }
476
774
  } finally {
477
775
  lifecycleState = "sealed";
776
+ if (card.accountId && card.conversationId) {
777
+ clearRuns(ctx.taskMeta?.runIds);
778
+ }
478
779
  }
479
780
  },
480
781
 
481
782
  async abort(_error: Error): Promise<void> {
482
783
  lifecycleState = "sealed";
784
+ if (card.accountId && card.conversationId) {
785
+ clearRuns(ctx.taskMeta?.runIds);
786
+ }
483
787
  if (!isCardInTerminalState(card.state)) {
484
788
  controller.stop();
485
789
  await controller.waitForInFlight();
486
790
  try {
487
- await finishAICard(card, "❌ 处理失败", log);
791
+ // For V2 template, finalize via instances API
792
+ const errorBlockListJson = JSON.stringify([{ type: 0, markdown: "❌ 处理失败" }]);
793
+ await commitAICardBlocks(card, {
794
+ blockListJson: errorBlockListJson,
795
+ content: "❌ 处理失败",
796
+ }, log);
488
797
  } catch (cardCloseErr: unknown) {
489
798
  log?.debug?.(`[DingTalk] Failed to finalize card after dispatch error: ${(cardCloseErr as Error).message}`);
490
799
  card.state = AICardStatus.FAILED;
@@ -6,7 +6,7 @@
6
6
  * Reasoning display is intentionally unsupported on DingTalk markdown.
7
7
  */
8
8
 
9
- import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
9
+ import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy-types";
10
10
  import { sendMessage } from "./send-service";
11
11
 
12
12
  const EMPTY_FINAL_FALLBACK_TEXT = "✅ Done";