@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 +24 -0
- package/package.json +1 -1
- package/src/card-service.ts +21 -2
- package/src/channel.ts +7 -1
- package/src/config-schema.ts +8 -0
- package/src/inbound-handler.ts +113 -2
- package/src/media-utils.ts +6 -1
- package/src/proactive-risk-registry.ts +64 -0
- package/src/send-service.ts +59 -10
- package/src/types.ts +10 -0
- package/src/utils.ts +53 -0
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
package/src/card-service.ts
CHANGED
|
@@ -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
|
-
|
|
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}
|
|
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
|
package/src/config-schema.ts
CHANGED
|
@@ -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
|
/**
|
package/src/inbound-handler.ts
CHANGED
|
@@ -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 (
|
|
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;
|
package/src/media-utils.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/send-service.ts
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
}
|