@soimy/dingtalk 3.0.0-beta.1 → 3.0.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.
package/README.md CHANGED
@@ -44,10 +44,70 @@ openclaw plugins install -l .
44
44
  2. 确保包含 `index.ts`, `openclaw.plugin.json` 和 `package.json`。
45
45
  3. 运行 `openclaw plugins list` 确认 `dingtalk` 已显示在列表中。
46
46
 
47
+ ### 安装后必做:配置插件信任白名单(`plugins.allow`)
48
+
49
+ 从 OpenClaw 新版本开始,如果发现了非内置插件且 `plugins.allow` 为空,会提示:
50
+
51
+ ```text
52
+ [plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load ...
53
+ ```
54
+
55
+ 这是一条安全告警(不是安装失败),建议显式写入你信任的插件 id。
56
+
57
+ #### 步骤 1:确认插件 id
58
+
59
+ 本插件 id 固定为:`dingtalk`(定义于 `openclaw.plugin.json`)。
60
+
61
+ 也可用下面命令查看已发现插件:
62
+
63
+ ```bash
64
+ openclaw plugins list
65
+ ```
66
+
67
+ #### 步骤 2:在 `~/.openclaw/openclaw.json` 添加 `plugins.allow`
68
+
69
+ ```json5
70
+ {
71
+ "plugins": {
72
+ "enabled": true,
73
+ "allow": ["dingtalk"]
74
+ }
75
+ }
76
+ ```
77
+
78
+ 如果你还有其他已安装且需要启用的插件,请一并加入,例如:
79
+
80
+ ```json5
81
+ {
82
+ "plugins": {
83
+ "allow": ["dingtalk", "telegram", "voice-call"]
84
+ }
85
+ }
86
+ ```
87
+
88
+ #### 步骤 3:重启 Gateway
89
+
90
+ ```bash
91
+ openclaw gateway restart
92
+ ```
93
+
94
+ > 注意:如果你之前已经配置过 `plugins.allow`,但没有 `dingtalk`,那么插件不会被加载。请把 `dingtalk` 加入该列表。
95
+
47
96
  ## 更新
48
97
 
98
+ `openclaw plugins update` 使用插件 id(不是 npm 包名),并且仅适用于 npm 安装来源。
99
+
100
+ 如果你是通过 npm 安装本插件:
101
+
102
+ ```bash
103
+ openclaw plugins update dingtalk
49
104
  ```
50
- openclaw plugins update @soimy/dingtalk
105
+
106
+ 如果你是本地源码/链接安装(`openclaw plugins install -l .`),请在插件目录更新代码后重启 Gateway:
107
+
108
+ ```bash
109
+ git pull
110
+ openclaw gateway restart
51
111
  ```
52
112
 
53
113
  ## 配置
@@ -140,12 +200,17 @@ openclaw configure --section channels
140
200
 
141
201
  ### 方法 2:手动配置文件
142
202
 
143
- 在 `~/.openclaw/openclaw.json` 的 `channels` 下添加(仅作参考,交互式配置会自动生成):
203
+ 在 `~/.openclaw/openclaw.json` 中添加(仅作参考,交互式配置会自动生成):
144
204
 
145
- > 只添加dingtalk部分,内容参考上文钉钉开发者配置指南
205
+ > 至少包含 `plugins.allow` 和 `channels.dingtalk` 两部分,内容参考上文钉钉开发者配置指南
146
206
 
