@soimy/dingtalk 2.7.0 → 3.0.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 +99 -3
- package/index.ts +9 -8
- package/package.json +32 -28
- package/src/access-control.ts +55 -0
- package/src/auth.ts +47 -0
- package/src/card-service.ts +338 -0
- package/src/channel.ts +145 -1590
- package/src/config-schema.ts +7 -12
- package/src/config.ts +79 -0
- package/src/connection-manager.ts +69 -32
- package/src/dedup.ts +66 -0
- package/src/group-members-store.ts +45 -0
- package/src/inbound-handler.ts +453 -0
- package/src/logger-context.ts +17 -0
- package/src/media-utils.ts +24 -20
- package/src/message-utils.ts +156 -0
- package/src/onboarding.ts +70 -67
- package/src/peer-id-registry.ts +6 -2
- package/src/runtime.ts +2 -2
- package/src/send-service.ts +281 -0
- package/src/signature.ts +15 -0
- package/src/types.ts +45 -30
- package/src/utils.ts +29 -16
- package/clawbot.plugin.json +0 -9
- package/src/AGENTS.md +0 -63
- package/src/openclaw-channel-dingtalk.code-workspace +0 -17
package/src/config-schema.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { z } from
|
|
1
|
+
import { z } from "zod";
|
|
2
2
|
|
|
3
3
|
const DingTalkAccountConfigSchema = z.object({
|
|
4
4
|
/** Account name (optional display name) */
|
|
@@ -23,10 +23,10 @@ const DingTalkAccountConfigSchema = z.object({
|
|
|
23
23
|
agentId: z.union([z.string(), z.number()]).optional(),
|
|
24
24
|
|
|
25
25
|
/** Direct message policy: open, pairing, or allowlist */
|
|
26
|
-
dmPolicy: z.enum([
|
|
26
|
+
dmPolicy: z.enum(["open", "pairing", "allowlist"]).optional().default("open"),
|
|
27
27
|
|
|
28
28
|
/** Group message policy: open or allowlist */
|
|
29
|
-
groupPolicy: z.enum([
|
|
29
|
+
groupPolicy: z.enum(["open", "allowlist"]).optional().default("open"),
|
|
30
30
|
|
|
31
31
|
/** List of allowed user IDs for allowlist policy */
|
|
32
32
|
allowFrom: z.array(z.string()).optional(),
|
|
@@ -38,7 +38,7 @@ const DingTalkAccountConfigSchema = z.object({
|
|
|
38
38
|
debug: z.boolean().optional().default(false),
|
|
39
39
|
|
|
40
40
|
/** Message type for replies: markdown or card */
|
|
41
|
-
messageType: z.enum([
|
|
41
|
+
messageType: z.enum(["markdown", "card"]).optional().default("markdown"),
|
|
42
42
|
|
|
43
43
|
/** Card template ID for AI interactive cards
|
|
44
44
|
* obtain the template ID from DingTalk Developer Console.
|
|
@@ -50,7 +50,7 @@ const DingTalkAccountConfigSchema = z.object({
|
|
|
50
50
|
* Default: 'msgContent' - maps to the content field in the card template
|
|
51
51
|
* This key is used in the streaming API to update specific fields in the card.
|
|
52
52
|
*/
|
|
53
|
-
cardTemplateKey: z.string().optional().default(
|
|
53
|
+
cardTemplateKey: z.string().optional().default("content"),
|
|
54
54
|
|
|
55
55
|
/** Per-group configuration, keyed by conversationId (supports "*" wildcard) */
|
|
56
56
|
groups: z
|
|
@@ -58,7 +58,7 @@ const DingTalkAccountConfigSchema = z.object({
|
|
|
58
58
|
z.string(),
|
|
59
59
|
z.object({
|
|
60
60
|
systemPrompt: z.string().optional(),
|
|
61
|
-
})
|
|
61
|
+
}),
|
|
62
62
|
)
|
|
63
63
|
.optional(),
|
|
64
64
|
|
|
@@ -83,12 +83,7 @@ const DingTalkAccountConfigSchema = z.object({
|
|
|
83
83
|
*/
|
|
84
84
|
export const DingTalkConfigSchema: z.ZodTypeAny = DingTalkAccountConfigSchema.extend({
|
|
85
85
|
/** Multi-account configuration */
|
|
86
|
-
accounts: z
|
|
87
|
-
.record(
|
|
88
|
-
z.string(),
|
|
89
|
-
DingTalkAccountConfigSchema.optional()
|
|
90
|
-
)
|
|
91
|
-
.optional(),
|
|
86
|
+
accounts: z.record(z.string(), DingTalkAccountConfigSchema.optional()).optional(),
|
|
92
87
|
});
|
|
93
88
|
|
|
94
89
|
export type DingTalkConfig = z.infer<typeof DingTalkConfigSchema>;
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
4
|
+
import type { DingTalkConfig } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve DingTalk config for an account.
|
|
8
|
+
* Falls back to top-level config for single-account setups.
|
|
9
|
+
*/
|
|
10
|
+
export function getConfig(cfg: OpenClawConfig, accountId?: string): DingTalkConfig {
|
|
11
|
+
const dingtalkCfg = cfg?.channels?.dingtalk as DingTalkConfig | undefined;
|
|
12
|
+
if (!dingtalkCfg) {
|
|
13
|
+
return {} as DingTalkConfig;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (accountId && dingtalkCfg.accounts?.[accountId]) {
|
|
17
|
+
return dingtalkCfg.accounts[accountId];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return dingtalkCfg;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isConfigured(cfg: OpenClawConfig, accountId?: string): boolean {
|
|
24
|
+
const config = getConfig(cfg, accountId);
|
|
25
|
+
return Boolean(config.clientId && config.clientSecret);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveRelativePath(input: string): string {
|
|
29
|
+
const trimmed = input.trim();
|
|
30
|
+
if (!trimmed) {
|
|
31
|
+
return trimmed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const segments = (value: string): string[] => value.split(/[\\/]+/).filter(Boolean);
|
|
35
|
+
|
|
36
|
+
// Expand bare "~" and "~/" or "~\\" prefixes into the user home directory.
|
|
37
|
+
if (trimmed === "~") {
|
|
38
|
+
return path.resolve(os.homedir());
|
|
39
|
+
}
|
|
40
|
+
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
|
41
|
+
return path.resolve(os.homedir(), ...segments(trimmed.slice(2)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Treat both "/" and "\\" as absolute root prefixes for cross-platform input.
|
|
45
|
+
if (/^[\\/]/.test(trimmed)) {
|
|
46
|
+
return path.resolve(path.sep, ...segments(trimmed));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Resolve relative path against cwd; supports mixed separators and "..\\..".
|
|
50
|
+
return path.resolve(process.cwd(), ...segments(trimmed));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const resolveUserPath = resolveRelativePath;
|
|
54
|
+
|
|
55
|
+
export function resolveGroupConfig(
|
|
56
|
+
cfg: DingTalkConfig,
|
|
57
|
+
groupId: string,
|
|
58
|
+
): { systemPrompt?: string } | undefined {
|
|
59
|
+
// Group config supports exact match first, then wildcard fallback.
|
|
60
|
+
const groups = cfg.groups;
|
|
61
|
+
if (!groups) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
return groups[groupId] || groups["*"] || undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Strip group/user prefixes used by CLI targeting.
|
|
69
|
+
* Returns raw DingTalk target ID and whether caller explicitly requested a user target.
|
|
70
|
+
*/
|
|
71
|
+
export function stripTargetPrefix(target: string): { targetId: string; isExplicitUser: boolean } {
|
|
72
|
+
if (target.startsWith("group:")) {
|
|
73
|
+
return { targetId: target.slice(6), isExplicitUser: false };
|
|
74
|
+
}
|
|
75
|
+
if (target.startsWith("user:")) {
|
|
76
|
+
return { targetId: target.slice(5), isExplicitUser: true };
|
|
77
|
+
}
|
|
78
|
+
return { targetId: target, isExplicitUser: false };
|
|
79
|
+
}
|
|
@@ -9,9 +9,14 @@
|
|
|
9
9
|
* - Structured logging for all connection events
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { DWClient } from
|
|
13
|
-
import type {
|
|
14
|
-
|
|
12
|
+
import type { DWClient } from "dingtalk-stream";
|
|
13
|
+
import type {
|
|
14
|
+
ConnectionState,
|
|
15
|
+
ConnectionManagerConfig,
|
|
16
|
+
ConnectionAttemptResult,
|
|
17
|
+
Logger,
|
|
18
|
+
} from "./types";
|
|
19
|
+
import { ConnectionState as ConnectionStateEnum } from "./types";
|
|
15
20
|
|
|
16
21
|
/**
|
|
17
22
|
* ConnectionManager handles the robust connection lifecycle for DWClient
|
|
@@ -84,14 +89,20 @@ export class ConnectionManager {
|
|
|
84
89
|
*/
|
|
85
90
|
private async attemptConnection(): Promise<ConnectionAttemptResult> {
|
|
86
91
|
if (this.stopped) {
|
|
87
|
-
return {
|
|
92
|
+
return {
|
|
93
|
+
success: false,
|
|
94
|
+
attempt: this.attemptCount,
|
|
95
|
+
error: new Error("Connection manager stopped"),
|
|
96
|
+
};
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
this.attemptCount++;
|
|
91
100
|
this.state = ConnectionStateEnum.CONNECTING;
|
|
92
101
|
this.notifyStateChange();
|
|
93
102
|
|
|
94
|
-
this.log?.info?.(
|
|
103
|
+
this.log?.info?.(
|
|
104
|
+
`[${this.accountId}] Connection attempt ${this.attemptCount}/${this.config.maxAttempts}...`,
|
|
105
|
+
);
|
|
95
106
|
|
|
96
107
|
try {
|
|
97
108
|
// Call DWClient connect method
|
|
@@ -101,17 +112,19 @@ export class ConnectionManager {
|
|
|
101
112
|
// This prevents race condition where stop() is called during connection
|
|
102
113
|
if (this.stopped) {
|
|
103
114
|
this.log?.warn?.(
|
|
104
|
-
`[${this.accountId}] Connection succeeded but manager was stopped during connect - disconnecting
|
|
115
|
+
`[${this.accountId}] Connection succeeded but manager was stopped during connect - disconnecting`,
|
|
105
116
|
);
|
|
106
117
|
try {
|
|
107
118
|
this.client.disconnect();
|
|
108
119
|
} catch (disconnectErr: any) {
|
|
109
|
-
this.log?.debug?.(
|
|
120
|
+
this.log?.debug?.(
|
|
121
|
+
`[${this.accountId}] Error during post-connect disconnect: ${disconnectErr.message}`,
|
|
122
|
+
);
|
|
110
123
|
}
|
|
111
124
|
return {
|
|
112
125
|
success: false,
|
|
113
126
|
attempt: this.attemptCount,
|
|
114
|
-
error: new Error(
|
|
127
|
+
error: new Error("Connection manager stopped during connect"),
|
|
115
128
|
};
|
|
116
129
|
}
|
|
117
130
|
|
|
@@ -125,14 +138,16 @@ export class ConnectionManager {
|
|
|
125
138
|
|
|
126
139
|
return { success: true, attempt: successfulAttempt };
|
|
127
140
|
} catch (err: any) {
|
|
128
|
-
this.log?.error?.(
|
|
141
|
+
this.log?.error?.(
|
|
142
|
+
`[${this.accountId}] Connection attempt ${this.attemptCount} failed: ${err.message}`,
|
|
143
|
+
);
|
|
129
144
|
|
|
130
145
|
// Check if we've exceeded max attempts
|
|
131
146
|
if (this.attemptCount >= this.config.maxAttempts) {
|
|
132
147
|
this.state = ConnectionStateEnum.FAILED;
|
|
133
|
-
this.notifyStateChange(
|
|
148
|
+
this.notifyStateChange("Max connection attempts reached");
|
|
134
149
|
this.log?.error?.(
|
|
135
|
-
`[${this.accountId}] Max connection attempts (${this.config.maxAttempts}) reached. Giving up
|
|
150
|
+
`[${this.accountId}] Max connection attempts (${this.config.maxAttempts}) reached. Giving up.`,
|
|
136
151
|
);
|
|
137
152
|
return { success: false, attempt: this.attemptCount, error: err };
|
|
138
153
|
}
|
|
@@ -142,7 +157,7 @@ export class ConnectionManager {
|
|
|
142
157
|
const nextDelay = this.calculateNextDelay(this.attemptCount - 1);
|
|
143
158
|
|
|
144
159
|
this.log?.warn?.(
|
|
145
|
-
`[${this.accountId}] Will retry connection in ${(nextDelay / 1000).toFixed(2)}s (attempt ${this.attemptCount + 1}/${this.config.maxAttempts})
|
|
160
|
+
`[${this.accountId}] Will retry connection in ${(nextDelay / 1000).toFixed(2)}s (attempt ${this.attemptCount + 1}/${this.config.maxAttempts})`,
|
|
146
161
|
);
|
|
147
162
|
|
|
148
163
|
return { success: false, attempt: this.attemptCount, error: err, nextDelay };
|
|
@@ -154,13 +169,15 @@ export class ConnectionManager {
|
|
|
154
169
|
*/
|
|
155
170
|
public async connect(): Promise<void> {
|
|
156
171
|
if (this.stopped) {
|
|
157
|
-
throw new Error(
|
|
172
|
+
throw new Error("Cannot connect: connection manager is stopped");
|
|
158
173
|
}
|
|
159
174
|
|
|
160
175
|
// Clear any existing reconnect timer
|
|
161
176
|
this.clearReconnectTimer();
|
|
162
177
|
|
|
163
|
-
this.log?.info?.(
|
|
178
|
+
this.log?.info?.(
|
|
179
|
+
`[${this.accountId}] Starting DingTalk Stream client with robust connection...`,
|
|
180
|
+
);
|
|
164
181
|
|
|
165
182
|
// Keep trying until success or max attempts reached
|
|
166
183
|
while (!this.stopped && this.state !== ConnectionStateEnum.CONNECTED) {
|
|
@@ -173,9 +190,11 @@ export class ConnectionManager {
|
|
|
173
190
|
}
|
|
174
191
|
|
|
175
192
|
// Check if connection was stopped during connect
|
|
176
|
-
if (result.error?.message ===
|
|
177
|
-
this.log?.info?.(
|
|
178
|
-
|
|
193
|
+
if (result.error?.message === "Connection manager stopped during connect") {
|
|
194
|
+
this.log?.info?.(
|
|
195
|
+
`[${this.accountId}] Connection cancelled: manager stopped during connect`,
|
|
196
|
+
);
|
|
197
|
+
throw new Error("Connection cancelled: connection manager stopped");
|
|
179
198
|
}
|
|
180
199
|
|
|
181
200
|
if (!result.nextDelay || this.attemptCount >= this.config.maxAttempts) {
|
|
@@ -213,7 +232,9 @@ export class ConnectionManager {
|
|
|
213
232
|
|
|
214
233
|
// If we believe we're connected but DWClient disagrees, trigger reconnection
|
|
215
234
|
if (this.state === ConnectionStateEnum.CONNECTED && !client.connected) {
|
|
216
|
-
this.log?.warn?.(
|
|
235
|
+
this.log?.warn?.(
|
|
236
|
+
`[${this.accountId}] Connection health check failed - detected disconnection`,
|
|
237
|
+
);
|
|
217
238
|
if (this.healthCheckInterval) {
|
|
218
239
|
clearInterval(this.healthCheckInterval);
|
|
219
240
|
}
|
|
@@ -230,7 +251,9 @@ export class ConnectionManager {
|
|
|
230
251
|
|
|
231
252
|
// Handler for socket close event
|
|
232
253
|
this.socketCloseHandler = (code: number, reason: string) => {
|
|
233
|
-
this.log?.warn?.(
|
|
254
|
+
this.log?.warn?.(
|
|
255
|
+
`[${this.accountId}] WebSocket closed event (code: ${code}, reason: ${reason || "none"})`,
|
|
256
|
+
);
|
|
234
257
|
|
|
235
258
|
// Only trigger reconnection if we were previously connected and not stopping
|
|
236
259
|
if (!this.stopped && this.state === ConnectionStateEnum.CONNECTED) {
|
|
@@ -243,14 +266,16 @@ export class ConnectionManager {
|
|
|
243
266
|
|
|
244
267
|
// Handler for socket error event
|
|
245
268
|
this.socketErrorHandler = (error: Error) => {
|
|
246
|
-
this.log?.error?.(
|
|
269
|
+
this.log?.error?.(
|
|
270
|
+
`[${this.accountId}] WebSocket error event: ${error?.message || "Unknown error"}`,
|
|
271
|
+
);
|
|
247
272
|
};
|
|
248
273
|
|
|
249
274
|
// Listen to socket events
|
|
250
275
|
// Use 'once' for close to avoid duplicate reconnection triggers
|
|
251
|
-
socket.once(
|
|
276
|
+
socket.once("close", this.socketCloseHandler);
|
|
252
277
|
// Use 'once' for error as well to prevent accumulation across reconnects
|
|
253
|
-
socket.once(
|
|
278
|
+
socket.once("error", this.socketErrorHandler);
|
|
254
279
|
}
|
|
255
280
|
}
|
|
256
281
|
|
|
@@ -270,11 +295,11 @@ export class ConnectionManager {
|
|
|
270
295
|
const socket = this.monitoredSocket;
|
|
271
296
|
|
|
272
297
|
if (this.socketCloseHandler) {
|
|
273
|
-
socket.removeListener(
|
|
298
|
+
socket.removeListener("close", this.socketCloseHandler);
|
|
274
299
|
this.socketCloseHandler = undefined;
|
|
275
300
|
}
|
|
276
301
|
if (this.socketErrorHandler) {
|
|
277
|
-
socket.removeListener(
|
|
302
|
+
socket.removeListener("error", this.socketErrorHandler);
|
|
278
303
|
this.socketErrorHandler = undefined;
|
|
279
304
|
}
|
|
280
305
|
|
|
@@ -287,12 +312,16 @@ export class ConnectionManager {
|
|
|
287
312
|
* Handle runtime disconnection and trigger reconnection
|
|
288
313
|
*/
|
|
289
314
|
private handleRuntimeDisconnection(): void {
|
|
290
|
-
if (this.stopped)
|
|
315
|
+
if (this.stopped) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
291
318
|
|
|
292
|
-
this.log?.warn?.(
|
|
319
|
+
this.log?.warn?.(
|
|
320
|
+
`[${this.accountId}] Runtime disconnection detected, initiating reconnection...`,
|
|
321
|
+
);
|
|
293
322
|
|
|
294
323
|
this.state = ConnectionStateEnum.DISCONNECTED;
|
|
295
|
-
this.notifyStateChange(
|
|
324
|
+
this.notifyStateChange("Runtime disconnection detected");
|
|
296
325
|
this.attemptCount = 0; // Reset attempt counter for runtime reconnection
|
|
297
326
|
|
|
298
327
|
// Clear any existing timer
|
|
@@ -300,7 +329,9 @@ export class ConnectionManager {
|
|
|
300
329
|
|
|
301
330
|
// Start reconnection with initial delay
|
|
302
331
|
const delay = this.calculateNextDelay(0);
|
|
303
|
-
this.log?.info?.(
|
|
332
|
+
this.log?.info?.(
|
|
333
|
+
`[${this.accountId}] Scheduling reconnection in ${(delay / 1000).toFixed(2)}s`,
|
|
334
|
+
);
|
|
304
335
|
|
|
305
336
|
this.reconnectTimer = setTimeout(() => {
|
|
306
337
|
this.reconnect().catch((err) => {
|
|
@@ -313,7 +344,9 @@ export class ConnectionManager {
|
|
|
313
344
|
* Reconnect after runtime disconnection
|
|
314
345
|
*/
|
|
315
346
|
private async reconnect(): Promise<void> {
|
|
316
|
-
if (this.stopped)
|
|
347
|
+
if (this.stopped) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
317
350
|
|
|
318
351
|
this.log?.info?.(`[${this.accountId}] Attempting to reconnect...`);
|
|
319
352
|
|
|
@@ -321,7 +354,9 @@ export class ConnectionManager {
|
|
|
321
354
|
await this.connect();
|
|
322
355
|
this.log?.info?.(`[${this.accountId}] Reconnection successful`);
|
|
323
356
|
} catch (err: any) {
|
|
324
|
-
if (this.stopped)
|
|
357
|
+
if (this.stopped) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
325
360
|
|
|
326
361
|
this.log?.error?.(`[${this.accountId}] Reconnection failed: ${err.message}`);
|
|
327
362
|
this.state = ConnectionStateEnum.FAILED;
|
|
@@ -332,7 +367,7 @@ export class ConnectionManager {
|
|
|
332
367
|
this.attemptCount = 0;
|
|
333
368
|
this.clearReconnectTimer();
|
|
334
369
|
this.log?.warn?.(
|
|
335
|
-
`[${this.accountId}] Reconnection cycle failed; scheduling next reconnect in ${(delay / 1000).toFixed(2)}s
|
|
370
|
+
`[${this.accountId}] Reconnection cycle failed; scheduling next reconnect in ${(delay / 1000).toFixed(2)}s`,
|
|
336
371
|
);
|
|
337
372
|
this.reconnectTimer = setTimeout(() => {
|
|
338
373
|
void this.reconnect();
|
|
@@ -344,7 +379,9 @@ export class ConnectionManager {
|
|
|
344
379
|
* Stop the connection manager and cleanup resources
|
|
345
380
|
*/
|
|
346
381
|
public stop(): void {
|
|
347
|
-
if (this.stopped)
|
|
382
|
+
if (this.stopped) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
348
385
|
|
|
349
386
|
this.log?.info?.(`[${this.accountId}] Stopping connection manager...`);
|
|
350
387
|
|
package/src/dedup.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// ============ Message Deduplication ============
|
|
2
|
+
// Prevent duplicate processing when DingTalk retries delivery.
|
|
3
|
+
// In-memory TTL map + lazy cleanup keeps overhead small.
|
|
4
|
+
|
|
5
|
+
const processedMessages = new Map<string, number>();
|
|
6
|
+
const MESSAGE_DEDUP_TTL = 60000;
|
|
7
|
+
const MESSAGE_DEDUP_MAX_SIZE = 1000;
|
|
8
|
+
let messageCounter = 0;
|
|
9
|
+
|
|
10
|
+
// Check whether message is still inside dedup window.
|
|
11
|
+
export function isMessageProcessed(dedupKey: string): boolean {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
const expiresAt = processedMessages.get(dedupKey);
|
|
14
|
+
|
|
15
|
+
if (expiresAt === undefined) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (now >= expiresAt) {
|
|
20
|
+
processedMessages.delete(dedupKey);
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Mark message as processed and lazily cleanup expired entries.
|
|
28
|
+
export function markMessageProcessed(dedupKey: string): void {
|
|
29
|
+
const expiresAt = Date.now() + MESSAGE_DEDUP_TTL;
|
|
30
|
+
processedMessages.set(dedupKey, expiresAt);
|
|
31
|
+
|
|
32
|
+
// Hard cap for burst protection.
|
|
33
|
+
if (processedMessages.size > MESSAGE_DEDUP_MAX_SIZE) {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
for (const [key, expiry] of processedMessages.entries()) {
|
|
36
|
+
if (now >= expiry) {
|
|
37
|
+
processedMessages.delete(key);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Safety valve: if still over cap after expired-entry sweep, drop oldest entries.
|
|
42
|
+
if (processedMessages.size > MESSAGE_DEDUP_MAX_SIZE) {
|
|
43
|
+
const removeCount = processedMessages.size - MESSAGE_DEDUP_MAX_SIZE;
|
|
44
|
+
let removed = 0;
|
|
45
|
+
for (const key of processedMessages.keys()) {
|
|
46
|
+
processedMessages.delete(key);
|
|
47
|
+
if (++removed >= removeCount) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Deterministic lightweight cleanup every 10 messages.
|
|
56
|
+
messageCounter++;
|
|
57
|
+
if (messageCounter >= 10) {
|
|
58
|
+
messageCounter = 0;
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
for (const [key, expiry] of processedMessages.entries()) {
|
|
61
|
+
if (now >= expiry) {
|
|
62
|
+
processedMessages.delete(key);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
function groupMembersFilePath(storePath: string, groupId: string): string {
|
|
5
|
+
const dir = path.join(path.dirname(storePath), "dingtalk-members");
|
|
6
|
+
const safeId = groupId.replace(/\+/g, "-").replace(/\//g, "_");
|
|
7
|
+
return path.join(dir, `${safeId}.json`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function noteGroupMember(
|
|
11
|
+
storePath: string,
|
|
12
|
+
groupId: string,
|
|
13
|
+
userId: string,
|
|
14
|
+
name: string,
|
|
15
|
+
): void {
|
|
16
|
+
if (!userId || !name) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
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 {}
|
|
24
|
+
if (roster[userId] === name) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
roster[userId] = name;
|
|
28
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
29
|
+
fs.writeFileSync(filePath, JSON.stringify(roster, null, 2));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
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
|
+
}
|
|
40
|
+
const entries = Object.entries(roster);
|
|
41
|
+
if (entries.length === 0) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return entries.map(([id, name]) => `${name} (${id})`).join(", ");
|
|
45
|
+
}
|