@soimy/dingtalk 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +796 -42
  3. package/index.ts +62 -0
  4. package/package.json +4 -2
  5. package/src/access-control.ts +83 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  8. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  9. package/src/ack-reaction-classifier.ts +75 -0
  10. package/src/ack-reaction-service.ts +182 -0
  11. package/src/attachment-text-extractor.ts +148 -0
  12. package/src/card-callback-service.ts +119 -0
  13. package/src/card-draft-controller.ts +114 -0
  14. package/src/card-service.ts +666 -26
  15. package/src/channel.ts +455 -150
  16. package/src/config-schema.ts +64 -6
  17. package/src/config.ts +161 -5
  18. package/src/connection-manager.ts +354 -47
  19. package/src/dedup.ts +1 -0
  20. package/src/docs-service.ts +198 -0
  21. package/src/draft-stream-loop.ts +119 -0
  22. package/src/feedback-learning-service.ts +643 -0
  23. package/src/feedback-learning-store.ts +543 -0
  24. package/src/group-members-store.ts +48 -14
  25. package/src/inbound-handler.ts +1374 -259
  26. package/src/learning-command-service.ts +339 -0
  27. package/src/media-utils.ts +94 -50
  28. package/src/message-context-store.ts +787 -0
  29. package/src/message-utils.ts +487 -46
  30. package/src/messaging/quoted-context.ts +269 -0
  31. package/src/messaging/quoted-ref.ts +97 -0
  32. package/src/onboarding.ts +96 -1
  33. package/src/peer-id-registry.ts +102 -0
  34. package/src/persistence-store.ts +131 -0
  35. package/src/quoted-file-service.ts +385 -0
  36. package/src/reply-strategy-card.ts +225 -0
  37. package/src/reply-strategy-markdown.ts +55 -0
  38. package/src/reply-strategy-with-reaction.ts +190 -0
  39. package/src/reply-strategy.ts +72 -0
  40. package/src/send-service.ts +267 -45
  41. package/src/session-command-service.ts +147 -0
  42. package/src/session-lock.ts +2 -0
  43. package/src/session-peer-store.ts +77 -0
  44. package/src/session-routing.ts +33 -0
  45. package/src/targeting/agent-name-matcher.ts +148 -0
  46. package/src/targeting/agent-routing.ts +181 -0
  47. package/src/targeting/target-directory-adapter.ts +151 -0
  48. package/src/targeting/target-directory-store.ts +396 -0
  49. package/src/targeting/target-input.ts +62 -0
  50. package/src/types.ts +261 -28
  51. package/src/utils.ts +231 -12
@@ -1,47 +1,392 @@
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
+ createSyntheticOutboundMsgId,
10
+ clearMessageContextCacheForTest,
11
+ DEFAULT_CARD_CONTENT_TTL_MS,
12
+ DEFAULT_CREATED_AT_MATCH_WINDOW_MS,
13
+ resolveByCreatedAtWindow,
14
+ upsertOutboundMessageContext,
15
+ } from "./message-context-store";
16
+ import {
17
+ readNamespaceJson,
18
+ resolveNamespacePath,
19
+ writeNamespaceJsonAtomic,
20
+ } from "./persistence-store";
7
21
  import type {
8
22
  AICardInstance,
9
23
  AICardStreamingRequest,
10
24
  DingTalkConfig,
25
+ DingTalkTrackingMetadata,
11
26
  Logger,
27
+ QuotedRef,
12
28
  } from "./types";
13
29
  import { AICardStatus } from "./types";
30
+ import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
14
31
 
15
32
  const DINGTALK_API = "https://api.dingtalk.com";
16
33
  // Thinking/tool stream snippets are truncated to keep card updates compact.
