@soimy/dingtalk 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +796 -42
  3. package/index.ts +62 -0
  4. package/package.json +4 -2
  5. package/src/access-control.ts +83 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  8. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  9. package/src/ack-reaction-classifier.ts +75 -0
  10. package/src/ack-reaction-service.ts +182 -0
  11. package/src/attachment-text-extractor.ts +148 -0
  12. package/src/card-callback-service.ts +119 -0
  13. package/src/card-draft-controller.ts +114 -0
  14. package/src/card-service.ts +666 -26
  15. package/src/channel.ts +455 -150
  16. package/src/config-schema.ts +64 -6
  17. package/src/config.ts +161 -5
  18. package/src/connection-manager.ts +354 -47
  19. package/src/dedup.ts +1 -0
  20. package/src/docs-service.ts +198 -0
  21. package/src/draft-stream-loop.ts +119 -0
  22. package/src/feedback-learning-service.ts +643 -0
  23. package/src/feedback-learning-store.ts +543 -0
  24. package/src/group-members-store.ts +48 -14
  25. package/src/inbound-handler.ts +1374 -259
  26. package/src/learning-command-service.ts +339 -0
  27. package/src/media-utils.ts +94 -50
  28. package/src/message-context-store.ts +787 -0
  29. package/src/message-utils.ts +487 -46
  30. package/src/messaging/quoted-context.ts +269 -0
  31. package/src/messaging/quoted-ref.ts +97 -0
  32. package/src/onboarding.ts +96 -1
  33. package/src/peer-id-registry.ts +102 -0
  34. package/src/persistence-store.ts +131 -0
  35. package/src/quoted-file-service.ts +385 -0
  36. package/src/reply-strategy-card.ts +225 -0
  37. package/src/reply-strategy-markdown.ts +55 -0
  38. package/src/reply-strategy-with-reaction.ts +190 -0
  39. package/src/reply-strategy.ts +72 -0
  40. package/src/send-service.ts +267 -45
  41. package/src/session-command-service.ts +147 -0
  42. package/src/session-lock.ts +2 -0
  43. package/src/session-peer-store.ts +77 -0
  44. package/src/session-routing.ts +33 -0
  45. package/src/targeting/agent-name-matcher.ts +148 -0
  46. package/src/targeting/agent-routing.ts +181 -0
  47. package/src/targeting/target-directory-adapter.ts +151 -0
  48. package/src/targeting/target-directory-store.ts +396 -0
  49. package/src/targeting/target-input.ts +62 -0
  50. package/src/types.ts +261 -28
  51. package/src/utils.ts +231 -12
