@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.
Files changed (36) hide show
  1. package/README.md +141 -12
  2. package/index.ts +71 -66
  3. package/package.json +6 -5
  4. package/src/access-control.ts +65 -0
  5. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  8. package/src/ack-reaction-classifier.ts +17 -4
  9. package/src/ack-reaction-service.ts +66 -19
  10. package/src/attachment-text-extractor.ts +2 -1
  11. package/src/card-service.ts +145 -257
  12. package/src/channel.ts +106 -47
  13. package/src/config-schema.ts +28 -6
  14. package/src/config.ts +30 -6
  15. package/src/connection-manager.ts +16 -5
  16. package/src/inbound-handler.ts +694 -520
  17. package/src/media-utils.ts +99 -36
  18. package/src/message-context-store.ts +787 -0
  19. package/src/message-utils.ts +221 -42
  20. package/src/messaging/quoted-context.ts +269 -0
  21. package/src/messaging/quoted-ref.ts +97 -0
  22. package/src/onboarding.ts +381 -269
  23. package/src/reply-strategy-card.ts +225 -0
  24. package/src/reply-strategy-markdown.ts +55 -0
  25. package/src/reply-strategy-with-reaction.ts +190 -0
  26. package/src/reply-strategy.ts +72 -0
  27. package/src/runtime.ts +5 -7
  28. package/src/send-service.ts +164 -62
  29. package/src/targeting/agent-name-matcher.ts +148 -0
  30. package/src/targeting/agent-routing.ts +181 -0
  31. package/src/targeting/target-directory-adapter.ts +152 -0
  32. package/src/targeting/target-directory-store.ts +396 -0
  33. package/src/targeting/target-input.ts +62 -0
  34. package/src/types.ts +124 -21
  35. package/src/quote-journal.ts +0 -242
  36. package/src/quoted-msg-cache.ts +0 -226