147
207
  ```json5
148
208
  {
209
+ "plugins": {
210
+ "enabled": true,
211
+ "allow": ["dingtalk"]
212
+ },
213
+
149
214
  ...
150
215
  "channels": {
151
216
  "telegram": { ... },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soimy/dingtalk",
3
- "version": "3.0.0-beta.1",
3
+ "version": "3.0.1",
4
4
  "description": "DingTalk (钉钉) channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
package/src/channel.ts CHANGED
@@ -30,6 +30,46 @@ import type {
30
30
  import { ConnectionState } from "./types";
31
31
  import { cleanupOrphanedTempFiles, getCurrentTimestamp } from "./utils";
32
32
 
33
+ const processingDedupKeys = new Set<string>();
34
+ const inboundCountersByAccount = new Map<
35
+ string,
36
+ {
37
+ received: number;
38
+ acked: number;
39
+ dedupSkipped: number;
40
+ inflightSkipped: number;
41
+ processed: number;
42
+ failed: number;
43
+ noMessageId: number;
44
+ }
45
+ >();
46
+ const INBOUND_COUNTER_LOG_EVERY = 10;
47
+
48
+ function getInboundCounters(accountId: string) {
49
+ const existing = inboundCountersByAccount.get(accountId);
50
+ if (existing) {
51
+ return existing;
52
+ }
53
+ const created = {
54
+ received: 0,
55
+ acked: 0,
56
+ dedupSkipped: 0,
57
+ inflightSkipped: 0,
58
+ processed: 0,
59
+ failed: 0,
60
+ noMessageId: 0,
61
+ };
62
+ inboundCountersByAccount.set(accountId, created);
63
+ return created;
64
+ }
65
+
66
+ function logInboundCounters(log: any, accountId: string, reason: string): void {
67
+ const stats = getInboundCounters(accountId);
68
+ log?.info?.(
69
+ `[${accountId}] Inbound counters (${reason}): received=${stats.received}, acked=${stats.acked}, processed=${stats.processed}, dedupSkipped=${stats.dedupSkipped}, inflightSkipped=${stats.inflightSkipped}, failed=${stats.failed}, noMessageId=${stats.noMessageId}`,
70
+ );
71
+ }
72
+
33
73
  // DingTalk Channel Definition (assembly layer).
34
74
  // Heavy logic is delegated to service modules for maintainability.
35
75
  export const dingtalkPlugin: DingTalkChannelPlugin = {
@@ -254,36 +294,75 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
254
294
 
255
295
  client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
256
296
  const messageId = res.headers?.messageId;
297
+ const stats = getInboundCounters(account.accountId);
298
+ stats.received += 1;
257
299
  try {
258
300
  if (messageId) {
259
301
  client.socketCallBackResponse(messageId, { success: true });
302
+ stats.acked += 1;
260
303
  }
261
304
  const data = JSON.parse(res.data) as DingTalkInboundMessage;
262
305
 
263
306
  // Message deduplication key is bot-scoped to avoid cross-account conflicts.
264
307
  const robotKey = config.robotCode || config.clientId || account.accountId;
265
308
  const msgId = data.msgId || messageId;
309
+ const dedupKey = msgId ? `${robotKey}:${msgId}` : undefined;
266
310
 
267
- if (!msgId) {
311
+ if (!dedupKey) {
268
312
  ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
269
- } else {
270
- const dedupKey = `${robotKey}:${msgId}`;
271
- if (isMessageProcessed(dedupKey)) {
272
- ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
273
- return;
313
+ stats.noMessageId += 1;
314
+ await handleDingTalkMessage({
315
+ cfg,
316
+ accountId: account.accountId,
317
+ data,
318
+ sessionWebhook: data.sessionWebhook,
319
+ log: ctx.log,
320
+ dingtalkConfig: config,
321
+ });
322
+ stats.processed += 1;
323
+ if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
324
+ logInboundCounters(ctx.log, account.accountId, "periodic");
274
325
  }
275
- markMessageProcessed(dedupKey);
326
+ return;
276
327
  }
277
328
 
278
- await handleDingTalkMessage({
279
- cfg,
280
- accountId: account.accountId,
281
- data,
282
- sessionWebhook: data.sessionWebhook,
283
- log: ctx.log,
284
- dingtalkConfig: config,
285
- });
329
+ if (isMessageProcessed(dedupKey)) {
330
+ ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
331
+ stats.dedupSkipped += 1;
332
+ logInboundCounters(ctx.log, account.accountId, "dedup-skipped");
333
+ return;
334
+ }
335
+
336
+ if (processingDedupKeys.has(dedupKey)) {
337
+ ctx.log?.debug?.(
338
+ `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
339
+ );
340
+ stats.inflightSkipped += 1;
341
+ logInboundCounters(ctx.log, account.accountId, "inflight-skipped");
342
+ return;
343
+ }
344
+
345
+ processingDedupKeys.add(dedupKey);
346
+ try {
347
+ await handleDingTalkMessage({
348
+ cfg,
349
+ accountId: account.accountId,
350
+ data,
351
+ sessionWebhook: data.sessionWebhook,
352
+ log: ctx.log,
353
+ dingtalkConfig: config,
354
+ });
355
+ stats.processed += 1;
356
+ markMessageProcessed(dedupKey);
357
+ if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
358
+ logInboundCounters(ctx.log, account.accountId, "periodic");
359
+ }
360
+ } finally {
361
+ processingDedupKeys.delete(dedupKey);
362
+ }
286
363
  } catch (error: any) {
364
+ stats.failed += 1;
365
+ logInboundCounters(ctx.log, account.accountId, "failed");
287
366
  ctx.log?.error?.(`[${account.accountId}] Error processing message: ${error.message}`);
288
367
  }