17
- const THINKING_TRUNCATE_LENGTH = 500;
34
+ const CARD_STATE_FILE_VERSION = 1;
35
+ const CARD_PENDING_NAMESPACE = "cards.active.pending";
36
+ const RECOVERY_FINALIZE_MESSAGE = "⚠️ 上一次回复处理中断,已自动结束。请重新发送你的问题。";
37
+ const AICARD_DEGRADE_DEFAULT_MS = 30 * 60 * 1000;
38
+ const CARD_CACHE_MAX_PER_CONVERSATION = 20;
39
+ const CARD_CACHE_MAX_CONVERSATIONS = 500;
40
+ const DYNAMIC_SUMMARY_EXTENSION = { dynamicSummary: "true" } as const;
41
+
42
+ const aicardDegradeByAccount = new Map<string, { untilMs: number; reason: string }>();
43
+ const inMemoryCardContentStore = new Map<
44
+ string,
45
+ {
46
+ entries: Array<{ content: string; createdAt: number; expiresAt: number }>;
47
+ lastActiveAt: number;
48
+ }
49
+ >();
50
+
51
+ function pruneInMemoryCardContentEntries(
52
+ entries: Array<{ content: string; createdAt: number; expiresAt: number }>,
53
+ nowMs: number,
54
+ ): Array<{ content: string; createdAt: number; expiresAt: number }> {
55
+ return entries.filter((entry) => nowMs < entry.expiresAt).slice(-CARD_CACHE_MAX_PER_CONVERSATION);
56
+ }
57
+
58
+ function touchInMemoryCardContentBucket(scopeKey: string, nowMs: number): {
59
+ entries: Array<{ content: string; createdAt: number; expiresAt: number }>;
60
+ lastActiveAt: number;
61
+ } {
62
+ const existing = inMemoryCardContentStore.get(scopeKey);
63
+ const bucket = existing
64
+ ? {
65
+ entries: pruneInMemoryCardContentEntries(existing.entries, nowMs),
66
+ lastActiveAt: nowMs,
67
+ }
68
+ : { entries: [], lastActiveAt: nowMs };
69
+ inMemoryCardContentStore.set(scopeKey, bucket);
70
+ if (inMemoryCardContentStore.size > CARD_CACHE_MAX_CONVERSATIONS) {
71
+ let oldestKey: string | undefined;
72
+ let oldestTime = Infinity;
73
+ for (const [key, candidate] of inMemoryCardContentStore) {
74
+ if (candidate.lastActiveAt < oldestTime) {
75
+ oldestTime = candidate.lastActiveAt;
76
+ oldestKey = key;
77
+ }
78
+ }
79
+ if (oldestKey) {
80
+ inMemoryCardContentStore.delete(oldestKey);
81
+ }
82
+ }
83
+ return bucket;
84
+ }
85
+
86
+ function getAICardDegradeMs(config?: DingTalkConfig): number {
87
+ const raw = config?.aicardDegradeMs;
88
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 60_000) {
89
+ return raw;
90
+ }
91
+ return AICARD_DEGRADE_DEFAULT_MS;
92
+ }
93
+
94
+ function normalizeDegradeErrorMessage(value: string): string {
95
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
96
+ }
97
+
98
+ function shouldTriggerAICardDegrade(err: unknown): boolean {
99
+ const maybeErr = err as {
100
+ response?: { status?: number; data?: { message?: string } };
101
+ message?: string;
102
+ };
103
+ const status = maybeErr.response?.status;
104
+ const msg = normalizeDegradeErrorMessage(
105
+ String(maybeErr.response?.data?.message || maybeErr.message || ""),
106
+ );
107
+ if (status === 403 || status === 429) {
108
+ return true;
109
+ }
110
+ if (typeof status === "number" && status >= 500 && status < 600) {
111
+ return true;
112
+ }
113
+ return [
114
+ "ipnotinwhitelist",
115
+ "forbiddenaccessdenied",
116
+ "timeout",
117
+ "etimedout",
118
+ "econnreset",
119
+ "eaiagain",
120
+ "sockethangup",
121
+ "badgateway",
122
+ ].some((keyword) => msg.includes(keyword));
123
+ }
124
+
125
+ export function isAICardDegraded(accountId: string): boolean {
126
+ const state = aicardDegradeByAccount.get(accountId);
127
+ if (!state) {
128
+ return false;
129
+ }
130
+ if (Date.now() >= state.untilMs) {
131
+ aicardDegradeByAccount.delete(accountId);
132
+ return false;
133
+ }
134
+ return true;
135
+ }
136
+
137
+ export function getAICardDegradeState(
138
+ accountId: string,
139
+ ): { remainingMs: number; reason: string } | null {
140
+ const state = aicardDegradeByAccount.get(accountId);
141
+ if (!state) {
142
+ return null;
143
+ }
144
+ const remainingMs = state.untilMs - Date.now();
145
+ if (remainingMs <= 0) {
146
+ aicardDegradeByAccount.delete(accountId);
147
+ return null;
148
+ }
149
+ return { remainingMs, reason: state.reason };
150
+ }
151
+
152
+ export function activateAICardDegrade(
153
+ accountId: string,
154
+ reason: string,
155
+ config?: DingTalkConfig,
156
+ log?: Logger,
157
+ ): void {
158
+ const durationMs = getAICardDegradeMs(config);
159
+ const untilMs = Date.now() + durationMs;
160
+ const existed = isAICardDegraded(accountId);
161
+ aicardDegradeByAccount.set(accountId, { untilMs, reason });
162
+ const minutes = Math.round(durationMs / 60000);
163
+ if (existed) {
164
+ log?.warn?.(
165
+ `[DingTalk][AICard][Degrade] Extended for account=${accountId}, minutes=${minutes}, reason=${reason}`,
166
+ );
167
+ } else {
168
+ log?.warn?.(
169
+ `[DingTalk][AICard][Degrade] Activated for account=${accountId}, minutes=${minutes}, reason=${reason}`,
170
+ );
171
+ }
172
+ }
173
+
174
+ export function clearAICardDegrade(accountId: string, log?: Logger): void {
175
+ if (!aicardDegradeByAccount.has(accountId)) {
176
+ return;
177
+ }
178
+ const reason = aicardDegradeByAccount.get(accountId)?.reason || "";
179
+ aicardDegradeByAccount.delete(accountId);
180
+ log?.info?.(`[DingTalk][AICard][Degrade] Cleared for account=${accountId}, lastReason=${reason}`);
181
+ }
182
+
183
+ function extractCardProcessQueryKey(payload: unknown): string | undefined {
184
+ if (!payload || typeof payload !== "object") {
185
+ return undefined;
186
+ }
187
+ const data = payload as Record<string, any>;
188
+ const deliverResults = data.result?.deliverResults;
189
+ if (Array.isArray(deliverResults)) {
190
+ for (const item of deliverResults) {
191
+ if (typeof item?.carrierId === "string" && item.carrierId.trim()) {
192
+ return item.carrierId.trim();
193
+ }
194
+ }
195
+ }
196
+ return undefined;
197
+ }
198
+
199
+ interface CreateAICardOptions {
200
+ accountId?: string;
201
+ storePath?: string;
202
+ persistPending?: boolean;
203
+ contextConversationId?: string;
204
+ }
205
+
206
+ interface PendingCardRecord {
207
+ accountId: string;
208
+ cardInstanceId: string;
209
+ outTrackId?: string;
210
+ conversationId: string;
211
+ contextConversationId?: string;
212
+ createdAt: number;
213
+ lastUpdated: number;
214
+ state: string;
215
+ }
216
+
217
+ interface PendingCardStateFile {
218
+ version: number;
219
+ updatedAt: number;
220
+ pendingCards: PendingCardRecord[];
221
+ }
222
+
223
+ function getCardStateFilePath(storePath?: string): string | null {
224
+ if (!storePath) {
225
+ return null;
226
+ }
227
+ return resolveNamespacePath(CARD_PENDING_NAMESPACE, {
228
+ storePath,
229
+ format: "json",
230
+ });
231
+ }
232
+
233
+ function getLegacyCardStateFilePath(storePath?: string): string | null {
234
+ if (!storePath) {
235
+ return null;
236
+ }
237
+ return path.join(path.dirname(storePath), "dingtalk-active-cards.json");
238
+ }
239
+
240
+ function normalizePendingState(parsed: Partial<PendingCardStateFile>): PendingCardStateFile {
241
+ const records = Array.isArray(parsed.pendingCards) ? parsed.pendingCards : [];
242
+ return {
243
+ version: typeof parsed.version === "number" ? parsed.version : CARD_STATE_FILE_VERSION,
244
+ updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
245
+ pendingCards: records.filter((entry): entry is PendingCardRecord =>
246
+ Boolean(
247
+ entry &&
248
+ typeof entry.accountId === "string" &&
249
+ typeof entry.cardInstanceId === "string" &&
250
+ (entry.outTrackId === undefined || typeof entry.outTrackId === "string") &&
251
+ typeof entry.conversationId === "string",
252
+ ),
253
+ ),
254
+ };
255
+ }
256
+
257
+ function readPendingCardState(storePath?: string, log?: Logger): PendingCardStateFile {
258
+ if (!storePath) {
259
+ return { version: CARD_STATE_FILE_VERSION, updatedAt: Date.now(), pendingCards: [] };
260
+ }
261
+
262
+ const filePath = getCardStateFilePath(storePath);
263
+ const legacyPath = getLegacyCardStateFilePath(storePath);
264
+
265
+ if (filePath && fs.existsSync(filePath)) {
266
+ const parsed = readNamespaceJson<Partial<PendingCardStateFile>>(CARD_PENDING_NAMESPACE, {
267
+ storePath,
268
+ format: "json",
269
+ fallback: {},
270
+ log,
271
+ });
272
+ return normalizePendingState(parsed);
273
+ }
274
+
275
+ try {
276
+ if (!legacyPath || !fs.existsSync(legacyPath)) {
277
+ return { version: CARD_STATE_FILE_VERSION, updatedAt: Date.now(), pendingCards: [] };
278
+ }
279
+ const raw = fs.readFileSync(legacyPath, "utf-8");
280
+ if (!raw.trim()) {
281
+ return { version: CARD_STATE_FILE_VERSION, updatedAt: Date.now(), pendingCards: [] };
282
+ }
283
+ const normalized = normalizePendingState(JSON.parse(raw) as Partial<PendingCardStateFile>);
284
+ writePendingCardState(normalized, storePath, log);
285
+ return normalized;
286
+ } catch (err: unknown) {
287
+ const message = err instanceof Error ? err.message : String(err);
288
+ log?.warn?.(`[DingTalk][AICard] Failed to read pending card state: ${message}`);
289
+ return { version: CARD_STATE_FILE_VERSION, updatedAt: Date.now(), pendingCards: [] };
290
+ }
291
+ }
292
+
293
+ function writePendingCardState(
294
+ state: PendingCardStateFile,
295
+ storePath?: string,
296
+ log?: Logger,
297
+ ): void {
298
+ if (!storePath) {
299
+ return;
300
+ }
301
+ writeNamespaceJsonAtomic(CARD_PENDING_NAMESPACE, {
302
+ storePath,
303
+ format: "json",
304
+ data: state,
305
+ log,
306
+ });
307
+ }
308
+
309
+ function upsertPendingCard(card: AICardInstance, storePath?: string, log?: Logger): void {
310
+ if (!card.accountId || !storePath) {
311
+ return;
312
+ }
313
+ const state = readPendingCardState(storePath, log);
314
+ const next: PendingCardRecord = {
315
+ accountId: card.accountId,
316
+ cardInstanceId: card.cardInstanceId,
317
+ outTrackId: card.outTrackId,
318
+ conversationId: card.conversationId,
319
+ contextConversationId: card.contextConversationId,
320
+ createdAt: card.createdAt,
321
+ lastUpdated: card.lastUpdated,
322
+ state: card.state,
323
+ };
324
+ const index = state.pendingCards.findIndex((item) => item.cardInstanceId === card.cardInstanceId);
325
+ if (index >= 0) {
326
+ state.pendingCards[index] = next;
327
+ } else {
328
+ state.pendingCards.push(next);
329
+ }
330
+ state.updatedAt = Date.now();
331
+ writePendingCardState(state, storePath, log);
332
+ }
333
+
334
+ function removePendingCard(card: AICardInstance, log?: Logger): void {
335
+ if (!card.accountId || !card.storePath) {
336
+ return;
337
+ }
338
+ removePendingCardById(card.cardInstanceId, card.storePath, log);
339
+ }
340
+
341
+ function removePendingCardById(cardInstanceId: string, storePath?: string, log?: Logger): void {
342
+ if (!storePath) {
343
+ return;
344
+ }
345
+ const state = readPendingCardState(storePath, log);
346
+ const remaining = state.pendingCards.filter((item) => item.cardInstanceId !== cardInstanceId);
347
+ if (remaining.length === state.pendingCards.length) {
348
+ return;
349
+ }
350
+ state.pendingCards = remaining;
351
+ state.updatedAt = Date.now();
352
+ writePendingCardState(state, storePath, log);
353
+ }
354
+
355
+ function listPendingCardsByAccount(
356
+ accountId: string,
357
+ storePath?: string,
358
+ log?: Logger,
359
+ ): PendingCardRecord[] {
360
+ const state = readPendingCardState(storePath, log);
361
+ return state.pendingCards.filter((item) => item.accountId === accountId);
362
+ }
363
+
364
+ function normalizeRecoveredState(state: string): AICardInstance["state"] {
365
+ if (state === AICardStatus.PROCESSING || state === AICardStatus.INPUTING) {
366
+ return state;
367
+ }
368
+ return AICardStatus.PROCESSING;
369
+ }
18
370
 
