@soimy/dingtalk 3.0.1 → 3.1.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 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.1",
3
+ "version": "3.1.0",
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;
@@ -221,6 +226,13 @@ export async function streamAICard(
221
226
  finished: boolean = false,
222
227
  log?: Logger,
223
228
  ): Promise<void> {
229
+ if (card.state === AICardStatus.FINISHED) {
230
+ log?.debug?.(
231
+ `[DingTalk][AICard] Skip stream update because card already finalized: outTrackId=${card.cardInstanceId}`,
232
+ );
233
+ return;
234
+ }
235
+
224
236
  // Refresh token defensively before DingTalk 2h token horizon.
225
237
  const tokenAge = Date.now() - card.createdAt;
226
238
  const tokenRefreshThreshold = 90 * 60 * 1000;
@@ -315,14 +327,28 @@ export async function streamAICard(
315
327
  return;
316
328
  } catch (retryErr: any) {
317
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
+ }
318
339
  }
319
340
  }
320
341
 
321
342
  card.state = AICardStatus.FAILED;
322
343
  card.lastUpdated = Date.now();
323
344
  log?.error?.(
324
- `[DingTalk][AICard] Streaming update failed: ${err.message}, resp=${JSON.stringify(err.response?.data)}`,
345
+ `[DingTalk][AICard] Streaming update failed: ${err.message}`,
325
346
  );
347
+ if (err.response?.data !== undefined) {
348
+ log?.error?.(
349
+ formatDingTalkErrorPayloadLog("card.stream", err.response.data, "[DingTalk][AICard]"),
350
+ );
351
+ }
326
352
  throw err;
327
353
  }
328
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
@@ -20,7 +20,7 @@ import { getDingTalkRuntime } from "./runtime";
20
20
  import { sendBySession, sendMessage } from "./send-service";
21
21
  import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
22
22
  import { AICardStatus } from "./types";
23
- import { maskSensitiveData } from "./utils";
23
+ import { formatDingTalkErrorPayloadLog, maskSensitiveData } from "./utils";
24
24
 
