@soimy/dingtalk 3.1.4 → 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,83 +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,
11
- DingTalkInboundMessage,
17
+ DingTalkTrackingMetadata,
12
18
  Logger,
13
19
  } from "./types";
14
20
  import { AICardStatus } from "./types";
21
+ import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
15
22
 
16
23
  const DINGTALK_API = "https://api.dingtalk.com";
17
- // Card cache TTL (1 hour) for terminal states.
18
- const CARD_CACHE_TTL = 60 * 60 * 1000;
19
24
  // Thinking/tool stream snippets are truncated to keep card updates compact.
20
- 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;
21
30
 
22
- // AI Card instance cache for streaming updates.
23
- const aiCardInstances = new Map<string, AICardInstance>();
24
- // accountId:conversationId -> cardInstanceId
25
- const activeCardsByTarget = new Map<string, string>();
31
+ const aicardDegradeByAccount = new Map<string, { untilMs: number; reason: string }>();
26
32
 
27
- // Helper to identify card terminal states.
28
- export function isCardInTerminalState(state: string): boolean {
29
- return state === AICardStatus.FINISHED || state === AICardStatus.FAILED;
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;
30
39
  }
31
40
 
32
- export function getCardById(cardId: string): AICardInstance | undefined {
33
- return aiCardInstances.get(cardId);
41
+ function normalizeDegradeErrorMessage(value: string): string {
42
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
34
43
  }
35
44
 
36
- export function getActiveCardIdByTarget(targetKey: string): string | undefined {
37
- return activeCardsByTarget.get(targetKey);
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));
38
70
  }
39
71
 
40
- export function deleteActiveCardByTarget(targetKey: string): void {
41
- activeCardsByTarget.delete(targetKey);
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;
42
82
  }
43
83
 
44
- export function cleanupCardCache(): void {
45
- const now = Date.now();
46
- // Clean terminal cards only; active cards stay in cache to support streaming continuity.
47
- for (const [cardInstanceId, instance] of aiCardInstances.entries()) {
48
- if (isCardInTerminalState(instance.state) && now - instance.lastUpdated > CARD_CACHE_TTL) {
49
- aiCardInstances.delete(cardInstanceId);
50
- for (const [targetKey, mappedCardId] of activeCardsByTarget.entries()) {
51
- if (mappedCardId === cardInstanceId) {
52
- activeCardsByTarget.delete(targetKey);
53
- break;
54
- }
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();
55
140
  }
56
141
  }
57
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
+ }
58
236
  }
59
237
 
60
- export function formatContentForCard(content: string, type: "thinking" | "tool"): string {
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
+ }
314
+
315
+ // Helper to identify card terminal states.
316
+ export function isCardInTerminalState(state: string): boolean {
317
+ return state === AICardStatus.FINISHED || state === AICardStatus.FAILED;
318
+ }
319
+
320
+ export function formatContentForCard(content: string | undefined, type: "thinking" | "tool"): string {
61
321
  if (!content) {
62
322
  return "";
63
323
  }
64
324
 
65
- // Truncate to configured length and keep a visual ellipsis when truncated.
66
- const truncated =
67
- content.slice(0, THINKING_TRUNCATE_LENGTH) +
68
- (content.length > THINKING_TRUNCATE_LENGTH ? "…" : "");
325
+ const emoji = type === "thinking" ? "🤔" : "🛠️";
326
+ const label = type === "thinking" ? "思考中" : "工具执行";
69
327
 
70
- // Quote each line to improve readability in markdown card content.
71
- const quotedLines = truncated
328
+ const escaped = content
72
329
  .split("\n")
73
330
  .map((line) => line.replace(/^_(?=[^ ])/, "*").replace(/(?<=[^ ])_(?=$)/, "*"))
74
- .map((line) => `> ${line}`)
75
331
  .join("\n");
76
332
 
77
- const emoji = type === "thinking" ? "🤔" : "🛠️";
78
- const label = type === "thinking" ? "思考中" : "工具执行";
79
-
80
- return `${emoji} **${label}**\n${quotedLines}`;
333
+ return `${emoji} **${label}**\n\n${escaped}`;
81
334
  }
82
335
 
83
336
  async function sendTemplateMismatchNotification(
@@ -122,22 +375,142 @@ async function sendTemplateMismatchNotification(
122
375
  }
123
376
  }
124
377
 
