@soimy/dingtalk 3.0.2 → 3.1.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
@@ -439,6 +439,30 @@ openclaw gateway restart
439
439
  1. 检查 clientId 和 clientSecret 是否正确
440
440
  2. 确认网络可以访问钉钉 API
441
441
 
442
+ ### 错误 payload 日志规范(`[ErrorPayload]`)
443
+
444
+ 为便于快速定位 4xx/5xx 参数问题,插件会在 API 错误分支输出统一格式日志:
445
+
446
+ - 通用前缀:`[DingTalk][ErrorPayload][<scope>]`
447
+ - AI Card 前缀:`[DingTalk][AICard][ErrorPayload][<scope>]`
448
+ - 内容格式:`code=<...> message=<...> payload=<...>`(同时保留脱敏后的完整 payload)
449
+
450
+ 常见 scope 示例:
451
+
452
+ - `send.proactiveMessage` / `send.proactiveMedia` / `send.message`
453
+ - `outbound.sendText` / `outbound.sendMedia`
454
+ - `inbound.downloadMedia` / `inbound.cardFinalize`
455
+ - `card.create` / `card.stream` / `card.stream.retryAfterRefresh`
456
+ - `retry.beforeDecision`
457
+
458
+ 排查建议:
459
+
460
+ ```bash
461
+ openclaw logs | grep "\[ErrorPayload\]"
462
+ ```
463
+
464
+ 如果你看到 `code=invalidParameter`,通常优先检查请求 payload 的必填字段(例如 `robotCode`、`userIds`、`msgKey`、`msgParam`)是否完整且格式正确。
465
+
442
466
  ## 开发指南
443
467
 
444
468
  ### 首次设置
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soimy/dingtalk",
3
- "version": "3.0.2",
3
+ "version": "3.1.1",
4
4
  "description": "DingTalk (钉钉) channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
@@ -3,6 +3,7 @@ import axios from "axios";
3
3
  import { getAccessToken } from "./auth";
4
4
  import { stripTargetPrefix } from "./config";
5
5
  import { resolveOriginalPeerId } from "./peer-id-registry";
