@soimy/dingtalk 3.2.0 → 3.3.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.
@@ -1,47 +1,336 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
2
4
  import axios from "axios";
3
5
  import { getAccessToken } from "./auth";
4
6
  import { stripTargetPrefix } from "./config";
5
7
  import { resolveOriginalPeerId } from "./peer-id-registry";
6
- import { formatDingTalkErrorPayloadLog } from "./utils";
8
+ import {
9
+ readNamespaceJson,
10
+ resolveNamespacePath,
11
+ writeNamespaceJsonAtomic,
12
+ } from "./persistence-store";
7
13
  import type {
8
14
  AICardInstance,
9
15
  AICardStreamingRequest,
10
16
  DingTalkConfig,
17
+ DingTalkTrackingMetadata,
11
18
  Logger,
12
19
  } from "./types";
13
20
  import { AICardStatus } from "./types";
21
+ import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
14
22
 
15
23
  const DINGTALK_API = "https://api.dingtalk.com";
16
24
  // Thinking/tool stream snippets are truncated to keep card updates compact.
17
- const THINKING_TRUNCATE_LENGTH = 500;
25
+ const CARD_STATE_FILE_VERSION = 1;
26
+ const CARD_PENDING_NAMESPACE = "cards.active.pending";
27
+ const CARD_PROCESS_QUERY_NAMESPACE = "cards.content.quote-process-query";
28
+ const RECOVERY_FINALIZE_MESSAGE = "⚠️ 上一次回复处理中断,已自动结束。请重新发送你的问题。";
29
+ const AICARD_DEGRADE_DEFAULT_MS = 30 * 60 * 1000;
30
+
31
+ const aicardDegradeByAccount = new Map<string, { untilMs: number; reason: string }>();
32
+
33
+ function getAICardDegradeMs(config?: DingTalkConfig): number {
34
+ const raw = config?.aicardDegradeMs;
35
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 60_000) {
36
+ return raw;
37
+ }
38
+ return AICARD_DEGRADE_DEFAULT_MS;
39
+ }
40
+
41
+ function normalizeDegradeErrorMessage(value: string): string {
42
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
43
+ }
44
+
45
+ function shouldTriggerAICardDegrade(err: unknown): boolean {
46
+ const maybeErr = err as {
47
+ response?: { status?: number; data?: { message?: string } };
48
+ message?: string;
49
+ };
50
+ const status = maybeErr.response?.status;
51
+ const msg = normalizeDegradeErrorMessage(
52
+ String(maybeErr.response?.data?.message || maybeErr.message || ""),
53
+ );
54
+ if (status === 403 || status === 429) {
55
+ return true;
56
+ }
57
+ if (typeof status === "number" && status >= 500 && status < 600) {
58
+ return true;
59
+ }
60
+ return [
61
+ "ipnotinwhitelist",
62
+ "forbiddenaccessdenied",
63
+ "timeout",
64
+ "etimedout",
65
+ "econnreset",
66
+ "eaiagain",
67
+ "sockethangup",
68
+ "badgateway",
69
+ ].some((keyword) => msg.includes(keyword));
70
+ }
71
+
72
+ export function isAICardDegraded(accountId: string): boolean {
73
+ const state = aicardDegradeByAccount.get(accountId);
74
+ if (!state) {
75
+ return false;
76
+ }
77
+ if (Date.now() >= state.untilMs) {
78
+ aicardDegradeByAccount.delete(accountId);
79
+ return false;
80
+ }
81
+ return true;
82
+ }
83
+
84
+ export function getAICardDegradeState(
85
+ accountId: string,
86
+ ): { remainingMs: number; reason: string } | null {
87
+ const state = aicardDegradeByAccount.get(accountId);
88
+ if (!state) {
89
+ return null;
90
+ }
91
+ const remainingMs = state.untilMs - Date.now();
92
+ if (remainingMs <= 0) {
93
+ aicardDegradeByAccount.delete(accountId);
94
+ return null;
95
+ }
96
+ return { remainingMs, reason: state.reason };
97
+ }
98
+
99
+ export function activateAICardDegrade(
100
+ accountId: string,
101
+ reason: string,
102
+ config?: DingTalkConfig,
103
+ log?: Logger,
104
+ ): void {
105
+ const durationMs = getAICardDegradeMs(config);
106
+ const untilMs = Date.now() + durationMs;
107
+ const existed = isAICardDegraded(accountId);
108
+ aicardDegradeByAccount.set(accountId, { untilMs, reason });
109
+ const minutes = Math.round(durationMs / 60000);
110
+ if (existed) {
111
+ log?.warn?.(
112
+ `[DingTalk][AICard][Degrade] Extended for account=${accountId}, minutes=${minutes}, reason=${reason}`,
113
+ );
114
+ } else {
115
+ log?.warn?.(
116
+ `[DingTalk][AICard][Degrade] Activated for account=${accountId}, minutes=${minutes}, reason=${reason}`,
117
+ );
118
+ }
119
+ }
120
+
121
+ export function clearAICardDegrade(accountId: string, log?: Logger): void {
122
+ if (!aicardDegradeByAccount.has(accountId)) {
123
+ return;
124
+ }
125
+ const reason = aicardDegradeByAccount.get(accountId)?.reason || "";
126
+ aicardDegradeByAccount.delete(accountId);
127
+ log?.info?.(`[DingTalk][AICard][Degrade] Cleared for account=${accountId}, lastReason=${reason}`);
128
+ }
129
+
130
+ function extractCardProcessQueryKey(payload: unknown): string | undefined {
131
+ if (!payload || typeof payload !== "object") {
132
+ return undefined;
133
+ }
134
+ const data = payload as Record<string, any>;
135
+ const deliverResults = data.result?.deliverResults;
136
+ if (Array.isArray(deliverResults)) {
137
+ for (const item of deliverResults) {
138
+ if (typeof item?.carrierId === "string" && item.carrierId.trim()) {
139
+ return item.carrierId.trim();
140
+ }
141
+ }
142
+ }
143
+ return undefined;
144
+ }
145
+
146
+ interface CreateAICardOptions {
147
+ accountId?: string;
148
+ storePath?: string;
149
+ persistPending?: boolean;
150
+ }
151
+
152
+ interface PendingCardRecord {
153
+ accountId: string;
154
+ cardInstanceId: string;
155
+ outTrackId?: string;
156
+ conversationId: string;
157
+ createdAt: number;
158
+ lastUpdated: number;
159
+ state: string;
160
+ }
161
+
162
+ interface PendingCardStateFile {
163
+ version: number;
164
+ updatedAt: number;
165
+ pendingCards: PendingCardRecord[];
166
+ }
167
+
168
+ function getCardStateFilePath(storePath?: string): string | null {
169
+ if (!storePath) {
170
+ return null;
171
+ }
172
+ return resolveNamespacePath(CARD_PENDING_NAMESPACE, {
173
+ storePath,
174
+ format: "json",
175
+ });
176
+ }
177
+
178
+ function getLegacyCardStateFilePath(storePath?: string): string | null {
179
+ if (!storePath) {
180
+ return null;
181
+ }
182
+ return path.join(path.dirname(storePath), "dingtalk-active-cards.json");
183
+ }
184
+
185
+ function normalizePendingState(parsed: Partial<PendingCardStateFile>): PendingCardStateFile {
186
+ const records = Array.isArray(parsed.pendingCards) ? parsed.pendingCards : [];
187
+ return {
188
+ version: typeof parsed.version === "number" ? parsed.version : CARD_STATE_FILE_VERSION,
189
+ updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
190
+ pendingCards: records.filter((entry): entry is PendingCardRecord =>
191
+ Boolean(
192
+ entry &&
193
+ typeof entry.accountId === "string" &&
194
+ typeof entry.cardInstanceId === "string" &&
195
+ (entry.outTrackId === undefined || typeof entry.outTrackId === "string") &&
196
+ typeof entry.conversationId === "string",
197
+ ),
198
+ ),
199
+ };
200
+ }
201
+
202
+ function readPendingCardState(storePath?: string, log?: Logger): PendingCardStateFile {
203
+ if (!storePath) {
204
+ return { version: CARD_STATE_FILE_VERSION, updatedAt: Date.now(), pendingCards: [] };
205
+ }
206
+
207
+ const filePath = getCardStateFilePath(storePath);
208
+ const legacyPath = getLegacyCardStateFilePath(storePath);
209
+
210
+ if (filePath && fs.existsSync(filePath)) {
211
+ const parsed = readNamespaceJson<Partial<PendingCardStateFile>>(CARD_PENDING_NAMESPACE, {
212
+ storePath,
213
+ format: "json",
214
+ fallback: {},
215
+ log,
216
+ });
217
+ return normalizePendingState(parsed);
218
+ }
219
+
220
+ try {
221
+ if (!legacyPath || !fs.existsSync(legacyPath)) {
222
+ return { version: CARD_STATE_FILE_VERSION, updatedAt: Date.now(), pendingCards: [] };
223
+ }
224
+ const raw = fs.readFileSync(legacyPath, "utf-8");
225
+ if (!raw.trim()) {
226
+ return { version: CARD_STATE_FILE_VERSION, updatedAt: Date.now(), pendingCards: [] };
227
+ }
228
+ const normalized = normalizePendingState(JSON.parse(raw) as Partial<PendingCardStateFile>);
229
+ writePendingCardState(normalized, storePath, log);
230
+ return normalized;
231
+ } catch (err: unknown) {
232
+ const message = err instanceof Error ? err.message : String(err);
233
+ log?.warn?.(`[DingTalk][AICard] Failed to read pending card state: ${message}`);
234
+ return { version: CARD_STATE_FILE_VERSION, updatedAt: Date.now(), pendingCards: [] };
235
+ }
236
+ }
237
+
238
+ function writePendingCardState(
239
+ state: PendingCardStateFile,
240
+ storePath?: string,
241
+ log?: Logger,
242
+ ): void {
243
+ if (!storePath) {
244
+ return;
245
+ }
246
+ writeNamespaceJsonAtomic(CARD_PENDING_NAMESPACE, {
247
+ storePath,
248
+ format: "json",
249
+ data: state,
250
+ log,
251
+ });
252
+ }
253
+
254
+ function upsertPendingCard(card: AICardInstance, storePath?: string, log?: Logger): void {
255
+ if (!card.accountId || !storePath) {
256
+ return;
257
+ }
258
+ const state = readPendingCardState(storePath, log);
259
+ const next: PendingCardRecord = {
260
+ accountId: card.accountId,
261
+ cardInstanceId: card.cardInstanceId,
262
+ outTrackId: card.outTrackId,
263
+ conversationId: card.conversationId,
264
+ createdAt: card.createdAt,
265
+ lastUpdated: card.lastUpdated,
266
+ state: card.state,
267
+ };
268
+ const index = state.pendingCards.findIndex((item) => item.cardInstanceId === card.cardInstanceId);
269
+ if (index >= 0) {
270
+ state.pendingCards[index] = next;
271
+ } else {
272
+ state.pendingCards.push(next);
273
+ }
274
+ state.updatedAt = Date.now();
275
+ writePendingCardState(state, storePath, log);
276
+ }
277
+
278
+ function removePendingCard(card: AICardInstance, log?: Logger): void {
279
+ if (!card.accountId || !card.storePath) {
280
+ return;
281
+ }
282
+ removePendingCardById(card.cardInstanceId, card.storePath, log);
283
+ }
284
+
285
+ function removePendingCardById(cardInstanceId: string, storePath?: string, log?: Logger): void {
286
+ if (!storePath) {
287
+ return;
288
+ }
289
+ const state = readPendingCardState(storePath, log);
290
+ const remaining = state.pendingCards.filter((item) => item.cardInstanceId !== cardInstanceId);
291
+ if (remaining.length === state.pendingCards.length) {
292
+ return;
293
+ }
294
+ state.pendingCards = remaining;
295
+ state.updatedAt = Date.now();
296
+ writePendingCardState(state, storePath, log);
297
+ }
298
+
299
+ function listPendingCardsByAccount(
300
+ accountId: string,
301
+ storePath?: string,
302
+ log?: Logger,
303
+ ): PendingCardRecord[] {
304
+ const state = readPendingCardState(storePath, log);
305
+ return state.pendingCards.filter((item) => item.accountId === accountId);
306
+ }
307
+
308
+ function normalizeRecoveredState(state: string): AICardInstance["state"] {
309
+ if (state === AICardStatus.PROCESSING || state === AICardStatus.INPUTING) {
310
+ return state;
311
+ }
312
+ return AICardStatus.PROCESSING;
313
+ }
18
314
 
