@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.
@@ -1,4 +1,4 @@
1
- import { z } from 'zod';
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(['open', 'pairing', 'allowlist']).optional().default('open'),
26
+ dmPolicy: z.enum(["open", "pairing", "allowlist"]).optional().default("open"),
27
27
 
28
28
  /** Group message policy: open or allowlist */
29
- groupPolicy: z.enum(['open', 'allowlist']).optional().default('open'),
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(['markdown', 'card']).optional().default('markdown'),
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('content'),
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 'dingtalk-stream';
13
- import type { ConnectionState, ConnectionManagerConfig, ConnectionAttemptResult, Logger } from './types';
14
- import { ConnectionState as ConnectionStateEnum } from './types';
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 { success: false, attempt: this.attemptCount, error: new Error('Connection manager stopped') };
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?.(`[${this.accountId}] Connection attempt ${this.attemptCount}/${this.config.maxAttempts}...`);
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?.(`[${this.accountId}] Error during post-connect disconnect: ${disconnectErr.message}`);
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('Connection manager stopped during connect'),
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?.(`[${this.accountId}] Connection attempt ${this.attemptCount} failed: ${err.message}`);
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('Max connection attempts reached');
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('Cannot connect: connection manager is stopped');
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?.(`[${this.accountId}] Starting DingTalk Stream client with robust connection...`);
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 === 'Connection manager stopped during connect') {
177
- this.log?.info?.(`[${this.accountId}] Connection cancelled: manager stopped during connect`);
178
- throw new Error('Connection cancelled: connection manager stopped');
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?.(`[${this.accountId}] Connection health check failed - detected disconnection`);
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?.(`[${this.accountId}] WebSocket closed event (code: ${code}, reason: ${reason || 'none'})`);
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?.(`[${this.accountId}] WebSocket error event: ${error?.message || 'Unknown 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('close', this.socketCloseHandler);
276
+ socket.once("close", this.socketCloseHandler);
252
277
  // Use 'once' for error as well to prevent accumulation across reconnects
253
- socket.once('error', this.socketErrorHandler);
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('close', this.socketCloseHandler);
298
+ socket.removeListener("close", this.socketCloseHandler);
274
299
  this.socketCloseHandler = undefined;
275
300
  }
276
301
  if (this.socketErrorHandler) {
277
- socket.removeListener('error', this.socketErrorHandler);
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) return;
315
+ if (this.stopped) {
316
+ return;
317
+ }
291
318
 
292
- this.log?.warn?.(`[${this.accountId}] Runtime disconnection detected, initiating reconnection...`);
319
+ this.log?.warn?.(
320
+ `[${this.accountId}] Runtime disconnection detected, initiating reconnection...`,
321
+ );
293
322
 
294
323
  this.state = ConnectionStateEnum.DISCONNECTED;
295
- this.notifyStateChange('Runtime disconnection detected');
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?.(`[${this.accountId}] Scheduling reconnection in ${(delay / 1000).toFixed(2)}s`);
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) return;
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) return;
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) return;
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
+ }