19
371
  // Helper to identify card terminal states.
20
372
  export function isCardInTerminalState(state: string): boolean {
21
373
  return state === AICardStatus.FINISHED || state === AICardStatus.FAILED;
22
374
  }
23
375
 
24
- export function formatContentForCard(content: string, type: "thinking" | "tool"): string {
376
+ export function formatContentForCard(content: string | undefined, type: "thinking" | "tool"): string {
25
377
  if (!content) {
26
378
  return "";
27
379
  }
28
380
 
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 ? "…" : "");
381
+ const emoji = type === "thinking" ? "🤔" : "🛠️";
382
+ const label = type === "thinking" ? "思考中" : "工具执行";
33
383
 
34
- // Quote each line to improve readability in markdown card content.
35
- const quotedLines = truncated
384
+ const escaped = content
36
385
  .split("\n")
37
386
  .map((line) => line.replace(/^_(?=[^ ])/, "*").replace(/(?<=[^ ])_(?=$)/, "*"))
38
- .map((line) => `> ${line}`)
39
387
  .join("\n");
40
388
 
41
- const emoji = type === "thinking" ? "🤔" : "🛠️";
42
- const label = type === "thinking" ? "思考中" : "工具执行";
43
-
44
- return `${emoji} **${label}**\n${quotedLines}`;
389
+ return `${emoji} **${label}**\n\n${escaped}`;
45
390
  }