289
368
  });
@@ -31,6 +31,21 @@ export class ConnectionManager {
31
31
  private attemptCount: number = 0;
32
32
  private reconnectTimer?: NodeJS.Timeout;
33
33
  private stopped: boolean = false;
34
+ private connectedAt?: number;
35
+ private consecutiveUnhealthyChecks: number = 0;
36
+
37
+ private static readonly HEALTH_CHECK_INTERVAL_MS = 5000;
38
+ private static readonly HEALTH_CHECK_GRACE_MS = 3000;
39
+ private static readonly HEALTH_CHECK_UNHEALTHY_THRESHOLD = 2;
40
+ private runtimeCounters = {
41
+ healthUnhealthyChecks: 0,
42
+ healthTriggeredReconnects: 0,
43
+ socketCloseEvents: 0,
44
+ runtimeDisconnects: 0,
45
+ reconnectAttempts: 0,
46
+ reconnectSuccess: 0,
47
+ reconnectFailures: 0,
48
+ };
34
49
 
35
50
  // Runtime monitoring resources
36
51
  private healthCheckInterval?: NodeJS.Timeout;
@@ -61,6 +76,13 @@ export class ConnectionManager {
61
76
  }
62
77
  }
63
78
 
79
+ private logRuntimeCounters(reason: string): void {
80
+ const c = this.runtimeCounters;
81
+ this.log?.info?.(
82
+ `[${this.accountId}] Runtime counters (${reason}): healthUnhealthyChecks=${c.healthUnhealthyChecks}, healthTriggeredReconnects=${c.healthTriggeredReconnects}, socketCloseEvents=${c.socketCloseEvents}, runtimeDisconnects=${c.runtimeDisconnects}, reconnectAttempts=${c.reconnectAttempts}, reconnectSuccess=${c.reconnectSuccess}, reconnectFailures=${c.reconnectFailures}`,
83
+ );
84
+ }
85
+
64
86
  /**
65
87
  * Calculate next reconnection delay with exponential backoff and jitter
66
88
  * Formula: delay = min(initialDelay * 2^attempt, maxDelay) * (1 ± jitter)
@@ -130,6 +152,8 @@ export class ConnectionManager {
130
152
 
131
153
  // Connection successful
132
154
  this.state = ConnectionStateEnum.CONNECTED;
155
+ this.connectedAt = Date.now();
156
+ this.consecutiveUnhealthyChecks = 0;
133
157
  this.notifyStateChange();
134
158
  const successfulAttempt = this.attemptCount;
135
159
  this.attemptCount = 0; // Reset counter on success
@@ -230,17 +254,51 @@ export class ConnectionManager {
230
254
  return;
231
255
  }
232
256
 
233
- // If we believe we're connected but DWClient disagrees, trigger reconnection
234
- if (this.state === ConnectionStateEnum.CONNECTED && !client.connected) {
235
- this.log?.warn?.(
236
- `[${this.accountId}] Connection health check failed - detected disconnection`,
257
+ if (this.state !== ConnectionStateEnum.CONNECTED) {
258
+ this.consecutiveUnhealthyChecks = 0;
259
+ return;
260
+ }
261
+
262
+ const now = Date.now();
263
+ const withinGraceWindow =
264
+ this.connectedAt !== undefined &&
265
+ now - this.connectedAt < ConnectionManager.HEALTH_CHECK_GRACE_MS;
266
+ if (withinGraceWindow) {
267
+ this.consecutiveUnhealthyChecks = 0;
268
+ return;
269
+ }
270
+
271
+ const socketReadyState = (client.socket as { readyState?: number } | undefined)?.readyState;
272
+ const socketOpen = socketReadyState === 1;
273
+ const unhealthy = !client.connected && !socketOpen;
274
+
275
+ if (!unhealthy) {
276
+ this.consecutiveUnhealthyChecks = 0;
277
+ return;
278
+ }
279
+
280
+ this.consecutiveUnhealthyChecks += 1;
281
+ this.runtimeCounters.healthUnhealthyChecks += 1;
282
+ if (
283
+ this.consecutiveUnhealthyChecks <
284
+ ConnectionManager.HEALTH_CHECK_UNHEALTHY_THRESHOLD
285
+ ) {
286
+ this.log?.debug?.(
287
+ `[${this.accountId}] Connection health check unhealthy (${this.consecutiveUnhealthyChecks}/${ConnectionManager.HEALTH_CHECK_UNHEALTHY_THRESHOLD}) connected=${String(client.connected)} socketReadyState=${socketReadyState ?? "unknown"}`,
237
288
  );
238
- if (this.healthCheckInterval) {
239
- clearInterval(this.healthCheckInterval);
240
- }
241
- this.handleRuntimeDisconnection();
289
+ return;
290
+ }
291
+
292
+ this.log?.warn?.(
293
+ `[${this.accountId}] Connection health check failed - detected disconnection`,
294
+ );
295
+ this.runtimeCounters.healthTriggeredReconnects += 1;
296
+ this.logRuntimeCounters("health-triggered-reconnect");
297
+ if (this.healthCheckInterval) {
298
+ clearInterval(this.healthCheckInterval);
242
299
  }
243
- }, 5000); // Check every 5 seconds
300
+ this.handleRuntimeDisconnection();
301
+ }, ConnectionManager.HEALTH_CHECK_INTERVAL_MS);
244
302
 
245
303
  // Additionally, if we have access to the socket, monitor its events
246
304
  // The DWClient uses 'ws' WebSocket library which extends EventEmitter
@@ -251,9 +309,11 @@ export class ConnectionManager {
251
309
 
252
310
  // Handler for socket close event
253
311
  this.socketCloseHandler = (code: number, reason: string) => {
312
+ this.runtimeCounters.socketCloseEvents += 1;
254
313
  this.log?.warn?.(
255
314
  `[${this.accountId}] WebSocket closed event (code: ${code}, reason: ${reason || "none"})`,
256
315
  );
316
+ this.logRuntimeCounters("socket-close");
257
317
 
258
318
  // Only trigger reconnection if we were previously connected and not stopping
259
319
  if (!this.stopped && this.state === ConnectionStateEnum.CONNECTED) {
@@ -319,10 +379,13 @@ export class ConnectionManager {
319
379
  this.log?.warn?.(
320
380
  `[${this.accountId}] Runtime disconnection detected, initiating reconnection...`,
321
381
  );
382
+ this.runtimeCounters.runtimeDisconnects += 1;
322
383
 
323
384
  this.state = ConnectionStateEnum.DISCONNECTED;
324
385
  this.notifyStateChange("Runtime disconnection detected");
325
386
  this.attemptCount = 0; // Reset attempt counter for runtime reconnection
387
+ this.connectedAt = undefined;
388
+ this.consecutiveUnhealthyChecks = 0;
326
389
 
327
390
  // Clear any existing timer
328
391
  this.clearReconnectTimer();
@@ -349,17 +412,24 @@ export class ConnectionManager {
349
412
  }
350
413
 
351
414
  this.log?.info?.(`[${this.accountId}] Attempting to reconnect...`);
415
+ this.runtimeCounters.reconnectAttempts += 1;
352
416
 
353
417
  try {
354
418
  await this.connect();
355
419
  this.log?.info?.(`[${this.accountId}] Reconnection successful`);
420
+ this.runtimeCounters.reconnectSuccess += 1;
421
+ this.logRuntimeCounters("reconnect-success");
356
422
  } catch (err: any) {
357
423
  if (this.stopped) {
358
424
  return;
359
425
  }
360
426
 
361
427
  this.log?.error?.(`[${this.accountId}] Reconnection failed: ${err.message}`);
428
+ this.runtimeCounters.reconnectFailures += 1;
429
+ this.logRuntimeCounters("reconnect-failed");
362
430
  this.state = ConnectionStateEnum.FAILED;
431
+ this.connectedAt = undefined;
432
+ this.consecutiveUnhealthyChecks = 0;
363
433
  this.notifyStateChange(err.message);
364
434
 
365
435
  // Continue runtime recovery instead of getting stuck in FAILED.
@@ -387,6 +457,8 @@ export class ConnectionManager {
387
457
 
388
458
  this.stopped = true;
389
459
  this.state = ConnectionStateEnum.DISCONNECTING;
460
+ this.connectedAt = undefined;
461
+ this.consecutiveUnhealthyChecks = 0;
390
462
 
391
463
  // Clear reconnect timer
392
464
  this.clearReconnectTimer();
@@ -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
+ }
@@ -1,5 +1,3 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
1
  import axios from "axios";
4
2
  import { normalizeAllowFrom, isSenderAllowed, isSenderGroupAllowed } from "./access-control";
5
3
  import { getAccessToken } from "./auth";
@@ -14,6 +12,7 @@ import {
14
12
  streamAICard,
15
13
  } from "./card-service";
16
14
  import { resolveGroupConfig } from "./config";
15
+ import { formatGroupMembers, noteGroupMember } from "./group-members-store";
17
16
  import { setCurrentLogger } from "./logger-context";
18
17
  import { extractMessageContent } from "./message-utils";
19
18
  import { registerPeerId } from "./peer-id-registry";
@@ -23,46 +22,6 @@ import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./t
23
22
  import { AICardStatus } from "./types";
24
23
  import { maskSensitiveData } from "./utils";
25
24
 
26
- // ============ Group Members Persistence ============
27
-
28
- function groupMembersFilePath(storePath: string, groupId: string): string {
29
- const dir = path.join(path.dirname(storePath), "dingtalk-members");
30
- const safeId = groupId.replace(/\+/g, "-").replace(/\//g, "_");
31
- return path.join(dir, `${safeId}.json`);
32
- }
33
-
34
- function noteGroupMember(storePath: string, groupId: string, userId: string, name: string): void {
35
- if (!userId || !name) {
36
- return;
37
- }
38
- const filePath = groupMembersFilePath(storePath, groupId);
39
- let roster: Record<string, string> = {};
40
- try {
41
- roster = JSON.parse(fs.readFileSync(filePath, "utf-8"));
42
- } catch {}
43
- if (roster[userId] === name) {
44
- return;
45
- }
46
- roster[userId] = name;
47
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
48
- fs.writeFileSync(filePath, JSON.stringify(roster, null, 2));
49
- }
50
-
51
- function formatGroupMembers(storePath: string, groupId: string): string | undefined {
52
- const filePath = groupMembersFilePath(storePath, groupId);
53
- let roster: Record<string, string> = {};
54
- try {
55
- roster = JSON.parse(fs.readFileSync(filePath, "utf-8"));
56
- } catch {
57
- return undefined;
58
- }
59
- const entries = Object.entries(roster);
60
- if (entries.length === 0) {
61
- return undefined;
62
- }
63
- return entries.map(([id, name]) => `${name} (${id})`).join(", ");
64
- }
65
-
66
25
  /**
67
26
  * Download DingTalk media file via runtime media service (sandbox-compatible).
68
27
  * Files are stored in the global media inbound directory.