6
+ import { formatDingTalkErrorPayloadLog } from "./utils";
6
7
  import type {
7
8
  AICardInstance,
8
9
  AICardStreamingRequest,
@@ -207,8 +208,12 @@ export async function createAICard(
207
208
  } catch (err: any) {
208
209
  log?.error?.(`[DingTalk][AICard] Create failed: ${err.message}`);
209
210
  if (err.response) {
211
+ const status = err.response.status;
212
+ const statusText = err.response.statusText;
213
+ const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ""}` : "";
214
+ log?.error?.(`[DingTalk][AICard] Create error response${statusLabel}`);
210
215
  log?.error?.(
211
- `[DingTalk][AICard] Error response: status=${err.response.status} data=${JSON.stringify(err.response.data)}`,
216
+ formatDingTalkErrorPayloadLog("card.create", err.response.data, "[DingTalk][AICard]"),
212
217
  );
213
218
  }
214
219
  return null;
@@ -322,14 +327,28 @@ export async function streamAICard(
322
327
  return;
323
328
  } catch (retryErr: any) {
324
329
  log?.error?.(`[DingTalk][AICard] Retry after token refresh failed: ${retryErr.message}`);
330
+ if (retryErr.response?.data !== undefined) {
331
+ log?.error?.(
332
+ formatDingTalkErrorPayloadLog(
333
+ "card.stream.retryAfterRefresh",
334
+ retryErr.response.data,
335
+ "[DingTalk][AICard]",
336
+ ),
337
+ );
338
+ }
325
339
  }
326
340
  }
327
341
 
328
342
  card.state = AICardStatus.FAILED;
329
343
  card.lastUpdated = Date.now();
330
344
  log?.error?.(
331
- `[DingTalk][AICard] Streaming update failed: ${err.message}, resp=${JSON.stringify(err.response?.data)}`,
345
+ `[DingTalk][AICard] Streaming update failed: ${err.message}`,
332
346
  );
347
+ if (err.response?.data !== undefined) {
348
+ log?.error?.(
349
+ formatDingTalkErrorPayloadLog("card.stream", err.response.data, "[DingTalk][AICard]"),
350
+ );
351
+ }
333
352
  throw err;
334
353
  }
335
354
  }
package/src/channel.ts CHANGED
@@ -28,7 +28,7 @@ import type {
28
28
  ResolvedAccount,
29
29
  } from "./types";
30
30
  import { ConnectionState } from "./types";
31
- import { cleanupOrphanedTempFiles, getCurrentTimestamp } from "./utils";
31
+ import { cleanupOrphanedTempFiles, formatDingTalkErrorPayloadLog, getCurrentTimestamp } from "./utils";
32
32
 
33
33
  const processingDedupKeys = new Set<string>();
34
34
  const inboundCountersByAccount = new Map<
@@ -187,6 +187,9 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
187
187
  typeof result.error === "string" ? result.error : JSON.stringify(result.error),
188
188
  );
189
189
  } catch (err: any) {
190
+ if (err?.response?.data !== undefined) {
191
+ log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendText", err.response.data));
192
+ }
190
193
  throw new Error(
191
194
  typeof err?.response?.data === "string"
192
195
  ? err.response.data
@@ -261,6 +264,9 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
261
264
  typeof result.error === "string" ? result.error : JSON.stringify(result.error),
262
265
  );
263
266
  } catch (err: any) {
267
+ if (err?.response?.data !== undefined) {
268
+ log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia", err.response.data));
269
+ }
264
270
  throw new Error(
265
271
  typeof err?.response?.data === "string"
266
272
  ? err.response.data
@@ -75,6 +75,14 @@ const DingTalkAccountConfigSchema = z.object({
75
75
 
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
+
79
+ proactivePermissionHint: z
80
+ .object({
81
+ enabled: z.boolean().optional().default(true),
82
+ cooldownHours: z.number().int().min(1).max(24 * 30).optional().default(24),
83
+ })
84
+ .optional()
85
+ .default({ enabled: true, cooldownHours: 24 }),
78
86
  });
79
87
 
80
88
  /**
@@ -16,11 +16,60 @@ import { formatGroupMembers, noteGroupMember } from "./group-members-store";
16
16
  import { setCurrentLogger } from "./logger-context";
17
17
  import { extractMessageContent } from "./message-utils";
18
18
  import { registerPeerId } from "./peer-id-registry";
19
+ import {
20
+ clearProactiveRiskObservationsForTest,
21
+ recordProactiveRiskObservation,
22
+ } from "./proactive-risk-registry";
19
23
  import { getDingTalkRuntime } from "./runtime";
20
24
  import { sendBySession, sendMessage } from "./send-service";
21
25
  import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
22
26
  import { AICardStatus } from "./types";
23
- import { maskSensitiveData } from "./utils";
27
+ import { formatDingTalkErrorPayloadLog, maskSensitiveData } from "./utils";
28
+
29
+ const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
30
+ const proactiveHintLastSentAt = new Map<string, number>();
31
+
32
+ export function resetProactivePermissionHintStateForTest(): void {
33
+ proactiveHintLastSentAt.clear();
34
+ clearProactiveRiskObservationsForTest();
35
+ }
36
+
37
+ function shouldSendProactivePermissionHint(params: {
38
+ isDirect: boolean;
39
+ accountId: string;
40
+ senderId: string;
41
+ senderStaffId?: string;
42
+ config: DingTalkConfig;
43
+ nowMs: number;
44
+ }): boolean {
45
+ if (!params.isDirect) {
46
+ return false;
47
+ }
48
+
49
+ const hintConfig = params.config.proactivePermissionHint;
50
+ if (hintConfig?.enabled === false) {
51
+ return false;
52
+ }
53
+
54
+ const targetId = (params.senderStaffId || params.senderId || "").trim();
55
+ if (!targetId || !/^\d+$/.test(targetId)) {
56
+ return false;
57
+ }
58
+
59
+ const cooldownHours =
60
+ hintConfig?.cooldownHours && hintConfig.cooldownHours > 0
61
+ ? hintConfig.cooldownHours
62
+ : DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS;
63
+ const cooldownMs = cooldownHours * 60 * 60 * 1000;
64
+ const key = `${params.accountId}:${targetId}`;
65
+ const lastSentAt = proactiveHintLastSentAt.get(key) || 0;
66
+ if (params.nowMs - lastSentAt < cooldownMs) {
67
+ return false;
68
+ }
69
+
70
+ proactiveHintLastSentAt.set(key, params.nowMs);
71
+ return true;
72
+ }
24
73
 
25
74
  /**
26
75
  * Download DingTalk media file via runtime media service (sandbox-compatible).
@@ -97,7 +146,9 @@ export async function downloadMedia(
97
146
  log.error(
98
147
  `[DingTalk] Failed to download media:${statusLabel}${code} message=${err.message}`,
99
148
  );
100
- if (dataDetail) {
149
+ if (err.response?.data !== undefined) {
150
+ log.error(formatDingTalkErrorPayloadLog("inbound.downloadMedia", err.response.data));
151
+ } else if (dataDetail) {
101
152
  log.error(`[DingTalk] downloadMedia response data: ${dataDetail}`);
102
153
  }
103
154
  } else {
@@ -141,10 +192,45 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
141
192
  if (groupId) {
142
193
  registerPeerId(groupId);
143
194
  }
195
+
196
+ if (
197
+ shouldSendProactivePermissionHint({
198
+ isDirect,
199
+ accountId,
200
+ senderId,
201
+ senderStaffId: data.senderStaffId,
202
+ config: dingtalkConfig,
203
+ nowMs: Date.now(),
204
+ })
205
+ ) {
206
+ try {
207
+ await sendBySession(
208
+ dingtalkConfig,
209
+ sessionWebhook,
210
+ `⚠️ 主动推送可能失败\n\n检测到当前用户标识为纯数字(\`${data.senderStaffId || senderId}\`)。企业内部机器人在定时/主动发送场景中,通常需要企业内部有效用户ID与完整授权。\n\n建议:\n1) 优先使用企业内部用户ID(如 managerXXXX)\n2) 确认应用已申请并获得主动发送相关权限\n3) 确认目标用户已加入机器人所属企业`,
211
+ { log },
212
+ );
213
+ } catch (err: any) {
214
+ log?.debug?.(`[DingTalk] Failed to send proactive permission hint: ${err.message}`);
215
+ if (err?.response?.data !== undefined) {
216
+ log?.debug?.(formatDingTalkErrorPayloadLog("inbound.proactivePermissionHint", err.response.data));
217
+ }
218
+ }
219
+ }
144
220
  if (senderId) {
145
221
  registerPeerId(senderId);
146
222
  }
