@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soimy/dingtalk",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "DingTalk (钉钉) channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
@@ -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,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
+ }
@@ -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,