@@ -0,0 +1,543 @@
1
+ import { readNamespaceJson, writeNamespaceJsonAtomic } from "./persistence-store";
2
+
3
+ const MAX_EVENTS = 200;
4
+ const MAX_SNAPSHOTS = 100;
5
+ const MAX_REFLECTIONS = 200;
6
+ const MAX_SESSION_NOTES = 20;
7
+ const MAX_RULES = 50;
8
+ const DEFAULT_NOTE_TTL_MS = 6 * 60 * 60 * 1000;
9
+
10
+ const EVENTS_NAMESPACE = "feedback.events";
11
+ const SNAPSHOTS_NAMESPACE = "feedback.snapshots";
12
+ const REFLECTIONS_NAMESPACE = "feedback.reflections";
13
+ const SESSION_NOTES_NAMESPACE = "feedback.session-notes";
14
+ const LEARNED_RULES_NAMESPACE = "feedback.learned-rules";
15
+ const TARGET_RULES_NAMESPACE = "feedback.target-rules";
16
+ const TARGET_RULE_INDEX_NAMESPACE = "feedback.target-rules-index";
17
+ const TARGET_SETS_NAMESPACE = "feedback.target-sets";
18
+
19
+ export type FeedbackKind = "explicit_positive" | "explicit_negative" | "implicit_negative";
20
+ export type ReflectionCategory =
21
+ | "missing_image_context"
22
+ | "quoted_context_missing"
23
+ | "misunderstood_intent"
24
+ | "generic_negative"
25
+ | "positive_direct_answer";
26
+
27
+ export interface FeedbackEventRecord {
28
+ id: string;
29
+ kind: FeedbackKind;
30
+ targetId: string;
31
+ sessionKey?: string;
32
+ processQueryKey?: string;
33
+ userId?: string;
34
+ createdAt: number;
35
+ signalText?: string;
36
+ snapshotId?: string;
37
+ }
38
+
39
+ export interface OutboundReplySnapshot {
40
+ id: string;
41
+ targetId: string;
42
+ sessionKey: string;
43
+ question: string;
44
+ answer: string;
45
+ createdAt: number;
46
+ processQueryKey?: string;
47
+ mode?: "card" | "markdown";
48
+ }
49
+
50
+ export interface ReflectionRecord {
51
+ id: string;
52
+ targetId: string;
53
+ sourceEventId: string;
54
+ kind: FeedbackKind;
55
+ category: ReflectionCategory;
56
+ diagnosis: string;
57
+ suggestedInstruction: string;
58
+ question?: string;
59
+ answer?: string;
60
+ createdAt: number;
61
+ }
62
+
63
+ export interface SessionLearningNote {
64
+ id: string;
65
+ targetId: string;
66
+ instruction: string;
67
+ source: FeedbackKind;
68
+ category: ReflectionCategory;
69
+ createdAt: number;
70
+ expiresAt: number;
71
+ }
72
+
73
+ export interface LearnedRuleRecord {
74
+ ruleId: string;
75
+ category: ReflectionCategory;
76
+ instruction: string;
77
+ negativeCount: number;
78
+ positiveCount: number;
79
+ updatedAt: number;
80
+ enabled: boolean;
81
+ manual?: boolean;
82
+ triggerText?: string;
83
+ forcedReply?: string;
84
+ }
85
+
86
+ interface ListBucket<T> {
87
+ updatedAt: number;
88
+ entries: T[];
89
+ }
90
+
91
+ interface LearnedRuleBucket {
92
+ updatedAt: number;
93
+ rules: Record<string, LearnedRuleRecord>;
94
+ }
95
+
96
+ interface TargetRuleIndexBucket {
97
+ updatedAt: number;
98
+ targetIds: string[];
99
+ }
100
+
101
+ export interface TargetSetRecord {
102
+ name: string;
103
+ targetIds: string[];
104
+ updatedAt: number;
105
+ }
106
+
107
+ interface TargetSetBucket {
108
+ updatedAt: number;
109
+ sets: Record<string, TargetSetRecord>;
110
+ }
111
+
112
+ export interface ScopedLearnedRuleRecord extends LearnedRuleRecord {
113
+ scope: "global" | "target";
114
+ targetId?: string;
115
+ }
116
+
117
+ function trimNewest<T extends { createdAt: number }>(entries: T[], limit: number): T[] {
118
+ return entries.toSorted((left, right) => right.createdAt - left.createdAt).slice(0, limit);
119
+ }
120
+
121
+ function readListBucket<T>(
122
+ namespace: string,
123
+ params: { storePath?: string; accountId: string; targetId: string },
124
+ ): ListBucket<T> {
125
+ if (!params.storePath) {
126
+ return { updatedAt: 0, entries: [] };
127
+ }
128
+ return readNamespaceJson<ListBucket<T>>(namespace, {
129
+ storePath: params.storePath,
130
+ scope: { accountId: params.accountId, targetId: params.targetId },
131
+ format: "json",
132
+ fallback: { updatedAt: 0, entries: [] },
133
+ });
134
+ }
135
+
136
+ function writeListBucket<T>(
137
+ namespace: string,
138
+ params: { storePath?: string; accountId: string; targetId: string; entries: T[] },
139
+ ): void {
140
+ if (!params.storePath) {
141
+ return;
142
+ }
143
+ writeNamespaceJsonAtomic(namespace, {
144
+ storePath: params.storePath,
145
+ scope: { accountId: params.accountId, targetId: params.targetId },
146
+ format: "json",
147
+ data: {
148
+ updatedAt: Date.now(),
149
+ entries: params.entries,
150
+ } satisfies ListBucket<T>,
151
+ });
152
+ }
153
+
154
+ export function appendFeedbackEvent(
155
+ params: { storePath?: string; accountId: string; targetId: string; event: FeedbackEventRecord },
156
+ ): void {
157
+ const bucket = readListBucket<FeedbackEventRecord>(EVENTS_NAMESPACE, params);
158
+ bucket.entries = trimNewest([...bucket.entries, params.event], MAX_EVENTS);
159
+ writeListBucket(EVENTS_NAMESPACE, { ...params, entries: bucket.entries });
160
+ }
161
+
162
+ export function listFeedbackEvents(
163
+ params: { storePath?: string; accountId: string; targetId: string },
164
+ ): FeedbackEventRecord[] {
165
+ return readListBucket<FeedbackEventRecord>(EVENTS_NAMESPACE, params).entries;
166
+ }
167
+
168
+ export function appendOutboundReplySnapshot(
169
+ params: { storePath?: string; accountId: string; targetId: string; snapshot: OutboundReplySnapshot },
170
+ ): void {
171
+ const bucket = readListBucket<OutboundReplySnapshot>(SNAPSHOTS_NAMESPACE, params);
172
+ bucket.entries = trimNewest([...bucket.entries, params.snapshot], MAX_SNAPSHOTS);
173
+ writeListBucket(SNAPSHOTS_NAMESPACE, { ...params, entries: bucket.entries });
174
+ }
175
+
176
+ export function listOutboundReplySnapshots(
177
+ params: { storePath?: string; accountId: string; targetId: string },
178
+ ): OutboundReplySnapshot[] {
179
+ return readListBucket<OutboundReplySnapshot>(SNAPSHOTS_NAMESPACE, params).entries;
180
+ }
181
+
182
+ export function appendReflectionRecord(
183
+ params: { storePath?: string; accountId: string; targetId: string; reflection: ReflectionRecord },
184
+ ): void {
185
+ const bucket = readListBucket<ReflectionRecord>(REFLECTIONS_NAMESPACE, params);
186
+ bucket.entries = trimNewest([...bucket.entries, params.reflection], MAX_REFLECTIONS);
187
+ writeListBucket(REFLECTIONS_NAMESPACE, { ...params, entries: bucket.entries });
188
+ }
189
+
190
+ export function listReflectionRecords(
191
+ params: { storePath?: string; accountId: string; targetId: string },
192
+ ): ReflectionRecord[] {
193
+ return readListBucket<ReflectionRecord>(REFLECTIONS_NAMESPACE, params).entries;
194
+ }
195
+
196
+ export function appendSessionLearningNote(
197
+ params: {
198
+ storePath?: string;
199
+ accountId: string;
200
+ targetId: string;
201
+ note: Omit<SessionLearningNote, "expiresAt"> & { expiresAt?: number };
202
+ ttlMs?: number;
203
+ },
204
+ ): void {
205
+ const ttlMs = params.ttlMs && params.ttlMs > 0 ? params.ttlMs : DEFAULT_NOTE_TTL_MS;
206
+ const nowMs = Date.now();
207
+ const bucket = readListBucket<SessionLearningNote>(SESSION_NOTES_NAMESPACE, params);
208
+ const retained = bucket.entries.filter((note) => note.expiresAt > nowMs);
209
+ retained.unshift({
210
+ ...params.note,
211
+ expiresAt: params.note.expiresAt ?? nowMs + ttlMs,
212
+ });
213
+ writeListBucket(SESSION_NOTES_NAMESPACE, {
214
+ ...params,
215
+ entries: trimNewest(retained, MAX_SESSION_NOTES),
216
+ });
217
+ }
218
+
219
+ export function listActiveSessionLearningNotes(
220
+ params: { storePath?: string; accountId: string; targetId: string; nowMs?: number },
221
+ ): SessionLearningNote[] {
222
+ const nowMs = params.nowMs ?? Date.now();
223
+ return readListBucket<SessionLearningNote>(SESSION_NOTES_NAMESPACE, params).entries.filter(
224
+ (note) => note.expiresAt > nowMs,
225
+ );
226
+ }
227
+
228
+ export function upsertLearnedRule(
229
+ params: { storePath?: string; accountId: string; rule: LearnedRuleRecord },
230
+ ): void {
231
+ if (!params.storePath) {
232
+ return;
233
+ }
234
+ const bucket = readNamespaceJson<LearnedRuleBucket>(LEARNED_RULES_NAMESPACE, {
235
+ storePath: params.storePath,
236
+ scope: { accountId: params.accountId },
237
+ format: "json",
238
+ fallback: { updatedAt: 0, rules: {} },
239
+ });
240
+ bucket.rules[params.rule.ruleId] = params.rule;
241
+ const trimmedRules = Object.values(bucket.rules)
242
+ .toSorted((left, right) => right.updatedAt - left.updatedAt)
243
+ .slice(0, MAX_RULES);
244
+ const rules: Record<string, LearnedRuleRecord> = {};
245
+ for (const rule of trimmedRules) {
246
+ rules[rule.ruleId] = rule;
247
+ }
248
+ writeNamespaceJsonAtomic(LEARNED_RULES_NAMESPACE, {
249
+ storePath: params.storePath,
250
+ scope: { accountId: params.accountId },
251
+ format: "json",
252
+ data: { updatedAt: Date.now(), rules } satisfies LearnedRuleBucket,
253
+ });
254
+ }
255
+
256
+ export function listLearnedRules(
257
+ params: { storePath?: string; accountId: string },
258
+ ): LearnedRuleRecord[] {
259
+ if (!params.storePath) {
260
+ return [];
261
+ }
262
+ const bucket = readNamespaceJson<LearnedRuleBucket>(LEARNED_RULES_NAMESPACE, {
263
+ storePath: params.storePath,
264
+ scope: { accountId: params.accountId },
265
+ format: "json",
266
+ fallback: { updatedAt: 0, rules: {} },
267
+ });
268
+ return Object.values(bucket.rules).toSorted((left, right) => right.updatedAt - left.updatedAt);
269
+ }
270
+
271
+ export function disableLearnedRule(
272
+ params: { storePath?: string; accountId: string; ruleId: string },
273
+ ): boolean {
274
+ if (!params.storePath) {
275
+ return false;
276
+ }
277
+ const bucket = readNamespaceJson<LearnedRuleBucket>(LEARNED_RULES_NAMESPACE, {
278
+ storePath: params.storePath,
279
+ scope: { accountId: params.accountId },
280
+ format: "json",
281
+ fallback: { updatedAt: 0, rules: {} },
282
+ });
283
+ const existing = bucket.rules[params.ruleId];
284
+ if (!existing) {
285
+ return false;
286
+ }
287
+ bucket.rules[params.ruleId] = {
288
+ ...existing,
289
+ enabled: false,
290
+ updatedAt: Date.now(),
291
+ };
292
+ writeNamespaceJsonAtomic(LEARNED_RULES_NAMESPACE, {
293
+ storePath: params.storePath,
294
+ scope: { accountId: params.accountId },
295
+ format: "json",
296
+ data: { updatedAt: Date.now(), rules: bucket.rules } satisfies LearnedRuleBucket,
297
+ });
298
+ return true;
299
+ }
300
+
301
+ export function deleteLearnedRule(
302
+ params: { storePath?: string; accountId: string; ruleId: string },
303
+ ): boolean {
304
+ if (!params.storePath) {
305
+ return false;
306
+ }
307
+ const bucket = readNamespaceJson<LearnedRuleBucket>(LEARNED_RULES_NAMESPACE, {
308
+ storePath: params.storePath,
309
+ scope: { accountId: params.accountId },
310
+ format: "json",
311
+ fallback: { updatedAt: 0, rules: {} },
312
+ });
313
+ if (!bucket.rules[params.ruleId]) {
314
+ return false;
315
+ }
316
+ delete bucket.rules[params.ruleId];
317
+ writeNamespaceJsonAtomic(LEARNED_RULES_NAMESPACE, {
318
+ storePath: params.storePath,
319
+ scope: { accountId: params.accountId },
320
+ format: "json",
321
+ data: { updatedAt: Date.now(), rules: bucket.rules } satisfies LearnedRuleBucket,
322
+ });
323
+ return true;
324
+ }
325
+
326
+ function readTargetRuleIndex(
327
+ params: { storePath?: string; accountId: string },
328
+ ): TargetRuleIndexBucket {
329
+ if (!params.storePath) {
330
+ return { updatedAt: 0, targetIds: [] };
331
+ }
332
+ return readNamespaceJson<TargetRuleIndexBucket>(TARGET_RULE_INDEX_NAMESPACE, {
333
+ storePath: params.storePath,
334
+ scope: { accountId: params.accountId },
335
+ format: "json",
336
+ fallback: { updatedAt: 0, targetIds: [] },
337
+ });
338
+ }
339
+
340
+ function writeTargetRuleIndex(
341
+ params: { storePath?: string; accountId: string; targetIds: string[] },
342
+ ): void {
343
+ if (!params.storePath) {
344
+ return;
345
+ }
346
+ writeNamespaceJsonAtomic(TARGET_RULE_INDEX_NAMESPACE, {
347
+ storePath: params.storePath,
348
+ scope: { accountId: params.accountId },
349
+ format: "json",
350
+ data: {
351
+ updatedAt: Date.now(),
352
+ targetIds: [...new Set(params.targetIds.filter((targetId) => targetId.trim()))],
353
+ } satisfies TargetRuleIndexBucket,
354
+ });
355
+ }
356
+
357
+ function readTargetRuleBucket(
358
+ params: { storePath?: string; accountId: string; targetId: string },
359
+ ): LearnedRuleBucket {
360
+ if (!params.storePath) {
361
+ return { updatedAt: 0, rules: {} };
362
+ }
363
+ return readNamespaceJson<LearnedRuleBucket>(TARGET_RULES_NAMESPACE, {
364
+ storePath: params.storePath,
365
+ scope: { accountId: params.accountId, targetId: params.targetId },
366
+ format: "json",
367
+ fallback: { updatedAt: 0, rules: {} },
368
+ });
369
+ }
370
+
371
+ function writeTargetRuleBucket(
372
+ params: { storePath?: string; accountId: string; targetId: string; bucket: LearnedRuleBucket },
373
+ ): void {
374
+ if (!params.storePath) {
375
+ return;
376
+ }
377
+ writeNamespaceJsonAtomic(TARGET_RULES_NAMESPACE, {
378
+ storePath: params.storePath,
379
+ scope: { accountId: params.accountId, targetId: params.targetId },
380
+ format: "json",
381
+ data: params.bucket,
382
+ });
383
+ }
384
+
385
+ export function upsertTargetRule(
386
+ params: { storePath?: string; accountId: string; targetId: string; rule: LearnedRuleRecord },
387
+ ): void {
388
+ if (!params.storePath) {
389
+ return;
390
+ }
391
+ const bucket = readTargetRuleBucket(params);
392
+ bucket.rules[params.rule.ruleId] = params.rule;
393
+ const trimmedRules = Object.values(bucket.rules)
394
+ .toSorted((left, right) => right.updatedAt - left.updatedAt)
395
+ .slice(0, MAX_RULES);
396
+ const rules: Record<string, LearnedRuleRecord> = {};
397
+ for (const rule of trimmedRules) {
398
+ rules[rule.ruleId] = rule;
399
+ }
400
+ writeTargetRuleBucket({
401
+ storePath: params.storePath,
402
+ accountId: params.accountId,
403
+ targetId: params.targetId,
404
+ bucket: { updatedAt: Date.now(), rules },
405
+ });
406
+ const index = readTargetRuleIndex({ storePath: params.storePath, accountId: params.accountId });
407
+ writeTargetRuleIndex({
408
+ storePath: params.storePath,
409
+ accountId: params.accountId,
410
+ targetIds: [...index.targetIds, params.targetId],
411
+ });
412
+ }
413
+
414
+ export function listTargetRules(
415
+ params: { storePath?: string; accountId: string; targetId: string },
416
+ ): LearnedRuleRecord[] {
417
+ return Object.values(readTargetRuleBucket(params).rules).toSorted(
418
+ (left, right) => right.updatedAt - left.updatedAt,
419
+ );
420
+ }
421
+
422
+ export function listAllScopedRules(
423
+ params: { storePath?: string; accountId: string },
424
+ ): ScopedLearnedRuleRecord[] {
425
+ const globalRules = listLearnedRules(params).map((rule) => ({ ...rule, scope: "global" as const }));
426
+ const targetIds = readTargetRuleIndex(params).targetIds;
427
+ const targetRules = targetIds.flatMap((targetId) =>
428
+ listTargetRules({ ...params, targetId }).map((rule) => ({
429
+ ...rule,
430
+ scope: "target" as const,
431
+ targetId,
432
+ })),
433
+ );
434
+ return [...targetRules, ...globalRules].toSorted((left, right) => right.updatedAt - left.updatedAt);
435
+ }
436
+
437
+ export function disableScopedRule(
438
+ params: { storePath?: string; accountId: string; ruleId: string },
439
+ ): { existed: boolean; scope?: "global" | "target"; targetId?: string } {
440
+ if (disableLearnedRule(params)) {
441
+ return { existed: true, scope: "global" };
442
+ }
443
+ const targetIds = readTargetRuleIndex(params).targetIds;
444
+ for (const targetId of targetIds) {
445
+ const bucket = readTargetRuleBucket({ ...params, targetId });
446
+ const existing = bucket.rules[params.ruleId];
447
+ if (!existing) {
448
+ continue;
449
+ }
450
+ bucket.rules[params.ruleId] = {
451
+ ...existing,
452
+ enabled: false,
453
+ updatedAt: Date.now(),
454
+ };
455
+ writeTargetRuleBucket({
456
+ storePath: params.storePath,
457
+ accountId: params.accountId,
458
+ targetId,
459
+ bucket: { updatedAt: Date.now(), rules: bucket.rules },
460
+ });
461
+ return { existed: true, scope: "target", targetId };
462
+ }
463
+ return { existed: false };
464
+ }
465
+
466
+ export function deleteScopedRule(
467
+ params: { storePath?: string; accountId: string; ruleId: string },
468
+ ): { existed: boolean; scope?: "global" | "target"; targetId?: string } {
469
+ if (deleteLearnedRule(params)) {
470
+ return { existed: true, scope: "global" };
471
+ }
472
+ const targetIds = readTargetRuleIndex(params).targetIds;
473
+ for (const targetId of targetIds) {
474
+ const bucket = readTargetRuleBucket({ ...params, targetId });
475
+ if (!bucket.rules[params.ruleId]) {
476
+ continue;
477
+ }
478
+ delete bucket.rules[params.ruleId];
479
+ writeTargetRuleBucket({
480
+ storePath: params.storePath,
481
+ accountId: params.accountId,
482
+ targetId,
483
+ bucket: { updatedAt: Date.now(), rules: bucket.rules },
484
+ });
485
+ return { existed: true, scope: "target", targetId };
486
+ }
487
+ return { existed: false };
488
+ }
489
+
490
+ export function upsertTargetSet(
491
+ params: { storePath?: string; accountId: string; name: string; targetIds: string[] },
492
+ ): void {
493
+ if (!params.storePath) {
494
+ return;
495
+ }
496
+ const bucket = readNamespaceJson<TargetSetBucket>(TARGET_SETS_NAMESPACE, {
497
+ storePath: params.storePath,
498
+ scope: { accountId: params.accountId },
499
+ format: "json",
500
+ fallback: { updatedAt: 0, sets: {} },
501
+ });
502
+ bucket.sets[params.name] = {
503
+ name: params.name,
504
+ targetIds: [...new Set(params.targetIds.filter((targetId) => targetId.trim()))],
505
+ updatedAt: Date.now(),
506
+ };
507
+ writeNamespaceJsonAtomic(TARGET_SETS_NAMESPACE, {
508
+ storePath: params.storePath,
509
+ scope: { accountId: params.accountId },
510
+ format: "json",
511
+ data: { updatedAt: Date.now(), sets: bucket.sets } satisfies TargetSetBucket,
512
+ });
513
+ }
514
+
515
+ export function getTargetSet(
516
+ params: { storePath?: string; accountId: string; name: string },
517
+ ): TargetSetRecord | null {
518
+ if (!params.storePath) {
519
+ return null;
520
+ }
521
+ const bucket = readNamespaceJson<TargetSetBucket>(TARGET_SETS_NAMESPACE, {
522
+ storePath: params.storePath,
523
+ scope: { accountId: params.accountId },
524
+ format: "json",
525
+ fallback: { updatedAt: 0, sets: {} },
526
+ });
527
+ return bucket.sets[params.name] || null;
528
+ }
529
+
530
+ export function listTargetSets(
531
+ params: { storePath?: string; accountId: string },
532
+ ): TargetSetRecord[] {
533
+ if (!params.storePath) {
534
+ return [];
535
+ }
536
+ const bucket = readNamespaceJson<TargetSetBucket>(TARGET_SETS_NAMESPACE, {
537
+ storePath: params.storePath,
538
+ scope: { accountId: params.accountId },
539
+ format: "json",
540
+ fallback: { updatedAt: 0, sets: {} },
541
+ });
542
+ return Object.values(bucket.sets).toSorted((left, right) => right.updatedAt - left.updatedAt);
543
+ }
@@ -1,5 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { readNamespaceJson, writeNamespaceJsonAtomic } from "./persistence-store";
4
+
5
+ const GROUP_MEMBERS_NAMESPACE = "members.group-roster";
3
6
 