147
223
 
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
+
148
234
  // 2) Authorization guard (DM/group policy).
149
235
  let commandAuthorized = true;
150
236
  if (isDirect) {
@@ -168,6 +254,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
168
254
  );
169
255
  } catch (err: any) {
170
256
  log?.debug?.(`[DingTalk] Failed to send access denied message: ${err.message}`);
257
+ if (err?.response?.data !== undefined) {
258
+ log?.debug?.(formatDingTalkErrorPayloadLog("inbound.accessDeniedReply", err.response.data));
259
+ }
171
260
  }
172
261
 
173
262
  return;
@@ -202,6 +291,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
202
291
  );
203
292
  } catch (err: any) {
204
293
  log?.debug?.(`[DingTalk] Failed to send group access denied message: ${err.message}`);
294
+ if (err?.response?.data !== undefined) {
295
+ log?.debug?.(
296
+ formatDingTalkErrorPayloadLog("inbound.groupAccessDeniedReply", err.response.data),
297
+ );
298
+ }
205
299
  }
206
300
 
207
301
  return;
@@ -355,6 +449,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
355
449
  }
356
450
  } catch (err: any) {
357
451
  log?.debug?.(`[DingTalk] Thinking message failed: ${err.message}`);
452
+ if (err?.response?.data !== undefined) {
453
+ log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingMessage", err.response.data));
454
+ }
358
455
  }
