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