@soimy/dingtalk 3.5.3 → 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.
@@ -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,12 @@ 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
+
207
219
  function extractCardProcessQueryKey(payload: unknown): string | undefined {
208
220
  if (!payload || typeof payload !== "object") {
209
221
  return undefined;
@@ -270,6 +282,7 @@ async function putAICardStreamingField(
270
282
  `[DingTalk][AICard] Streaming response: status=${streamResp.status}, data=${JSON.stringify(streamResp.data)}`,
271
283
  );
272
284
  card.lastUpdated = Date.now();
285
+ incrementCardDapiCount(card);
273
286
  } catch (err: any) {
274
287
  if (err.response?.status === 401 && card.config && !tokenAlreadyRefreshed) {
275
288
  log?.warn?.("[DingTalk][AICard] Received 401 error, attempting token refresh and retry...");
@@ -286,6 +299,7 @@ async function putAICardStreamingField(
286
299
  `[DingTalk][AICard] Retry after token refresh succeeded: status=${retryResp.status}`,
287
300
  );
288
301
  card.lastUpdated = Date.now();
302
+ incrementCardDapiCount(card);
289
303
  return;
290
304
  } catch (retryErr: any) {
291
305
  log?.error?.(`[DingTalk][AICard] Retry after token refresh failed: ${retryErr.message}`);
@@ -324,6 +338,10 @@ interface CreateAICardOptions {
324
338
  storePath?: string;
325
339
  persistPending?: boolean;
326
340
  contextConversationId?: string;
341
+ /** Quote content to display in card header (shown when non-empty) */
342
+ quoteContent?: string;
343
+ /** Initial statusLine string to show on the first createAndDeliver render. */
344
+ statusLine?: string;
327
345
  }
328
346
 
329
347
  interface PendingCardRecord {
@@ -335,6 +353,8 @@ interface PendingCardRecord {
335
353
  createdAt: number;
336
354
  lastUpdated: number;
337
355
  state: string;
356
+ lastContent?: string;
357
+ lastBlockListJson?: string;
338
358
  }
339
359
 
340
360
  interface PendingCardStateFile {
@@ -371,7 +391,9 @@ function normalizePendingState(parsed: Partial<PendingCardStateFile>): PendingCa
371
391
  typeof entry.accountId === "string" &&
372
392
  typeof entry.cardInstanceId === "string" &&
373
393
  (entry.outTrackId === undefined || typeof entry.outTrackId === "string") &&
374
- typeof entry.conversationId === "string",
394
+ typeof entry.conversationId === "string" &&
395
+ (entry.lastContent === undefined || typeof entry.lastContent === "string") &&
396
+ (entry.lastBlockListJson === undefined || typeof entry.lastBlockListJson === "string"),
375
397
  ),
376
398
  ),
377
399
  };
@@ -443,6 +465,8 @@ function upsertPendingCard(card: AICardInstance, storePath?: string, log?: Logge
443
465
  createdAt: card.createdAt,
444
466
  lastUpdated: card.lastUpdated,
445
467
  state: card.state,
468
+ lastContent: card.lastStreamedContent,
469
+ lastBlockListJson: card.lastBlockListJson,
446
470
  };
447
471
  const index = state.pendingCards.findIndex((item) => item.cardInstanceId === card.cardInstanceId);
448
472
  if (index >= 0) {
@@ -454,6 +478,36 @@ function upsertPendingCard(card: AICardInstance, storePath?: string, log?: Logge
454
478
  writePendingCardState(state, storePath, log);
455
479
  }
456
480
 
481
+ function parseStoredBlockList(blockListJson?: string): CardBlock[] {
482
+ if (!blockListJson?.trim()) {
483
+ return [];
484
+ }
485
+ try {
486
+ const parsed = JSON.parse(blockListJson) as unknown;
487
+ return Array.isArray(parsed) ? (parsed as CardBlock[]) : [];
488
+ } catch {
489
+ return [];
490
+ }
491
+ }
492
+
493
+ function buildStoppedCardFinalizePayload(params: {
494
+ reason: string;
495
+ previousContent?: string;
496
+ previousBlockListJson?: string;
497
+ }): { blockListJson: string; content: string } {
498
+ const markerText = params.reason.trim();
499
+ const baseContent = params.previousContent?.trim() || "";
500
+ const blocks = parseStoredBlockList(params.previousBlockListJson);
501
+ blocks.push({ type: 0, markdown: markerText });
502
+ const content = baseContent
503
+ ? `${baseContent}\n\n---\n*${markerText}*`
504
+ : markerText;
505
+ return {
506
+ blockListJson: JSON.stringify(blocks),
507
+ content,
508
+ };
509
+ }
510
+
457
511
  function removePendingCard(card: AICardInstance, log?: Logger): void {
458
512
  if (!card.accountId || !card.storePath) {
459
513
  return;
@@ -500,6 +554,26 @@ export function isCardInTerminalState(state: string): boolean {
500
554
  );
501
555
  }
502
556
 
557
+ /**
558
+ * Ensure card access token is fresh (refresh if >90min old).
559
+ * Mutates card.accessToken in place if refreshed.
560
+ */
561
+ async function ensureFreshToken(card: AICardInstance, log?: Logger): Promise<void> {
562
+ const tokenAge = Date.now() - card.createdAt;
563
+ const tokenRefreshThreshold = 90 * 60 * 1000;
564
+
565
+ if (tokenAge > tokenRefreshThreshold && card.config) {
566
+ log?.debug?.("[DingTalk][AICard] Token age exceeds threshold, refreshing...");
567
+ try {
568
+ card.accessToken = await getAccessToken(card.config, log);
569
+ log?.debug?.("[DingTalk][AICard] Token refreshed successfully");
570
+ } catch (err: unknown) {
571
+ const msg = err instanceof Error ? err.message : String(err);
572
+ log?.warn?.(`[DingTalk][AICard] Failed to refresh token: ${msg}`);
573
+ }
574
+ }
575
+ }
576
+
503
577
  export function formatContentForCard(content: string | undefined, type: "thinking" | "tool"): string {
504
578
  if (!content) {
505
579
  return "";
@@ -657,16 +731,23 @@ async function finalizePendingCardsByAccount(
657
731
  lastUpdated: entry.lastUpdated || Date.now(),
658
732
  state: normalizeRecoveredState(entry.state),
659
733
  config,
734
+ lastStreamedContent: entry.lastContent,
735
+ lastBlockListJson: entry.lastBlockListJson,
660
736
  };
661
737
  try {
662
- await finishAICard(card, reason, log);
738
+ await finalizeStoppedAICard(card, {
739
+ reason,
740
+ previousContent: entry.lastContent,
741
+ previousBlockListJson: entry.lastBlockListJson,
742
+ }, log);
663
743
  finalizedCount += 1;
664
- } catch (err: any) {
744
+ } catch (err: unknown) {
665
745
  const action = mode === "recover" ? "recover" : "finalize";
746
+ const message = err instanceof Error ? err.message : String(err);
666
747
  log?.warn?.(
667
- `[DingTalk][AICard] Failed to ${action} active card ${entry.cardInstanceId}: ${err.message}`,
748
+ `[DingTalk][AICard] Failed to ${action} active card ${entry.cardInstanceId}: ${message}`,
668
749
  );
669
- removePendingCardById(entry.cardInstanceId, storePath, log);
750
+ // Pending record intentionally kept for manual investigation
670
751
  }
671
752
  }
672
753
  return finalizedCount;
@@ -705,8 +786,13 @@ export async function createAICard(
705
786
  // Status is set to "streaming" via the streaming API immediately after creation.
706
787
  const cardParamMap = {
707
788
  config: JSON.stringify({ autoLayout: true, enableForward: true }),
708
- [template.contentKey]: "",
709
- stop_action: STOP_ACTION_VISIBLE,
789
+ [template.streamingKey]: "",
790
+ quoteContent: options.quoteContent || "",
791
+ ...(options.statusLine?.trim() ? { statusLine: options.statusLine } : {}),
792
+ // V2 template uses hasAction (string), V1 uses stop_action (string)
793
+ // DingTalk cardParamMap requires all values to be strings
794
+ hasAction: String(STOP_ACTION_VISIBLE),
795
+ stop_action: String(STOP_ACTION_VISIBLE),
710
796
  };
711
797
  const createAndDeliverBody = {
712
798
  cardTemplateId: template.templateId,
@@ -758,6 +844,13 @@ export async function createAICard(
758
844
  cardInstanceId?: unknown;
759
845
  }
760
846
  | undefined;
847
+ const deliverResults = (responseData?.result as { deliverResults?: Array<{ success?: boolean; errorMsg?: string }> } | undefined)?.deliverResults;
848
+ if (Array.isArray(deliverResults)) {
849
+ const failedDelivery = deliverResults.find((item) => item?.success === false);
850
+ if (failedDelivery) {
851
+ throw new Error(failedDelivery.errorMsg?.trim() || "DingTalk card delivery failed");
852
+ }
853
+ }
761
854
  const responseTracking = responseData?.result;
762
855
  const processQueryKey =
763
856
  typeof responseTracking?.processQueryKey === "string" &&
@@ -793,6 +886,7 @@ export async function createAICard(
793
886
  config,
794
887
  processQueryKey: processQueryKey || extractCardProcessQueryKey(resp.data),
795
888
  outTrackId,
889
+ dapiUsage: 1,
796
890
  };
797
891
  if (shouldPersistPending) {
798
892
  upsertPendingCard(aiCardInstance, options.storePath, log);
@@ -836,6 +930,216 @@ export async function createAICard(
836
930
  }
837
931
  }
838
932
 
933
+ /**
934
+ * Update statusLine via PUT /v1.0/card/instances API.
935
+ */
936
+ export async function updateAICardStatusLine(
937
+ card: AICardInstance,
938
+ statusLine: string,
939
+ log?: Logger,
940
+ ): Promise<void> {
941
+ if (isCardInTerminalState(card.state) || !statusLine.trim()) {
942
+ return;
943
+ }
944
+
945
+ await ensureFreshToken(card, log);
946
+
947
+ try {
948
+ await updateCardVariables(
949
+ card.outTrackId || card.cardInstanceId,
950
+ { statusLine },
951
+ card.accessToken,
952
+ card.config,
953
+ );
954
+ incrementCardDapiCount(card);
955
+ card.lastUpdated = Date.now();
956
+ } catch (err: unknown) {
957
+ const message = err instanceof Error ? err.message : String(err);
958
+ log?.warn?.(`[DingTalk][AICard] StatusLine update failed: ${message}`);
959
+ }
960
+ }
961
+
962
+ export async function updateAICardBlockList(
963
+ card: AICardInstance,
964
+ blockListJson: string,
965
+ log?: Logger,
966
+ options?: { statusLine?: string },
967
+ ): Promise<void> {
968
+ if (isCardInTerminalState(card.state)) {
969
+ log?.debug?.(
970
+ `[DingTalk][AICard] Skip blockList update because card already terminal: outTrackId=${card.cardInstanceId} state=${card.state}`,
971
+ );
972
+ return;
973
+ }
974
+
975
+ // Ensure token is fresh before API call
976
+ await ensureFreshToken(card, log);
977
+
978
+ const template = DINGTALK_CARD_TEMPLATE;
979
+ const params: Record<string, unknown> = {
980
+ [template.blockListKey]: blockListJson,
981
+ };
982
+ if (options?.statusLine?.trim()) {
983
+ params.statusLine = options.statusLine;
984
+ }
985
+
986
+ try {
987
+ await updateCardVariables(
988
+ card.outTrackId || card.cardInstanceId,
989
+ params,
990
+ card.accessToken,
991
+ card.config,
992
+ );
993
+ incrementCardDapiCount(card);
994
+ card.lastBlockListJson = blockListJson;
995
+ card.lastUpdated = Date.now();
996
+ if (card.state === AICardStatus.PROCESSING) {
997
+ card.state = AICardStatus.INPUTING;
998
+ }
999
+ upsertPendingCard(card, card.storePath, log);
1000
+ } catch (err: unknown) {
1001
+ const message = err instanceof Error ? err.message : String(err);
1002
+ log?.error?.(`[DingTalk][AICard] BlockList update failed: ${message}`);
1003
+ throw err;
1004
+ }
1005
+ }
1006
+
1007
+ /**
1008
+ * Stream answer text to content key for real-time display.
1009
+ * Only used when cardRealTimeStream=true.
1010
+ * Uses streaming API because content is a simple string type.
1011
+ */
1012
+ export async function streamAICardContent(
1013
+ card: AICardInstance,
1014
+ text: string,
1015
+ log?: Logger,
1016
+ ): Promise<void> {
1017
+ if (isCardInTerminalState(card.state)) {
1018
+ return;
1019
+ }
1020
+ const template = DINGTALK_CARD_TEMPLATE;
1021
+ await putAICardStreamingField(card, template.streamingKey, text, false, log);
1022
+ card.lastStreamedContent = text;
1023
+ upsertPendingCard(card, card.storePath, log);
1024
+ }
1025
+
1026
+ /**
1027
+ * Clear the streaming content key.
1028
+ * Called when transitioning from streaming to blockList commit.
1029
+ */
1030
+ export async function clearAICardStreamingContent(
1031
+ card: AICardInstance,
1032
+ log?: Logger,
1033
+ ): Promise<void> {
1034
+ if (isCardInTerminalState(card.state)) {
1035
+ return;
1036
+ }
1037
+ const template = DINGTALK_CARD_TEMPLATE;
1038
+ try {
1039
+ await putAICardStreamingField(card, template.streamingKey, "", false, log);
1040
+ } catch (err: unknown) {
1041
+ const message = err instanceof Error ? err.message : String(err);
1042
+ log?.debug?.(`[DingTalk][AICard] Non-critical: failed to clear streaming content: ${message}`);
1043
+ }
1044
+ }
1045
+
1046
+ /**
1047
+ * Options for finalizing an AI Card via instances API.
1048
+ * All variables are written in a single API call for V2 template compatibility.
1049
+ */
1050
+ export interface FinalizeCardOptions {
1051
+ /** CardBlock[] JSON string for blockList variable */
1052
+ blockListJson: string;
1053
+ /** Pure markdown answer text for copy action (content variable) */
1054
+ content: string;
1055
+ /** Optional quoted message preview text */
1056
+ quoteContent?: string;
1057
+ /** Optional statusLine string for card template */
1058
+ statusLine?: string;
1059
+ /** Optional quoted message reference for caching */
1060
+ quotedRef?: QuotedRef;
1061
+ }
1062
+
1063
+ /**
1064
+ * Commit blocks and finalize card via single instances API call.
1065
+ * V2 template requires finalize through instances API (not streaming API).
1066
+ * Writes blockList, content, quoteContent, statusLine, and flowStatus in one call.
1067
+ */
1068
+ export async function commitAICardBlocks(
1069
+ card: AICardInstance,
1070
+ options: FinalizeCardOptions,
1071
+ log?: Logger,
1072
+ ): Promise<void> {
1073
+ if (isCardInTerminalState(card.state)) {
1074
+ log?.debug?.(
1075
+ `[DingTalk][AICard] Skip finalize because card already terminal: outTrackId=${card.cardInstanceId} state=${card.state}`,
1076
+ );
1077
+ return;
1078
+ }
1079
+
1080
+ await ensureFreshToken(card, log);
1081
+
1082
+ const template = DINGTALK_CARD_TEMPLATE;
1083
+ const updates: Record<string, unknown> = {
1084
+ [template.blockListKey]: options.blockListJson,
1085
+ [template.streamingKey]: options.content, // markdown content for display
1086
+ [template.copyContentKey]: options.content, // same markdown as String type for card copy action
1087
+ flowStatus: 3, // completed state - V2 template hides stop button automatically
1088
+ };
1089
+
1090
+ // Optional fields
1091
+ if (options.quoteContent?.trim()) {
1092
+ updates.quoteContent = options.quoteContent;
1093
+ }
1094
+ if (options.statusLine?.trim()) {
1095
+ updates.statusLine = options.statusLine;
1096
+ }
1097
+
1098
+ log?.debug?.(
1099
+ `[DingTalk][AICard] Finalizing via instances API: outTrackId=${card.outTrackId || card.cardInstanceId} ` +
1100
+ `blockListLen=${options.blockListJson.length} contentLen=${options.content.length} flowStatus=3` +
1101
+ (options.statusLine ? ` statusLine="${options.statusLine}"` : ""),
1102
+ );
1103
+
1104
+ try {
1105
+ await updateCardVariables(
1106
+ card.outTrackId || card.cardInstanceId,
1107
+ updates,
1108
+ card.accessToken,
1109
+ card.config,
1110
+ );
1111
+ incrementCardDapiCount(card);
1112
+ card.lastBlockListJson = options.blockListJson;
1113
+ card.lastStreamedContent = options.content;
1114
+ card.lastUpdated = Date.now();
1115
+ } catch (err: unknown) {
1116
+ const message = err instanceof Error ? err.message : String(err);
1117
+ log?.error?.(`[DingTalk][AICard] Finalize via instances API failed: ${message}`);
1118
+ throw err;
1119
+ }
1120
+
1121
+ // Cache card content for quote recovery
1122
+ if (card.conversationId && options.content.trim() && card.accountId && card.processQueryKey) {
1123
+ const primaryConversationId = card.contextConversationId || card.conversationId;
1124
+ cacheCardContentByProcessQueryKey(
1125
+ card.accountId,
1126
+ primaryConversationId,
1127
+ card.processQueryKey,
1128
+ options.content,
1129
+ card.storePath,
1130
+ options.quotedRef,
1131
+ log,
1132
+ );
1133
+ }
1134
+
1135
+ // Update local state
1136
+ card.state = AICardStatus.FINISHED;
1137
+ card.lastUpdated = Date.now();
1138
+ removePendingCard(card, log);
1139
+ log?.info?.(`[DingTalk][AICard] Card finalized: outTrackId=${card.outTrackId || card.cardInstanceId} state=FINISHED`);
1140
+ }
1141
+
1142
+
839
1143
  export async function streamAICard(
840
1144
  card: AICardInstance,
841
1145
  content: string,
@@ -858,6 +1162,7 @@ export async function streamAICard(
858
1162
  removePendingCard(card, log);
859
1163
  } else if (card.state === AICardStatus.PROCESSING) {
860
1164
  card.state = AICardStatus.INPUTING;
1165
+ upsertPendingCard(card, card.storePath, log);
861
1166
  }
862
1167
  } catch (err: any) {
863
1168
  card.state = AICardStatus.FAILED;
@@ -873,6 +1178,14 @@ export async function streamAICard(
873
1178
  }
874
1179
  }
875
1180
 
1181
+ /**
1182
+ * Finalize AI Card via streaming API.
1183
+ *
1184
+ * @deprecated For V2 template, use `commitAICardBlocks()` instead which finalizes
1185
+ * via instances API (single call writes blockList, content, flowStatus=3).
1186
+ * This function is kept for backward compatibility with V1 template and for
1187
+ * card-stop-handler which uses streaming API for immediate stop acknowledgment.
1188
+ */
876
1189
  export async function finishAICard(
877
1190
  card: AICardInstance,
878
1191
  content: string,
@@ -1039,6 +1352,53 @@ export async function finishStoppedAICard(
1039
1352
  }
1040
1353
  }
1041
1354
 
1355
+ export async function finalizeStoppedAICard(
1356
+ card: AICardInstance,
1357
+ options: {
1358
+ reason: string;
1359
+ previousContent?: string;
1360
+ previousBlockListJson?: string;
1361
+ },
1362
+ log?: Logger,
1363
+ ): Promise<void> {
1364
+ if (isCardInTerminalState(card.state)) {
1365
+ log?.debug?.(
1366
+ `[DingTalk][AICard] finalizeStoppedAICard skipped — already terminal: ${card.state}`,
1367
+ );
1368
+ return;
1369
+ }
1370
+
1371
+ await ensureFreshToken(card, log);
1372
+ const template = DINGTALK_CARD_TEMPLATE;
1373
+ const payload = buildStoppedCardFinalizePayload(options);
1374
+ try {
1375
+ await updateCardVariables(
1376
+ card.outTrackId || card.cardInstanceId,
1377
+ {
1378
+ [template.blockListKey]: payload.blockListJson,
1379
+ [template.streamingKey]: payload.content,
1380
+ [template.copyContentKey]: payload.content,
1381
+ flowStatus: 3,
1382
+ },
1383
+ card.accessToken,
1384
+ card.config,
1385
+ );
1386
+ incrementCardDapiCount(card);
1387
+ card.lastBlockListJson = payload.blockListJson;
1388
+ card.lastStreamedContent = payload.content;
1389
+ card.state = AICardStatus.STOPPED;
1390
+ card.lastUpdated = Date.now();
1391
+ removePendingCard(card, log);
1392
+ } catch (err: unknown) {
1393
+ card.lastBlockListJson = payload.blockListJson;
1394
+ card.lastStreamedContent = payload.content;
1395
+ card.state = AICardStatus.STOPPED;
1396
+ card.lastUpdated = Date.now();
1397
+ // Keep pending record for manual investigation on API failure
1398
+ throw err;
1399
+ }
1400
+ }
1401
+
1042
1402
  function cacheCardContentByProcessQueryKey(
1043
1403
  accountId: string,
1044
1404
  conversationId: string,