@soimy/dingtalk 3.5.2 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +6 -23
  2. package/index.ts +7 -0
  3. package/openclaw.plugin.json +799 -0
  4. package/package.json +5 -5
  5. package/src/card/card-markdown-image-reroute.ts +106 -0
  6. package/src/card/card-run-registry.ts +54 -1
  7. package/src/card/card-stop-handler.ts +10 -20
  8. package/src/card/card-streaming-mode.ts +30 -0
  9. package/src/card/card-template.ts +14 -3
  10. package/src/card/reasoning-answer-split.ts +162 -0
  11. package/src/card/statusline-renderer.ts +94 -0
  12. package/src/card-draft-controller.ts +326 -54
  13. package/src/card-service.ts +479 -8
  14. package/src/channel.ts +19 -1062
  15. package/src/config-schema.ts +81 -38
  16. package/src/config.ts +142 -4
  17. package/src/device-registration.ts +245 -0
  18. package/src/gateway/channel-gateway.ts +636 -0
  19. package/src/inbound-handler.ts +489 -49
  20. package/src/media-utils.ts +169 -7
  21. package/src/message-utils.ts +153 -17
  22. package/src/messaging/btw-deliver.ts +85 -0
  23. package/src/messaging/channel-actions.ts +173 -0
  24. package/src/messaging/channel-outbound.ts +158 -0
  25. package/src/messaging/quoted-file-service.ts +9 -4
  26. package/src/onboarding.ts +323 -205
  27. package/src/platform/channel-status.ts +81 -0
  28. package/src/plugin-sdk-channel-actions-augment.ts +11 -0
  29. package/src/reply-strategy-card.ts +568 -44
  30. package/src/reply-strategy-markdown.ts +2 -2
  31. package/src/reply-strategy-types.ts +93 -0
  32. package/src/reply-strategy-with-reaction.ts +1 -1
  33. package/src/reply-strategy.ts +14 -56
  34. package/src/run-usage-store.ts +59 -0
  35. package/src/send-service.ts +225 -7
  36. package/src/session-state.ts +62 -0
  37. package/src/targeting/agent-name-matcher.ts +28 -0
  38. package/src/targeting/agent-routing.ts +44 -28
  39. package/src/types.ts +49 -117
  40. package/src/utils.ts +25 -0
@@ -7,32 +7,133 @@
7
7
  */
8
8
 