25
25
  /**
26
26
  * Download DingTalk media file via runtime media service (sandbox-compatible).
@@ -97,7 +97,9 @@ export async function downloadMedia(
97
97
  log.error(
98
98
  `[DingTalk] Failed to download media:${statusLabel}${code} message=${err.message}`,
99
99
  );
100
- if (dataDetail) {
100
+ if (err.response?.data !== undefined) {
101
+ log.error(formatDingTalkErrorPayloadLog("inbound.downloadMedia", err.response.data));
102
+ } else if (dataDetail) {
101
103
  log.error(`[DingTalk] downloadMedia response data: ${dataDetail}`);
102
104
  }
103
105
  } else {
@@ -168,6 +170,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
168
170
  );
169
171
  } catch (err: any) {
170
172
  log?.debug?.(`[DingTalk] Failed to send access denied message: ${err.message}`);
173
+ if (err?.response?.data !== undefined) {
174
+ log?.debug?.(formatDingTalkErrorPayloadLog("inbound.accessDeniedReply", err.response.data));
175
+ }
171
176
  }
172
177
 
173
178
  return;
@@ -202,6 +207,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
202
207
  );
203
208
  } catch (err: any) {
204
209
  log?.debug?.(`[DingTalk] Failed to send group access denied message: ${err.message}`);
210
+ if (err?.response?.data !== undefined) {
211
+ log?.debug?.(
212
+ formatDingTalkErrorPayloadLog("inbound.groupAccessDeniedReply", err.response.data),
213
+ );
214
+ }
205
215
  }
206
216
 
207
217
  return;
@@ -355,6 +365,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
355
365
  }
356
366
  } catch (err: any) {
357
367
  log?.debug?.(`[DingTalk] Thinking message failed: ${err.message}`);
368
+ if (err?.response?.data !== undefined) {
369
+ log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingMessage", err.response.data));
370
+ }
358
371
  }
359
372
  }
360
373
 
@@ -370,8 +383,20 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
370
383
  return;
371
384
  }
372
385
 
386
+ if (useCardMode && currentAICard && info?.kind === "final") {
387
+ lastCardContent = textToSend;
388
+ return;
389
+ }
390
+
373
391
  // Tool outputs are rendered into card stream as a separate formatted block.
374
392
  if (useCardMode && currentAICard && info?.kind === "tool") {
393
+ if (isCardInTerminalState(currentAICard.state)) {
394
+ log?.debug?.(
395
+ `[DingTalk] Skipping tool stream update because card is terminal: state=${currentAICard.state}`,
396
+ );
397
+ return;
398
+ }
399
+
375
400
  log?.info?.(
376
401
  `[DingTalk] Tool result received, streaming to AI Card: ${textToSend.slice(0, 100)}`,
377
402
  );
@@ -391,6 +416,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
391
416
  });
392
417
  } catch (err: any) {
393
418
  log?.error?.(`[DingTalk] Reply failed: ${err.message}`);
419
+ if (err?.response?.data !== undefined) {
420
+ log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", err.response.data));
421
+ }
394
422
  throw err;
395
423
  }
396
424
  },
@@ -401,6 +429,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
401
429
  if (!useCardMode || !currentAICard) {
402
430
  return;
403
431
  }
432
+ if (isCardInTerminalState(currentAICard.state)) {
433
+ log?.debug?.(
434
+ `[DingTalk] Skipping thinking stream update because card is terminal: state=${currentAICard.state}`,
435
+ );
436
+ return;
437
+ }
404
438
  const thinkingText = formatContentForCard(payload.text, "thinking");
405
439
  if (!thinkingText) {
406
440
  return;
@@ -409,6 +443,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
409
443
  await streamAICard(currentAICard, thinkingText, false, log);
410
444
  } catch (err: any) {
411
445
  log?.debug?.(`[DingTalk] Thinking stream update failed: ${err.message}`);
446
+ if (err?.response?.data !== undefined) {
447
+ log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingStream", err.response.data));
448
+ }
412
449
  }
413
450
  },
414
451
  },
@@ -417,6 +454,13 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
417
454
  // 5) Finalize card stream if card mode is active.
418
455
  if (useCardMode && currentAICard) {
419
456
  try {
457
+ if (isCardInTerminalState(currentAICard.state)) {
458
+ log?.debug?.(
459
+ `[DingTalk] Skipping AI Card finalization because card is terminal: state=${currentAICard.state}`,
460
+ );
461
+ return;
462
+ }
463
+
420
464
  const isNonEmptyString = (value: any): boolean =>
421
465
  typeof value === "string" && value.trim().length > 0;
422
466
 
@@ -440,6 +484,9 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
440
484
  }
441
485
  } catch (err: any) {
442
486
  log?.debug?.(`[DingTalk] AI Card finalization failed: ${err.message}`);
487
+ if (err?.response?.data !== undefined) {
488
+ log?.debug?.(formatDingTalkErrorPayloadLog("inbound.cardFinalize", err.response.data));
489
+ }
443
490
  try {
444
491
  if (currentAICard.state !== AICardStatus.FINISHED) {
445
492
  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;
@@ -13,6 +13,7 @@ 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 { formatDingTalkErrorPayloadLog } from "./utils";
16
17
  import type {
17
18
  AxiosResponse,
18
19
  DingTalkConfig,
@@ -43,8 +44,8 @@ export async function sendProactiveTextOrMarkdown(
43
44
  text: string,
44
45
  options: SendMessageOptions = {},
45
46
  ): Promise<AxiosResponse> {
46
- const token = await getAccessToken(config, options.log);
47
47
  const log = options.log || getLogger();
48
+ const token = await getAccessToken(config, log);
48
49
 
49
50
  // Support group:/user: prefix and restore original case-sensitive conversationId.
50
51
  const { targetId, isExplicitUser } = stripTargetPrefix(target);
@@ -79,13 +80,40 @@ export async function sendProactiveTextOrMarkdown(
79
80
  payload.userIds = [resolvedTarget];
80
81
  }
81
82
 
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;
83
+ try {
84
+ const result = await axios({
85
+ url,
86
+ method: "POST",
87
+ data: payload,
88
+ headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
89
+ });
90
+ return result.data;
91
+ } catch (err: unknown) {
92
+ const maybeAxiosError = err as {
93
+ response?: { status?: number; statusText?: string; data?: unknown };
94
+ message?: string;
95
+ };
96
+ if (maybeAxiosError?.response) {
97
+ const status = maybeAxiosError.response.status;
98
+ const statusText = maybeAxiosError.response.statusText;
99
+ const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ""}` : "";
100
+ log?.error?.(
101
+ `[DingTalk] Failed to send proactive message:${statusLabel} message=${
102
+ maybeAxiosError.message || String(err)
103
+ }`,
104
+ );
105
+ if (maybeAxiosError.response.data !== undefined) {
106
+ log?.error?.(
107
+ formatDingTalkErrorPayloadLog("send.proactiveMessage", maybeAxiosError.response.data),
108
+ );
109
+ }
110
+ } else if (err instanceof Error) {
111
+ log?.error?.(`[DingTalk] Failed to send proactive message: ${err.message}`);
112
+ } else {
113
+ log?.error?.(`[DingTalk] Failed to send proactive message: ${String(err)}`);
114
+ }
115
+ throw err;
116
+ }
89
117
  }
90
118
 
91
119
  export async function sendProactiveMedia(
@@ -161,7 +189,11 @@ export async function sendProactiveMedia(
161
189
  } catch (err: any) {
162
190
  log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
163
191
  if (axios.isAxiosError(err) && err.response) {
164
- log?.error?.(`[DingTalk] Response: ${JSON.stringify(err.response.data)}`);
192
+ const status = err.response.status;
193
+ const statusText = err.response.statusText;
194
+ const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ""}` : "";
195
+ log?.error?.(`[DingTalk] Proactive media response${statusLabel}`);
196
+ log?.error?.(formatDingTalkErrorPayloadLog("send.proactiveMedia", err.response.data));
165
197
  }
166
198
  return { ok: false, error: err.message };
167
199
  }
@@ -276,6 +308,9 @@ export async function sendMessage(
276
308
  return { ok: true, data: result };
277
309
  } catch (err: any) {
278
310
  options.log?.error?.(`[DingTalk] Send message failed: ${err.message}`);
311
+ if (err?.response?.data !== undefined) {
312
+ options.log?.error?.(formatDingTalkErrorPayloadLog("send.message", err.response.data));
313
+ }
279
314
  return { ok: false, error: err.message };
280
315
  }
281
316
  }
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
  }