@soimy/dingtalk 3.3.0 → 3.4.1
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/README.md +141 -12
- package/index.ts +71 -66
- package/package.json +6 -5
- package/src/access-control.ts +65 -0
- package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
- package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
- package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
- package/src/ack-reaction-classifier.ts +17 -4
- package/src/ack-reaction-service.ts +66 -19
- package/src/attachment-text-extractor.ts +2 -1
- package/src/card-service.ts +145 -257
- package/src/channel.ts +106 -47
- package/src/config-schema.ts +28 -6
- package/src/config.ts +30 -6
- package/src/connection-manager.ts +16 -5
- package/src/inbound-handler.ts +694 -520
- package/src/media-utils.ts +99 -36
- package/src/message-context-store.ts +787 -0
- package/src/message-utils.ts +221 -42
- package/src/messaging/quoted-context.ts +269 -0
- package/src/messaging/quoted-ref.ts +97 -0
- package/src/onboarding.ts +381 -269
- package/src/reply-strategy-card.ts +225 -0
- package/src/reply-strategy-markdown.ts +55 -0
- package/src/reply-strategy-with-reaction.ts +190 -0
- package/src/reply-strategy.ts +72 -0
- package/src/runtime.ts +5 -7
- package/src/send-service.ts +164 -62
- package/src/targeting/agent-name-matcher.ts +148 -0
- package/src/targeting/agent-routing.ts +181 -0
- package/src/targeting/target-directory-adapter.ts +152 -0
- package/src/targeting/target-directory-store.ts +396 -0
- package/src/targeting/target-input.ts +62 -0
- package/src/types.ts +124 -21
- package/src/quote-journal.ts +0 -242
- package/src/quoted-msg-cache.ts +0 -226
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readNamespaceJson, writeNamespaceJsonAtomic } from "./persistence-store";
|
|
3
|
+
import type { AttachmentTextSource, Logger, QuotedRef } from "./types";
|
|
4
|
+
|
|
5
|
+
const MESSAGE_CONTEXT_NAMESPACE = "messages.context";
|
|
6
|
+
const MESSAGE_CONTEXT_VERSION = 1;
|
|
7
|
+
export const DEFAULT_MESSAGE_CONTEXT_TTL_DAYS = 7;
|
|
8
|
+
export const DEFAULT_CARD_CONTENT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
export const DEFAULT_MEDIA_CONTEXT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
export const DEFAULT_CREATED_AT_MATCH_WINDOW_MS = 2000;
|
|
11
|
+
const MAX_RECORDS_PER_SCOPE = 1000;
|
|
12
|
+
|
|
13
|
+
export type MessageContextDirection = "inbound" | "outbound";
|
|
14
|
+
export type MessageAliasKind = "inboundMsgId" | "messageId" | "processQueryKey" | "outTrackId" | "cardInstanceId";
|
|
15
|
+
export type MessageDeliveryKind = "session" | "proactive-text" | "proactive-card" | "proactive-media";
|
|
16
|
+
|
|
17
|
+
export interface MessageRecord {
|
|
18
|
+
msgId: string;
|
|
19
|
+
direction: MessageContextDirection;
|
|
20
|
+
topic: string | null;
|
|
21
|
+
accountId: string;
|
|
22
|
+
conversationId: string | null;
|
|
23
|
+
createdAt: number;
|
|
24
|
+
updatedAt: number;
|
|
25
|
+
expiresAt?: number;
|
|
26
|
+
messageType?: string;
|
|
27
|
+
text?: string;
|
|
28
|
+
attachmentText?: string;
|
|
29
|
+
attachmentTextSource?: AttachmentTextSource;
|
|
30
|
+
attachmentTextTruncated?: boolean;
|
|
31
|
+
attachmentFileName?: string;
|
|
32
|
+
quotedRef?: QuotedRef;
|
|
33
|
+
media?: {
|
|
34
|
+
downloadCode?: string;
|
|
35
|
+
spaceId?: string;
|
|
36
|
+
fileId?: string;
|
|
37
|
+
};
|
|
38
|
+
delivery?: {
|
|
39
|
+
messageId?: string;
|
|
40
|
+
processQueryKey?: string;
|
|
41
|
+
outTrackId?: string;
|
|
42
|
+
cardInstanceId?: string;
|
|
43
|
+
kind?: MessageDeliveryKind;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface MessageContextState {
|
|
48
|
+
version: number;
|
|
49
|
+
updatedAt: number;
|
|
50
|
+
records: Record<string, MessageRecord>;
|
|
51
|
+
byAlias: Record<string, string>;
|
|
52
|
+
recentByCreatedAt: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface PersistedMessageContextState {
|
|
56
|
+
version: number;
|
|
57
|
+
updatedAt: number;
|
|
58
|
+
records: Record<string, MessageRecord>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface BaseUpsertParams {
|
|
62
|
+
storePath?: string;
|
|
63
|
+
accountId: string;
|
|
64
|
+
conversationId: string | null;
|
|
65
|
+
createdAt: number;
|
|
66
|
+
updatedAt?: number;
|
|
67
|
+
ttlMs?: number;
|
|
68
|
+
ttlReferenceMs?: number;
|
|
69
|
+
topic?: string | null;
|
|
70
|
+
messageType?: string;
|
|
71
|
+
text?: string;
|
|
72
|
+
attachmentText?: string;
|
|
73
|
+
attachmentTextSource?: AttachmentTextSource;
|
|
74
|
+
attachmentTextTruncated?: boolean;
|
|
75
|
+
attachmentFileName?: string;
|
|
76
|
+
quotedRef?: QuotedRef;
|
|
77
|
+
media?: {
|
|
78
|
+
downloadCode?: string;
|
|
79
|
+
spaceId?: string;
|
|
80
|
+
fileId?: string;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface UpsertInboundMessageContextParams extends BaseUpsertParams {
|
|
85
|
+
msgId: string;
|
|
86
|
+
cleanupCreatedAtTtlDays?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface UpsertOutboundMessageContextParams extends BaseUpsertParams {
|
|
90
|
+
msgId?: string;
|
|
91
|
+
delivery?: MessageRecord["delivery"];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface ScopeParams {
|
|
95
|
+
storePath?: string;
|
|
96
|
+
accountId: string;
|
|
97
|
+
conversationId: string | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const stateCache = new Map<string, MessageContextState>();
|
|
101
|
+
|
|
102
|
+
function getScopeKey(params: ScopeParams): string {
|
|
103
|
+
return JSON.stringify([params.storePath || "__memory__", params.accountId, params.conversationId || null]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function fallbackState(): MessageContextState {
|
|
107
|
+
return {
|
|
108
|
+
version: MESSAGE_CONTEXT_VERSION,
|
|
109
|
+
updatedAt: Date.now(),
|
|
110
|
+
records: {},
|
|
111
|
+
byAlias: {},
|
|
112
|
+
recentByCreatedAt: [],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
117
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return value as Record<string, unknown>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeMedia(value: unknown): MessageRecord["media"] | undefined {
|
|
124
|
+
const candidate = asRecord(value);
|
|
125
|
+
if (!candidate) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const downloadCode = typeof candidate.downloadCode === "string" && candidate.downloadCode.trim()
|
|
129
|
+
? candidate.downloadCode.trim()
|
|
130
|
+
: undefined;
|
|
131
|
+
const spaceId = typeof candidate.spaceId === "string" && candidate.spaceId.trim()
|
|
132
|
+
? candidate.spaceId.trim()
|
|
133
|
+
: undefined;
|
|
134
|
+
const fileId = typeof candidate.fileId === "string" && candidate.fileId.trim()
|
|
135
|
+
? candidate.fileId.trim()
|
|
136
|
+
: undefined;
|
|
137
|
+
if (!downloadCode && !spaceId && !fileId) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return { downloadCode, spaceId, fileId };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function normalizeQuotedRef(value: unknown): QuotedRef | undefined {
|
|
144
|
+
const candidate = asRecord(value);
|
|
145
|
+
if (!candidate) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
const targetDirection =
|
|
149
|
+
candidate.targetDirection === "outbound"
|
|
150
|
+
? "outbound"
|
|
151
|
+
: candidate.targetDirection === "inbound"
|
|
152
|
+
? "inbound"
|
|
153
|
+
: undefined;
|
|
154
|
+
const key =
|
|
155
|
+
candidate.key === "msgId" ||
|
|
156
|
+
candidate.key === "messageId" ||
|
|
157
|
+
candidate.key === "processQueryKey" ||
|
|
158
|
+
candidate.key === "outTrackId" ||
|
|
159
|
+
candidate.key === "cardInstanceId"
|
|
160
|
+
? candidate.key
|
|
161
|
+
: undefined;
|
|
162
|
+
const valueString =
|
|
163
|
+
typeof candidate.value === "string" && candidate.value.trim() ? candidate.value.trim() : undefined;
|
|
164
|
+
const fallbackCreatedAt =
|
|
165
|
+
typeof candidate.fallbackCreatedAt === "number" && Number.isFinite(candidate.fallbackCreatedAt)
|
|
166
|
+
? candidate.fallbackCreatedAt
|
|
167
|
+
: undefined;
|
|
168
|
+
if (!targetDirection) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
if (!key && !fallbackCreatedAt) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
if (key && !valueString) {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
targetDirection,
|
|
179
|
+
key,
|
|
180
|
+
value: valueString,
|
|
181
|
+
fallbackCreatedAt,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeAttachmentTextSource(value: unknown): AttachmentTextSource | undefined {
|
|
186
|
+
return value === "text" || value === "html" || value === "pdf" || value === "docx"
|
|
187
|
+
? value
|
|
188
|
+
: undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function normalizeDelivery(value: unknown): MessageRecord["delivery"] | undefined {
|
|
192
|
+
const candidate = asRecord(value);
|
|
193
|
+
if (!candidate) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
const messageId = typeof candidate.messageId === "string" && candidate.messageId.trim()
|
|
197
|
+
? candidate.messageId.trim()
|
|
198
|
+
: undefined;
|
|
199
|
+
const processQueryKey = typeof candidate.processQueryKey === "string" && candidate.processQueryKey.trim()
|
|
200
|
+
? candidate.processQueryKey.trim()
|
|
201
|
+
: undefined;
|
|
202
|
+
const outTrackId = typeof candidate.outTrackId === "string" && candidate.outTrackId.trim()
|
|
203
|
+
? candidate.outTrackId.trim()
|
|
204
|
+
: undefined;
|
|
205
|
+
const cardInstanceId = typeof candidate.cardInstanceId === "string" && candidate.cardInstanceId.trim()
|
|
206
|
+
? candidate.cardInstanceId.trim()
|
|
207
|
+
: undefined;
|
|
208
|
+
const kind = typeof candidate.kind === "string" && candidate.kind.trim()
|
|
209
|
+
? (candidate.kind.trim() as MessageDeliveryKind)
|
|
210
|
+
: undefined;
|
|
211
|
+
if (!messageId && !processQueryKey && !outTrackId && !cardInstanceId && !kind) {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
return { messageId, processQueryKey, outTrackId, cardInstanceId, kind };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizeMessageRecord(value: unknown): MessageRecord | null {
|
|
218
|
+
const candidate = asRecord(value);
|
|
219
|
+
if (!candidate) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const msgId = typeof candidate.msgId === "string" && candidate.msgId.trim() ? candidate.msgId.trim() : "";
|
|
223
|
+
const direction = candidate.direction === "outbound" ? "outbound" : candidate.direction === "inbound" ? "inbound" : null;
|
|
224
|
+
const accountId = typeof candidate.accountId === "string" && candidate.accountId.trim()
|
|
225
|
+
? candidate.accountId.trim()
|
|
226
|
+
: "";
|
|
227
|
+
const conversationId = typeof candidate.conversationId === "string" && candidate.conversationId.trim()
|
|
228
|
+
? candidate.conversationId.trim()
|
|
229
|
+
: null;
|
|
230
|
+
const createdAt = typeof candidate.createdAt === "number" && Number.isFinite(candidate.createdAt)
|
|
231
|
+
? candidate.createdAt
|
|
232
|
+
: NaN;
|
|
233
|
+
const updatedAt = typeof candidate.updatedAt === "number" && Number.isFinite(candidate.updatedAt)
|
|
234
|
+
? candidate.updatedAt
|
|
235
|
+
: Date.now();
|
|
236
|
+
if (!msgId || !direction || !accountId || !Number.isFinite(createdAt)) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
const expiresAt = typeof candidate.expiresAt === "number" && Number.isFinite(candidate.expiresAt)
|
|
240
|
+
? candidate.expiresAt
|
|
241
|
+
: undefined;
|
|
242
|
+
return {
|
|
243
|
+
msgId,
|
|
244
|
+
direction,
|
|
245
|
+
topic: candidate.topic === null ? null : typeof candidate.topic === "string" ? candidate.topic : null,
|
|
246
|
+
accountId,
|
|
247
|
+
conversationId,
|
|
248
|
+
createdAt,
|
|
249
|
+
updatedAt,
|
|
250
|
+
expiresAt,
|
|
251
|
+
messageType: typeof candidate.messageType === "string" ? candidate.messageType : undefined,
|
|
252
|
+
text: typeof candidate.text === "string" ? candidate.text : undefined,
|
|
253
|
+
attachmentText: typeof candidate.attachmentText === "string" ? candidate.attachmentText : undefined,
|
|
254
|
+
attachmentTextSource: normalizeAttachmentTextSource(candidate.attachmentTextSource),
|
|
255
|
+
attachmentTextTruncated: candidate.attachmentTextTruncated === true ? true : undefined,
|
|
256
|
+
attachmentFileName:
|
|
257
|
+
typeof candidate.attachmentFileName === "string" ? candidate.attachmentFileName : undefined,
|
|
258
|
+
quotedRef: normalizeQuotedRef(candidate.quotedRef),
|
|
259
|
+
media: normalizeMedia(candidate.media),
|
|
260
|
+
delivery: normalizeDelivery(candidate.delivery),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildAliasKey(kind: MessageAliasKind, value: string): string {
|
|
265
|
+
return `${kind}:${value.trim()}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function buildAliasEntries(record: MessageRecord): Array<[string, string]> {
|
|
269
|
+
const aliases: Array<[string, string]> = [];
|
|
270
|
+
if (record.direction === "inbound" && record.msgId.trim()) {
|
|
271
|
+
aliases.push([buildAliasKey("inboundMsgId", record.msgId), record.msgId]);
|
|
272
|
+
}
|
|
273
|
+
const delivery = record.delivery;
|
|
274
|
+
if (!delivery) {
|
|
275
|
+
return aliases;
|
|
276
|
+
}
|
|
277
|
+
if (delivery.messageId) {
|
|
278
|
+
aliases.push([buildAliasKey("messageId", delivery.messageId), record.msgId]);
|
|
279
|
+
}
|
|
280
|
+
if (delivery.processQueryKey) {
|
|
281
|
+
aliases.push([buildAliasKey("processQueryKey", delivery.processQueryKey), record.msgId]);
|
|
282
|
+
}
|
|
283
|
+
if (delivery.outTrackId) {
|
|
284
|
+
aliases.push([buildAliasKey("outTrackId", delivery.outTrackId), record.msgId]);
|
|
285
|
+
}
|
|
286
|
+
if (delivery.cardInstanceId) {
|
|
287
|
+
aliases.push([buildAliasKey("cardInstanceId", delivery.cardInstanceId), record.msgId]);
|
|
288
|
+
}
|
|
289
|
+
return aliases;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isRecordExpired(record: MessageRecord, nowMs: number): boolean {
|
|
293
|
+
return typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt) && nowMs >= record.expiresAt;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function normalizeState(state: MessageContextState, nowMs: number): { state: MessageContextState; removed: number } {
|
|
297
|
+
const normalizedRecords: Record<string, MessageRecord> = {};
|
|
298
|
+
const sortedRecords = Object.values(state.records)
|
|
299
|
+
.map((record) => normalizeMessageRecord(record))
|
|
300
|
+
.filter((record): record is MessageRecord => record !== null)
|
|
301
|
+
.filter((record) => !isRecordExpired(record, nowMs))
|
|
302
|
+
.toSorted((left, right) => left.createdAt - right.createdAt);
|
|
303
|
+
const keptRecords = sortedRecords.slice(-MAX_RECORDS_PER_SCOPE);
|
|
304
|
+
for (const record of keptRecords) {
|
|
305
|
+
normalizedRecords[record.msgId] = record;
|
|
306
|
+
}
|
|
307
|
+
const byAlias: Record<string, string> = {};
|
|
308
|
+
for (const record of keptRecords) {
|
|
309
|
+
for (const [key, value] of buildAliasEntries(record)) {
|
|
310
|
+
byAlias[key] = value;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
removed: Object.keys(state.records).length - Object.keys(normalizedRecords).length,
|
|
315
|
+
state: {
|
|
316
|
+
version: MESSAGE_CONTEXT_VERSION,
|
|
317
|
+
updatedAt: state.updatedAt,
|
|
318
|
+
records: normalizedRecords,
|
|
319
|
+
byAlias,
|
|
320
|
+
recentByCreatedAt: keptRecords.map((record) => record.msgId),
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function hydrateState(params: ScopeParams, nowMs: number): MessageContextState {
|
|
326
|
+
if (!params.storePath) {
|
|
327
|
+
return fallbackState();
|
|
328
|
+
}
|
|
329
|
+
const persisted = readNamespaceJson<Partial<PersistedMessageContextState>>(MESSAGE_CONTEXT_NAMESPACE, {
|
|
330
|
+
storePath: params.storePath,
|
|
331
|
+
scope: { accountId: params.accountId, conversationId: params.conversationId || undefined },
|
|
332
|
+
format: "json",
|
|
333
|
+
fallback: { version: MESSAGE_CONTEXT_VERSION, updatedAt: Date.now(), records: {} },
|
|
334
|
+
});
|
|
335
|
+
const parsedRecords = asRecord(persisted.records) || {};
|
|
336
|
+
const hydrated = fallbackState();
|
|
337
|
+
hydrated.updatedAt = typeof persisted.updatedAt === "number" ? persisted.updatedAt : Date.now();
|
|
338
|
+
for (const [key, value] of Object.entries(parsedRecords)) {
|
|
339
|
+
const normalized = normalizeMessageRecord(value);
|
|
340
|
+
if (normalized) {
|
|
341
|
+
hydrated.records[key] = normalized;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return normalizeState(hydrated, nowMs).state;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function loadState(params: ScopeParams, nowMs: number = Date.now()): MessageContextState {
|
|
348
|
+
const scopeKey = getScopeKey(params);
|
|
349
|
+
const cached = stateCache.get(scopeKey);
|
|
350
|
+
if (cached) {
|
|
351
|
+
return cached;
|
|
352
|
+
}
|
|
353
|
+
const hydrated = params.storePath ? hydrateState(params, nowMs) : fallbackState();
|
|
354
|
+
stateCache.set(scopeKey, hydrated);
|
|
355
|
+
return hydrated;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function writeState(params: ScopeParams, state: MessageContextState): void {
|
|
359
|
+
stateCache.set(getScopeKey(params), state);
|
|
360
|
+
if (!params.storePath) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
writeNamespaceJsonAtomic(MESSAGE_CONTEXT_NAMESPACE, {
|
|
364
|
+
storePath: params.storePath,
|
|
365
|
+
scope: { accountId: params.accountId, conversationId: params.conversationId || undefined },
|
|
366
|
+
format: "json",
|
|
367
|
+
data: {
|
|
368
|
+
version: MESSAGE_CONTEXT_VERSION,
|
|
369
|
+
updatedAt: state.updatedAt,
|
|
370
|
+
records: state.records,
|
|
371
|
+
} satisfies PersistedMessageContextState,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function cloneStateForMutation(state: MessageContextState): MessageContextState {
|
|
376
|
+
return {
|
|
377
|
+
version: state.version,
|
|
378
|
+
updatedAt: state.updatedAt,
|
|
379
|
+
records: { ...state.records },
|
|
380
|
+
byAlias: state.byAlias,
|
|
381
|
+
recentByCreatedAt: state.recentByCreatedAt,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function mergeText(existing: string | undefined, next: string | undefined): string | undefined {
|
|
386
|
+
if (typeof next !== "string") {
|
|
387
|
+
return existing;
|
|
388
|
+
}
|
|
389
|
+
return next;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function mergeAttachmentText(existing: string | undefined, next: string | undefined): string | undefined {
|
|
393
|
+
if (typeof next !== "string") {
|
|
394
|
+
return existing;
|
|
395
|
+
}
|
|
396
|
+
return next;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function mergeQuotedRef(existing: QuotedRef | undefined, next: QuotedRef | undefined): QuotedRef | undefined {
|
|
400
|
+
if (!existing) {
|
|
401
|
+
return next;
|
|
402
|
+
}
|
|
403
|
+
if (!next) {
|
|
404
|
+
return existing;
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
targetDirection: next.targetDirection,
|
|
408
|
+
key: next.key || existing.key,
|
|
409
|
+
value: next.value || existing.value,
|
|
410
|
+
fallbackCreatedAt: next.fallbackCreatedAt ?? existing.fallbackCreatedAt,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function mergeMedia(
|
|
415
|
+
existing: MessageRecord["media"] | undefined,
|
|
416
|
+
next: MessageRecord["media"] | undefined,
|
|
417
|
+
): MessageRecord["media"] | undefined {
|
|
418
|
+
if (!existing) {
|
|
419
|
+
return next;
|
|
420
|
+
}
|
|
421
|
+
if (!next) {
|
|
422
|
+
return existing;
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
downloadCode: next.downloadCode || existing.downloadCode,
|
|
426
|
+
spaceId: next.spaceId || existing.spaceId,
|
|
427
|
+
fileId: next.fileId || existing.fileId,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function mergeDelivery(
|
|
432
|
+
existing: MessageRecord["delivery"] | undefined,
|
|
433
|
+
next: MessageRecord["delivery"] | undefined,
|
|
434
|
+
): MessageRecord["delivery"] | undefined {
|
|
435
|
+
if (!existing) {
|
|
436
|
+
return next;
|
|
437
|
+
}
|
|
438
|
+
if (!next) {
|
|
439
|
+
return existing;
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
messageId: next.messageId || existing.messageId,
|
|
443
|
+
processQueryKey: next.processQueryKey || existing.processQueryKey,
|
|
444
|
+
outTrackId: next.outTrackId || existing.outTrackId,
|
|
445
|
+
cardInstanceId: next.cardInstanceId || existing.cardInstanceId,
|
|
446
|
+
kind: next.kind || existing.kind,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function mergeAttachmentTextSource(
|
|
451
|
+
existing: AttachmentTextSource | undefined,
|
|
452
|
+
next: AttachmentTextSource | undefined,
|
|
453
|
+
): AttachmentTextSource | undefined {
|
|
454
|
+
return next ?? existing;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function mergeAttachmentTextTruncated(
|
|
458
|
+
existing: boolean | undefined,
|
|
459
|
+
next: boolean | undefined,
|
|
460
|
+
): boolean | undefined {
|
|
461
|
+
return next === undefined ? existing : next;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function mergeAttachmentFileName(existing: string | undefined, next: string | undefined): string | undefined {
|
|
465
|
+
if (typeof next !== "string") {
|
|
466
|
+
return existing;
|
|
467
|
+
}
|
|
468
|
+
return next;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function resolveExistingMsgId(
|
|
472
|
+
state: MessageContextState,
|
|
473
|
+
params: { direction: MessageContextDirection; msgId?: string; delivery?: MessageRecord["delivery"] },
|
|
474
|
+
nowMs: number,
|
|
475
|
+
): string | undefined {
|
|
476
|
+
if (params.direction === "inbound" && params.msgId) {
|
|
477
|
+
const direct = state.records[params.msgId];
|
|
478
|
+
if (direct && !isRecordExpired(direct, nowMs)) {
|
|
479
|
+
return params.msgId;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const delivery = params.delivery;
|
|
483
|
+
if (!delivery) {
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
const candidates: Array<[MessageAliasKind, string | undefined]> = [
|
|
487
|
+
["messageId", delivery.messageId],
|
|
488
|
+
["processQueryKey", delivery.processQueryKey],
|
|
489
|
+
["outTrackId", delivery.outTrackId],
|
|
490
|
+
["cardInstanceId", delivery.cardInstanceId],
|
|
491
|
+
];
|
|
492
|
+
for (const [kind, value] of candidates) {
|
|
493
|
+
if (!value) {
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
const aliasHit = state.byAlias[buildAliasKey(kind, value)];
|
|
497
|
+
if (!aliasHit) {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const record = state.records[aliasHit];
|
|
501
|
+
if (record && !isRecordExpired(record, nowMs)) {
|
|
502
|
+
return aliasHit;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function computeExpiresAt(nowMs: number, ttlMs?: number, ttlReferenceMs?: number): number | undefined {
|
|
509
|
+
if (typeof ttlMs !== "number" || !Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
return (typeof ttlReferenceMs === "number" && Number.isFinite(ttlReferenceMs) ? ttlReferenceMs : nowMs) + ttlMs;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function pruneStateByCreatedAt(
|
|
516
|
+
state: MessageContextState,
|
|
517
|
+
ttlDays: number,
|
|
518
|
+
nowMs: number,
|
|
519
|
+
): { state: MessageContextState; removed: number } {
|
|
520
|
+
if (!ttlDays || ttlDays <= 0) {
|
|
521
|
+
return { state, removed: 0 };
|
|
522
|
+
}
|
|
523
|
+
const cutoff = nowMs - ttlDays * 24 * 60 * 60 * 1000;
|
|
524
|
+
const nextRecords: Record<string, MessageRecord> = {};
|
|
525
|
+
for (const [msgId, record] of Object.entries(state.records)) {
|
|
526
|
+
if (record.createdAt >= cutoff) {
|
|
527
|
+
nextRecords[msgId] = record;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const removed = Object.keys(state.records).length - Object.keys(nextRecords).length;
|
|
531
|
+
if (removed === 0) {
|
|
532
|
+
return { state, removed: 0 };
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
removed,
|
|
536
|
+
state: {
|
|
537
|
+
...state,
|
|
538
|
+
records: nextRecords,
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function upsertRecord(
|
|
544
|
+
params: ScopeParams & {
|
|
545
|
+
direction: MessageContextDirection;
|
|
546
|
+
msgId?: string;
|
|
547
|
+
createdAt: number;
|
|
548
|
+
updatedAt?: number;
|
|
549
|
+
topic?: string | null;
|
|
550
|
+
ttlMs?: number;
|
|
551
|
+
ttlReferenceMs?: number;
|
|
552
|
+
messageType?: string;
|
|
553
|
+
text?: string;
|
|
554
|
+
attachmentText?: string;
|
|
555
|
+
attachmentTextSource?: AttachmentTextSource;
|
|
556
|
+
attachmentTextTruncated?: boolean;
|
|
557
|
+
attachmentFileName?: string;
|
|
558
|
+
quotedRef?: QuotedRef;
|
|
559
|
+
media?: MessageRecord["media"];
|
|
560
|
+
delivery?: MessageRecord["delivery"];
|
|
561
|
+
cleanupCreatedAtTtlDays?: number;
|
|
562
|
+
},
|
|
563
|
+
): string | undefined {
|
|
564
|
+
const nowMs = params.updatedAt ?? Date.now();
|
|
565
|
+
let state = cloneStateForMutation(loadState(params, nowMs));
|
|
566
|
+
if (params.cleanupCreatedAtTtlDays && params.cleanupCreatedAtTtlDays > 0) {
|
|
567
|
+
state = pruneStateByCreatedAt(state, params.cleanupCreatedAtTtlDays, nowMs).state;
|
|
568
|
+
}
|
|
569
|
+
const existingMsgId = resolveExistingMsgId(state, {
|
|
570
|
+
direction: params.direction,
|
|
571
|
+
msgId: params.msgId,
|
|
572
|
+
delivery: params.delivery,
|
|
573
|
+
}, nowMs);
|
|
574
|
+
const canonicalMsgId =
|
|
575
|
+
existingMsgId ||
|
|
576
|
+
params.msgId ||
|
|
577
|
+
params.delivery?.messageId ||
|
|
578
|
+
params.delivery?.processQueryKey ||
|
|
579
|
+
params.delivery?.outTrackId;
|
|
580
|
+
if (!canonicalMsgId || !canonicalMsgId.trim()) {
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
583
|
+
const existing = state.records[canonicalMsgId];
|
|
584
|
+
const expiresAt = computeExpiresAt(nowMs, params.ttlMs, params.ttlReferenceMs);
|
|
585
|
+
const normalizedQuotedRef = normalizeQuotedRef(params.quotedRef);
|
|
586
|
+
state.records[canonicalMsgId] = {
|
|
587
|
+
msgId: canonicalMsgId,
|
|
588
|
+
direction: params.direction,
|
|
589
|
+
topic: params.topic ?? existing?.topic ?? null,
|
|
590
|
+
accountId: params.accountId,
|
|
591
|
+
conversationId: params.conversationId,
|
|
592
|
+
createdAt: existing?.createdAt ?? params.createdAt,
|
|
593
|
+
updatedAt: nowMs,
|
|
594
|
+
expiresAt:
|
|
595
|
+
expiresAt === undefined
|
|
596
|
+
? existing?.expiresAt
|
|
597
|
+
: Math.max(expiresAt, existing?.expiresAt ?? 0),
|
|
598
|
+
messageType: params.messageType || existing?.messageType,
|
|
599
|
+
text: mergeText(existing?.text, params.text),
|
|
600
|
+
attachmentText: mergeAttachmentText(existing?.attachmentText, params.attachmentText),
|
|
601
|
+
attachmentTextSource: mergeAttachmentTextSource(
|
|
602
|
+
existing?.attachmentTextSource,
|
|
603
|
+
params.attachmentTextSource,
|
|
604
|
+
),
|
|
605
|
+
attachmentTextTruncated: mergeAttachmentTextTruncated(
|
|
606
|
+
existing?.attachmentTextTruncated,
|
|
607
|
+
params.attachmentTextTruncated,
|
|
608
|
+
),
|
|
609
|
+
attachmentFileName: mergeAttachmentFileName(
|
|
610
|
+
existing?.attachmentFileName,
|
|
611
|
+
params.attachmentFileName,
|
|
612
|
+
),
|
|
613
|
+
quotedRef: mergeQuotedRef(existing?.quotedRef, normalizedQuotedRef),
|
|
614
|
+
media: mergeMedia(existing?.media, params.media),
|
|
615
|
+
delivery: mergeDelivery(existing?.delivery, params.delivery),
|
|
616
|
+
};
|
|
617
|
+
state.updatedAt = nowMs;
|
|
618
|
+
writeState(params, normalizeState(state, nowMs).state);
|
|
619
|
+
return canonicalMsgId;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function upsertInboundMessageContext(params: UpsertInboundMessageContextParams): string {
|
|
623
|
+
return (
|
|
624
|
+
upsertRecord({
|
|
625
|
+
...params,
|
|
626
|
+
direction: "inbound",
|
|
627
|
+
topic: params.topic ?? null,
|
|
628
|
+
msgId: params.msgId,
|
|
629
|
+
cleanupCreatedAtTtlDays: params.cleanupCreatedAtTtlDays,
|
|
630
|
+
}) || params.msgId
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export function upsertOutboundMessageContext(params: UpsertOutboundMessageContextParams): string | undefined {
|
|
635
|
+
return upsertRecord({
|
|
636
|
+
...params,
|
|
637
|
+
direction: "outbound",
|
|
638
|
+
topic: params.topic ?? null,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export function createSyntheticOutboundMsgId(createdAt: number): string {
|
|
643
|
+
return `createdAt:${createdAt}:${randomUUID()}`;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export function resolveByMsgId(
|
|
647
|
+
params: ScopeParams & { msgId: string; nowMs?: number },
|
|
648
|
+
): MessageRecord | null {
|
|
649
|
+
const nowMs = params.nowMs ?? Date.now();
|
|
650
|
+
const state = loadState(params, nowMs);
|
|
651
|
+
const direct = state.records[params.msgId];
|
|
652
|
+
if (direct && !isRecordExpired(direct, nowMs)) {
|
|
653
|
+
return direct;
|
|
654
|
+
}
|
|
655
|
+
const aliasTarget = state.byAlias[buildAliasKey("inboundMsgId", params.msgId)];
|
|
656
|
+
if (!aliasTarget) {
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
const record = state.records[aliasTarget];
|
|
660
|
+
return record && !isRecordExpired(record, nowMs) ? record : null;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export function resolveByAlias(
|
|
664
|
+
params: ScopeParams & { kind: MessageAliasKind; value: string; nowMs?: number },
|
|
665
|
+
): MessageRecord | null {
|
|
666
|
+
const nowMs = params.nowMs ?? Date.now();
|
|
667
|
+
const state = loadState(params, nowMs);
|
|
668
|
+
const msgId = state.byAlias[buildAliasKey(params.kind, params.value)];
|
|
669
|
+
if (!msgId) {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
const record = state.records[msgId];
|
|
673
|
+
return record && !isRecordExpired(record, nowMs) ? record : null;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export function resolveByQuotedRef(
|
|
677
|
+
params: ScopeParams & { quotedRef: QuotedRef; nowMs?: number; log?: Logger },
|
|
678
|
+
): MessageRecord | null {
|
|
679
|
+
const nowMs = params.nowMs ?? Date.now();
|
|
680
|
+
const quotedRef = normalizeQuotedRef(params.quotedRef);
|
|
681
|
+
const log = params.log;
|
|
682
|
+
if (!quotedRef) {
|
|
683
|
+
log?.debug?.(
|
|
684
|
+
`[DingTalk][QuotedRef] Resolve skipped: invalid quotedRef accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`,
|
|
685
|
+
);
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
if (quotedRef.targetDirection === "inbound") {
|
|
689
|
+
if (quotedRef.key !== "msgId" || !quotedRef.value) {
|
|
690
|
+
log?.debug?.(
|
|
691
|
+
`[DingTalk][QuotedRef] Resolve skipped: inbound quotedRef missing msgId accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`,
|
|
692
|
+
);
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
const record = resolveByMsgId({
|
|
696
|
+
...params,
|
|
697
|
+
msgId: quotedRef.value,
|
|
698
|
+
nowMs,
|
|
699
|
+
});
|
|
700
|
+
log?.debug?.(
|
|
701
|
+
`[DingTalk][QuotedRef] Resolve inbound by msgId=${quotedRef.value} hit=${record ? "yes" : "no"} accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`,
|
|
702
|
+
);
|
|
703
|
+
return record;
|
|
704
|
+
}
|
|
705
|
+
if (quotedRef.key && quotedRef.value && quotedRef.key !== "msgId") {
|
|
706
|
+
const aliasRecord = resolveByAlias({
|
|
707
|
+
...params,
|
|
708
|
+
kind: quotedRef.key,
|
|
709
|
+
value: quotedRef.value,
|
|
710
|
+
nowMs,
|
|
711
|
+
});
|
|
712
|
+
if (aliasRecord) {
|
|
713
|
+
log?.debug?.(
|
|
714
|
+
`[DingTalk][QuotedRef] Resolve outbound by ${quotedRef.key}=${quotedRef.value} hit=yes accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`,
|
|
715
|
+
);
|
|
716
|
+
return aliasRecord;
|
|
717
|
+
}
|
|
718
|
+
log?.debug?.(
|
|
719
|
+
`[DingTalk][QuotedRef] Resolve outbound by ${quotedRef.key}=${quotedRef.value} hit=no accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
if (typeof quotedRef.fallbackCreatedAt === "number" && Number.isFinite(quotedRef.fallbackCreatedAt)) {
|
|
723
|
+
const record = resolveByCreatedAtWindow({
|
|
724
|
+
...params,
|
|
725
|
+
createdAt: quotedRef.fallbackCreatedAt,
|
|
726
|
+
direction: "outbound",
|
|
727
|
+
nowMs,
|
|
728
|
+
});
|
|
729
|
+
log?.debug?.(
|
|
730
|
+
`[DingTalk][QuotedRef] Resolve outbound by createdAt=${quotedRef.fallbackCreatedAt} hit=${record ? "yes" : "no"} accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`,
|
|
731
|
+
);
|
|
732
|
+
return record;
|
|
733
|
+
}
|
|
734
|
+
log?.debug?.(
|
|
735
|
+
`[DingTalk][QuotedRef] Resolve outbound missed without createdAt fallback accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`,
|
|
736
|
+
);
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export function resolveByCreatedAtWindow(
|
|
741
|
+
params: ScopeParams & {
|
|
742
|
+
createdAt: number;
|
|
743
|
+
windowMs?: number;
|
|
744
|
+
direction?: MessageContextDirection;
|
|
745
|
+
nowMs?: number;
|
|
746
|
+
},
|
|
747
|
+
): MessageRecord | null {
|
|
748
|
+
const nowMs = params.nowMs ?? Date.now();
|
|
749
|
+
const windowMs = params.windowMs ?? DEFAULT_CREATED_AT_MATCH_WINDOW_MS;
|
|
750
|
+
const state = loadState(params, nowMs);
|
|
751
|
+
let bestRecord: MessageRecord | null = null;
|
|
752
|
+
let bestDelta = Infinity;
|
|
753
|
+
for (const msgId of state.recentByCreatedAt) {
|
|
754
|
+
const record = state.records[msgId];
|
|
755
|
+
if (!record || isRecordExpired(record, nowMs)) {
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (params.direction && record.direction !== params.direction) {
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
const delta = Math.abs(record.createdAt - params.createdAt);
|
|
762
|
+
if (delta <= windowMs && delta < bestDelta) {
|
|
763
|
+
bestDelta = delta;
|
|
764
|
+
bestRecord = record;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return bestRecord;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export function cleanupExpiredMessageContexts(
|
|
771
|
+
params: ScopeParams & { nowMs?: number },
|
|
772
|
+
): number {
|
|
773
|
+
const nowMs = params.nowMs ?? Date.now();
|
|
774
|
+
const state = loadState(params, nowMs);
|
|
775
|
+
const beforeCount = Object.keys(state.records).length;
|
|
776
|
+
const normalized = normalizeState(state, nowMs).state;
|
|
777
|
+
const removed = beforeCount - Object.keys(normalized.records).length;
|
|
778
|
+
if (removed > 0) {
|
|
779
|
+
normalized.updatedAt = nowMs;
|
|
780
|
+
writeState(params, normalized);
|
|
781
|
+
}
|
|
782
|
+
return removed;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
export function clearMessageContextCacheForTest(): void {
|
|
786
|
+
stateCache.clear();
|
|
787
|
+
}
|