@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
@@ -25,6 +25,7 @@ import {
25
25
  import type {
26
26
  AICardInstance,
27
27
  AICardStreamingRequest,
28
+ CardBlock,
28
29
  DingTalkConfig,
29
30
  DingTalkTrackingMetadata,
30
31
  Logger,
@@ -53,7 +54,12 @@ export async function hideCardStopButton(
53
54
  ): Promise<void> {
54
55
  for (let attempt = 0; ; attempt++) {
55
56
  try {
56
- await updateCardVariables(outTrackId, { stop_action: STOP_ACTION_HIDDEN }, token, config);
57
+ await updateCardVariables(
58
+ outTrackId,
59
+ { hasAction: String(STOP_ACTION_HIDDEN), stop_action: String(STOP_ACTION_HIDDEN) },
60
+ token,
61
+ config,
62
+ );
57
63
  return;
58
64
  } catch (err) {
59
65
  if (attempt >= retries) {
@@ -204,6 +210,16 @@ export function clearAICardDegrade(accountId: string, log?: Logger): void {
204
210
  log?.info?.(`[DingTalk][AICard][Degrade] Cleared for account=${accountId}, lastReason=${reason}`);
205
211
  }
206
212
 
213
+ export function incrementCardDapiCount(card: AICardInstance): number {
214
+ const next = (card.dapiUsage || 0) + 1;
215
+ card.dapiUsage = next;
216
+ return next;
217
+ }
218
+
219
+ function markStreamingLifecycleAcknowledged(card: AICardInstance, finished: boolean): void {
220
+ card.streamLifecycleOpened = !finished;
221
+ }
222
+
207
223
  function extractCardProcessQueryKey(payload: unknown): string | undefined {
208
224
  if (!payload || typeof payload !== "object") {
209
225
  return undefined;
@@ -226,6 +242,7 @@ async function putAICardStreamingField(
226
242
  content: string,
227
243
  finished: boolean,
228
244
  log?: Logger,
245
+ options: { suppressDegrade?: boolean } = {},
229
246
  ): Promise<void> {
230
247
  const tokenAge = Date.now() - card.createdAt;
231
248
  const tokenRefreshThreshold = 90 * 60 * 1000;
@@ -270,6 +287,8 @@ async function putAICardStreamingField(
270
287
  `[DingTalk][AICard] Streaming response: status=${streamResp.status}, data=${JSON.stringify(streamResp.data)}`,
271
288
  );
272
289
  card.lastUpdated = Date.now();
290
+ incrementCardDapiCount(card);
291
+ markStreamingLifecycleAcknowledged(card, finished);
273
292
  } catch (err: any) {
274
293
  if (err.response?.status === 401 && card.config && !tokenAlreadyRefreshed) {
275
294
  log?.warn?.("[DingTalk][AICard] Received 401 error, attempting token refresh and retry...");
@@ -286,6 +305,8 @@ async function putAICardStreamingField(
286
305
  `[DingTalk][AICard] Retry after token refresh succeeded: status=${retryResp.status}`,
287
306
  );
288
307
  card.lastUpdated = Date.now();
308
+ incrementCardDapiCount(card);
309
+ markStreamingLifecycleAcknowledged(card, finished);
289
310
  return;
290
311
  } catch (retryErr: any) {
291
312
  log?.error?.(`[DingTalk][AICard] Retry after token refresh failed: ${retryErr.message}`);
@@ -301,7 +322,7 @@ async function putAICardStreamingField(
301
322
  }
302
323
  }
303
324
 
304
- if (card.accountId && shouldTriggerAICardDegrade(err)) {
325
+ if (!options.suppressDegrade && card.accountId && shouldTriggerAICardDegrade(err)) {
305
326
  activateAICardDegrade(
306
327
  card.accountId,
307
328
  `card.stream:${err?.response?.status || "unknown"}`,
@@ -324,6 +345,10 @@ interface CreateAICardOptions {
324
345
  storePath?: string;
325
346
  persistPending?: boolean;
326
347
  contextConversationId?: string;
348
+ /** Quote content to display in card header (shown when non-empty) */
349
+ quoteContent?: string;
350
+ /** Initial statusLine string to show on the first createAndDeliver render. */
351
+ statusLine?: string;
327
352
  }
328
353
 
329
354
  interface PendingCardRecord {
@@ -335,6 +360,9 @@ interface PendingCardRecord {
335
360
  createdAt: number;
336
361
  lastUpdated: number;
337
362
  state: string;
363
+ lastContent?: string;
364
+ lastBlockListJson?: string;
365
+ streamLifecycleOpened?: boolean;
338
366
  }
339
367
 
340
368
  interface PendingCardStateFile {
@@ -371,7 +399,10 @@ function normalizePendingState(parsed: Partial<PendingCardStateFile>): PendingCa
371
399
  typeof entry.accountId === "string" &&
372
400
  typeof entry.cardInstanceId === "string" &&
373
401
  (entry.outTrackId === undefined || typeof entry.outTrackId === "string") &&
374
- typeof entry.conversationId === "string",
402
+ typeof entry.conversationId === "string" &&
403
+ (entry.lastContent === undefined || typeof entry.lastContent === "string") &&
404
+ (entry.lastBlockListJson === undefined || typeof entry.lastBlockListJson === "string") &&
405
+ (entry.streamLifecycleOpened === undefined || typeof entry.streamLifecycleOpened === "boolean"),
375
406
  ),
376
407
  ),
377
408
  };
@@ -443,6 +474,9 @@ function upsertPendingCard(card: AICardInstance, storePath?: string, log?: Logge
443
474
  createdAt: card.createdAt,
444
475
  lastUpdated: card.lastUpdated,
445
476
  state: card.state,
477
+ lastContent: card.lastStreamedContent,
478
+ lastBlockListJson: card.lastBlockListJson,
479
+ streamLifecycleOpened: card.streamLifecycleOpened,
446
480
  };
447
481
  const index = state.pendingCards.findIndex((item) => item.cardInstanceId === card.cardInstanceId);
448
482
  if (index >= 0) {
@@ -454,6 +488,36 @@ function upsertPendingCard(card: AICardInstance, storePath?: string, log?: Logge
454
488
  writePendingCardState(state, storePath, log);
455
489
  }
456
490
 
491
+ function parseStoredBlockList(blockListJson?: string): CardBlock[] {
492
+ if (!blockListJson?.trim()) {
493
+ return [];
494
+ }
495
+ try {
496
+ const parsed = JSON.parse(blockListJson) as unknown;
497
+ return Array.isArray(parsed) ? (parsed as CardBlock[]) : [];
498
+ } catch {
499
+ return [];
500
+ }
501
+ }
502
+
503
+ function buildStoppedCardFinalizePayload(params: {
504
+ reason: string;
505
+ previousContent?: string;
506
+ previousBlockListJson?: string;
507
+ }): { blockListJson: string; content: string } {
508
+ const markerText = params.reason.trim();
509
+ const baseContent = params.previousContent?.trim() || "";
510
+ const blocks = parseStoredBlockList(params.previousBlockListJson);
511
+ blocks.push({ type: 0, markdown: markerText });
512
+ const content = baseContent
513
+ ? `${baseContent}\n\n---\n*${markerText}*`
514
+ : markerText;
515
+ return {
516
+ blockListJson: JSON.stringify(blocks),
517
+ content,
518
+ };
519
+ }
520
+
457
521
  function removePendingCard(card: AICardInstance, log?: Logger): void {
458
522
  if (!card.accountId || !card.storePath) {
459
523
  return;
@@ -500,6 +564,26 @@ export function isCardInTerminalState(state: string): boolean {
500
564
  );
501
565
  }
502
566
 
567
+ /**
568
+ * Ensure card access token is fresh (refresh if >90min old).
569
+ * Mutates card.accessToken in place if refreshed.
570
+ */
571
+ async function ensureFreshToken(card: AICardInstance, log?: Logger): Promise<void> {
572
+ const tokenAge = Date.now() - card.createdAt;
573
+ const tokenRefreshThreshold = 90 * 60 * 1000;
574
+
575
+ if (tokenAge > tokenRefreshThreshold && card.config) {
576
+ log?.debug?.("[DingTalk][AICard] Token age exceeds threshold, refreshing...");
577
+ try {
578
+ card.accessToken = await getAccessToken(card.config, log);
579
+ log?.debug?.("[DingTalk][AICard] Token refreshed successfully");
580
+ } catch (err: unknown) {
581
+ const msg = err instanceof Error ? err.message : String(err);
582
+ log?.warn?.(`[DingTalk][AICard] Failed to refresh token: ${msg}`);
583
+ }
584
+ }
585
+ }
586
+
503
587
  export function formatContentForCard(content: string | undefined, type: "thinking" | "tool"): string {
504
588
  if (!content) {
505
589
  return "";
@@ -573,7 +657,11 @@ export async function sendProactiveCardText(
573
657
  if (!card) {
574
658
  return { ok: false, error: "Failed to create AI card" };
575
659
  }
576
- await finishAICard(card, content, log);
660
+ const blockListJson = JSON.stringify([{ type: 0, markdown: content } satisfies CardBlock]);
661
+ await commitAICardBlocks(card, {
662
+ blockListJson,
663
+ content,
664
+ }, log);
577
665
  return {
578
666
  ok: true,
579
667
  processQueryKey: card.processQueryKey,
@@ -657,16 +745,24 @@ async function finalizePendingCardsByAccount(
657
745
  lastUpdated: entry.lastUpdated || Date.now(),
658
746
  state: normalizeRecoveredState(entry.state),
659
747
  config,
748
+ lastStreamedContent: entry.lastContent,
749
+ lastBlockListJson: entry.lastBlockListJson,
750
+ streamLifecycleOpened: entry.streamLifecycleOpened,
660
751
  };
661
752
  try {
662
- await finishAICard(card, reason, log);
753
+ await finalizeStoppedAICard(card, {
754
+ reason,
755
+ previousContent: entry.lastContent,
756
+ previousBlockListJson: entry.lastBlockListJson,
757
+ }, log);
663
758
  finalizedCount += 1;
664
- } catch (err: any) {
759
+ } catch (err: unknown) {
665
760
  const action = mode === "recover" ? "recover" : "finalize";
761
+ const message = err instanceof Error ? err.message : String(err);
666
762
  log?.warn?.(
667
- `[DingTalk][AICard] Failed to ${action} active card ${entry.cardInstanceId}: ${err.message}`,
763
+ `[DingTalk][AICard] Failed to ${action} active card ${entry.cardInstanceId}: ${message}`,
668
764
  );
669
- removePendingCardById(entry.cardInstanceId, storePath, log);
765
+ // Pending record intentionally kept for manual investigation
670
766
  }
671
767
  }
672
768
  return finalizedCount;
@@ -702,11 +798,17 @@ export async function createAICard(
702
798
  // DingTalk createAndDeliver API payload.
703
799
  // Note: do NOT include template.statusKey here — the createAndDeliver API may
704
800
  // reject unknown fields if the template variable is not yet provisioned.
705
- // Status is set to "streaming" via the streaming API immediately after creation.
801
+ // flowStatus=2 (INPUTING) is set directly so the card shows "输出中" immediately.
706
802
  const cardParamMap = {
707
803
  config: JSON.stringify({ autoLayout: true, enableForward: true }),
708
- [template.contentKey]: "",
709
- stop_action: STOP_ACTION_VISIBLE,
804
+ [template.streamingKey]: "",
805
+ quoteContent: options.quoteContent || "",
806
+ ...(options.statusLine?.trim() ? { statusLine: options.statusLine } : {}),
807
+ flowStatus: AICardStatus.INPUTING,
808
+ // V2 template uses hasAction (string), V1 uses stop_action (string)
809
+ // DingTalk cardParamMap requires all values to be strings
810
+ hasAction: String(STOP_ACTION_VISIBLE),
811
+ stop_action: String(STOP_ACTION_VISIBLE),
710
812
  };
711
813
  const createAndDeliverBody = {
712
814
  cardTemplateId: template.templateId,
@@ -758,6 +860,13 @@ export async function createAICard(
758
860
  cardInstanceId?: unknown;
759
861
  }
760
862
  | undefined;
863
+ const deliverResults = (responseData?.result as { deliverResults?: Array<{ success?: boolean; errorMsg?: string }> } | undefined)?.deliverResults;
864
+ if (Array.isArray(deliverResults)) {
865
+ const failedDelivery = deliverResults.find((item) => item?.success === false);
866
+ if (failedDelivery) {
867
+ throw new Error(failedDelivery.errorMsg?.trim() || "DingTalk card delivery failed");
868
+ }
869
+ }
761
870
  const responseTracking = responseData?.result;
762
871
  const processQueryKey =
763
872
  typeof responseTracking?.processQueryKey === "string" &&
@@ -793,6 +902,7 @@ export async function createAICard(
793
902
  config,
794
903
  processQueryKey: processQueryKey || extractCardProcessQueryKey(resp.data),
795
904
  outTrackId,
905
+ dapiUsage: 1,
796
906
  };
797
907
  if (shouldPersistPending) {
798
908
  upsertPendingCard(aiCardInstance, options.storePath, log);
@@ -800,18 +910,6 @@ export async function createAICard(
800
910
 
801
911
  clearAICardDegrade(accountId, log);
802
912
 
803
- // Kick the card into streaming mode immediately so the UI shows "输出中" and the
804
- // stop button becomes visible. Without this, the card sits in "创建中" skeleton state
805
- // until the first real content arrives — which may never happen for non-streaming replies.
806
- // This sends an empty content stream (isFull=true, isFinalize=false) which transitions
807
- // the card from PROCESSING to INPUTING on the DingTalk side.
808
- try {
809
- await putAICardStreamingField(aiCardInstance, template.contentKey, "", false, log);
810
- aiCardInstance.state = AICardStatus.INPUTING;
811
- } catch (kickErr: any) {
812
- log?.debug?.(`[DingTalk][AICard] Non-critical: failed to kick card into streaming mode: ${kickErr.message}`);
813
- }
814
-
815
913
  return aiCardInstance;
816
914
  } catch (err: any) {
817
915
  log?.error?.(`[DingTalk][AICard] Create failed: ${err.message}`);
@@ -836,6 +934,236 @@ export async function createAICard(
836
934
  }
837
935
  }
838
936
 
937
+ /**
938
+ * Update statusLine via PUT /v1.0/card/instances API.
939
+ */
940
+ export async function updateAICardStatusLine(
941
+ card: AICardInstance,
942
+ statusLine: string,
943
+ log?: Logger,
944
+ ): Promise<void> {
945
+ if (isCardInTerminalState(card.state) || !statusLine.trim()) {
946
+ return;
947
+ }
948
+
949
+ await ensureFreshToken(card, log);
950
+
951
+ try {
952
+ await updateCardVariables(
953
+ card.outTrackId || card.cardInstanceId,
954
+ { statusLine },
955
+ card.accessToken,
956
+ card.config,
957
+ );
958
+ incrementCardDapiCount(card);
959
+ card.lastUpdated = Date.now();
960
+ } catch (err: unknown) {
961
+ const message = err instanceof Error ? err.message : String(err);
962
+ log?.warn?.(`[DingTalk][AICard] StatusLine update failed: ${message}`);
963
+ }
964
+ }
965
+
966
+ export async function updateAICardBlockList(
967
+ card: AICardInstance,
968
+ blockListJson: string,
969
+ log?: Logger,
970
+ options?: { statusLine?: string },
971
+ ): Promise<void> {
972
+ if (isCardInTerminalState(card.state)) {
973
+ log?.debug?.(
974
+ `[DingTalk][AICard] Skip blockList update because card already terminal: outTrackId=${card.cardInstanceId} state=${card.state}`,
975
+ );
976
+ return;
977
+ }
978
+
979
+ // Ensure token is fresh before API call
980
+ await ensureFreshToken(card, log);
981
+
982
+ const template = DINGTALK_CARD_TEMPLATE;
983
+ const params: Record<string, unknown> = {
984
+ [template.blockListKey]: blockListJson,
985
+ };
986
+ if (options?.statusLine?.trim()) {
987
+ params.statusLine = options.statusLine;
988
+ }
989
+
990
+ try {
991
+ await updateCardVariables(
992
+ card.outTrackId || card.cardInstanceId,
993
+ params,
994
+ card.accessToken,
995
+ card.config,
996
+ );
997
+ incrementCardDapiCount(card);
998
+ card.lastBlockListJson = blockListJson;
999
+ card.lastUpdated = Date.now();
1000
+ if (card.state === AICardStatus.PROCESSING) {
1001
+ card.state = AICardStatus.INPUTING;
1002
+ }
1003
+ upsertPendingCard(card, card.storePath, log);
1004
+ } catch (err: unknown) {
1005
+ const message = err instanceof Error ? err.message : String(err);
1006
+ log?.error?.(`[DingTalk][AICard] BlockList update failed: ${message}`);
1007
+ throw err;
1008
+ }
1009
+ }
1010
+
1011
+ /**
1012
+ * Stream answer text to content key for real-time display.
1013
+ * Only used when cardRealTimeStream=true.
1014
+ * Uses streaming API because content is a simple string type.
1015
+ */
1016
+ export async function streamAICardContent(
1017
+ card: AICardInstance,
1018
+ text: string,
1019
+ log?: Logger,
1020
+ ): Promise<void> {
1021
+ if (isCardInTerminalState(card.state)) {
1022
+ return;
1023
+ }
1024
+ const template = DINGTALK_CARD_TEMPLATE;
1025
+ await putAICardStreamingField(card, template.streamingKey, text, false, log);
1026
+ card.lastStreamedContent = text;
1027
+ upsertPendingCard(card, card.storePath, log);
1028
+ }
1029
+
1030
+ /**
1031
+ * Clear the streaming content key.
1032
+ * Called when transitioning from streaming to blockList commit.
1033
+ */
1034
+ export async function clearAICardStreamingContent(
1035
+ card: AICardInstance,
1036
+ log?: Logger,
1037
+ ): Promise<void> {
1038
+ if (isCardInTerminalState(card.state)) {
1039
+ return;
1040
+ }
1041
+ const template = DINGTALK_CARD_TEMPLATE;
1042
+ try {
1043
+ await putAICardStreamingField(card, template.streamingKey, "", false, log);
1044
+ } catch (err: unknown) {
1045
+ const message = err instanceof Error ? err.message : String(err);
1046
+ log?.debug?.(`[DingTalk][AICard] Non-critical: failed to clear streaming content: ${message}`);
1047
+ }
1048
+ }
1049
+
1050
+ async function finalizeAICardStreamingLifecycleIfNeeded(
1051
+ card: AICardInstance,
1052
+ content: string,
1053
+ log?: Logger,
1054
+ ): Promise<void> {
1055
+ if (!card.streamLifecycleOpened) {
1056
+ return;
1057
+ }
1058
+ const template = DINGTALK_CARD_TEMPLATE;
1059
+ try {
1060
+ await putAICardStreamingField(card, template.streamingKey, content, true, log, {
1061
+ suppressDegrade: true,
1062
+ });
1063
+ } catch (err: unknown) {
1064
+ const message = err instanceof Error ? err.message : String(err);
1065
+ log?.warn?.(`[DingTalk][AICard] Streaming lifecycle finalize failed; continuing instances finalize: ${message}`);
1066
+ }
1067
+ }
1068
+
1069
+ /**
1070
+ * Options for finalizing an AI Card via instances API.
1071
+ * All variables are written in a single API call for V2 template compatibility.
1072
+ */
1073
+ export interface FinalizeCardOptions {
1074
+ /** CardBlock[] JSON string for blockList variable */
1075
+ blockListJson: string;
1076
+ /** Pure markdown answer text for copy action (content variable) */
1077
+ content: string;
1078
+ /** Optional quoted message preview text */
1079
+ quoteContent?: string;
1080
+ /** Optional statusLine string for card template */
1081
+ statusLine?: string;
1082
+ /** Optional quoted message reference for caching */
1083
+ quotedRef?: QuotedRef;
1084
+ }
1085
+
1086
+ /**
1087
+ * Commit blocks and finalize card via single instances API call.
1088
+ * V2 template requires finalize through instances API (not streaming API).
1089
+ * Writes blockList, content, quoteContent, statusLine, and flowStatus in one call.
1090
+ */
1091
+ export async function commitAICardBlocks(
1092
+ card: AICardInstance,
1093
+ options: FinalizeCardOptions,
1094
+ log?: Logger,
1095
+ ): Promise<void> {
1096
+ if (isCardInTerminalState(card.state)) {
1097
+ log?.debug?.(
1098
+ `[DingTalk][AICard] Skip finalize because card already terminal: outTrackId=${card.cardInstanceId} state=${card.state}`,
1099
+ );
1100
+ return;
1101
+ }
1102
+
1103
+ await ensureFreshToken(card, log);
1104
+ await finalizeAICardStreamingLifecycleIfNeeded(card, options.content, log);
1105
+
1106
+ const template = DINGTALK_CARD_TEMPLATE;
1107
+ const updates: Record<string, unknown> = {
1108
+ [template.blockListKey]: options.blockListJson,
1109
+ [template.streamingKey]: options.content, // markdown content for display
1110
+ [template.copyContentKey]: options.content, // same markdown as String type for card copy action
1111
+ flowStatus: 3, // completed state - V2 template hides stop button automatically
1112
+ };
1113
+
1114
+ // Optional fields
1115
+ if (options.quoteContent?.trim()) {
1116
+ updates.quoteContent = options.quoteContent;
1117
+ }
1118
+ if (options.statusLine?.trim()) {
1119
+ updates.statusLine = options.statusLine;
1120
+ }
1121
+
1122
+ log?.debug?.(
1123
+ `[DingTalk][AICard] Finalizing via instances API: outTrackId=${card.outTrackId || card.cardInstanceId} ` +
1124
+ `blockListLen=${options.blockListJson.length} contentLen=${options.content.length} flowStatus=3` +
1125
+ (options.statusLine ? ` statusLine="${options.statusLine}"` : ""),
1126
+ );
1127
+
1128
+ try {
1129
+ await updateCardVariables(
1130
+ card.outTrackId || card.cardInstanceId,
1131
+ updates,
1132
+ card.accessToken,
1133
+ card.config,
1134
+ );
1135
+ incrementCardDapiCount(card);
1136
+ card.lastBlockListJson = options.blockListJson;
1137
+ card.lastStreamedContent = options.content;
1138
+ card.lastUpdated = Date.now();
1139
+ } catch (err: unknown) {
1140
+ const message = err instanceof Error ? err.message : String(err);
1141
+ log?.error?.(`[DingTalk][AICard] Finalize via instances API failed: ${message}`);
1142
+ throw err;
1143
+ }
1144
+
1145
+ // Cache card content for quote recovery
1146
+ if (card.conversationId && options.content.trim() && card.accountId && card.processQueryKey) {
1147
+ const primaryConversationId = card.contextConversationId || card.conversationId;
1148
+ cacheCardContentByProcessQueryKey(
1149
+ card.accountId,
1150
+ primaryConversationId,
1151
+ card.processQueryKey,
1152
+ options.content,
1153
+ card.storePath,
1154
+ options.quotedRef,
1155
+ log,
1156
+ );
1157
+ }
1158
+
1159
+ // Update local state
1160
+ card.state = AICardStatus.FINISHED;
1161
+ card.lastUpdated = Date.now();
1162
+ removePendingCard(card, log);
1163
+ log?.info?.(`[DingTalk][AICard] Card finalized: outTrackId=${card.outTrackId || card.cardInstanceId} state=FINISHED`);
1164
+ }
1165
+
1166
+
839
1167
  export async function streamAICard(
840
1168
  card: AICardInstance,
841
1169
  content: string,
@@ -858,6 +1186,7 @@ export async function streamAICard(
858
1186
  removePendingCard(card, log);
859
1187
  } else if (card.state === AICardStatus.PROCESSING) {
860
1188
  card.state = AICardStatus.INPUTING;
1189
+ upsertPendingCard(card, card.storePath, log);
861
1190
  }
862
1191
  } catch (err: any) {
863
1192
  card.state = AICardStatus.FAILED;
@@ -873,6 +1202,14 @@ export async function streamAICard(
873
1202
  }
874
1203
  }
875
1204
 
1205
+ /**
1206
+ * Finalize AI Card via streaming API.
1207
+ *
1208
+ * @deprecated For V2 template, use `commitAICardBlocks()` instead which finalizes
1209
+ * via instances API (single call writes blockList, content, flowStatus=3).
1210
+ * This function is kept for backward compatibility with V1 template and for
1211
+ * card-stop-handler which uses streaming API for immediate stop acknowledgment.
1212
+ */
876
1213
  export async function finishAICard(
877
1214
  card: AICardInstance,
878
1215
  content: string,
@@ -1039,6 +1376,54 @@ export async function finishStoppedAICard(
1039
1376
  }
1040
1377
  }
1041
1378
 
1379
+ export async function finalizeStoppedAICard(
1380
+ card: AICardInstance,
1381
+ options: {
1382
+ reason: string;
1383
+ previousContent?: string;
1384
+ previousBlockListJson?: string;
1385
+ },
1386
+ log?: Logger,
1387
+ ): Promise<void> {
1388
+ if (isCardInTerminalState(card.state)) {
1389
+ log?.debug?.(
1390
+ `[DingTalk][AICard] finalizeStoppedAICard skipped — already terminal: ${card.state}`,
1391
+ );
1392
+ return;
1393
+ }
1394
+
1395
+ await ensureFreshToken(card, log);
1396
+ const template = DINGTALK_CARD_TEMPLATE;
1397
+ const payload = buildStoppedCardFinalizePayload(options);
1398
+ await finalizeAICardStreamingLifecycleIfNeeded(card, payload.content, log);
1399
+ try {
1400
+ await updateCardVariables(
1401
+ card.outTrackId || card.cardInstanceId,
1402
+ {
1403
+ [template.blockListKey]: payload.blockListJson,
1404
+ [template.streamingKey]: payload.content,
1405
+ [template.copyContentKey]: payload.content,
1406
+ flowStatus: 3,
1407
+ },
1408
+ card.accessToken,
1409
+ card.config,
1410
+ );
1411
+ incrementCardDapiCount(card);
1412
+ card.lastBlockListJson = payload.blockListJson;
1413
+ card.lastStreamedContent = payload.content;
1414
+ card.state = AICardStatus.STOPPED;
1415
+ card.lastUpdated = Date.now();
1416
+ removePendingCard(card, log);
1417
+ } catch (err: unknown) {
1418
+ card.lastBlockListJson = payload.blockListJson;
1419
+ card.lastStreamedContent = payload.content;
1420
+ card.state = AICardStatus.STOPPED;
1421
+ card.lastUpdated = Date.now();
1422
+ // Keep pending record for manual investigation on API failure
1423
+ throw err;
1424
+ }
1425
+ }
1426
+
1042
1427
  function cacheCardContentByProcessQueryKey(
1043
1428
  accountId: string,
1044
1429
  conversationId: string,