359
456
  }
360
457
 
@@ -370,6 +467,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
370
467
  return;
371
468
  }
372
469
 
470
+ if (useCardMode && currentAICard && info?.kind === "final") {
471
+ lastCardContent = textToSend;
472
+ return;
473
+ }
474
+
373
475
  // Tool outputs are rendered into card stream as a separate formatted block.
374
476
  if (useCardMode && currentAICard && info?.kind === "tool") {
375
477
  if (isCardInTerminalState(currentAICard.state)) {
@@ -398,6 +500,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
398
500
  });
399
501
  } catch (err: any) {
400
502
  log?.error?.(`[DingTalk] Reply failed: ${err.message}`);
503
+ if (err?.response?.data !== undefined) {
504
+ log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", err.response.data));
505
+ }
401
506
  throw err;
402
507
  }
403
508
  },
@@ -422,6 +527,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
422
527
  await streamAICard(currentAICard, thinkingText, false, log);
423
528
  } catch (err: any) {
424
529
  log?.debug?.(`[DingTalk] Thinking stream update failed: ${err.message}`);
530
+ if (err?.response?.data !== undefined) {
531
+ log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingStream", err.response.data));
532
+ }
425
533
  }
426
534
  },
427
535
  },
@@ -460,6 +568,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
460
568
  }
