@soimy/dingtalk 3.1.0 → 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/package.json +1 -1
- package/src/config-schema.ts +8 -0
- package/src/inbound-handler.ts +84 -0
- package/src/proactive-risk-registry.ts +64 -0
- package/src/send-service.ts +17 -3
- package/src/types.ts +10 -0
package/package.json
CHANGED
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,12 +16,61 @@ 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
27
|
import { formatDingTalkErrorPayloadLog, maskSensitiveData } from "./utils";
|
|
24
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
|
+
}
|
|
73
|
+
|
|
25
74
|
/**
|
|
26
75
|
* Download DingTalk media file via runtime media service (sandbox-compatible).
|
|
27
76
|
* Files are stored in the global media inbound directory.
|
|
@@ -143,10 +192,45 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
143
192
|
if (groupId) {
|
|
144
193
|
registerPeerId(groupId);
|
|
145
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
|
+
}
|
|
146
220
|
if (senderId) {
|
|
147
221
|
registerPeerId(senderId);
|
|
148
222
|
}
|
|
149
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
|
+
|
|
150
234
|
// 2) Authorization guard (DM/group policy).
|
|
151
235
|
let commandAuthorized = true;
|
|
152
236
|
if (isDirect) {
|
|
@@ -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,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 { getProactiveRiskObservation } from "./proactive-risk-registry";
|
|
16
17
|
import { formatDingTalkErrorPayloadLog } from "./utils";
|
|
17
18
|
import type {
|
|
18
19
|
AxiosResponse,
|
|
@@ -51,6 +52,12 @@ export async function sendProactiveTextOrMarkdown(
|
|
|
51
52
|
const { targetId, isExplicitUser } = stripTargetPrefix(target);
|
|
52
53
|
const resolvedTarget = resolveOriginalPeerId(targetId);
|
|
53
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
|
+
: "";
|
|
54
61
|
|
|
55
62
|
const url = isGroup
|
|
56
63
|
? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
|
|
@@ -59,7 +66,7 @@ export async function sendProactiveTextOrMarkdown(
|
|
|
59
66
|
const { useMarkdown, title } = detectMarkdownAndExtractTitle(text, options, "OpenClaw 提醒");
|
|
60
67
|
|
|
61
68
|
log?.debug?.(
|
|
62
|
-
`[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}`,
|
|
63
70
|
);
|
|
64
71
|
|
|
65
72
|
// DingTalk proactive API uses message templates (sampleMarkdown / sampleText).
|
|
@@ -100,7 +107,7 @@ export async function sendProactiveTextOrMarkdown(
|
|
|
100
107
|
log?.error?.(
|
|
101
108
|
`[DingTalk] Failed to send proactive message:${statusLabel} message=${
|
|
102
109
|
maybeAxiosError.message || String(err)
|
|
103
|
-
}`,
|
|
110
|
+
}${proactiveRiskTag}`,
|
|
104
111
|
);
|
|
105
112
|
if (maybeAxiosError.response.data !== undefined) {
|
|
106
113
|
log?.error?.(
|
|
@@ -188,11 +195,18 @@ export async function sendProactiveMedia(
|
|
|
188
195
|
return { ok: true, data: result.data, messageId };
|
|
189
196
|
} catch (err: any) {
|
|
190
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
|
+
: "";
|
|
191
205
|
if (axios.isAxiosError(err) && err.response) {
|
|
192
206
|
const status = err.response.status;
|
|
193
207
|
const statusText = err.response.statusText;
|
|
194
208
|
const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ""}` : "";
|
|
195
|
-
log?.error?.(`[DingTalk] Proactive media response${statusLabel}`);
|
|
209
|
+
log?.error?.(`[DingTalk] Proactive media response${statusLabel}${proactiveRiskTag}`);
|
|
196
210
|
log?.error?.(formatDingTalkErrorPayloadLog("send.proactiveMedia", err.response.data));
|
|
197
211
|
}
|
|
198
212
|
return { ok: false, error: err.message };
|
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,
|