@soimy/dingtalk 3.1.1 → 3.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soimy/dingtalk",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "description": "DingTalk (钉钉) channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
package/src/channel.ts CHANGED
@@ -381,6 +381,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
381
381
  initialDelay: config.initialReconnectDelay ?? 1000,
382
382
  maxDelay: config.maxReconnectDelay ?? 60000,
383
383
  jitter: config.reconnectJitter ?? 0.3,
384
+ maxReconnectCycles: config.maxReconnectCycles,
384
385
  onStateChange: (state: ConnectionState, error?: string) => {
385
386
  if (stopped) {
386
387
  return;
@@ -76,6 +76,9 @@ const DingTalkAccountConfigSchema = z.object({
76
76
  /** Jitter factor for reconnection delay randomization (0-1, default: 0.3) */
77
77
  reconnectJitter: z.number().min(0).max(1).optional().default(0.3),
78
78
 
79
+ /** Maximum number of runtime reconnect cycles before giving up (default: 10) */
80
+ maxReconnectCycles: z.number().int().min(1).optional().default(10),
81
+
79
82
  proactivePermissionHint: z
80
83
  .object({
81
84
  enabled: z.boolean().optional().default(true),
@@ -37,6 +37,8 @@ export class ConnectionManager {
37
37
  private static readonly HEALTH_CHECK_INTERVAL_MS = 5000;
38
38
  private static readonly HEALTH_CHECK_GRACE_MS = 3000;
39
39
  private static readonly HEALTH_CHECK_UNHEALTHY_THRESHOLD = 2;
40
+ private static readonly DEFAULT_MAX_RECONNECT_CYCLES = 10;
41
+ private runtimeReconnectCycles: number = 0;
40
42
  private runtimeCounters = {
41
43
  healthUnhealthyChecks: 0,
42
44
  healthTriggeredReconnects: 0,
@@ -160,6 +162,9 @@ export class ConnectionManager {
160
162
 
161
163
  this.log?.info?.(`[${this.accountId}] DingTalk Stream client connected successfully`);
162
164
 
165
+ // Reset runtime reconnect cycle counter on successful connection
166
+ this.runtimeReconnectCycles = 0;
167
+
163
168
  return { success: true, attempt: successfulAttempt };
164
169
  } catch (err: any) {
165
170
  this.log?.error?.(
@@ -427,17 +432,34 @@ export class ConnectionManager {
427
432
  this.log?.error?.(`[${this.accountId}] Reconnection failed: ${err.message}`);
428
433
  this.runtimeCounters.reconnectFailures += 1;
429
434
  this.logRuntimeCounters("reconnect-failed");
435
+
436
+ // Track runtime reconnect cycles to prevent infinite loops
437
+ this.runtimeReconnectCycles += 1;
438
+ const maxCycles = this.config.maxReconnectCycles ?? ConnectionManager.DEFAULT_MAX_RECONNECT_CYCLES;
439
+
440
+ if (this.runtimeReconnectCycles >= maxCycles) {
441
+ this.log?.error?.(
442
+ `[${this.accountId}] Max runtime reconnect cycles (${maxCycles}) reached. Giving up. ` +
443
+ `Please check network connectivity or restart the gateway.`,
444
+ );
445
+ this.state = ConnectionStateEnum.FAILED;
446
+ this.connectedAt = undefined;
447
+ this.consecutiveUnhealthyChecks = 0;
448
+ this.notifyStateChange(`Max runtime reconnect cycles (${maxCycles}) reached`);
449
+ return;
450
+ }
451
+
430
452
  this.state = ConnectionStateEnum.FAILED;
431
453
  this.connectedAt = undefined;
432
454
  this.consecutiveUnhealthyChecks = 0;
433
455
  this.notifyStateChange(err.message);
434
456
 
435
- // Continue runtime recovery instead of getting stuck in FAILED.
436
- const delay = this.calculateNextDelay(0);
457
+ // Continue runtime recovery with exponential backoff based on cycle count
458
+ const delay = this.calculateNextDelay(Math.min(this.runtimeReconnectCycles - 1, 6)); // Cap at ~64x initial delay
437
459
  this.attemptCount = 0;
438
460
  this.clearReconnectTimer();
439
461
  this.log?.warn?.(
440
- `[${this.accountId}] Reconnection cycle failed; scheduling next reconnect in ${(delay / 1000).toFixed(2)}s`,
462
+ `[${this.accountId}] Reconnection cycle ${this.runtimeReconnectCycles}/${maxCycles} failed; scheduling next reconnect in ${(delay / 1000).toFixed(2)}s`,
441
463
  );
442
464
  this.reconnectTimer = setTimeout(() => {
443
465
  void this.reconnect();
@@ -18,7 +18,7 @@ import { extractMessageContent } from "./message-utils";
18
18
  import { registerPeerId } from "./peer-id-registry";
19
19
  import {
20
20
  clearProactiveRiskObservationsForTest,
21
- recordProactiveRiskObservation,
21
+ getProactiveRiskObservationForAny,
22
22
  } from "./proactive-risk-registry";
23
23
  import { getDingTalkRuntime } from "./runtime";
24
24
  import { sendBySession, sendMessage } from "./send-service";
@@ -51,8 +51,17 @@ function shouldSendProactivePermissionHint(params: {
51
51
  return false;
52
52
  }
53
53
 
54
- const targetId = (params.senderStaffId || params.senderId || "").trim();
55
- if (!targetId || !/^\d+$/.test(targetId)) {
54
+ const targetId = (params.senderId || "").trim();
55
+ if (!targetId) {
56
+ return false;
57
+ }
58
+
59
+ const riskObservation = getProactiveRiskObservationForAny(
60
+ params.accountId,
61
+ [params.senderId, params.senderStaffId],
62
+ params.nowMs,
63
+ );
64
+ if (!riskObservation || riskObservation.source !== "proactive-api") {
56
65
  return false;
57
66
  }
58
67
 
@@ -71,6 +80,14 @@ function shouldSendProactivePermissionHint(params: {
71
80
  return true;
72
81
  }
73
82
 
83
+ function isUnhandledStopReasonText(value: string): boolean {
84
+ const normalized = value.trim();
85
+ if (!normalized) {
86
+ return false;
87
+ }
88
+ return /^Unhandled stop reason:\s*[A-Za-z0-9_-]+/i.test(normalized);
89
+ }
90
+
74
91
  /**
75
92
  * Download DingTalk media file via runtime media service (sandbox-compatible).
76
93
  * Files are stored in the global media inbound directory.
@@ -207,7 +224,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
207
224
  await sendBySession(
208
225
  dingtalkConfig,
209
226
  sessionWebhook,
210
- `⚠️ 主动推送可能失败\n\n检测到当前用户标识为纯数字(\`${data.senderStaffId || senderId}\`)。企业内部机器人在定时/主动发送场景中,通常需要企业内部有效用户ID与完整授权。\n\n建议:\n1) 优先使用企业内部用户ID(如 managerXXXX)\n2) 确认应用已申请并获得主动发送相关权限\n3) 确认目标用户已加入机器人所属企业`,
227
+ "⚠️ 主动推送可能失败\n\n检测到该用户最近一次主动发送调用返回了权限或目标不可达错误。当前会话回复仍可正常使用,但定时/主动发送可能失败。\n\n建议:\n1) 在钉钉开放平台确认应用已申请并获得主动发送相关权限\n2) 确认目标用户属于当前企业并在应用可见范围内\n3) 使用相同账号进行一次主动发送验证并检查错误码详情",
211
228
  { log },
212
229
  );
213
230
  } catch (err: any) {
@@ -221,16 +238,6 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
221
238
  registerPeerId(senderId);
222
239
  }
223
240
 
224
- if (isDirect && /^\d+$/.test((data.senderStaffId || senderId || "").trim())) {
225
- recordProactiveRiskObservation({
226
- accountId,
227
- targetId: data.senderStaffId || senderId,
228
- level: "high",
229
- reason: "numeric-user-id",
230
- source: "webhook-hint",
231
- });
232
- }
233
-
234
241
  // 2) Authorization guard (DM/group policy).
235
242
  let commandAuthorized = true;
236
243
  if (isDirect) {
@@ -467,6 +474,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
467
474
  return;
468
475
  }
469
476
 
477
+ if (typeof textToSend === "string" && isUnhandledStopReasonText(textToSend)) {
478
+ log?.warn?.(`[DingTalk] Suppressed stop reason from outbound chat content: ${textToSend}`);
479
+ return;
480
+ }
481
+
470
482
  if (useCardMode && currentAICard && info?.kind === "final") {
471
483
  lastCardContent = textToSend;
472
484
  return;
@@ -552,12 +564,21 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
552
564
  const hasQueuedFinalString = isNonEmptyString(queuedFinal);
553
565
 
554
566
  if (hasLastCardContent || hasQueuedFinalString) {
555
- const finalContent =
567
+ const finalContentCandidate =
556
568
  hasLastCardContent && typeof lastCardContent === "string"
557
569
  ? lastCardContent
558
570
  : typeof queuedFinal === "string"
559
571
  ? queuedFinal
560
572
  : "";
573
+ if (isUnhandledStopReasonText(finalContentCandidate)) {
574
+ log?.warn?.(
575
+ `[DingTalk] Suppressed stop reason from AI Card final content: ${finalContentCandidate}`,
576
+ );
577
+ currentAICard.state = AICardStatus.FINISHED;
578
+ currentAICard.lastUpdated = Date.now();
579
+ return;
580
+ }
581
+ const finalContent = finalContentCandidate;
561
582
  await finishAICard(currentAICard, finalContent, log);
562
583
  } else {
563
584
  log?.debug?.(
package/src/onboarding.ts CHANGED
@@ -106,6 +106,9 @@ function applyAccountConfig(params: {
106
106
  ...(input.messageType ? { messageType: input.messageType } : {}),
107
107
  ...(input.cardTemplateId ? { cardTemplateId: input.cardTemplateId } : {}),
108
108
  ...(input.cardTemplateKey ? { cardTemplateKey: input.cardTemplateKey } : {}),
109
+ ...(typeof input.maxReconnectCycles === "number"
110
+ ? { maxReconnectCycles: input.maxReconnectCycles }
111
+ : {}),
109
112
  };
110
113
 
111
114
  if (useDefault) {
@@ -304,6 +307,35 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
304
307
  initialValue: resolved.groupPolicy ?? "open",
305
308
  });
306
309
 
310
+ let maxReconnectCycles: number | undefined;
311
+ const wantsReconnectLimits = await prompter.confirm({
312
+ message: "Configure runtime reconnect cycle limit? (recommended)",
313
+ initialValue: typeof resolved.maxReconnectCycles === "number",
314
+ });
315
+ if (wantsReconnectLimits) {
316
+ const parsedCycles = Number(
317
+ String(
318
+ await prompter.text({
319
+ message: "Max runtime reconnect cycles",
320
+ placeholder: "10",
321
+ initialValue: String(resolved.maxReconnectCycles ?? 10),
322
+ validate: (value) => {
323
+ const raw = String(value ?? "").trim();
324
+ const num = Number(raw);
325
+ if (!raw) {
326
+ return "Required";
327
+ }
328
+ if (!Number.isInteger(num) || num < 1) {
329
+ return "Must be an integer >= 1";
330
+ }
331
+ return undefined;
332
+ },
333
+ }),
334
+ ).trim(),
335
+ );
336
+ maxReconnectCycles = Number.isInteger(parsedCycles) && parsedCycles > 0 ? parsedCycles : 10;
337
+ }
338
+
307
339
  const next = applyAccountConfig({
308
340
  cfg,
309
341
  accountId,
@@ -319,6 +351,7 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
319
351
  messageType,
320
352
  cardTemplateId,
321
353
  cardTemplateKey,
354
+ maxReconnectCycles,
322
355
  },
323
356
  });
324
357
 
@@ -59,6 +59,31 @@ export function getProactiveRiskObservation(
59
59
  return entry;
60
60
  }
61
61
 
62
+ export function getProactiveRiskObservationForAny(
63
+ accountId: string,
64
+ targetIds: Array<string | null | undefined>,
65
+ nowMs = Date.now(),
66
+ ): ProactiveRiskSnapshot | null {
67
+ for (const candidate of targetIds) {
68
+ if (!candidate) {
69
+ continue;
70
+ }
71
+ const observation = getProactiveRiskObservation(accountId, candidate, nowMs);
72
+ if (observation) {
73
+ return observation;
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+
79
+ export function deleteProactiveRiskObservation(accountId: string, targetId: string): void {
80
+ const target = targetId?.trim();
81
+ if (!accountId || !target) {
82
+ return;
83
+ }
84
+ store.delete(keyOf(accountId, target));
85
+ }
86
+
62
87
  export function clearProactiveRiskObservationsForTest(): void {
63
88
  store.clear();
64
89
  }
@@ -13,7 +13,11 @@ import { getLogger } from "./logger-context";
13
13
  import { uploadMedia as uploadMediaUtil } from "./media-utils";
14
14
  import { detectMarkdownAndExtractTitle } from "./message-utils";
15
15
  import { resolveOriginalPeerId } from "./peer-id-registry";
16
- import { getProactiveRiskObservation } from "./proactive-risk-registry";
16
+ import {
17
+ deleteProactiveRiskObservation,
18
+ getProactiveRiskObservation,
19
+ recordProactiveRiskObservation,
20
+ } from "./proactive-risk-registry";
17
21
  import { formatDingTalkErrorPayloadLog } from "./utils";
18
22
  import type {
19
23
  AxiosResponse,
@@ -27,6 +31,38 @@ import { AICardStatus } from "./types";
27
31
 
28
32
  export { detectMediaTypeFromExtension } from "./media-utils";
29
33
 
34
+ function extractErrorCodeFromResponseData(data: unknown): string | null {
35
+ if (!data || typeof data !== "object") {
36
+ return null;
37
+ }
38
+
39
+ const payload = data as Record<string, unknown>;
40
+ const code = payload.code;
41
+ if (typeof code === "string" && code.trim()) {
42
+ return code.trim();
43
+ }
44
+
45
+ const subCode = payload.subCode;
46
+ if (typeof subCode === "string" && subCode.trim()) {
47
+ return subCode.trim();
48
+ }
49
+
50
+ return null;
51
+ }
52
+
53
+ function isProactivePermissionOrScopeError(code: string | null): boolean {
54
+ if (!code) {
55
+ return false;
56
+ }
57
+ return (
58
+ code.startsWith("Forbidden.AccessDenied") ||
59
+ code === "invalidParameter.userIds.invalid" ||
60
+ code === "invalidParameter.userIds.empty" ||
61
+ code === "invalidParameter.openConversationId.invalid" ||
62
+ code === "invalidParameter.robotCode.empty"
63
+ );
64
+ }
65
+
30
66
  /**
31
67
  * Wrapper to upload media with shared getAccessToken binding.
32
68
  */
@@ -94,6 +130,9 @@ export async function sendProactiveTextOrMarkdown(
94
130
  data: payload,
95
131
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
96
132
  });
133
+ if (options.accountId) {
134
+ deleteProactiveRiskObservation(options.accountId, resolvedTarget);
135
+ }
97
136
  return result.data;
98
137
  } catch (err: unknown) {
99
138
  const maybeAxiosError = err as {
@@ -101,6 +140,16 @@ export async function sendProactiveTextOrMarkdown(
101
140
  message?: string;
102
141
  };
103
142
  if (maybeAxiosError?.response) {
143
+ const errCode = extractErrorCodeFromResponseData(maybeAxiosError.response.data);
144
+ if (options.accountId && isProactivePermissionOrScopeError(errCode)) {
145
+ recordProactiveRiskObservation({
146
+ accountId: options.accountId,
147
+ targetId: resolvedTarget,
148
+ level: "high",
149
+ reason: errCode || "proactive-permission-error",
150
+ source: "proactive-api",
151
+ });
152
+ }
104
153
  const status = maybeAxiosError.response.status;
105
154
  const statusText = maybeAxiosError.response.statusText;
106
155
  const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ""}` : "";
@@ -190,6 +239,9 @@ export async function sendProactiveMedia(
190
239
  data: payload,
191
240
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
192
241
  });
242
+ if (options.accountId) {
243
+ deleteProactiveRiskObservation(options.accountId, resolvedTarget);
244
+ }
193
245
 
194
246
  const messageId = result.data?.processQueryKey || result.data?.messageId;
195
247
  return { ok: true, data: result.data, messageId };
@@ -203,6 +255,16 @@ export async function sendProactiveMedia(
203
255
  ? ` proactiveRisk=${proactiveRisk.level}:${proactiveRisk.reason}`
204
256
  : "";
205
257
  if (axios.isAxiosError(err) && err.response) {
258
+ const errCode = extractErrorCodeFromResponseData(err.response.data);
259
+ if (options.accountId && isProactivePermissionOrScopeError(errCode)) {
260
+ recordProactiveRiskObservation({
261
+ accountId: options.accountId,
262
+ targetId: normalizedTarget,
263
+ level: "high",
264
+ reason: errCode || "proactive-permission-error",
265
+ source: "proactive-api",
266
+ });
267
+ }
206
268
  const status = err.response.status;
207
269
  const statusText = err.response.statusText;
208
270
  const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ""}` : "";
package/src/types.ts CHANGED
@@ -52,6 +52,8 @@ export interface DingTalkConfig extends OpenClawConfig {
52
52
  initialReconnectDelay?: number;
53
53
  maxReconnectDelay?: number;
54
54
  reconnectJitter?: number;
55
+ /** Maximum number of runtime reconnect cycles before giving up (default: 10) */
56
+ maxReconnectCycles?: number;
55
57
  proactivePermissionHint?: {
56
58
  enabled?: boolean;
57
59
  cooldownHours?: number;
@@ -83,6 +85,8 @@ export interface DingTalkChannelConfig {
83
85
  initialReconnectDelay?: number;
84
86
  maxReconnectDelay?: number;
85
87
  reconnectJitter?: number;
88
+ /** Maximum number of runtime reconnect cycles before giving up (default: 10) */
89
+ maxReconnectCycles?: number;
86
90
  proactivePermissionHint?: {
87
91
  enabled?: boolean;
88
92
  cooldownHours?: number;
@@ -476,6 +480,8 @@ export interface ConnectionManagerConfig {
476
480
  initialDelay: number;
477
481
  maxDelay: number;
478
482
  jitter: number;
483
+ /** Maximum number of runtime reconnect cycles before giving up (default: 10) */
484
+ maxReconnectCycles?: number;
479
485
  /** Callback invoked when connection state changes */
480
486
  onStateChange?: (state: ConnectionState, error?: string) => void;
481
487
  }
@@ -560,6 +566,7 @@ export function resolveDingTalkAccount(
560
566
  initialReconnectDelay: dingtalk?.initialReconnectDelay,
561
567
  maxReconnectDelay: dingtalk?.maxReconnectDelay,
562
568
  reconnectJitter: dingtalk?.reconnectJitter,
569
+ maxReconnectCycles: dingtalk?.maxReconnectCycles,
563
570
  proactivePermissionHint: dingtalk?.proactivePermissionHint,
564
571
  };
565
572
  return {