@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.
- package/LICENSE +21 -0
- package/README.md +657 -32
- package/index.ts +62 -0
- package/package.json +3 -1
- package/src/access-control.ts +18 -0
- package/src/ack-reaction-classifier.ts +62 -0
- package/src/ack-reaction-service.ts +135 -0
- package/src/attachment-text-extractor.ts +147 -0
- package/src/card-callback-service.ts +119 -0
- package/src/card-draft-controller.ts +114 -0
- package/src/card-service.ts +776 -24
- package/src/channel.ts +408 -135
- package/src/config-schema.ts +40 -4
- package/src/config.ts +136 -4
- package/src/connection-manager.ts +354 -47
- package/src/dedup.ts +1 -0
- package/src/docs-service.ts +198 -0
- package/src/draft-stream-loop.ts +119 -0
- package/src/feedback-learning-service.ts +643 -0
- package/src/feedback-learning-store.ts +543 -0
- package/src/group-members-store.ts +48 -14
- package/src/inbound-handler.ts +1100 -159
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +94 -50
- package/src/message-utils.ts +301 -39
- package/src/onboarding.ts +38 -0
- package/src/peer-id-registry.ts +102 -0
- package/src/persistence-store.ts +131 -0
- package/src/quote-journal.ts +242 -0
- package/src/quoted-file-service.ts +385 -0
- package/src/quoted-msg-cache.ts +226 -0
- package/src/send-service.ts +177 -43
- package/src/session-command-service.ts +147 -0
- package/src/session-lock.ts +2 -0
- package/src/session-peer-store.ts +77 -0
- package/src/session-routing.ts +33 -0
- package/src/types.ts +152 -24
- package/src/utils.ts +231 -12
package/src/card-service.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
30
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
325
|
-
|
|
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
|
}
|