@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.
@@ -0,0 +1,131 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { Logger } from "./types";
4
+
5
+ type NamespaceFormat = "json";
6
+
7
+ export interface PersistenceScope {
8
+ accountId?: string;
9
+ agentId?: string;
10
+ conversationId?: string;
11
+ groupId?: string;
12
+ targetId?: string;
13
+ }
14
+
15
+ export interface ResolveNamespacePathOptions {
16
+ storePath: string;
17
+ scope?: PersistenceScope;
18
+ format?: NamespaceFormat;
19
+ }
20
+
21
+ export interface ReadNamespaceJsonOptions<T> extends ResolveNamespacePathOptions {
22
+ fallback: T;
23
+ log?: Logger;
24
+ }
25
+
26
+ export interface WriteNamespaceJsonOptions<T> extends ResolveNamespacePathOptions {
27
+ data: T;
28
+ log?: Logger;
29
+ }
30
+
31
+ const NAMESPACE_ROOT_DIR = "dingtalk-state";
32
+
33
+ function toErrorMessage(err: unknown): string {
34
+ if (err instanceof Error) {
35
+ return err.message;
36
+ }
37
+ try {
38
+ return JSON.stringify(err);
39
+ } catch {
40
+ return String(err);
41
+ }
42
+ }
43
+
44
+ function sanitizeSegment(value: string): string {
45
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_");
46
+ }
47
+
48
+ function encodeScopeValue(value: string): string {
49
+ return Buffer.from(value, "utf8").toString("base64url");
50
+ }
51
+
52
+ function buildScopeSuffix(scope?: PersistenceScope): string {
53
+ if (!scope) {
54
+ return "";
55
+ }
56
+ const ordered: Array<[keyof PersistenceScope, string | undefined]> = [
57
+ ["accountId", scope.accountId],
58
+ ["agentId", scope.agentId],
59
+ ["conversationId", scope.conversationId],
60
+ ["groupId", scope.groupId],
61
+ ["targetId", scope.targetId],
62
+ ];
63
+
64
+ const segments = ordered
65
+ .filter(([, value]) => Boolean(value && value.trim()))
66
+ .map(([key, value]) => `${key.replace(/Id$/, "")}-${encodeScopeValue((value || "").trim())}`);
67
+
68
+ if (segments.length === 0) {
69
+ return "";
70
+ }
71
+ return `.${segments.join(".")}`;
72
+ }
73
+
74
+ export function resolveNamespacePath(namespace: string, options: ResolveNamespacePathOptions): string {
75
+ const format = options.format || "json";
76
+ const baseDir = path.join(path.dirname(options.storePath), NAMESPACE_ROOT_DIR);
77
+ const safeNamespace = sanitizeSegment(namespace.trim());
78
+ const suffix = buildScopeSuffix(options.scope);
79
+ return path.join(baseDir, `${safeNamespace}${suffix}.${format}`);
80
+ }
81
+
82
+ export function readNamespaceJson<T>(
83
+ namespace: string,
84
+ options: ReadNamespaceJsonOptions<T>,
85
+ ): T {
86
+ const filePath = resolveNamespacePath(namespace, options);
87
+ try {
88
+ if (!fs.existsSync(filePath)) {
89
+ return options.fallback;
90
+ }
91
+ const raw = fs.readFileSync(filePath, "utf-8");
92
+ if (!raw.trim()) {
93
+ return options.fallback;
94
+ }
95
+ return JSON.parse(raw) as T;
96
+ } catch (err: unknown) {
97
+ options.log?.warn?.(
98
+ `[DingTalk][Persistence] Failed to read namespace=${namespace} path=${filePath}: ${toErrorMessage(err)}`,
99
+ );
100
+ return options.fallback;
101
+ }
102
+ }
103
+
104
+ export function writeNamespaceJsonAtomic<T>(
105
+ namespace: string,
106
+ options: WriteNamespaceJsonOptions<T>,
107
+ ): void {
108
+ const filePath = resolveNamespacePath(namespace, options);
109
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
110
+ try {
111
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
112
+ fs.writeFileSync(tempPath, JSON.stringify(options.data, null, 2));
113
+ try {
114
+ fs.renameSync(tempPath, filePath);
115
+ } catch (err: unknown) {
116
+ if (fs.existsSync(filePath)) {
117
+ fs.rmSync(filePath, { force: true });
118
+ fs.renameSync(tempPath, filePath);
119
+ } else {
120
+ throw err;
121
+ }
122
+ }
123
+ } catch (err: unknown) {
124
+ options.log?.warn?.(
125
+ `[DingTalk][Persistence] Failed to write namespace=${namespace} path=${filePath}: ${toErrorMessage(err)}`,
126
+ );
127
+ if (fs.existsSync(tempPath)) {
128
+ fs.rmSync(tempPath, { force: true });
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,242 @@
1
+ import { readNamespaceJson, writeNamespaceJsonAtomic } from "./persistence-store";
2
+
3
+ const QUOTE_JOURNAL_NAMESPACE = "quoted.msg-journal";
4
+ const QUOTE_JOURNAL_VERSION = 1;
5
+ export const DEFAULT_JOURNAL_TTL_DAYS = 7;
6
+ const MAX_RECORDS_PER_SCOPE = 1000;
7
+
8
+ type JournalEntry = {
9
+ msgId: string;
10
+ messageType: string;
11
+ text?: string;
12
+ createdAt: number;
13
+ };
14
+
15
+ type QuoteJournalState = {
16
+ version: number;
17
+ updatedAt: number;
18
+ records: JournalEntry[];
19
+ };
20
+
21
+ const stateCache = new Map<string, QuoteJournalState>();
22
+
23
+ function getScopeKey(params: {
24
+ storePath: string;
25
+ accountId: string;
26
+ conversationId: string | null;
27
+ }): string {
28
+ return JSON.stringify([
29
+ params.storePath,
30
+ params.accountId,
31
+ params.conversationId || null,
32
+ ]);
33
+ }
34
+
35
+ function fallbackState(): QuoteJournalState {
36
+ return {
37
+ version: QUOTE_JOURNAL_VERSION,
38
+ updatedAt: Date.now(),
39
+ records: [],
40
+ };
41
+ }
42
+
43
+ function normalizeEntry(entry: unknown): JournalEntry | null {
44
+ if (!entry || typeof entry !== "object") {
45
+ return null;
46
+ }
47
+ const candidate = entry as Partial<JournalEntry>;
48
+ if (typeof candidate.msgId !== "string" || typeof candidate.createdAt !== "number") {
49
+ return null;
50
+ }
51
+ return {
52
+ msgId: candidate.msgId,
53
+ messageType: typeof candidate.messageType === "string" ? candidate.messageType : "text",
54
+ text: typeof candidate.text === "string" ? candidate.text : undefined,
55
+ createdAt: candidate.createdAt,
56
+ };
57
+ }
58
+
59
+ function normalizeState(parsed: Partial<QuoteJournalState>): QuoteJournalState {
60
+ const records = Array.isArray(parsed.records)
61
+ ? parsed.records.map((entry) => normalizeEntry(entry)).filter((entry): entry is JournalEntry => entry !== null)
62
+ : [];
63
+ return {
64
+ version: typeof parsed.version === "number" ? parsed.version : QUOTE_JOURNAL_VERSION,
65
+ updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
66
+ records,
67
+ };
68
+ }
69
+
70
+ function loadState(params: {
71
+ storePath: string;
72
+ accountId: string;
73
+ conversationId: string | null;
74
+ }): QuoteJournalState {
75
+ const scopeKey = getScopeKey(params);
76
+ const cached = stateCache.get(scopeKey);
77
+ if (cached) {
78
+ return cached;
79
+ }
80
+
81
+ const persisted = readNamespaceJson<Partial<QuoteJournalState>>(QUOTE_JOURNAL_NAMESPACE, {
82
+ storePath: params.storePath,
83
+ scope: { accountId: params.accountId, conversationId: params.conversationId || undefined },
84
+ format: "json",
85
+ fallback: fallbackState(),
86
+ });
87
+ const normalized = normalizeState(persisted);
88
+ stateCache.set(scopeKey, normalized);
89
+ return normalized;
90
+ }
91
+
92
+ function writeState(params: {
93
+ storePath: string;
94
+ accountId: string;
95
+ conversationId: string | null;
96
+ state: QuoteJournalState;
97
+ }): void {
98
+ stateCache.set(getScopeKey(params), params.state);
99
+ writeNamespaceJsonAtomic(QUOTE_JOURNAL_NAMESPACE, {
100
+ storePath: params.storePath,
101
+ scope: { accountId: params.accountId, conversationId: params.conversationId || undefined },
102
+ format: "json",
103
+ data: params.state,
104
+ });
105
+ }
106
+
107
+ function pruneByTtl(records: JournalEntry[], ttlDays: number, nowMs: number): JournalEntry[] {
108
+ if (!ttlDays || ttlDays <= 0) {
109
+ return records;
110
+ }
111
+ const cutoff = nowMs - ttlDays * 24 * 60 * 60 * 1000;
112
+ return records.filter((entry) => entry.createdAt >= cutoff);
113
+ }
114
+
115
+ function capRecords(records: JournalEntry[]): JournalEntry[] {
116
+ if (records.length <= MAX_RECORDS_PER_SCOPE) {
117
+ return records;
118
+ }
119
+ return records.slice(-MAX_RECORDS_PER_SCOPE);
120
+ }
121
+
122
+ export function appendQuoteJournalEntry(params: {
123
+ storePath: string;
124
+ accountId: string;
125
+ conversationId: string | null;
126
+ msgId: string;
127
+ messageType: string;
128
+ text?: string;
129
+ createdAt: number;
130
+ ttlDays?: number;
131
+ nowMs?: number;
132
+ }): void {
133
+ const now = params.nowMs ?? Date.now();
134
+ const ttlDays = params.ttlDays ?? DEFAULT_JOURNAL_TTL_DAYS;
135
+ const state = loadState(params);
136
+ const records = pruneByTtl(state.records, ttlDays, now);
137
+ records.push({
138
+ msgId: params.msgId,
139
+ messageType: params.messageType,
140
+ text: params.text,
141
+ createdAt: params.createdAt,
142
+ });
143
+ const cappedRecords = capRecords(records);
144
+ writeState({
145
+ storePath: params.storePath,
146
+ accountId: params.accountId,
147
+ conversationId: params.conversationId,
148
+ state: {
149
+ version: QUOTE_JOURNAL_VERSION,
150
+ updatedAt: now,
151
+ records: cappedRecords,
152
+ },
153
+ });
154
+ }
155
+
156
+ export function cleanupExpiredQuoteJournalEntries(params: {
157
+ storePath: string;
158
+ accountId: string;
159
+ conversationId: string | null;
160
+ ttlDays: number;
161
+ nowMs?: number;
162
+ }): number {
163
+ const now = params.nowMs ?? Date.now();
164
+ const state = loadState(params);
165
+ const kept = pruneByTtl(state.records, params.ttlDays, now);
166
+ const removed = state.records.length - kept.length;
167
+ if (removed > 0) {
168
+ writeState({
169
+ storePath: params.storePath,
170
+ accountId: params.accountId,
171
+ conversationId: params.conversationId,
172
+ state: {
173
+ version: QUOTE_JOURNAL_VERSION,
174
+ updatedAt: now,
175
+ records: kept,
176
+ },
177
+ });
178
+ }
179
+ return removed;
180
+ }
181
+
182
+ export function resolveQuotedMessageById(params: {
183
+ storePath: string;
184
+ accountId: string;
185
+ conversationId: string | null;
186
+ originalMsgId: string;
187
+ ttlDays?: number;
188
+ nowMs?: number;
189
+ }): { msgId: string; text?: string; createdAt: number } | null {
190
+ const state = loadState(params);
191
+ const now = params.nowMs ?? Date.now();
192
+ const ttlDays = params.ttlDays ?? DEFAULT_JOURNAL_TTL_DAYS;
193
+ const records = capRecords(pruneByTtl(state.records, ttlDays, now));
194
+ for (let i = records.length - 1; i >= 0; i--) {
195
+ const entry = records[i];
196
+ if (entry.msgId === params.originalMsgId) {
197
+ return { msgId: entry.msgId, text: entry.text, createdAt: entry.createdAt };
198
+ }
199
+ }
200
+ return null;
201
+ }
202
+
203
+ export async function appendOutboundToQuoteJournal(params: {
204
+ storePath: string;
205
+ accountId: string;
206
+ conversationId: string | null;
207
+ messageId?: string;
208
+ text?: string;
209
+ messageType?: string;
210
+ log?: unknown;
211
+ }): Promise<void> {
212
+ try {
213
+ if (!params.messageId) {
214
+ return;
215
+ }
216
+ appendQuoteJournalEntry({
217
+ storePath: params.storePath,
218
+ accountId: params.accountId,
219
+ conversationId: params.conversationId || null,
220
+ msgId: params.messageId,
221
+ messageType: params.messageType || "outbound",
222
+ text: params.text,
223
+ createdAt: Date.now(),
224
+ });
225
+ } catch (err) {
226
+ (params.log as { debug?: (message: string) => void } | undefined)?.debug?.(
227
+ `[quote-journal] appendOutbound failed: ${String(err)}`,
228
+ );
229
+ }
230
+ }
231
+
232
+ export async function appendProactiveOutboundJournal(params: {
233
+ storePath: string;
234
+ accountId: string;
235
+ conversationId: string | null;
236
+ messageId?: string;
237
+ text?: string;
238
+ messageType?: string;
239
+ log?: unknown;
240
+ }): Promise<void> {
241
+ return appendOutboundToQuoteJournal(params);
242
+ }