4
7
  function groupMembersFilePath(storePath: string, groupId: string): string {
5
8
  const dir = path.join(path.dirname(storePath), "dingtalk-members");
@@ -7,6 +10,48 @@ function groupMembersFilePath(storePath: string, groupId: string): string {
7
10
  return path.join(dir, `${safeId}.json`);
8
11
  }
9
12
 
13
+ function readLegacyRoster(storePath: string, groupId: string): Record<string, string> | null {
14
+ const filePath = groupMembersFilePath(storePath, groupId);
15
+ try {
16
+ return JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<string, string>;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ function readRoster(storePath: string, groupId: string): Record<string, string> {
23
+ const namespaced = readNamespaceJson<Record<string, string>>(GROUP_MEMBERS_NAMESPACE, {
24
+ storePath,
25
+ scope: { groupId },
26
+ format: "json",
27
+ fallback: {},
28
+ });
29
+ if (Object.keys(namespaced).length > 0) {
30
+ return namespaced;
31
+ }
32
+
33
+ const legacy = readLegacyRoster(storePath, groupId);
34
+ if (legacy && Object.keys(legacy).length > 0) {
35
+ writeNamespaceJsonAtomic(GROUP_MEMBERS_NAMESPACE, {
36
+ storePath,
37
+ scope: { groupId },
38
+ format: "json",
39
+ data: legacy,
40
+ });
41
+ return legacy;
42
+ }
43
+ return {};
44
+ }
45
+
46
+ function writeRoster(storePath: string, groupId: string, roster: Record<string, string>): void {
47
+ writeNamespaceJsonAtomic(GROUP_MEMBERS_NAMESPACE, {
48
+ storePath,
49
+ scope: { groupId },
50
+ format: "json",
51
+ data: roster,
52
+ });
53
+ }
54
+
10
55
  export function noteGroupMember(
11
56
  storePath: string,
12
57
  groupId: string,
@@ -16,27 +61,16 @@ export function noteGroupMember(
16
61
  if (!userId || !name) {
17
62
  return;
18
63
  }
19
- const filePath = groupMembersFilePath(storePath, groupId);
20
- let roster: Record<string, string> = {};
21
- try {
22
- roster = JSON.parse(fs.readFileSync(filePath, "utf-8"));
23
- } catch {}
64
+ const roster = readRoster(storePath, groupId);
24
65
  if (roster[userId] === name) {
25
66
  return;
26
67
  }
27
68
  roster[userId] = name;
28
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
29
- fs.writeFileSync(filePath, JSON.stringify(roster, null, 2));
69
+ writeRoster(storePath, groupId, roster);
30
70
  }
31
71
 
32
72
  export function formatGroupMembers(storePath: string, groupId: string): string | undefined {
33
- const filePath = groupMembersFilePath(storePath, groupId);
34
- let roster: Record<string, string> = {};
35
- try {
36
- roster = JSON.parse(fs.readFileSync(filePath, "utf-8"));
37
- } catch {
38
- return undefined;
39
- }
73
+ const roster = readRoster(storePath, groupId);
40
74
  const entries = Object.entries(roster);
41
75
  if (entries.length === 0) {
42
76
  return undefined;