19
315
  // Helper to identify card terminal states.
20
316
  export function isCardInTerminalState(state: string): boolean {
21
317
  return state === AICardStatus.FINISHED || state === AICardStatus.FAILED;
22
318
  }
23
319
 
24
- export function formatContentForCard(content: string, type: "thinking" | "tool"): string {
320
+ export function formatContentForCard(content: string | undefined, type: "thinking" | "tool"): string {
25
321
  if (!content) {
26
322
  return "";
27
323
  }
28
324
 
29
- // Truncate to configured length and keep a visual ellipsis when truncated.
30
- const truncated =
31
- content.slice(0, THINKING_TRUNCATE_LENGTH) +
32
- (content.length > THINKING_TRUNCATE_LENGTH ? "…" : "");
325
+ const emoji = type === "thinking" ? "🤔" : "🛠️";
326
+ const label = type === "thinking" ? "思考中" : "工具执行";
33
327
 
34
- // Quote each line to improve readability in markdown card content.
35
- const quotedLines = truncated
328
+ const escaped = content
36
329
  .split("\n")
37
330
  .map((line) => line.replace(/^_(?=[^ ])/, "*").replace(/(?<=[^ ])_(?=$)/, "*"))
38
- .map((line) => `> ${line}`)
39
331
  .join("\n");
40
332
 
41
- const emoji = type === "thinking" ? "🤔" : "🛠️";
42
- const label = type === "thinking" ? "思考中" : "工具执行";
43
-
44
- return `${emoji} **${label}**\n${quotedLines}`;
333
+ return `${emoji} **${label}**\n\n${escaped}`;
45
334
  }
46
335
 
47
336
  async function sendTemplateMismatchNotification(
@@ -95,26 +384,128 @@ export async function sendProactiveCardText(
95
384
  conversationId: string,
96
385
  content: string,
97
386
  log?: Logger,
98
- ): Promise<{ ok: boolean; error?: string }> {
387
+ ): Promise<{ ok: boolean; error?: string } & DingTalkTrackingMetadata> {
99
388
  try {
100
- const card = await createAICard(config, conversationId, log);
389
+ const card = await createAICard(config, conversationId, log, { persistPending: false });
101
390
  if (!card) {
102
391
  return { ok: false, error: "Failed to create AI card" };
103
392
  }
104
393
  await finishAICard(card, content, log);
105
- return { ok: true };
394
+ return {
395
+ ok: true,
396
+ processQueryKey: card.processQueryKey,
397
+ outTrackId: card.outTrackId,
398
+ cardInstanceId: card.cardInstanceId,
399
+ };
106
400
  } catch (err: any) {
107
401
  log?.error?.(`[DingTalk][AICard] Proactive card send failed: ${err.message}`);
108
402
  return { ok: false, error: err.message };
109
403
  }
110
404
  }
111
405
 
406
+ export async function recoverPendingCardsForAccount(
407
+ config: DingTalkConfig,
408
+ accountId: string,
409
+ storePath?: string,
410
+ log?: Logger,
411
+ ): Promise<number> {
412
+ return finalizePendingCardsByAccount(
413
+ config,
414
+ accountId,
415
+ RECOVERY_FINALIZE_MESSAGE,
416
+ storePath,
417
+ "recover",
418
+ log,
419
+ );
420
+ }
421
+
422
+ export async function finalizeActiveCardsForAccount(
423
+ config: DingTalkConfig,
424
+ accountId: string,
425
+ reason: string,
426
+ storePath?: string,
427
+ log?: Logger,
428
+ ): Promise<number> {
429
+ return finalizePendingCardsByAccount(config, accountId, reason, storePath, "finalize", log);
430
+ }
431
+
432
+ async function finalizePendingCardsByAccount(
433
+ config: DingTalkConfig,
434
+ accountId: string,
435
+ reason: string,
436
+ storePath: string | undefined,
437
+ mode: "recover" | "finalize",
438
+ log?: Logger,
439
+ ): Promise<number> {
440
+ if (!storePath) {
441
+ return 0;
442
+ }
443
+
444
+ const pendingCards = listPendingCardsByAccount(accountId, storePath, log).filter(
445
+ (item) => !isCardInTerminalState(item.state),
446
+ );
447
+ if (pendingCards.length === 0) {
448
+ return 0;
449
+ }
450
+
451
+ let token = "";
452
+ try {
453
+ token = await getAccessToken(config, log);
454
+ } catch (err: any) {
455
+ const tokenFailureScope =
456
+ mode === "recover" ? "pending card recovery" : "finalizing active cards";
457
+ log?.warn?.(
458
+ `[DingTalk][AICard] Failed to fetch token for ${tokenFailureScope}: ${err.message}`,
459
+ );
460
+ return 0;
461
+ }
462
+
463
+ let finalizedCount = 0;
464
+ for (const entry of pendingCards) {
465
+ const card: AICardInstance = {
466
+ cardInstanceId: entry.cardInstanceId,
467
+ accessToken: token,
468
+ conversationId: entry.conversationId,
469
+ accountId: entry.accountId,
470
+ storePath,
471
+ outTrackId: entry.outTrackId,
472
+ createdAt: entry.createdAt || Date.now(),
473
+ lastUpdated: entry.lastUpdated || Date.now(),
474
+ state: normalizeRecoveredState(entry.state),
475
+ config,
476
+ };
477
+ try {
478
+ await finishAICard(card, reason, log);
479
+ finalizedCount += 1;
480
+ } catch (err: any) {
481
+ const action = mode === "recover" ? "recover" : "finalize";
482
+ log?.warn?.(
483
+ `[DingTalk][AICard] Failed to ${action} active card ${entry.cardInstanceId}: ${err.message}`,
484
+ );
485
+ removePendingCardById(entry.cardInstanceId, storePath, log);
486
+ }
487
+ }
488
+ return finalizedCount;
489
+ }
490
+
112
491
  export async function createAICard(
113
492
  config: DingTalkConfig,
114
493
  conversationId: string,
115
494
  log?: Logger,
495
+ options: CreateAICardOptions = {},
116
496
  ): Promise<AICardInstance | null> {
497
+ const accountId = options.accountId ?? "default";
498
+ if (isAICardDegraded(accountId)) {
499
+ const state = getAICardDegradeState(accountId);
500
+ log?.warn?.(
501
+ `[DingTalk][AICard][Degrade] Skip create for account=${accountId}, remainingMs=${state?.remainingMs || 0}, reason=${state?.reason || "unknown"}`,
502
+ );
503
+ return null;
504
+ }
505
+
117
506
  try {
507
+ const shouldPersistPending =
508
+ options.persistPending ?? Boolean(options.accountId && options.storePath);
118
509
  const token = await getAccessToken(config, log);
119
510
  // Use randomUUID to avoid collisions across workers/restarts.
120
511
  const cardInstanceId = `card_${randomUUID()}`;
@@ -129,11 +520,15 @@ export async function createAICard(
129
520
 
130
521
  // DingTalk createAndDeliver API payload.
131
522
  const cardTemplateKey = config.cardTemplateKey || "content";
523
+ const cardParamMap = {
524
+ config: JSON.stringify({ autoLayout: true, enableForward: true }),
525
+ [cardTemplateKey]: "",
526
+ };
132
527
  const createAndDeliverBody = {
133
528
  cardTemplateId: config.cardTemplateId,
134
529
  outTrackId: cardInstanceId,
135
530
  cardData: {
136
- cardParamMap: { [cardTemplateKey]: "" },
531
+ cardParamMap,
137
532
  },
138
533
  callbackType: "STREAM",
139
534
  imGroupOpenSpaceModel: { supportForward: true },
@@ -165,23 +560,60 @@ export async function createAICard(
165
560
  createAndDeliverBody,
166
561
  {
167
562
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
563
+ ...getProxyBypassOption(config),
168
564
  },
169
565
  );
170
566
  log?.debug?.(
171
567
  `[DingTalk][AICard] CreateAndDeliver response: status=${resp.status} data=${JSON.stringify(resp.data)}`,
172
568
  );
569
+ const responseData = resp.data as
570
+ | {
571
+ result?: DingTalkTrackingMetadata;
572
+ processQueryKey?: unknown;
573
+ outTrackId?: unknown;
574
+ cardInstanceId?: unknown;
575
+ }
576
+ | undefined;
577
+ const responseTracking = responseData?.result;
578
+ const processQueryKey =
579
+ typeof responseTracking?.processQueryKey === "string" &&
580
+ responseTracking.processQueryKey.trim()
581
+ ? responseTracking.processQueryKey.trim()
582
+ : typeof responseData?.processQueryKey === "string" && responseData.processQueryKey.trim()
583
+ ? responseData.processQueryKey.trim()
584
+ : undefined;
585
+ const outTrackId =
586
+ typeof responseTracking?.outTrackId === "string" && responseTracking.outTrackId.trim()
587
+ ? responseTracking.outTrackId.trim()
588
+ : typeof responseData?.outTrackId === "string" && responseData.outTrackId.trim()
589
+ ? responseData.outTrackId.trim()
590
+ : cardInstanceId;
591
+ const resolvedCardInstanceId =
592
+ typeof responseTracking?.cardInstanceId === "string" && responseTracking.cardInstanceId.trim()
593
+ ? responseTracking.cardInstanceId.trim()
594
+ : typeof responseData?.cardInstanceId === "string" && responseData.cardInstanceId.trim()
595
+ ? responseData.cardInstanceId.trim()
596
+ : cardInstanceId;
173
597
 
174
598
  // Return the AI card instance with config reference for token refresh/recovery.
175
599
  const aiCardInstance: AICardInstance = {
176
- cardInstanceId,
600
+ cardInstanceId: resolvedCardInstanceId,
177
601
  accessToken: token,
178
602
  conversationId,
603
+ accountId,
604
+ storePath: options.storePath,
179
605
  createdAt: Date.now(),
180
606
  lastUpdated: Date.now(),
181
607
  state: AICardStatus.PROCESSING,
182
608
  config,
609
+ processQueryKey: processQueryKey || extractCardProcessQueryKey(resp.data),
610
+ outTrackId,
183
611
  };
612
+ if (shouldPersistPending) {
613
+ upsertPendingCard(aiCardInstance, options.storePath, log);
614
+ }
184
615
 
616
+ clearAICardDegrade(accountId, log);
185
617
  return aiCardInstance;
186
618
  } catch (err: any) {
187
619
  log?.error?.(`[DingTalk][AICard] Create failed: ${err.message}`);
@@ -194,6 +626,14 @@ export async function createAICard(
194
626
  formatDingTalkErrorPayloadLog("card.create", err.response.data, "[DingTalk][AICard]"),
195
627
  );
196
628
  }
629
+ if (shouldTriggerAICardDegrade(err)) {
630
+ activateAICardDegrade(
631
+ accountId,
632
+ `card.create:${err?.response?.status || "unknown"}`,
633
+ config,
634
+ log,
635
+ );
636
+ }
197
637
  return null;
198
638
  }
199
639
  }
@@ -227,7 +667,7 @@ export async function streamAICard(
227
667
 
228
668
  // Always use full replacement to make client rendering deterministic.
229
669
  const streamBody: AICardStreamingRequest = {
230
- outTrackId: card.cardInstanceId,
670
+ outTrackId: card.outTrackId || card.cardInstanceId,
231
671
  guid: randomUUID(),
232
672
  key: card.config?.cardTemplateKey || "content",
233
673
  content: content,
@@ -246,6 +686,7 @@ export async function streamAICard(
246
686
  "x-acs-dingtalk-access-token": card.accessToken,
247
687
  "Content-Type": "application/json",
248
688
  },
689
+ ...(card.config ? getProxyBypassOption(card.config) : {}),
249
690
  });
250
691
  log?.debug?.(
251
692
  `[DingTalk][AICard] Streaming response: status=${streamResp.status}, data=${JSON.stringify(streamResp.data)}`,
@@ -255,6 +696,7 @@ export async function streamAICard(
255
696
  card.lastStreamedContent = content;
256
697
  if (finished) {
257
698
  card.state = AICardStatus.FINISHED;
699
+ removePendingCard(card, log);
258
700
  } else if (card.state === AICardStatus.PROCESSING) {
259
701
  card.state = AICardStatus.INPUTING;
260
702
  }
@@ -279,6 +721,7 @@ export async function streamAICard(
279
721
 
280
722
  card.state = AICardStatus.FAILED;
281
723
  card.lastUpdated = Date.now();
724
+ removePendingCard(card, log);
282
725
  await sendTemplateMismatchNotification(card, errorMsg, log);
283
726
  throw err;
284
727
  }
@@ -293,6 +736,7 @@ export async function streamAICard(
293
736
  "x-acs-dingtalk-access-token": card.accessToken,
294
737
  "Content-Type": "application/json",
295
738
  },
739
+ ...(card.config ? getProxyBypassOption(card.config) : {}),
296
740
  });
297
741
  log?.debug?.(
298
742
  `[DingTalk][AICard] Retry after token refresh succeeded: status=${retryResp.status}`,
@@ -301,6 +745,7 @@ export async function streamAICard(
301
745
  card.lastStreamedContent = content;
302
746
  if (finished) {
303
747
  card.state = AICardStatus.FINISHED;
748
+ removePendingCard(card, log);
304
749
  } else if (card.state === AICardStatus.PROCESSING) {
305
750
  card.state = AICardStatus.INPUTING;
306
751
  }
@@ -321,9 +766,16 @@ export async function streamAICard(
321
766
 
322
767
  card.state = AICardStatus.FAILED;
323
768
  card.lastUpdated = Date.now();
324
- log?.error?.(
325
- `[DingTalk][AICard] Streaming update failed: ${err.message}`,
326
- );
769
+ removePendingCard(card, log);
770
+ if (card.accountId && shouldTriggerAICardDegrade(err)) {
771
+ activateAICardDegrade(
772
+ card.accountId,
773
+ `card.stream:${err?.response?.status || "unknown"}`,
774
+ card.config,
775
+ log,
776
+ );
777
+ }
778
+ log?.error?.(`[DingTalk][AICard] Streaming update failed: ${err.message}`);
327
779
  if (err.response?.data !== undefined) {
328
780
  log?.error?.(
329
781
  formatDingTalkErrorPayloadLog("card.stream", err.response.data, "[DingTalk][AICard]"),
@@ -339,6 +791,306 @@ export async function finishAICard(
339
791
  log?: Logger,
340
792
  ): Promise<void> {
341
793
  log?.debug?.(`[DingTalk][AICard] Starting finish, final content length=${content.length}`);
342
- // Finalize by streaming one last full payload with isFinalize=true.
343
794
  await streamAICard(card, content, true, log);
795
+ if (card.conversationId && content.trim() && card.accountId && card.processQueryKey) {
796
+ cacheCardContentByProcessQueryKey(
797
+ card.accountId,
798
+ card.conversationId,
799
+ card.processQueryKey,
800
+ content,
801
+ card.storePath,
802
+ );
803
+ }
804
+ }
805
+
806
+ interface ProcessQueryCardContentEntry {
807
+ content: string;
808
+ createdAt: number;
809
+ expiresAt: number;
810
+ }
811
+
812
+ interface PersistedProcessQueryCardContent {
813
+ updatedAt: number;
814
+ entries: Record<string, ProcessQueryCardContentEntry>;
815
+ }
816
+
817
+ function readProcessQueryCardContent(
818
+ accountId: string,
819
+ conversationId: string,
820
+ storePath?: string,
821
+ ): PersistedProcessQueryCardContent {
822
+ if (!storePath) {
823
+ return { updatedAt: 0, entries: {} };
824
+ }
825
+ return readNamespaceJson<PersistedProcessQueryCardContent>(CARD_PROCESS_QUERY_NAMESPACE, {
826
+ storePath,
827
+ scope: { accountId, conversationId },
828
+ format: "json",
829
+ fallback: { updatedAt: 0, entries: {} },
830
+ });
831
+ }
832
+
833
+ function writeProcessQueryCardContent(
834
+ accountId: string,
835
+ conversationId: string,
836
+ data: PersistedProcessQueryCardContent,
837
+ storePath?: string,
838
+ ): void {
839
+ if (!storePath) {
840
+ return;
841
+ }
842
+ writeNamespaceJsonAtomic(CARD_PROCESS_QUERY_NAMESPACE, {
843
+ storePath,
844
+ scope: { accountId, conversationId },
845
+ format: "json",
846
+ data,
847
+ });
848
+ }
849
+
850
+ function purgeExpiredProcessQueryEntries(
851
+ persisted: PersistedProcessQueryCardContent,
852
+ nowMs: number,
853
+ ): boolean {
854
+ let changed = false;
855
+ for (const [key, entry] of Object.entries(persisted.entries)) {
856
+ if (!entry || typeof entry.expiresAt !== "number" || nowMs >= entry.expiresAt) {
857
+ delete persisted.entries[key];
858
+ changed = true;
859
+ }
860
+ }
861
+ return changed;
862
+ }
863
+
864
+ export function cacheCardContentByProcessQueryKey(
865
+ accountId: string,
866
+ conversationId: string,
867
+ processQueryKey: string,
868
+ content: string,
869
+ storePath?: string,
870
+ ): void {
871
+ if (!processQueryKey.trim() || !content.trim() || !storePath) {
872
+ return;
873
+ }
874
+ const nowMs = Date.now();
875
+ const persisted = readProcessQueryCardContent(accountId, conversationId, storePath);
876
+ purgeExpiredProcessQueryEntries(persisted, nowMs);
877
+ persisted.entries[processQueryKey] = {
878
+ content,
879
+ createdAt: nowMs,
880
+ expiresAt: nowMs + CARD_CACHE_TTL_MS,
881
+ };
882
+ persisted.updatedAt = nowMs;
883
+ writeProcessQueryCardContent(accountId, conversationId, persisted, storePath);
884
+ }
885
+
886
+ export function getCardContentByProcessQueryKey(
887
+ accountId: string,
888
+ conversationId: string,
889
+ processQueryKey: string,
890
+ storePath?: string,
891
+ ): string | null {
892
+ if (!processQueryKey.trim() || !storePath) {
893
+ return null;
894
+ }
895
+ const nowMs = Date.now();
896
+ const persisted = readProcessQueryCardContent(accountId, conversationId, storePath);
897
+ const changed = purgeExpiredProcessQueryEntries(persisted, nowMs);
898
+ const entry = persisted.entries[processQueryKey];
899
+ if (!entry) {
900
+ if (changed) {
901
+ persisted.updatedAt = nowMs;
902
+ writeProcessQueryCardContent(accountId, conversationId, persisted, storePath);
903
+ }
904
+ return null;
905
+ }
906
+ return entry.content;
907
+ }
908
+
909
+ // ============ Card content cache (for quoted card lookup) ============
910
+
911
+ const CARD_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
912
+ const CARD_CACHE_MAX_PER_CONVERSATION = 20;
913
+ const CARD_CACHE_MAX_CONVERSATIONS = 500;
914
+ const CARD_CACHE_MATCH_WINDOW_MS = 2000;
915
+ const CARD_CONTENT_NAMESPACE = "cards.content.quote-lookup";
916
+
917
+ interface CardContentEntry {
918
+ content: string;
919
+ createdAt: number;
920
+ expiresAt: number;
921
+ }
922
+
923
+ interface CardConversationBucket {
924
+ entries: CardContentEntry[];
925
+ lastActiveAt: number;
926
+ }
927
+
928
+ interface PersistedCardContentBucket {
929
+ updatedAt: number;
930
+ entries: CardContentEntry[];
931
+ }
932
+
933
+ const cardContentStore = new Map<string, CardConversationBucket>();
934
+
935
+ function loadCardContentBucketFromPersistence(
936
+ accountId: string,
937
+ conversationId: string,
938
+ storePath?: string,
939
+ ): CardConversationBucket | null {
940
+ if (!storePath) {
941
+ return null;
942
+ }
943
+ const persisted = readNamespaceJson<PersistedCardContentBucket>(CARD_CONTENT_NAMESPACE, {
944
+ storePath,
945
+ scope: { accountId, conversationId },
946
+ format: "json",
947
+ fallback: { updatedAt: 0, entries: [] },
948
+ });
949
+ if (!Array.isArray(persisted.entries) || persisted.entries.length === 0) {
950
+ return null;
951
+ }
952
+
953
+ const now = Date.now();
954
+ const entries: CardContentEntry[] = [];
955
+ for (const entry of persisted.entries) {
956
+ if (
957
+ !entry ||
958
+ typeof entry.content !== "string" ||
959
+ typeof entry.createdAt !== "number" ||
960
+ typeof entry.expiresAt !== "number" ||
961
+ now >= entry.expiresAt
962
+ ) {
963
+ continue;
964
+ }
965
+ const insertAt = entries.findIndex((item) => item.createdAt > entry.createdAt);
966
+ if (insertAt < 0) {
967
+ entries.push(entry);
968
+ } else {
969
+ entries.splice(insertAt, 0, entry);
970
+ }
971
+ }
972
+ const normalizedEntries = entries.slice(-CARD_CACHE_MAX_PER_CONVERSATION);
973
+
974
+ if (normalizedEntries.length === 0) {
975
+ return null;
976
+ }
977
+ return {
978
+ entries: normalizedEntries,
979
+ lastActiveAt: now,
980
+ };
981
+ }
982
+
983
+ function persistCardContentBucket(
984
+ accountId: string,
985
+ conversationId: string,
986
+ bucket: CardConversationBucket,
987
+ storePath?: string,
988
+ ): void {
989
+ if (!storePath) {
990
+ return;
991
+ }
992
+ writeNamespaceJsonAtomic(CARD_CONTENT_NAMESPACE, {
993
+ storePath,
994
+ scope: { accountId, conversationId },
995
+ format: "json",
996
+ data: {
997
+ updatedAt: Date.now(),
998
+ entries: bucket.entries,
999
+ } satisfies PersistedCardContentBucket,
1000
+ });
1001
+ }
1002
+
1003
+ export function cacheCardContent(
1004
+ accountId: string,
1005
+ conversationId: string,
1006
+ content: string,
1007
+ createdAt: number,
1008
+ storePath?: string,
1009
+ ): void {
1010
+ const scopedKey = `${accountId}:${conversationId}`;
1011
+ let bucket = cardContentStore.get(scopedKey);
1012
+ if (!bucket) {
1013
+ bucket = { entries: [], lastActiveAt: Date.now() };
1014
+ cardContentStore.set(scopedKey, bucket);
1015
+ if (cardContentStore.size > CARD_CACHE_MAX_CONVERSATIONS) {
1016
+ let oldestKey: string | undefined;
1017
+ let oldestTime = Infinity;
1018
+ for (const [key, b] of cardContentStore) {
1019
+ if (b.lastActiveAt < oldestTime) {
1020
+ oldestTime = b.lastActiveAt;
1021
+ oldestKey = key;
1022
+ }
1023
+ }
1024
+ if (oldestKey) {
1025
+ cardContentStore.delete(oldestKey);
1026
+ }
1027
+ }
1028
+ }
1029
+ bucket.lastActiveAt = Date.now();
1030
+
1031
+ const now = Date.now();
1032
+ bucket.entries = bucket.entries.filter((e) => now < e.expiresAt);
1033
+
1034
+ bucket.entries.push({ content, createdAt, expiresAt: now + CARD_CACHE_TTL_MS });
1035
+
1036
+ if (bucket.entries.length > CARD_CACHE_MAX_PER_CONVERSATION) {
1037
+ bucket.entries.sort((a, b) => a.createdAt - b.createdAt);
1038
+ bucket.entries = bucket.entries.slice(-CARD_CACHE_MAX_PER_CONVERSATION);
1039
+ }
1040
+
1041
+ persistCardContentBucket(accountId, conversationId, bucket, storePath);
1042
+ }
1043
+
1044
+ export function findCardContent(
1045
+ accountId: string,
1046
+ conversationId: string,
1047
+ repliedCreatedAt: number,
1048
+ storePath?: string,
1049
+ ): string | null {
1050
+ const scopedKey = `${accountId}:${conversationId}`;
1051
+ let bucket = cardContentStore.get(scopedKey);
1052
+ if (!bucket && storePath) {
1053
+ const loaded = loadCardContentBucketFromPersistence(accountId, conversationId, storePath);
1054
+ if (loaded) {
1055
+ cardContentStore.set(scopedKey, loaded);
1056
+ bucket = loaded;
1057
+ if (cardContentStore.size > CARD_CACHE_MAX_CONVERSATIONS) {
1058
+ let oldestKey: string | undefined;
1059
+ let oldestTime = Infinity;
1060
+ for (const [key, b] of cardContentStore) {
1061
+ if (b.lastActiveAt < oldestTime) {
1062
+ oldestTime = b.lastActiveAt;
1063
+ oldestKey = key;
1064
+ }
1065
+ }
1066
+ if (oldestKey) {
1067
+ cardContentStore.delete(oldestKey);
1068
+ }
1069
+ }
1070
+ }
1071
+ }
1072
+ if (!bucket) {
1073
+ return null;
1074
+ }
1075
+ bucket.lastActiveAt = Date.now();
1076
+
1077
+ let bestContent: string | null = null;
1078
+ let bestDelta = Infinity;
1079
+
1080
+ for (const entry of bucket.entries) {
1081
+ if (Date.now() >= entry.expiresAt) {
1082
+ continue;
1083
+ }
1084
+ const delta = Math.abs(entry.createdAt - repliedCreatedAt);
1085
+ if (delta <= CARD_CACHE_MATCH_WINDOW_MS && delta < bestDelta) {
1086
+ bestDelta = delta;
1087
+ bestContent = entry.content;
1088
+ }
1089
+ }
1090
+
1091
+ return bestContent;
1092
+ }
1093
+
1094
+ export function clearCardContentCacheForTest(): void {
1095
+ cardContentStore.clear();
344
1096
  }