461
569
  } catch (err: any) {
462
570
  log?.debug?.(`[DingTalk] AI Card finalization failed: ${err.message}`);
571
+ if (err?.response?.data !== undefined) {
572
+ log?.debug?.(formatDingTalkErrorPayloadLog("inbound.cardFinalize", err.response.data));
573
+ }
463
574
  try {
464
575
  if (currentAICard.state !== AICardStatus.FINISHED) {
465
576
  currentAICard.state = AICardStatus.FAILED;
@@ -11,6 +11,7 @@ import * as path from "path";
11
11
  import axios from "axios";
12
12
  import FormData from "form-data";
13
13
  import type { DingTalkConfig, Logger } from "./types";
14
+ import { formatDingTalkErrorPayloadLog } from "./utils";
14
15
 
15
16
  export type DingTalkMediaType = "image" | "voice" | "video" | "file";
16
17
 
@@ -123,7 +124,11 @@ export async function uploadMedia(
123
124
  } else {
124
125
  log?.error?.(`[DingTalk] Failed to upload media: ${err.message}`);
125
126
  if (axios.isAxiosError(err) && err.response) {
126
- log?.error?.(`[DingTalk] Upload response: ${JSON.stringify(err.response.data)}`);
127
+ const status = err.response.status;
128
+ const statusText = err.response.statusText;
129
+ const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ""}` : "";
130
+ log?.error?.(`[DingTalk] Upload response${statusLabel}`);
131
+ log?.error?.(formatDingTalkErrorPayloadLog("media.upload", err.response.data));
127
132
  }
128
133
  }
129
134
  return null;
@@ -0,0 +1,64 @@
1
+ type ProactiveRiskLevel = "low" | "medium" | "high";
2
+
3
+ export interface ProactiveRiskObservation {
4
+ accountId: string;
5
+ targetId: string;
6
+ level: ProactiveRiskLevel;
7
+ reason: string;
8
+ source: string;
9
+ observedAtMs?: number;
10
+ }
11
+
12
+ export interface ProactiveRiskSnapshot {
13
+ level: ProactiveRiskLevel;
14
+ reason: string;
15
+ source: string;
16
+ observedAtMs: number;
17
+ }
18
+
19
+ const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
+ const store = new Map<string, ProactiveRiskSnapshot>();
21
+
22
+ function keyOf(accountId: string, targetId: string): string {
23
+ return `${accountId}:${targetId.trim()}`;
24
+ }
25
+
26
+ export function recordProactiveRiskObservation(observation: ProactiveRiskObservation): void {
27
+ const targetId = observation.targetId?.trim();
28
+ if (!observation.accountId || !targetId) {
29
+ return;
30
+ }
31
+
32
+ store.set(keyOf(observation.accountId, targetId), {
33
+ level: observation.level,
34
+ reason: observation.reason,
35
+ source: observation.source,
36
+ observedAtMs: observation.observedAtMs ?? Date.now(),
37
+ });
38
+ }
39
+
40
+ export function getProactiveRiskObservation(
41
+ accountId: string,
42
+ targetId: string,
43
+ nowMs = Date.now(),
44
+ ): ProactiveRiskSnapshot | null {
45
+ const target = targetId?.trim();
46
+ if (!accountId || !target) {
47
+ return null;
48
+ }
49
+
50
+ const key = keyOf(accountId, target);
51
+ const entry = store.get(key);
52
+ if (!entry) {
53
+ return null;
54
+ }
55
+ if (nowMs - entry.observedAtMs > DEFAULT_TTL_MS) {
56
+ store.delete(key);
57
+ return null;
58
+ }
59
+ return entry;
60
+ }
61
+
62
+ export function clearProactiveRiskObservationsForTest(): void {
63
+ store.clear();
64
+ }
@@ -13,6 +13,8 @@ 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";
17
+ import { formatDingTalkErrorPayloadLog } from "./utils";
16
18
  import type {
17
19
  AxiosResponse,
18
20
  DingTalkConfig,
@@ -43,13 +45,19 @@ export async function sendProactiveTextOrMarkdown(
43
45
  text: string,
44
46
  options: SendMessageOptions = {},
45
47
  ): Promise<AxiosResponse> {
46
- const token = await getAccessToken(config, options.log);
47
48
  const log = options.log || getLogger();
49
+ const token = await getAccessToken(config, log);
48
50
 
49
51
  // Support group:/user: prefix and restore original case-sensitive conversationId.
50
52
  const { targetId, isExplicitUser } = stripTargetPrefix(target);
51
53
  const resolvedTarget = resolveOriginalPeerId(targetId);
52
54
  const isGroup = !isExplicitUser && resolvedTarget.startsWith("cid");
55
+ const proactiveRisk = options.accountId
56
+ ? getProactiveRiskObservation(options.accountId, resolvedTarget)
57
+ : null;
58
+ const proactiveRiskTag = proactiveRisk
59
+ ? ` proactiveRisk=${proactiveRisk.level}:${proactiveRisk.reason}`
60
+ : "";
53
61
 
54
62
  const url = isGroup
55
63
  ? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
@@ -58,7 +66,7 @@ export async function sendProactiveTextOrMarkdown(
58
66
  const { useMarkdown, title } = detectMarkdownAndExtractTitle(text, options, "OpenClaw 提醒");
59
67
 
60
68
  log?.debug?.(
61
- `[DingTalk] Sending proactive message to ${isGroup ? "group" : "user"} ${resolvedTarget} with title "${title}"`,
69
+ `[DingTalk] Sending proactive message to ${isGroup ? "group" : "user"} ${resolvedTarget} with title "${title}"${proactiveRiskTag}`,
62
70
  );
63
71
 
64
72
  // DingTalk proactive API uses message templates (sampleMarkdown / sampleText).
@@ -79,13 +87,40 @@ export async function sendProactiveTextOrMarkdown(
79
87
  payload.userIds = [resolvedTarget];
80
88
  }
81
89
 
82
- const result = await axios({
83
- url,
84
- method: "POST",
85
- data: payload,
86
- headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
87
- });
88
- return result.data;
90
+ try {
91
+ const result = await axios({
92
+ url,
93
+ method: "POST",
94
+ data: payload,
95
+ headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
96
+ });
97
+ return result.data;
98
+ } catch (err: unknown) {
99
+ const maybeAxiosError = err as {
100
+ response?: { status?: number; statusText?: string; data?: unknown };
101
+ message?: string;
102
+ };
103
+ if (maybeAxiosError?.response) {
104
+ const status = maybeAxiosError.response.status;
105
+ const statusText = maybeAxiosError.response.statusText;
106
+ const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ""}` : "";
107
+ log?.error?.(
108
+ `[DingTalk] Failed to send proactive message:${statusLabel} message=${
109
+ maybeAxiosError.message || String(err)
110
+ }${proactiveRiskTag}`,
111
+ );
112
+ if (maybeAxiosError.response.data !== undefined) {
113
+ log?.error?.(
114
+ formatDingTalkErrorPayloadLog("send.proactiveMessage", maybeAxiosError.response.data),
115
+ );
116
+ }
117
+ } else if (err instanceof Error) {
118
+ log?.error?.(`[DingTalk] Failed to send proactive message: ${err.message}`);
119
+ } else {
120
+ log?.error?.(`[DingTalk] Failed to send proactive message: ${String(err)}`);
121
+ }
122
+ throw err;
123
+ }
89
124
  }
90
125
 
91
126
  export async function sendProactiveMedia(
@@ -160,8 +195,19 @@ export async function sendProactiveMedia(
160
195
  return { ok: true, data: result.data, messageId };
161
196
  } catch (err: any) {
162
197
  log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
198
+ const normalizedTarget = resolveOriginalPeerId(stripTargetPrefix(target).targetId);
199
+ const proactiveRisk = options.accountId
200
+ ? getProactiveRiskObservation(options.accountId, normalizedTarget)
201
+ : null;
202
+ const proactiveRiskTag = proactiveRisk
203
+ ? ` proactiveRisk=${proactiveRisk.level}:${proactiveRisk.reason}`
204
+ : "";
163
205
  if (axios.isAxiosError(err) && err.response) {
164
- log?.error?.(`[DingTalk] Response: ${JSON.stringify(err.response.data)}`);
206
+ const status = err.response.status;
207
+ const statusText = err.response.statusText;
208
+ const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ""}` : "";
209
+ log?.error?.(`[DingTalk] Proactive media response${statusLabel}${proactiveRiskTag}`);
210
+ log?.error?.(formatDingTalkErrorPayloadLog("send.proactiveMedia", err.response.data));
165
211
  }
166
212
  return { ok: false, error: err.message };
167
213
  }
@@ -276,6 +322,9 @@ export async function sendMessage(
276
322
  return { ok: true, data: result };
277
323
  } catch (err: any) {
278
324
  options.log?.error?.(`[DingTalk] Send message failed: ${err.message}`);
325
+ if (err?.response?.data !== undefined) {
326
+ options.log?.error?.(formatDingTalkErrorPayloadLog("send.message", err.response.data));
327
+ }
279
328
  return { ok: false, error: err.message };
280
329
  }
281
330
  }
package/src/types.ts CHANGED
@@ -52,6 +52,10 @@ export interface DingTalkConfig extends OpenClawConfig {
52
52
  initialReconnectDelay?: number;
53
53
  maxReconnectDelay?: number;
54
54
  reconnectJitter?: number;
55
+ proactivePermissionHint?: {
56
+ enabled?: boolean;
57
+ cooldownHours?: number;
58
+ };
55
59
  }
56
60
 
57
61
  /**
@@ -79,6 +83,10 @@ export interface DingTalkChannelConfig {
79
83
  initialReconnectDelay?: number;
80
84
  maxReconnectDelay?: number;
81
85
  reconnectJitter?: number;
86
+ proactivePermissionHint?: {
87
+ enabled?: boolean;
88
+ cooldownHours?: number;
89
+ };
82
90
  }
83
91
 
84
92
  /**
@@ -201,6 +209,7 @@ export interface SendMessageOptions {
201
209
  filePath?: string;
202
210
  mediaUrl?: string;
203
211
  mediaType?: "image" | "voice" | "video" | "file";
212
+ accountId?: string;
204
213
  }
205
214
 
206
215
  /**
@@ -551,6 +560,7 @@ export function resolveDingTalkAccount(
551
560
  initialReconnectDelay: dingtalk?.initialReconnectDelay,
552
561
  maxReconnectDelay: dingtalk?.maxReconnectDelay,
553
562
  reconnectJitter: dingtalk?.reconnectJitter,
563
+ proactivePermissionHint: dingtalk?.proactivePermissionHint,
554
564
  };
555
565
  return {
556
566
  ...config,
package/src/utils.ts CHANGED
@@ -38,6 +38,55 @@ export function maskSensitiveData(data: unknown): any {
38
38
  return masked;
39
39
  }
40
40
 
41
+ export function formatDingTalkErrorPayload(payload: unknown): string {
42
+ if (payload === null || payload === undefined) {
43
+ return "payload=unknown";
44
+ }
45
+
46
+ let code: string | undefined;
47
+ let message: string | undefined;
48
+ if (typeof payload === "object" && !Array.isArray(payload)) {
49
+ const obj = payload as Record<string, unknown>;
50
+ if (typeof obj.code === "string" || typeof obj.code === "number") {
51
+ code = String(obj.code);
52
+ }
53
+ if (typeof obj.message === "string") {
54
+ message = obj.message;
55
+ }
56
+ }
57
+
58
+ let serialized: string;
59
+ try {
60
+ serialized = JSON.stringify(maskSensitiveData(payload));
61
+ } catch {
62
+ if (typeof payload === "string") {
63
+ serialized = payload;
64
+ } else if (typeof payload === "number" || typeof payload === "boolean" || typeof payload === "bigint") {
65
+ serialized = `${payload}`;
66
+ } else {
67
+ serialized = "[unserializable-payload]";
68
+ }
69
+ }
70
+
71
+ const parts: string[] = [];
72
+ if (code) {
73
+ parts.push(`code=${code}`);
74
+ }
75
+ if (message) {
76
+ parts.push(`message=${message}`);
77
+ }
78
+ parts.push(`payload=${serialized}`);
79
+ return parts.join(" ");
80
+ }
81
+
82
+ export function formatDingTalkErrorPayloadLog(
83
+ scope: string,
84
+ payload: unknown,
85
+ prefix: "[DingTalk]" | "[DingTalk][AICard]" = "[DingTalk]",
86
+ ): string {
87
+ return `${prefix}[ErrorPayload][${scope}] ${formatDingTalkErrorPayload(payload)}`;
88
+ }
89
+
41
90
  /**
42
91
  * Cleanup orphaned temp files from dingtalk media
43
92
  * Run at startup to clean up files from crashed processes
@@ -98,6 +147,10 @@ export async function retryWithBackoff<T>(
98
147
  const isRetryable =
99
148
  statusCode === 401 || statusCode === 429 || (statusCode && statusCode >= 500);
100
149
 
150
+ if (err.response?.data !== undefined) {
151
+ log?.debug?.(formatDingTalkErrorPayloadLog("retry.beforeDecision", err.response.data));
152
+ }
153
+
101
154
  if (!isRetryable || attempt === maxRetries) {
102
155
  throw err;
103
156
  }