@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,396 @@
1
+ import { readNamespaceJson, writeNamespaceJsonAtomic } from "../persistence-store";
2
+
3
+ const TARGET_DIRECTORY_NAMESPACE = "targets.directory";
4
+ const MAX_HISTORICAL_NAMES = 20;
5
+ const MAX_RECENT_CONVERSATIONS = 20;
6
+ const LAST_SEEN_WRITE_THROTTLE_MS = 60 * 1000;
7
+
8
+ export interface GroupTargetEntry {
9
+ conversationId: string;
10
+ currentTitle: string;
11
+ historicalTitles: string[];
12
+ lastSeenAt: number;
13
+ }
14
+
15
+ export interface UserTargetEntry {
16
+ canonicalUserId: string;
17
+ staffId?: string;
18
+ senderId: string;
19
+ currentDisplayName: string;
20
+ historicalDisplayNames: string[];
21
+ lastSeenAt: number;
22
+ lastSeenInConversationIds: string[];
23
+ }
24
+
25
+ interface TargetDirectoryState {
26
+ version: 1;
27
+ groups: Record<string, GroupTargetEntry>;
28
+ users: Record<string, UserTargetEntry>;
29
+ }
30
+
31
+ const inMemoryFallbackState = new Map<string, TargetDirectoryState>();
32
+ const persistedStateCache = new Map<string, TargetDirectoryState>();
33
+
34
+ function fallbackState(): TargetDirectoryState {
35
+ return {
36
+ version: 1,
37
+ groups: {},
38
+ users: {},
39
+ };
40
+ }
41
+
42
+ function trimValue(value: string | undefined): string {
43
+ return String(value || "").trim();
44
+ }
45
+
46
+ function normalizeLookup(value: string | undefined): string {
47
+ return trimValue(value).replace(/\s+/g, " ").toLowerCase();
48
+ }
49
+
50
+ function normalizeScopeKey(storePath: string | undefined, accountId: string): string {
51
+ return JSON.stringify([storePath || "__memory__", accountId]);
52
+ }
53
+
54
+ function readState(params: { storePath?: string; accountId: string }): TargetDirectoryState {
55
+ const scopeKey = normalizeScopeKey(params.storePath, params.accountId);
56
+ if (!params.storePath) {
57
+ return inMemoryFallbackState.get(scopeKey) || fallbackState();
58
+ }
59
+ const cached = persistedStateCache.get(scopeKey);
60
+ if (cached) {
61
+ return cached;
62
+ }
63
+ const state = readNamespaceJson<TargetDirectoryState>(TARGET_DIRECTORY_NAMESPACE, {
64
+ storePath: params.storePath,
65
+ scope: { accountId: params.accountId },
66
+ fallback: fallbackState(),
67
+ });
68
+ persistedStateCache.set(scopeKey, state);
69
+ return state;
70
+ }
71
+
72
+ function writeState(params: {
73
+ storePath?: string;
74
+ accountId: string;
75
+ state: TargetDirectoryState;
76
+ }): void {
77
+ const scopeKey = normalizeScopeKey(params.storePath, params.accountId);
78
+ if (!params.storePath) {
79
+ inMemoryFallbackState.set(scopeKey, params.state);
80
+ return;
81
+ }
82
+ persistedStateCache.set(scopeKey, params.state);
83
+ writeNamespaceJsonAtomic(TARGET_DIRECTORY_NAMESPACE, {
84
+ storePath: params.storePath,
85
+ scope: { accountId: params.accountId },
86
+ data: params.state,
87
+ });
88
+ }
89
+
90
+ export function clearTargetDirectoryStateCache(): void {
91
+ inMemoryFallbackState.clear();
92
+ persistedStateCache.clear();
93
+ }
94
+
95
+ function appendUniqueText(list: string[], value: string, maxSize: number): string[] {
96
+ const normalized = normalizeLookup(value);
97
+ if (!normalized) {
98
+ return list;
99
+ }
100
+ const existingIndex = list.findIndex((item) => normalizeLookup(item) === normalized);
101
+ if (existingIndex >= 0) {
102
+ return list;
103
+ }
104
+ const nextList = [...list, value.trim()];
105
+ return nextList.length > maxSize ? nextList.slice(nextList.length - maxSize) : nextList;
106
+ }
107
+
108
+ function appendUniqueRecentConversation(list: string[], value: string, maxSize: number): string[] {
109
+ const normalized = normalizeLookup(value);
110
+ if (!normalized) {
111
+ return list;
112
+ }
113
+ const exists = list.some((item) => normalizeLookup(item) === normalized);
114
+ if (exists) {
115
+ return list;
116
+ }
117
+ const nextList = [...list, value.trim()];
118
+ return nextList.length > maxSize ? nextList.slice(nextList.length - maxSize) : nextList;
119
+ }
120
+
121
+ function shouldRefreshLastSeen(
122
+ existingLastSeen: number | undefined,
123
+ nextLastSeen: number,
124
+ ): boolean {
125
+ const previous = Number.isFinite(existingLastSeen) ? Number(existingLastSeen) : 0;
126
+ if (previous <= 0) {
127
+ return true;
128
+ }
129
+ if (nextLastSeen <= previous) {
130
+ return false;
131
+ }
132
+ return nextLastSeen - previous >= LAST_SEEN_WRITE_THROTTLE_MS;
133
+ }
134
+
135
+ function findUserKeyByIdentifiers(
136
+ state: TargetDirectoryState,
137
+ params: { canonicalUserId?: string; staffId?: string; senderId?: string },
138
+ ): string | undefined {
139
+ const canonical = normalizeLookup(params.canonicalUserId);
140
+ if (canonical && state.users[params.canonicalUserId || ""]) {
141
+ return params.canonicalUserId;
142
+ }
143
+
144
+ const staffNorm = normalizeLookup(params.staffId);
145
+ const senderNorm = normalizeLookup(params.senderId);
146
+ for (const [key, entry] of Object.entries(state.users)) {
147
+ if (canonical && normalizeLookup(key) === canonical) {
148
+ return key;
149
+ }
150
+ if (staffNorm && normalizeLookup(entry.staffId) === staffNorm) {
151
+ return key;
152
+ }
153
+ if (senderNorm && normalizeLookup(entry.senderId) === senderNorm) {
154
+ return key;
155
+ }
156
+ }
157
+ return undefined;
158
+ }
159
+
160
+ export function upsertObservedGroupTarget(params: {
161
+ storePath?: string;
162
+ accountId: string;
163
+ conversationId: string;
164
+ title?: string;
165
+ seenAt?: number;
166
+ }): void {
167
+ const conversationId = trimValue(params.conversationId);
168
+ if (!conversationId) {
169
+ return;
170
+ }
171
+ const nowMs = params.seenAt && Number.isFinite(params.seenAt) ? params.seenAt : Date.now();
172
+ const title = trimValue(params.title) || conversationId;
173
+ const state = readState({ storePath: params.storePath, accountId: params.accountId });
174
+ const existingEntry = state.groups[conversationId];
175
+
176
+ if (!existingEntry) {
177
+ const nextState: TargetDirectoryState = {
178
+ ...state,
179
+ groups: {
180
+ ...state.groups,
181
+ [conversationId]: {
182
+ conversationId,
183
+ currentTitle: title,
184
+ historicalTitles: [],
185
+ lastSeenAt: nowMs,
186
+ },
187
+ },
188
+ };
189
+ writeState({ storePath: params.storePath, accountId: params.accountId, state: nextState });
190
+ return;
191
+ }
192
+
193
+ const titleChanged = normalizeLookup(existingEntry.currentTitle) !== normalizeLookup(title);
194
+ const nextHistoricalTitles = titleChanged
195
+ ? appendUniqueText(
196
+ existingEntry.historicalTitles,
197
+ existingEntry.currentTitle,
198
+ MAX_HISTORICAL_NAMES,
199
+ )
200
+ : existingEntry.historicalTitles;
201
+ const nextLastSeenCandidate = Math.max(existingEntry.lastSeenAt || 0, nowMs);
202
+ const nextLastSeenAt =
203
+ titleChanged || nextHistoricalTitles !== existingEntry.historicalTitles
204
+ ? nextLastSeenCandidate
205
+ : shouldRefreshLastSeen(existingEntry.lastSeenAt, nextLastSeenCandidate)
206
+ ? nextLastSeenCandidate
207
+ : existingEntry.lastSeenAt;
208
+
209
+ if (
210
+ !titleChanged &&
211
+ nextHistoricalTitles === existingEntry.historicalTitles &&
212
+ nextLastSeenAt === existingEntry.lastSeenAt
213
+ ) {
214
+ return;
215
+ }
216
+
217
+ const nextEntry: GroupTargetEntry = {
218
+ conversationId,
219
+ currentTitle: titleChanged ? title : existingEntry.currentTitle,
220
+ historicalTitles: nextHistoricalTitles,
221
+ lastSeenAt: nextLastSeenAt,
222
+ };
223
+ const nextState: TargetDirectoryState = {
224
+ ...state,
225
+ groups: {
226
+ ...state.groups,
227
+ [conversationId]: nextEntry,
228
+ },
229
+ };
230
+ writeState({ storePath: params.storePath, accountId: params.accountId, state: nextState });
231
+ }
232
+
233
+ export function upsertObservedUserTarget(params: {
234
+ storePath?: string;
235
+ accountId: string;
236
+ senderId: string;
237
+ staffId?: string;
238
+ displayName?: string;
239
+ conversationId?: string;
240
+ seenAt?: number;
241
+ }): void {
242
+ const senderId = trimValue(params.senderId);
243
+ if (!senderId) {
244
+ return;
245
+ }
246
+ const staffId = trimValue(params.staffId);
247
+ const canonicalUserId = staffId || senderId;
248
+ const nowMs = params.seenAt && Number.isFinite(params.seenAt) ? params.seenAt : Date.now();
249
+ const displayName = trimValue(params.displayName) || canonicalUserId;
250
+ const conversationId = trimValue(params.conversationId);
251
+
252
+ const state = readState({ storePath: params.storePath, accountId: params.accountId });
253
+ const existingKey = findUserKeyByIdentifiers(state, {
254
+ canonicalUserId,
255
+ staffId,
256
+ senderId,
257
+ });
258
+ const existingEntry = existingKey ? state.users[existingKey] : undefined;
259
+
260
+ if (!existingEntry) {
261
+ const nextState: TargetDirectoryState = {
262
+ ...state,
263
+ users: {
264
+ ...state.users,
265
+ [canonicalUserId]: {
266
+ canonicalUserId,
267
+ staffId: staffId || undefined,
268
+ senderId,
269
+ currentDisplayName: displayName,
270
+ historicalDisplayNames: [],
271
+ lastSeenAt: nowMs,
272
+ lastSeenInConversationIds: conversationId ? [conversationId] : [],
273
+ },
274
+ },
275
+ };
276
+ writeState({ storePath: params.storePath, accountId: params.accountId, state: nextState });
277
+ return;
278
+ }
279
+
280
+ const displayNameChanged =
281
+ normalizeLookup(existingEntry.currentDisplayName) !== normalizeLookup(displayName);
282
+ const nextHistoricalDisplayNames = displayNameChanged
283
+ ? appendUniqueText(
284
+ existingEntry.historicalDisplayNames,
285
+ existingEntry.currentDisplayName,
286
+ MAX_HISTORICAL_NAMES,
287
+ )
288
+ : existingEntry.historicalDisplayNames;
289
+ const nextConversationIds = conversationId
290
+ ? appendUniqueRecentConversation(
291
+ existingEntry.lastSeenInConversationIds,
292
+ conversationId,
293
+ MAX_RECENT_CONVERSATIONS,
294
+ )
295
+ : existingEntry.lastSeenInConversationIds;
296
+ const normalizedStaffId = staffId || undefined;
297
+ const staffIdChanged = existingEntry.staffId !== normalizedStaffId;
298
+ const senderIdChanged = existingEntry.senderId !== senderId;
299
+ const canonicalUserIdChanged =
300
+ existingEntry.canonicalUserId !== canonicalUserId || existingKey !== canonicalUserId;
301
+ const metadataChanged =
302
+ displayNameChanged ||
303
+ nextHistoricalDisplayNames !== existingEntry.historicalDisplayNames ||
304
+ nextConversationIds !== existingEntry.lastSeenInConversationIds ||
305
+ staffIdChanged ||
306
+ senderIdChanged ||
307
+ canonicalUserIdChanged;
308
+ const nextLastSeenCandidate = Math.max(existingEntry.lastSeenAt || 0, nowMs);
309
+ const nextLastSeenAt = metadataChanged
310
+ ? nextLastSeenCandidate
311
+ : shouldRefreshLastSeen(existingEntry.lastSeenAt, nextLastSeenCandidate)
312
+ ? nextLastSeenCandidate
313
+ : existingEntry.lastSeenAt;
314
+
315
+ if (!metadataChanged && nextLastSeenAt === existingEntry.lastSeenAt) {
316
+ return;
317
+ }
318
+
319
+ const nextEntry: UserTargetEntry = {
320
+ canonicalUserId,
321
+ staffId: normalizedStaffId,
322
+ senderId,
323
+ currentDisplayName: displayNameChanged ? displayName : existingEntry.currentDisplayName,
324
+ historicalDisplayNames: nextHistoricalDisplayNames,
325
+ lastSeenAt: nextLastSeenAt,
326
+ lastSeenInConversationIds: nextConversationIds,
327
+ };
328
+
329
+ const nextUsers =
330
+ existingKey && existingKey !== canonicalUserId
331
+ ? Object.fromEntries(Object.entries(state.users).filter(([key]) => key !== existingKey))
332
+ : { ...state.users };
333
+ nextUsers[canonicalUserId] = nextEntry;
334
+
335
+ const nextState: TargetDirectoryState = {
336
+ ...state,
337
+ users: nextUsers,
338
+ };
339
+ writeState({ storePath: params.storePath, accountId: params.accountId, state: nextState });
340
+ }
341
+
342
+ function matchesGroupQuery(entry: GroupTargetEntry, query: string): boolean {
343
+ const normalizedQuery = normalizeLookup(query);
344
+ if (!normalizedQuery) {
345
+ return true;
346
+ }
347
+ const candidates = [entry.conversationId, entry.currentTitle, ...entry.historicalTitles];
348
+ return candidates.some((value) => normalizeLookup(value) === normalizedQuery);
349
+ }
350
+
351
+ function matchesUserQuery(entry: UserTargetEntry, query: string): boolean {
352
+ const normalizedQuery = normalizeLookup(query);
353
+ if (!normalizedQuery) {
354
+ return true;
355
+ }
356
+ const candidates = [
357
+ entry.canonicalUserId,
358
+ entry.staffId || "",
359
+ entry.senderId,
360
+ entry.currentDisplayName,
361
+ ...entry.historicalDisplayNames,
362
+ ];
363
+ return candidates.some((value) => normalizeLookup(value) === normalizedQuery);
364
+ }
365
+
366
+ export function listKnownGroupTargets(params: {
367
+ storePath?: string;
368
+ accountId: string;
369
+ query?: string;
370
+ limit?: number;
371
+ }): GroupTargetEntry[] {
372
+ const state = readState({ storePath: params.storePath, accountId: params.accountId });
373
+ const entries = Object.values(state.groups)
374
+ .filter((entry) => matchesGroupQuery(entry, params.query || ""))
375
+ .toSorted((a, b) => b.lastSeenAt - a.lastSeenAt);
376
+ if (params.limit && params.limit > 0) {
377
+ return entries.slice(0, params.limit);
378
+ }
379
+ return entries;
380
+ }
381
+
382
+ export function listKnownUserTargets(params: {
383
+ storePath?: string;
384
+ accountId: string;
385
+ query?: string;
386
+ limit?: number;
387
+ }): UserTargetEntry[] {
388
+ const state = readState({ storePath: params.storePath, accountId: params.accountId });
389
+ const entries = Object.values(state.users)
390
+ .filter((entry) => matchesUserQuery(entry, params.query || ""))
391
+ .toSorted((a, b) => b.lastSeenAt - a.lastSeenAt);
392
+ if (params.limit && params.limit > 0) {
393
+ return entries.slice(0, params.limit);
394
+ }
395
+ return entries;
396
+ }
@@ -0,0 +1,62 @@
1
+ import { stripTargetPrefix } from "../config";
2
+
3
+ function stripProviderPrefix(raw: string): string {
4
+ return raw.replace(/^(dingtalk|dd|ding)\s*:\s*/i, "");
5
+ }
6
+
7
+ function parseDingTalkTargetInput(raw: string): {
8
+ targetId: string;
9
+ explicitUser: boolean;
10
+ explicitGroup: boolean;
11
+ } {
12
+ const providerStripped = stripProviderPrefix(raw.trim()).trim();
13
+ const explicitGroup = providerStripped.startsWith("group:");
14
+ const { targetId, isExplicitUser } = stripTargetPrefix(providerStripped);
15
+ return {
16
+ targetId: targetId.trim(),
17
+ explicitUser: isExplicitUser,
18
+ explicitGroup,
19
+ };
20
+ }
21
+
22
+ export function normalizeDingTalkTarget(raw: string): string | undefined {
23
+ const trimmed = raw.trim();
24
+ if (!trimmed) {
25
+ return undefined;
26
+ }
27
+ const providerStripped = stripProviderPrefix(trimmed).trim();
28
+ const { targetId } = stripTargetPrefix(providerStripped);
29
+ const normalized = targetId.trim();
30
+ return normalized || undefined;
31
+ }
32
+
33
+ export function looksLikeDingTalkTargetId(raw: string, normalized?: string): boolean {
34
+ const trimmed = raw.trim();
35
+ if (!trimmed) {
36
+ return false;
37
+ }
38
+ const parsed = parseDingTalkTargetInput(trimmed);
39
+ if (parsed.explicitUser || parsed.explicitGroup) {
40
+ return true;
41
+ }
42
+ const candidate = (normalized || parsed.targetId).trim();
43
+ if (!candidate) {
44
+ return false;
45
+ }
46
+ if (/^cid[\w+\-/=]*$/i.test(candidate)) {
47
+ return true;
48
+ }
49
+ if (/^\+?\d{6,}$/.test(candidate)) {
50
+ return true;
51
+ }
52
+ if (/^[A-Za-z0-9+/=]{16,}$/.test(candidate) && /[+/=]/.test(candidate)) {
53
+ return true;
54
+ }
55
+ if (/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9]{24,}$/.test(candidate)) {
56
+ return true;
57
+ }
58
+ if (/^[A-Za-z0-9_-]{24,}$/.test(candidate) && /\d/.test(candidate)) {
59
+ return true;
60
+ }
61
+ return false;
62
+ }