@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.
- package/LICENSE +21 -0
- package/README.md +808 -40
- package/index.ts +62 -0
- package/package.json +4 -2
- 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 +794 -62
- package/src/channel.ts +675 -179
- package/src/config-schema.ts +49 -5
- package/src/config.ts +136 -4
- package/src/connection-manager.ts +356 -36
- 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 +1191 -206
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +566 -8
- package/src/message-utils.ts +301 -39
- package/src/onboarding.ts +85 -3
- 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 +239 -59
- package/src/session-command-service.ts +147 -0
- package/src/session-lock.ts +34 -0
- package/src/session-peer-store.ts +77 -0
- package/src/session-routing.ts +33 -0
- package/src/types.ts +165 -22
- package/src/utils.ts +231 -12
package/src/card-service.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
return
|
|
41
|
+
function normalizeDegradeErrorMessage(value: string): string {
|
|
42
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
41
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
345
|
-
|
|
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
|
}
|