125
- export async function createAICard(
378
+ /**
379
+ * Send a proactive text message via card API (createAndDeliver + immediate finalize).
380
+ * Used in card mode to replace oToMessages/batchSend for single-chat users.
381
+ */
382
+ export async function sendProactiveCardText(
126
383
  config: DingTalkConfig,
127
384
  conversationId: string,
128
- data: DingTalkInboundMessage,
385
+ content: string,
386
+ log?: Logger,
387
+ ): Promise<{ ok: boolean; error?: string } & DingTalkTrackingMetadata> {
388
+ try {
389
+ const card = await createAICard(config, conversationId, log, { persistPending: false });
390
+ if (!card) {
391
+ return { ok: false, error: "Failed to create AI card" };
392
+ }
393
+ await finishAICard(card, content, log);
394
+ return {
395
+ ok: true,
396
+ processQueryKey: card.processQueryKey,
397
+ outTrackId: card.outTrackId,
398
+ cardInstanceId: card.cardInstanceId,
399
+ };
400
+ } catch (err: any) {
401
+ log?.error?.(`[DingTalk][AICard] Proactive card send failed: ${err.message}`);
402
+ return { ok: false, error: err.message };
403
+ }
404
+ }
405
+
406
+ export async function recoverPendingCardsForAccount(
407
+ config: DingTalkConfig,
129
408
  accountId: string,
409
+ storePath?: string,
130
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
+
491
+ export async function createAICard(
492
+ config: DingTalkConfig,
493
+ conversationId: string,
494
+ log?: Logger,
495
+ options: CreateAICardOptions = {},
131
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
+
132
506
  try {
507
+ const shouldPersistPending =
508
+ options.persistPending ?? Boolean(options.accountId && options.storePath);
133
509
  const token = await getAccessToken(config, log);
134
510
  // Use randomUUID to avoid collisions across workers/restarts.
135
511
  const cardInstanceId = `card_${randomUUID()}`;
136
512
 
137
513
  log?.info?.(`[DingTalk][AICard] Creating and delivering card outTrackId=${cardInstanceId}`);
138
- log?.debug?.(
139
- `[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${conversationId}`,
140
- );
141
514
 
142
515
  const isGroup = conversationId.startsWith("cid");
143
516
 
@@ -146,11 +519,16 @@ export async function createAICard(
146
519
  }
147
520
 
148
521
  // DingTalk createAndDeliver API payload.
522
+ const cardTemplateKey = config.cardTemplateKey || "content";
523
+ const cardParamMap = {
524
+ config: JSON.stringify({ autoLayout: true, enableForward: true }),
525
+ [cardTemplateKey]: "",
526
+ };
149
527
  const createAndDeliverBody = {
150
528
  cardTemplateId: config.cardTemplateId,
151
529
  outTrackId: cardInstanceId,
152
530
  cardData: {
153
- cardParamMap: {},
531
+ cardParamMap,
154
532
  },
155
533
  callbackType: "STREAM",
156
534
  imGroupOpenSpaceModel: { supportForward: true },
@@ -162,7 +540,9 @@ export async function createAICard(
162
540
  imGroupOpenDeliverModel: isGroup
163
541
  ? { robotCode: config.robotCode || config.clientId }
164
542
  : undefined,
165
- imRobotOpenDeliverModel: !isGroup ? { spaceType: "IM_ROBOT" } : undefined,
543
+ imRobotOpenDeliverModel: !isGroup
544
+ ? { spaceType: "IM_ROBOT", robotCode: config.robotCode || config.clientId }
545
+ : undefined,
166
546
  };
167
547
 
168
548
  if (isGroup && !config.robotCode) {
@@ -180,30 +560,60 @@ export async function createAICard(
180
560
  createAndDeliverBody,
181
561
  {
182
562
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
563
+ ...getProxyBypassOption(config),
183
564
  },
184
565
  );
185
566
  log?.debug?.(
186
567
  `[DingTalk][AICard] CreateAndDeliver response: status=${resp.status} data=${JSON.stringify(resp.data)}`,
187
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;
188
597
 
189
- // Cache the AI card instance with config reference for token refresh/recovery.
598
+ // Return the AI card instance with config reference for token refresh/recovery.
190
599
  const aiCardInstance: AICardInstance = {
191
- cardInstanceId,
600
+ cardInstanceId: resolvedCardInstanceId,
192
601
  accessToken: token,
193
602
  conversationId,
603
+ accountId,
604
+ storePath: options.storePath,
194
605
  createdAt: Date.now(),
195
606
  lastUpdated: Date.now(),
196
607
  state: AICardStatus.PROCESSING,
197
608
  config,
609
+ processQueryKey: processQueryKey || extractCardProcessQueryKey(resp.data),
610
+ outTrackId,
198
611
  };
199
- aiCardInstances.set(cardInstanceId, aiCardInstance);
200
-
201
- const targetKey = `${accountId}:${conversationId}`;
202
- activeCardsByTarget.set(targetKey, cardInstanceId);
203
- log?.debug?.(
204
- `[DingTalk][AICard] Registered active card mapping: ${targetKey} -> ${cardInstanceId}`,
205
- );
612
+ if (shouldPersistPending) {
613
+ upsertPendingCard(aiCardInstance, options.storePath, log);
614
+ }
206
615
 
616
+ clearAICardDegrade(accountId, log);
207
617
  return aiCardInstance;
208
618
  } catch (err: any) {
209
619
  log?.error?.(`[DingTalk][AICard] Create failed: ${err.message}`);
@@ -216,6 +626,14 @@ export async function createAICard(
216
626
  formatDingTalkErrorPayloadLog("card.create", err.response.data, "[DingTalk][AICard]"),
217
627
  );
218
628
  }
629
+ if (shouldTriggerAICardDegrade(err)) {
630
+ activateAICardDegrade(
631
+ accountId,
632
+ `card.create:${err?.response?.status || "unknown"}`,
633
+ config,
634
+ log,
635
+ );
636
+ }
219
637
  return null;
220
638
  }
221
639
  }
@@ -249,7 +667,7 @@ export async function streamAICard(
249
667
 
250
668
  // Always use full replacement to make client rendering deterministic.
251
669
  const streamBody: AICardStreamingRequest = {
252
- outTrackId: card.cardInstanceId,
670
+ outTrackId: card.outTrackId || card.cardInstanceId,
253
671
  guid: randomUUID(),
254
672
  key: card.config?.cardTemplateKey || "content",
255
673
  content: content,
@@ -268,14 +686,17 @@ export async function streamAICard(
268
686
  "x-acs-dingtalk-access-token": card.accessToken,
269
687
  "Content-Type": "application/json",
270
688
  },
689
+ ...(card.config ? getProxyBypassOption(card.config) : {}),
271
690
  });
272
691
  log?.debug?.(
273
692
  `[DingTalk][AICard] Streaming response: status=${streamResp.status}, data=${JSON.stringify(streamResp.data)}`,
274
693
  );
275
694
 
276
695
  card.lastUpdated = Date.now();
696
+ card.lastStreamedContent = content;
277
697
  if (finished) {
278
698
  card.state = AICardStatus.FINISHED;
699
+ removePendingCard(card, log);
279
700
  } else if (card.state === AICardStatus.PROCESSING) {
280
701
  card.state = AICardStatus.INPUTING;
281
702
  }
@@ -300,6 +721,7 @@ export async function streamAICard(
300
721
 
301
722
  card.state = AICardStatus.FAILED;
302
723
  card.lastUpdated = Date.now();
724
+ removePendingCard(card, log);
303
725
  await sendTemplateMismatchNotification(card, errorMsg, log);
304
726
  throw err;
305
727
  }
@@ -314,13 +736,16 @@ export async function streamAICard(
314
736
  "x-acs-dingtalk-access-token": card.accessToken,
315
737
  "Content-Type": "application/json",
316
738
  },
739
+ ...(card.config ? getProxyBypassOption(card.config) : {}),
317
740
  });
318
741
  log?.debug?.(
319
742
  `[DingTalk][AICard] Retry after token refresh succeeded: status=${retryResp.status}`,
320
743
  );
321
744
  card.lastUpdated = Date.now();
745
+ card.lastStreamedContent = content;
322
746
  if (finished) {
323
747
  card.state = AICardStatus.FINISHED;
748
+ removePendingCard(card, log);
324
749
  } else if (card.state === AICardStatus.PROCESSING) {
325
750
  card.state = AICardStatus.INPUTING;
326
751
  }
@@ -341,9 +766,16 @@ export async function streamAICard(
341
766
 
342
767
  card.state = AICardStatus.FAILED;
343
768
  card.lastUpdated = Date.now();
344
- log?.error?.(
345
- `[DingTalk][AICard] Streaming update failed: ${err.message}`,
346
- );
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}`);
347
779
  if (err.response?.data !== undefined) {
348
780
  log?.error?.(
349
781
  formatDingTalkErrorPayloadLog("card.stream", err.response.data, "[DingTalk][AICard]"),
@@ -359,6 +791,306 @@ export async function finishAICard(
359
791
  log?: Logger,
360
792
  ): Promise<void> {
361
793
  log?.debug?.(`[DingTalk][AICard] Starting finish, final content length=${content.length}`);
362
- // Finalize by streaming one last full payload with isFinalize=true.
363
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();
364
1096
  }