9
9
  import {
10
- finishAICard,
10
+ commitAICardBlocks,
11
11
  isCardInTerminalState,
12
+ updateAICardStatusLine,
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";
20
+ import {
21
+ buildImagePlaceholderText,
22
+ extractMarkdownImageCandidates,
23
+ } from "./card/card-markdown-image-reroute";
14
24
  import { createCardDraftController } from "./card-draft-controller";
15
25
  import { attachCardRunController } from "./card/card-run-registry";
16
- import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
17
- 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";
18
34
  import type { AICardInstance } from "./types";
19
35
  import { AICardStatus } from "./types";
20
36
  import { formatDingTalkErrorPayloadLog } from "./utils";
21
37
 
22
38
  const EMPTY_FINAL_REPLY = "✅ Done";
39
+ const DEFAULT_CARD_FAILED_MESSAGE = "回复生成失败,请重试";
40
+ type CardReplyLifecycleState = "open" | "final_seen" | "sealed";
41
+
42
+ /** Deferred media attachment for out-of-card delivery */
43
+ interface DeferredMedia {
44
+ url: string;
45
+ type: "voice" | "video" | "file";
46
+ }
23
47
 
24
48
  export function createCardReplyStrategy(
25
49
  ctx: ReplyStrategyContext & { card: AICardInstance; isStopRequested?: () => boolean },
26
50
  ): ReplyStrategy {
27
51
  const { card, config, log, isStopRequested } = ctx;
28
52
 
29
- const controller = createCardDraftController({ card, log });
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
+ };
103
+ const { mode, usedDeprecatedCardRealTimeStream } = resolveCardStreamingMode(config);
104
+ const streamAnswerLive = mode === "answer" || mode === "all";
105
+ const renderAnswerBlocksLive = mode === "all";
106
+ const streamThinkingLive = mode === "all";
107
+ let lifecycleState: CardReplyLifecycleState = "open";
108
+ const shouldAcceptAnswerSnapshot = () => lifecycleState === "open";
109
+ const isLifecycleSealed = () => lifecycleState === "sealed";
110
+
111
+ if (usedDeprecatedCardRealTimeStream) {
112
+ const warningKey = `dingtalk-card-streaming:${ctx.accountId || config.clientId || "default"}`;
113
+ if (shouldWarnDeprecatedCardRealTimeStreamOnce(warningKey)) {
114
+ log?.warn?.(
115
+ "[DingTalk][Config] `cardRealTimeStream` is deprecated. Use `cardStreamingMode` with `off` | `answer` | `all`.",
116
+ );
117
+ }
118
+ }
119
+
120
+ const controller = createCardDraftController({
121
+ card,
122
+ log,
123
+ realTimeStreamEnabled: streamAnswerLive,
124
+ throttleMs: config.cardStreamInterval ?? 1000,
125
+ getStatusLine: buildStatusLine,
126
+ });
30
127
  const reasoningAssembler = createReasoningBlockAssembler();
31
128
  if (card.outTrackId) {
32
129
  attachCardRunController(card.outTrackId, controller);
33
130
  }
34
131
  let finalTextForFallback: string | undefined;
35
132
  let sawFinalDelivery = false;
133
+ /** Tracks the latest reasoning snapshot text for non-streaming boundary flush. */
134
+ let latestReasoningSnapshot = "";
135
+ /** Non-image media attachments deferred for out-of-card delivery. */
136
+ let pendingNonImageMedia: DeferredMedia[] = [];
36
137
 
37
138
  const getRenderedTimeline = (options: { preferFinalAnswer?: boolean } = {}): string => {
38
139
  const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
@@ -51,25 +152,236 @@ export function createCardReplyStrategy(
51
152
  }
52
153
  };
53
154
 
54
- const ingestReasoningSnapshot = async (text: string | undefined): Promise<void> => {
155
+ const applyModeAwareDeliveredReasoning = async (text: string | undefined): Promise<void> => {
156
+ if (typeof text !== "string" || !text.trim() || isStopRequested?.()) {
157
+ return;
158
+ }
159
+ if (streamThinkingLive) {
160
+ await controller.appendThinkingBlock(text);
161
+ return;
162
+ }
163
+ await applyModeAwareReasoningSnapshot(text);
164
+ };
165
+
166
+ const applyModeAwareReasoningSnapshot = async (text: string | undefined): Promise<void> => {
167
+ if (typeof text !== "string" || !text.trim() || isStopRequested?.()) {
168
+ return;
169
+ }
170
+ if (streamThinkingLive) {
171
+ latestReasoningSnapshot = text;
172
+ await controller.updateReasoning(text);
173
+ return;
174
+ }
55
175
  const blocks = reasoningAssembler.ingestSnapshot(text);
176
+ const trimmed = text.trimStart();
56
177
  if (
57
178
  blocks.length === 0
58
- && typeof text === "string"
59
- && text.trim()
60
- && !text.trimStart().startsWith("Reasoning:")
179
+ && !trimmed.startsWith("Reasoning:")
61
180
  ) {
62
- await appendAssembledThinkingBlocks([text.trim()]);
181
+ if (trimmed.startsWith("Reason:")) {
182
+ latestReasoningSnapshot = "";
183
+ return;
184
+ }
185
+ latestReasoningSnapshot = text.trim();
63
186
  return;
64
187
  }
188
+ latestReasoningSnapshot = "";
65
189
  await appendAssembledThinkingBlocks(blocks);
66
190
  };
67
191
 
68
192
  const flushPendingReasoning = async (): Promise<void> => {
193
+ if (streamThinkingLive) {
194
+ await controller.sealActiveThinking();
195
+ latestReasoningSnapshot = "";
196
+ return;
197
+ }
69
198
  const blocks = reasoningAssembler.flushPendingAtBoundary();
199
+ if (latestReasoningSnapshot) {
200
+ blocks.push(latestReasoningSnapshot);
201
+ latestReasoningSnapshot = "";
202
+ }
70
203
  await appendAssembledThinkingBlocks(blocks);
71
204
  };
72
205
 
206
+ const handleAssistantBoundary = async (): Promise<void> => {
207
+ if (streamThinkingLive) {
208
+ await controller.sealActiveThinking();
209
+ latestReasoningSnapshot = "";
210
+ reasoningAssembler.reset();
211
+ await controller.notifyNewAssistantTurn();
212
+ return;
213
+ }
214
+ const pendingReasoningBlocks = reasoningAssembler.flushPendingAtBoundary();
215
+ if (latestReasoningSnapshot) {
216
+ pendingReasoningBlocks.push(latestReasoningSnapshot);
217
+ latestReasoningSnapshot = "";
218
+ }
219
+ reasoningAssembler.reset();
220
+ const turnBoundary = controller.notifyNewAssistantTurn();
221
+ if (pendingReasoningBlocks.length > 0) {
222
+ await turnBoundary;
223
+ await appendAssembledThinkingBlocks(pendingReasoningBlocks);
224
+ return;
225
+ }
226
+ await turnBoundary;
227
+ };
228
+
229
+ const normalizeDeliveredText = (
230
+ text: string,
231
+ options: { isReasoning: boolean },
232
+ ): { reasoningText?: string; answerText?: string } => {
233
+ if (options.isReasoning) {
234
+ const split = splitCardReasoningAnswerText(text);
235
+ return { reasoningText: split.reasoningText || text };
236
+ }
237
+ const split = splitCardReasoningAnswerText(text);
238
+ return {
239
+ reasoningText: split.reasoningText,
240
+ answerText: split.answerText,
241
+ };
242
+ };
243
+
244
+ const applyDeliveredContent = async (
245
+ normalized: { reasoningText?: string; answerText?: string },
246
+ options: {
247
+ routeReasoningThroughModePolicy: boolean;
248
+ answerHandling?: "update" | "capture" | "ignore";
249
+ },
250
+ ): Promise<void> => {
251
+ if (normalized.reasoningText) {
252
+ if (options.routeReasoningThroughModePolicy) {
253
+ await applyModeAwareDeliveredReasoning(normalized.reasoningText);
254
+ } else {
255
+ // Conservative local split fallback: keep existing behavior for mixed payloads.
256
+ await controller.appendThinkingBlock(normalized.reasoningText);
257
+ }
258
+ }
259
+ if (normalized.answerText && options.answerHandling !== "ignore") {
260
+ if (options.answerHandling === "capture") {
261
+ finalTextForFallback = normalized.answerText;
262
+ return;
263
+ }
264
+ await controller.updateAnswer(normalized.answerText);
265
+ }
266
+ };
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
+
285
+ const handleAnswerSnapshot = async (text: string | undefined): Promise<void> => {
286
+ if (!shouldAcceptAnswerSnapshot() || isStopRequested?.()) {
287
+ return;
288
+ }
289
+ if (!text) {
290
+ return;
291
+ }
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
+ });
309
+ };
310
+
311
+ const applySplitTextToTimeline = async (
312
+ text: string,
313
+ options: { answerHandling?: "update" | "capture" | "ignore" } = {},
314
+ ) => {
315
+ const normalized = normalizeDeliveredText(text, { isReasoning: false });
316
+ await applyDeliveredContent(normalized, {
317
+ routeReasoningThroughModePolicy: true,
318
+ answerHandling: options.answerHandling ?? "update",
319
+ });
320
+ return normalized;
321
+ };
322
+
323
+ const rerouteMarkdownImagesFromAnswer = async (text: string): Promise<string> => {
324
+ const candidates = extractMarkdownImageCandidates(text);
325
+ if (candidates.length === 0) {
326
+ return text;
327
+ }
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
+ }
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;
383
+ };
384
+
73
385
  return {
74
386
  getReplyOptions(): ReplyOptions {
75
387
  return {
@@ -78,37 +390,58 @@ export function createCardReplyStrategy(
78
390
  disableBlockStreaming: ctx.disableBlockStreaming ?? true,
79
391
 
80
392
  onAssistantMessageStart: async () => {
81
- if (isStopRequested?.()) {
393
+ if (isLifecycleSealed() || isStopRequested?.()) {
82
394
  return;
83
395
  }
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);
396
+ await handleAssistantBoundary();
397
+ },
398
+
399
+ onAgentRunStart: (runId: string) => {
400
+ if (isLifecycleSealed()) {
90
401
  return;
91
402
  }
92
- await turnBoundary;
403
+ recordRunStart(runId);
404
+ if (ctx.taskMeta) {
405
+ if (!ctx.taskMeta.runIds) { ctx.taskMeta.runIds = new Set(); }
406
+ ctx.taskMeta.runIds.add(runId);
407
+ }
93
408
  },
94
409
 
95
- onPartialReply: config.cardRealTimeStream
96
- ? async (payload) => {
97
- if (payload.text && !isStopRequested?.()) {
98
- await controller.updateAnswer(payload.text);
99
- }
100
- }
101
- : undefined,
410
+ onPartialReply: async (payload) => {
411
+ await handleAnswerSnapshot(payload.text);
412
+ },
102
413
 
103
414
  onReasoningStream: async (payload) => {
104
- if (payload.text && !isStopRequested?.()) {
105
- await ingestReasoningSnapshot(payload.text);
415
+ if (isLifecycleSealed() || isStopRequested?.()) {
416
+ return;
417
+ }
418
+ await applyModeAwareReasoningSnapshot(payload.text);
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);
106
436
  }
107
437
  },
108
438
  };
109
439
  },
110
440
 
111
441
  async deliver(payload: DeliverPayload): Promise<void> {
442
+ if (isLifecycleSealed()) {
443
+ return;
444
+ }
112
445
  const textToSend = payload.text;
113
446
 
114
447
  // Empty-payload guard — card final is an exception (e.g. file-only response).
@@ -120,8 +453,12 @@ export function createCardReplyStrategy(
120
453
 
121
454
  // ---- final: defer to finalize, just save text ----
122
455
  if (payload.kind === "final") {
456
+ const isFirstFinalDelivery = !sawFinalDelivery;
457
+ lifecycleState = "final_seen";
123
458
  await flushPendingReasoning();
124
- sawFinalDelivery = true;
459
+ if (isFirstFinalDelivery) {
460
+ sawFinalDelivery = true;
461
+ }
125
462
  log?.info?.(
126
463
  `[DingTalk][Finalize] deliver(final) received — cardState=${card.state} ` +
127
464
  `textLen=${typeof textToSend === "string" ? textToSend.length : "null"} ` +
@@ -129,12 +466,47 @@ export function createCardReplyStrategy(
129
466
  `lastAnswer="${(controller.getLastAnswerContent() ?? "").slice(0, 80)}" ` +
130
467
  `lastContent="${(controller.getLastContent() ?? "").slice(0, 80)}"`,
131
468
  );
469
+ // Inline media upload → image blocks in card; defer non-image attachments
132
470
  if (payload.mediaUrls.length > 0) {
133
- await ctx.deliverMedia(payload.mediaUrls);
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
+ }
134
494
  }
135
495
  const rawFinalText = typeof textToSend === "string" ? textToSend : "";
136
496
  if (rawFinalText) {
137
- finalTextForFallback = rawFinalText;
497
+ if (payload.isReasoning === true) {
498
+ await applyModeAwareReasoningSnapshot(rawFinalText);
499
+ await flushPendingReasoning();
500
+ } else {
501
+ const rewrittenFinalText = await rerouteMarkdownImagesFromAnswer(rawFinalText);
502
+ const normalizedFinal = await applySplitTextToTimeline(rewrittenFinalText, {
503
+ answerHandling: "capture",
504
+ });
505
+ if (isFirstFinalDelivery && !normalizedFinal.answerText && !normalizedFinal.reasoningText) {
506
+ finalTextForFallback = rewrittenFinalText;
507
+ }
508
+ await flushPendingReasoning();
509
+ }
138
510
  }
139
511
  return;
140
512
  }
@@ -149,22 +521,51 @@ export function createCardReplyStrategy(
149
521
  log?.info?.(
150
522
  `[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
151
523
  );
152
- await controller.appendTool(textToSend ?? "");
524
+ if (lifecycleState === "final_seen") {
525
+ await controller.appendToolBeforeCurrentAnswer(textToSend ?? "");
526
+ } else {
527
+ await controller.appendTool(textToSend ?? "");
528
+ }
153
529
  return;
154
530
  }
155
531
 
156
532
  const isReasoningBlock = payload.isReasoning === true;
157
533
  if (typeof textToSend === "string" && textToSend.trim()) {
158
534
  if (isReasoningBlock) {
159
- await ingestReasoningSnapshot(textToSend);
535
+ const normalized = normalizeDeliveredText(textToSend, { isReasoning: true });
536
+ await applyDeliveredContent(normalized, {
537
+ routeReasoningThroughModePolicy: false,
538
+ answerHandling: "ignore",
539
+ });
160
540
  } else {
161
- await controller.updateAnswer(textToSend);
541
+ await applySplitTextToTimeline(rewriteLocalMarkdownImagesToPlaceholders(textToSend), {
542
+ answerHandling: lifecycleState === "open" ? "update" : "capture",
543
+ });
162
544
  }
163
545
  }
164
546
 
165
547
  // ---- block: only handle reasoning/media (other text blocks are unused) ----
166
548
  if (payload.mediaUrls.length > 0) {
167
- await ctx.deliverMedia(payload.mediaUrls);
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
+ }
168
569
  }
169
570
  },
170
571
 
@@ -180,16 +581,59 @@ export function createCardReplyStrategy(
180
581
 
181
582
  if (isStopRequested?.()) {
182
583
  log?.info?.("[DingTalk][Finalize] Skipping — card stop was requested");
584
+ lifecycleState = "sealed";
585
+ if (card.accountId && card.conversationId) {
586
+ clearRuns(ctx.taskMeta?.runIds);
587
+ }
183
588
  return;
184
589
  }
185
590
 
186
591
  if (card.state === AICardStatus.FINISHED) {
187
- 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
+ }
624
+ lifecycleState = "sealed";
625
+ if (card.accountId && card.conversationId) {
626
+ clearRuns(ctx.taskMeta?.runIds);
627
+ }
188
628
  return;
189
629
  }
190
630
 
191
631
  if (card.state === AICardStatus.STOPPED) {
192
632
  log?.info?.("[DingTalk][Finalize] Skipping — card already STOPPED");
633
+ lifecycleState = "sealed";
634
+ if (card.accountId && card.conversationId) {
635
+ clearRuns(ctx.taskMeta?.runIds);
636
+ }
193
637
  return;
194
638
  }
195
639
 
@@ -197,8 +641,7 @@ export function createCardReplyStrategy(
197
641
  if (card.state === AICardStatus.FAILED || controller.isFailed()) {
198
642
  const fallbackText = getRenderedTimeline({ preferFinalAnswer: true })
199
643
  || controller.getLastAnswerContent()
200
- || controller.getLastContent()
201
- || card.lastStreamedContent;
644
+ || DEFAULT_CARD_FAILED_MESSAGE;
202
645
  if (fallbackText) {
203
646
  log?.debug?.("[DingTalk] Card failed during streaming, sending markdown fallback");
204
647
  const sendResult = await sendMessage(ctx.config, ctx.to, fallbackText, {
@@ -217,25 +660,92 @@ export function createCardReplyStrategy(
217
660
  } else {
218
661
  log?.debug?.("[DingTalk] Card failed but no content to fallback with");
219
662
  }
663
+ lifecycleState = "sealed";
664
+ if (card.accountId && card.conversationId) {
665
+ clearRuns(ctx.taskMeta?.runIds);
666
+ }
220
667
  return;
221
668
  }
222
669
 
223
- // Normal finalize.
670
+ // Normal finalize (V2 template path: single instances API call).
224
671
  try {
225
672
  await flushPendingReasoning();
673
+
226
674
  await controller.flush();
227
675
  await controller.waitForInFlight();
228
- const renderedTimeline = getRenderedTimeline({ preferFinalAnswer: true });
229
- 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
+
230
688
  controller.stop();
231
689
  log?.info?.(
232
- `[DingTalk][Finalize] Calling finishAICardfinalTextLen=${finalText.length} ` +
690
+ `[DingTalk][Finalize] Calling commitAICardBlocks — ` +
691
+ `blockListLen=${blockListJson.length} contentLen=${content.length} ` +
233
692
  `source=${finalTextForFallback ? "final.payload" : controller.getFinalAnswerContent() ? "timeline.answer" : sawFinalDelivery ? "timeline.fileOnly" : "fallbackDone"} ` +
234
- `preview="${finalText.slice(0, 120)}"`,
693
+ `preview="${content.slice(0, 120)}"`,
235
694
  );
236
- 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,
237
703
  quotedRef: ctx.replyQuotedRef,
238
- });
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
+
748
+ lifecycleState = "sealed";
239
749
 
240
750
  // In group chats, send a lightweight @mention via session webhook
241
751
  // so the sender gets a notification — card API doesn't support @mention.
@@ -261,15 +771,29 @@ export function createCardReplyStrategy(
261
771
  card.state = AICardStatus.FAILED;
262
772
  card.lastUpdated = Date.now();
263
773
  }
774
+ } finally {
775
+ lifecycleState = "sealed";
776
+ if (card.accountId && card.conversationId) {
777
+ clearRuns(ctx.taskMeta?.runIds);
778
+ }
264
779
  }
265
780
  },
266
781
 
267
782
  async abort(_error: Error): Promise<void> {
783
+ lifecycleState = "sealed";
784
+ if (card.accountId && card.conversationId) {
785
+ clearRuns(ctx.taskMeta?.runIds);
786
+ }
268
787
  if (!isCardInTerminalState(card.state)) {
269
788
  controller.stop();
270
789
  await controller.waitForInFlight();
271
790
  try {
272
- 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);
273
797
  } catch (cardCloseErr: unknown) {
274
798
  log?.debug?.(`[DingTalk] Failed to finalize card after dispatch error: ${(cardCloseErr as Error).message}`);
275
799
  card.state = AICardStatus.FAILED;