@@ -0,0 +1,152 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
+ import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/directory-runtime";
3
+ import { getConfig, stripTargetPrefix } from "../config";
4
+ import { resolveOriginalPeerId } from "../peer-id-registry";
5
+ import { getDingTalkRuntime } from "../runtime";
6
+ import { listKnownGroupTargets, listKnownUserTargets } from "./target-directory-store";
7
+
8
+ export type DirectoryListParams = {
9
+ cfg: OpenClawConfig;
10
+ accountId?: string | null;
11
+ query?: string | null;
12
+ limit?: number | null;
13
+ runtime?: unknown;
14
+ };
15
+
16
+ type RuntimeSessionResolver = {
17
+ resolveStorePath?: (store: unknown, options: { agentId?: string | null | undefined }) => string;
18
+ };
19
+
20
+ function normalizeDirectoryAccountId(accountId?: string | null): string {
21
+ const resolved = String(accountId || "default").trim();
22
+ return resolved || "default";
23
+ }
24
+
25
+ function normalizeDirectoryLimit(limit?: number | null): number | undefined {
26
+ if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) {
27
+ return undefined;
28
+ }
29
+ return Math.floor(limit);
30
+ }
31
+
32
+ function resolveDirectoryStorePath(params: {
33
+ cfg: OpenClawConfig;
34
+ accountId?: string | null;
35
+ runtime?: unknown;
36
+ }): string | undefined {
37
+ const normalizedAccountId = normalizeDirectoryAccountId(params.accountId);
38
+ const runtimeSession = (
39
+ params.runtime as { channel?: { session?: RuntimeSessionResolver } } | undefined
40
+ )?.channel?.session;
41
+ if (runtimeSession?.resolveStorePath) {
42
+ return runtimeSession.resolveStorePath(params.cfg.session?.store, {
43
+ agentId: normalizedAccountId,
44
+ });
45
+ }
46
+ try {
47
+ const rt = getDingTalkRuntime();
48
+ return rt.channel.session.resolveStorePath(params.cfg.session?.store, {
49
+ agentId: normalizedAccountId,
50
+ });
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ function shouldFilterDirectoryByQuery(params: DirectoryListParams): boolean {
57
+ // OpenClaw target-resolver currently uses a query-insensitive cache key and
58
+ // always calls directory list APIs with limit=undefined. If we filter by
59
+ // query at this layer during resolver calls, one miss can poison cache for
60
+ // later different queries. Keep resolver reads query-agnostic.
61
+ return params.limit !== undefined;
62
+ }
63
+
64
+ function isDisplayNameResolutionEnabled(params: {
65
+ cfg: OpenClawConfig;
66
+ accountId?: string | null;
67
+ }): boolean {
68
+ const mode = getConfig(params.cfg, params.accountId ?? undefined).displayNameResolution;
69
+ // Current upstream target resolution does not pass requester owner/authz context into
70
+ // plugin targetResolver/directory callbacks, so owner-only resolution is not yet safe.
71
+ // Keep the config explicit: only "all" enables learned displayName resolution for now.
72
+ // TODO(upstream target-resolver): add a true owner-only mode once requester authorization
73
+ // is plumbed into plugin resolver and directory entry points.
74
+ return mode === "all";
75
+ }
76
+
77
+ export function listDingTalkDirectoryGroups(params: DirectoryListParams): ChannelDirectoryEntry[] {
78
+ if (!isDisplayNameResolutionEnabled(params)) {
79
+ return [];
80
+ }
81
+ const accountId = normalizeDirectoryAccountId(params.accountId);
82
+ const storePath = resolveDirectoryStorePath(params);
83
+ const filterByQuery = shouldFilterDirectoryByQuery(params);
84
+ const groups = listKnownGroupTargets({
85
+ storePath,
86
+ accountId,
87
+ query: filterByQuery ? (params.query ?? undefined) : undefined,
88
+ limit: filterByQuery ? normalizeDirectoryLimit(params.limit) : undefined,
89
+ });
90
+ const groupEntries: ChannelDirectoryEntry[] = groups.map((entry) => ({
91
+ kind: "group" as const,
92
+ id: resolveOriginalPeerId(entry.conversationId),
93
+ name: entry.currentTitle,
94
+ handle: entry.conversationId,
95
+ rank: entry.lastSeenAt,
96
+ raw: entry,
97
+ }));
98
+
99
+ if (filterByQuery) {
100
+ return groupEntries;
101
+ }
102
+
103
+ // TODO(upstream target-resolver): remove this fallback once bare user labels
104
+ // can be classified and routed to listPeers() instead of only listGroups().
105
+ // Temporary hack for the current upstream target-resolver flow: bare names
106
+ // are classified as "group" before directory lookup, so user displayName
107
+ // targets never reach listPeers(). Merge users into the resolver-only group
108
+ // lookup set so "displayName -> targetId" can still resolve for DingTalk.
109
+ const users = listKnownUserTargets({
110
+ storePath,
111
+ accountId,
112
+ });
113
+ const userEntries: ChannelDirectoryEntry[] = users.map((entry) => ({
114
+ kind: "user" as const,
115
+ id: entry.canonicalUserId,
116
+ name: entry.currentDisplayName,
117
+ handle: entry.staffId || entry.senderId,
118
+ rank: entry.lastSeenAt,
119
+ raw: entry,
120
+ }));
121
+
122
+ return [...groupEntries, ...userEntries].toSorted(
123
+ (left, right) => (right.rank || 0) - (left.rank || 0),
124
+ );
125
+ }
126
+
127
+ export function listDingTalkDirectoryUsers(params: DirectoryListParams): ChannelDirectoryEntry[] {
128
+ if (!isDisplayNameResolutionEnabled(params)) {
129
+ return [];
130
+ }
131
+ const accountId = normalizeDirectoryAccountId(params.accountId);
132
+ const storePath = resolveDirectoryStorePath(params);
133
+ const filterByQuery = shouldFilterDirectoryByQuery(params);
134
+ const users = listKnownUserTargets({
135
+ storePath,
136
+ accountId,
137
+ query: filterByQuery ? (params.query ?? undefined) : undefined,
138
+ limit: filterByQuery ? normalizeDirectoryLimit(params.limit) : undefined,
139
+ });
140
+ return users.map((entry) => ({
141
+ kind: "user",
142
+ id: entry.canonicalUserId,
143
+ name: entry.currentDisplayName,
144
+ handle: entry.staffId || entry.senderId,
145
+ rank: entry.lastSeenAt,
146
+ raw: entry,
147
+ }));
148
+ }
149
+
150
+ export function normalizeResolvedDingTalkTarget(raw: string): string {
151
+ return resolveOriginalPeerId(stripTargetPrefix(raw).targetId);
152
+ }
@@ -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
+ }