46
391
 
47
392
  async function sendTemplateMismatchNotification(
@@ -95,26 +440,129 @@ export async function sendProactiveCardText(
95
440
  conversationId: string,
96
441
  content: string,
97
442
  log?: Logger,
98
- ): Promise<{ ok: boolean; error?: string }> {
443
+ ): Promise<{ ok: boolean; error?: string } & DingTalkTrackingMetadata> {
99
444
  try {
100
- const card = await createAICard(config, conversationId, log);
445
+ const card = await createAICard(config, conversationId, log, { persistPending: false });
101
446
  if (!card) {
102
447
  return { ok: false, error: "Failed to create AI card" };
103
448
  }
104
449
  await finishAICard(card, content, log);
105
- return { ok: true };
450
+ return {
451
+ ok: true,
452
+ processQueryKey: card.processQueryKey,
453
+ outTrackId: card.outTrackId,
454
+ cardInstanceId: card.cardInstanceId,
455
+ };
106
456
  } catch (err: any) {
107
457
  log?.error?.(`[DingTalk][AICard] Proactive card send failed: ${err.message}`);
108
458
  return { ok: false, error: err.message };
109
459
  }
110
460
  }
111
461
 
462
+ export async function recoverPendingCardsForAccount(
463
+ config: DingTalkConfig,
464
+ accountId: string,
465
+ storePath?: string,
466
+ log?: Logger,
467
+ ): Promise<number> {
468
+ return finalizePendingCardsByAccount(
469
+ config,
470
+ accountId,
471
+ RECOVERY_FINALIZE_MESSAGE,
472
+ storePath,
473
+ "recover",
474
+ log,
475
+ );
476
+ }
477
+
478
+ export async function finalizeActiveCardsForAccount(
479
+ config: DingTalkConfig,
480
+ accountId: string,
481
+ reason: string,
482
+ storePath?: string,
483
+ log?: Logger,
484
+ ): Promise<number> {
485
+ return finalizePendingCardsByAccount(config, accountId, reason, storePath, "finalize", log);
486
+ }
487
+
488
+ async function finalizePendingCardsByAccount(
489
+ config: DingTalkConfig,
490
+ accountId: string,
491
+ reason: string,
492
+ storePath: string | undefined,
493
+ mode: "recover" | "finalize",
494
+ log?: Logger,
495
+ ): Promise<number> {
496
+ if (!storePath) {
497
+ return 0;
498
+ }
499
+
500
+ const pendingCards = listPendingCardsByAccount(accountId, storePath, log).filter(
501
+ (item) => !isCardInTerminalState(item.state),
502
+ );
503
+ if (pendingCards.length === 0) {
504
+ return 0;
505
+ }
506
+
507
+ let token = "";
508
+ try {
509
+ token = await getAccessToken(config, log);
510
+ } catch (err: any) {
511
+ const tokenFailureScope =
512
+ mode === "recover" ? "pending card recovery" : "finalizing active cards";
513
+ log?.warn?.(
514
+ `[DingTalk][AICard] Failed to fetch token for ${tokenFailureScope}: ${err.message}`,
515
+ );
516
+ return 0;
517
+ }
518
+
519
+ let finalizedCount = 0;
520
+ for (const entry of pendingCards) {
521
+ const card: AICardInstance = {
522
+ cardInstanceId: entry.cardInstanceId,
523
+ accessToken: token,
524
+ conversationId: entry.conversationId,
525
+ contextConversationId: entry.contextConversationId,
526
+ accountId: entry.accountId,
527
+ storePath,
528
+ outTrackId: entry.outTrackId,
529
+ createdAt: entry.createdAt || Date.now(),
530
+ lastUpdated: entry.lastUpdated || Date.now(),
531
+ state: normalizeRecoveredState(entry.state),
532
+ config,
533
+ };
534
+ try {
535
+ await finishAICard(card, reason, log);
536
+ finalizedCount += 1;
537
+ } catch (err: any) {
538
+ const action = mode === "recover" ? "recover" : "finalize";
539
+ log?.warn?.(
540
+ `[DingTalk][AICard] Failed to ${action} active card ${entry.cardInstanceId}: ${err.message}`,
541
+ );
542
+ removePendingCardById(entry.cardInstanceId, storePath, log);
543
+ }
544
+ }
545
+ return finalizedCount;
546
+ }
547
+
112
548
  export async function createAICard(
113
549
  config: DingTalkConfig,
114
550
  conversationId: string,
115
551
  log?: Logger,
552
+ options: CreateAICardOptions = {},
116
553
  ): Promise<AICardInstance | null> {
554
+ const accountId = options.accountId ?? "default";
555
+ if (isAICardDegraded(accountId)) {
556
+ const state = getAICardDegradeState(accountId);
557
+ log?.warn?.(
558
+ `[DingTalk][AICard][Degrade] Skip create for account=${accountId}, remainingMs=${state?.remainingMs || 0}, reason=${state?.reason || "unknown"}`,
559
+ );
560
+ return null;
561
+ }
562
+
117
563
  try {
564
+ const shouldPersistPending =
565
+ options.persistPending ?? Boolean(options.accountId && options.storePath);
118
566
  const token = await getAccessToken(config, log);
119
567
  // Use randomUUID to avoid collisions across workers/restarts.
120
568
  const cardInstanceId = `card_${randomUUID()}`;
@@ -129,11 +577,15 @@ export async function createAICard(
129
577
 
130
578
  // DingTalk createAndDeliver API payload.
131
579
  const cardTemplateKey = config.cardTemplateKey || "content";
580
+ const cardParamMap = {
581
+ config: JSON.stringify({ autoLayout: true, enableForward: true }),
582
+ [cardTemplateKey]: "",
583
+ };
132
584
  const createAndDeliverBody = {
133
585
  cardTemplateId: config.cardTemplateId,
134
586
  outTrackId: cardInstanceId,
135
587
  cardData: {
136
- cardParamMap: { [cardTemplateKey]: "" },
588
+ cardParamMap,
137
589
  },
138
590
  callbackType: "STREAM",
139
591
  imGroupOpenSpaceModel: { supportForward: true },
@@ -143,10 +595,17 @@ export async function createAICard(
143
595
  : `dtv1.card//IM_ROBOT.${conversationId}`,
144
596
  userIdType: 1,
145
597
  imGroupOpenDeliverModel: isGroup
146
- ? { robotCode: config.robotCode || config.clientId }
598
+ ? {
599
+ robotCode: config.robotCode || config.clientId,
600
+ extension: DYNAMIC_SUMMARY_EXTENSION,
601
+ }
147
602
  : undefined,
148
603
  imRobotOpenDeliverModel: !isGroup
149
- ? { spaceType: "IM_ROBOT", robotCode: config.robotCode || config.clientId }
604
+ ? {
605
+ spaceType: "IM_ROBOT",
606
+ robotCode: config.robotCode || config.clientId,
607
+ extension: DYNAMIC_SUMMARY_EXTENSION,
608
+ }
150
609
  : undefined,
151
610
  };
152
611
 
@@ -165,23 +624,61 @@ export async function createAICard(
165
624
  createAndDeliverBody,
166
625
  {
167
626
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
627
+ ...getProxyBypassOption(config),
168
628
  },
169
629
  );
170
630
  log?.debug?.(
171
631
  `[DingTalk][AICard] CreateAndDeliver response: status=${resp.status} data=${JSON.stringify(resp.data)}`,
172
632
  );
633
+ const responseData = resp.data as
634
+ | {
635
+ result?: DingTalkTrackingMetadata;
636
+ processQueryKey?: unknown;
637
+ outTrackId?: unknown;
638
+ cardInstanceId?: unknown;
639
+ }
640
+ | undefined;
641
+ const responseTracking = responseData?.result;
642
+ const processQueryKey =
643
+ typeof responseTracking?.processQueryKey === "string" &&
644
+ responseTracking.processQueryKey.trim()
645
+ ? responseTracking.processQueryKey.trim()
646
+ : typeof responseData?.processQueryKey === "string" && responseData.processQueryKey.trim()
647
+ ? responseData.processQueryKey.trim()
648
+ : undefined;
649
+ const outTrackId =
650
+ typeof responseTracking?.outTrackId === "string" && responseTracking.outTrackId.trim()
651
+ ? responseTracking.outTrackId.trim()
652
+ : typeof responseData?.outTrackId === "string" && responseData.outTrackId.trim()
653
+ ? responseData.outTrackId.trim()
654
+ : cardInstanceId;
655
+ const resolvedCardInstanceId =
656
+ typeof responseTracking?.cardInstanceId === "string" && responseTracking.cardInstanceId.trim()
657
+ ? responseTracking.cardInstanceId.trim()
658
+ : typeof responseData?.cardInstanceId === "string" && responseData.cardInstanceId.trim()
659
+ ? responseData.cardInstanceId.trim()
660
+ : cardInstanceId;
173
661
 
174
662
  // Return the AI card instance with config reference for token refresh/recovery.
175
663
  const aiCardInstance: AICardInstance = {
176
- cardInstanceId,
664
+ cardInstanceId: resolvedCardInstanceId,
177
665
  accessToken: token,
178
666
  conversationId,
667
+ contextConversationId: options.contextConversationId || conversationId,
668
+ accountId,
669
+ storePath: options.storePath,
179
670
  createdAt: Date.now(),
180
671
  lastUpdated: Date.now(),
181
672
  state: AICardStatus.PROCESSING,
182
673
  config,
674
+ processQueryKey: processQueryKey || extractCardProcessQueryKey(resp.data),
675
+ outTrackId,
183
676
  };
677
+ if (shouldPersistPending) {
678
+ upsertPendingCard(aiCardInstance, options.storePath, log);
679
+ }
184
680
 
681
+ clearAICardDegrade(accountId, log);
185
682
  return aiCardInstance;
186
683
  } catch (err: any) {
187
684
  log?.error?.(`[DingTalk][AICard] Create failed: ${err.message}`);
@@ -194,6 +691,14 @@ export async function createAICard(
194
691
  formatDingTalkErrorPayloadLog("card.create", err.response.data, "[DingTalk][AICard]"),
195
692
  );
196
693
  }
694
+ if (shouldTriggerAICardDegrade(err)) {
695
+ activateAICardDegrade(
696
+ accountId,
697
+ `card.create:${err?.response?.status || "unknown"}`,
698
+ config,
699
+ log,
700
+ );
701
+ }
197
702
  return null;
198
703
  }
199
704
  }
@@ -227,7 +732,7 @@ export async function streamAICard(
227
732
 
228
733
  // Always use full replacement to make client rendering deterministic.
229
734
  const streamBody: AICardStreamingRequest = {
230
- outTrackId: card.cardInstanceId,
735
+ outTrackId: card.outTrackId || card.cardInstanceId,
231
736
  guid: randomUUID(),
232
737
  key: card.config?.cardTemplateKey || "content",
233
738
  content: content,
@@ -246,6 +751,7 @@ export async function streamAICard(
246
751
  "x-acs-dingtalk-access-token": card.accessToken,
247
752
  "Content-Type": "application/json",
248
753
  },
754
+ ...(card.config ? getProxyBypassOption(card.config) : {}),
249
755
  });
250
756
  log?.debug?.(
251
757
  `[DingTalk][AICard] Streaming response: status=${streamResp.status}, data=${JSON.stringify(streamResp.data)}`,
@@ -255,6 +761,7 @@ export async function streamAICard(
255
761
  card.lastStreamedContent = content;
256
762
  if (finished) {
257
763
  card.state = AICardStatus.FINISHED;
764
+ removePendingCard(card, log);
258
765
  } else if (card.state === AICardStatus.PROCESSING) {
259
766
  card.state = AICardStatus.INPUTING;
260
767
  }
@@ -279,6 +786,7 @@ export async function streamAICard(
279
786
 
280
787
  card.state = AICardStatus.FAILED;
281
788
  card.lastUpdated = Date.now();
789
+ removePendingCard(card, log);
282
790
  await sendTemplateMismatchNotification(card, errorMsg, log);
283
791
  throw err;
284
792
  }
@@ -293,6 +801,7 @@ export async function streamAICard(
293
801
  "x-acs-dingtalk-access-token": card.accessToken,
294
802
  "Content-Type": "application/json",
295
803
  },
804
+ ...(card.config ? getProxyBypassOption(card.config) : {}),
296
805
  });
297
806
  log?.debug?.(
298
807
  `[DingTalk][AICard] Retry after token refresh succeeded: status=${retryResp.status}`,
@@ -301,6 +810,7 @@ export async function streamAICard(
301
810
  card.lastStreamedContent = content;
302
811
  if (finished) {
303
812
  card.state = AICardStatus.FINISHED;
813
+ removePendingCard(card, log);
304
814
  } else if (card.state === AICardStatus.PROCESSING) {
305
815
  card.state = AICardStatus.INPUTING;
306
816
  }
@@ -321,9 +831,16 @@ export async function streamAICard(
321
831
 
322
832
  card.state = AICardStatus.FAILED;
323
833
  card.lastUpdated = Date.now();
324
- log?.error?.(
325
- `[DingTalk][AICard] Streaming update failed: ${err.message}`,
326
- );
834
+ removePendingCard(card, log);
835
+ if (card.accountId && shouldTriggerAICardDegrade(err)) {
836
+ activateAICardDegrade(
837
+ card.accountId,
838
+ `card.stream:${err?.response?.status || "unknown"}`,
839
+ card.config,
840
+ log,
841
+ );
842
+ }
843
+ log?.error?.(`[DingTalk][AICard] Streaming update failed: ${err.message}`);
327
844
  if (err.response?.data !== undefined) {
328
845
  log?.error?.(
329
846
  formatDingTalkErrorPayloadLog("card.stream", err.response.data, "[DingTalk][AICard]"),
@@ -337,8 +854,131 @@ export async function finishAICard(
337
854
  card: AICardInstance,
338
855
  content: string,
339
856
  log?: Logger,
857
+ options: { quotedRef?: QuotedRef } = {},
340
858
  ): Promise<void> {
341
859
  log?.debug?.(`[DingTalk][AICard] Starting finish, final content length=${content.length}`);
342
- // Finalize by streaming one last full payload with isFinalize=true.
343
860
  await streamAICard(card, content, true, log);
861
+ if (card.conversationId && content.trim() && card.accountId && card.processQueryKey) {
862
+ const primaryConversationId = card.contextConversationId || card.conversationId;
863
+ cacheCardContentByProcessQueryKey(
864
+ card.accountId,
865
+ primaryConversationId,
866
+ card.processQueryKey,
867
+ content,
868
+ card.storePath,
869
+ options.quotedRef,
870
+ log,
871
+ );
872
+ }
873
+ }
874
+
875
+ function cacheCardContentByProcessQueryKey(
876
+ accountId: string,
877
+ conversationId: string,
878
+ processQueryKey: string,
879
+ content: string,
880
+ storePath?: string,
881
+ quotedRef?: QuotedRef,
882
+ log?: Logger,
883
+ ): void {
884
+ if (!processQueryKey.trim() || !content.trim() || !storePath) {
885
+ return;
886
+ }
887
+ log?.debug?.(
888
+ `[DingTalk][QuotedRef][Persist] direction=outbound scope=${conversationId} messageType=card ` +
889
+ `processQueryKey=${processQueryKey} quotedRef=${quotedRef ? JSON.stringify(quotedRef) : "(none)"}`,
890
+ );
891
+ upsertOutboundMessageContext({
892
+ storePath,
893
+ accountId,
894
+ conversationId,
895
+ createdAt: Date.now(),
896
+ text: content,
897
+ messageType: "card",
898
+ ttlMs: DEFAULT_CARD_CONTENT_TTL_MS,
899
+ topic: null,
900
+ quotedRef,
901
+ delivery: {
902
+ processQueryKey,
903
+ kind: "proactive-card",
904
+ },
905
+ });
906
+ }
907
+
908
+ export function cacheCardContent(
909
+ accountId: string,
910
+ conversationId: string,
911
+ content: string,
912
+ createdAt: number,
913
+ storePath?: string,
914
+ ): void {
915
+ if (!storePath) {
916
+ // This fallback only serves short-lived, no-storePath sessions. It is kept
917
+ // local to card-service instead of using the shared message context store
918
+ // because there is no durable scope to share across modules or restarts.
919
+ const scopeKey = `${accountId}:${conversationId}`;
920
+ const nowMs = Date.now();
921
+ const bucket = touchInMemoryCardContentBucket(scopeKey, nowMs);
922
+ bucket.entries.push({ content, createdAt, expiresAt: nowMs + DEFAULT_CARD_CONTENT_TTL_MS });
923
+ bucket.entries.sort((left, right) => left.createdAt - right.createdAt);
924
+ bucket.entries = bucket.entries.slice(-CARD_CACHE_MAX_PER_CONVERSATION);
925
+ return;
926
+ }
927
+ upsertOutboundMessageContext({
928
+ storePath,
929
+ accountId,
930
+ conversationId,
931
+ msgId: createSyntheticOutboundMsgId(createdAt),
932
+ createdAt,
933
+ text: content,
934
+ messageType: "card",
935
+ ttlMs: DEFAULT_CARD_CONTENT_TTL_MS,
936
+ topic: null,
937
+ });
938
+ }
939
+
940
+ export function findCardContent(
941
+ accountId: string,
942
+ conversationId: string,
943
+ repliedCreatedAt: number,
944
+ storePath?: string,
945
+ ): string | null {
946
+ if (!storePath) {
947
+ const scopeKey = `${accountId}:${conversationId}`;
948
+ const nowMs = Date.now();
949
+ const bucket = inMemoryCardContentStore.get(scopeKey);
950
+ if (!bucket) {
951
+ return null;
952
+ }
953
+ bucket.entries = pruneInMemoryCardContentEntries(bucket.entries, nowMs);
954
+ bucket.lastActiveAt = nowMs;
955
+ if (bucket.entries.length === 0) {
956
+ inMemoryCardContentStore.delete(scopeKey);
957
+ return null;
958
+ }
959
+ let bestContent: string | null = null;
960
+ let bestDelta = Infinity;
961
+ for (const entry of bucket.entries) {
962
+ const delta = Math.abs(entry.createdAt - repliedCreatedAt);
963
+ if (delta <= DEFAULT_CREATED_AT_MATCH_WINDOW_MS && delta < bestDelta) {
964
+ bestDelta = delta;
965
+ bestContent = entry.content;
966
+ }
967
+ }
968
+ return bestContent;
969
+ }
970
+ const record = resolveByCreatedAtWindow({
971
+ storePath,
972
+ accountId,
973
+ conversationId,
974
+ createdAt: repliedCreatedAt,
975
+ windowMs: DEFAULT_CREATED_AT_MATCH_WINDOW_MS,
976
+ direction: "outbound",
977
+ });
978
+ return record?.text || null;
979
+ }
980
+
981
+ export function clearCardContentCacheForTest(): void {
982
+ inMemoryCardContentStore.clear();
983
+ clearMessageContextCacheForTest();
344
984
  }