@soimy/dingtalk 3.2.0 → 3.3.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.
@@ -0,0 +1,339 @@
1
+ import { normalizeAllowFrom, isSenderOwner } from "./access-control";
2
+ import type { DingTalkConfig } from "./types";
3
+
4
+ export interface ParsedLearnCommand {
5
+ scope:
6
+ | "help"
7
+ | "global"
8
+ | "session"
9
+ | "here"
10
+ | "target"
11
+ | "targets"
12
+ | "list"
13
+ | "disable"
14
+ | "delete"
15
+ | "whoami"
16
+ | "whereami"
17
+ | "owner-status"
18
+ | "target-set-create"
19
+ | "target-set-apply"
20
+ | "unknown";
21
+ instruction?: string;
22
+ ruleId?: string;
23
+ targetId?: string;
24
+ targetIds?: string[];
25
+ setName?: string;
26
+ }
27
+
28
+ const TARGET_DELIMITER = "#@#";
29
+
30
+ export function parseLearnCommand(text: string | undefined): ParsedLearnCommand {
31
+ const raw = String(text || "").trim();
32
+ if (!raw) {
33
+ return { scope: "unknown" };
34
+ }
35
+ const normalized = raw.toLowerCase();
36
+ if (
37
+ normalized === "/learn help"
38
+ ) {
39
+ return { scope: "help" };
40
+ }
41
+ if (
42
+ normalized === "/learn whoami"
43
+ || normalized === "/whoami"
44
+ || normalized === "我是谁"
45
+ || normalized === "我的信息"
46
+ ) {
47
+ return { scope: "whoami" };
48
+ }
49
+ if (
50
+ normalized === "/learn whereami"
51
+ || normalized === "这里是谁"
52
+ || normalized === "这个群是谁"
53
+ || normalized === "这个会话是谁"
54
+ ) {
55
+ return { scope: "whereami" };
56
+ }
57
+ if (
58
+ normalized === "/learn owner status"
59
+ || normalized === "/owner status"
60
+ || normalized === "/owner-status"
61
+ ) {
62
+ return { scope: "owner-status" };
63
+ }
64
+ if (normalized === "/learn list") {
65
+ return { scope: "list" };
66
+ }
67
+ if (normalized === "/learn target-set list") {
68
+ return { scope: "list" };
69
+ }
70
+ if (normalized.startsWith("/learn disable ")) {
71
+ const ruleId = raw.slice("/learn disable ".length).trim();
72
+ return ruleId ? { scope: "disable", ruleId } : { scope: "unknown" };
73
+ }
74
+ if (normalized.startsWith("/learn delete ")) {
75
+ const ruleId = raw.slice("/learn delete ".length).trim();
76
+ return ruleId ? { scope: "delete", ruleId } : { scope: "unknown" };
77
+ }
78
+ if (normalized.startsWith("/learn global ")) {
79
+ return { scope: "global", instruction: raw.slice("/learn global ".length).trim() };
80
+ }
81
+ if (normalized.startsWith("/learn session ")) {
82
+ return { scope: "session", instruction: raw.slice("/learn session ".length).trim() };
83
+ }
84
+ if (normalized.startsWith("/learn here ")) {
85
+ const rawInstruction = raw.slice("/learn here ".length).trim();
86
+ const instruction = rawInstruction.startsWith(TARGET_DELIMITER)
87
+ ? rawInstruction.slice(TARGET_DELIMITER.length).trim()
88
+ : rawInstruction;
89
+ return instruction ? { scope: "here", instruction } : { scope: "unknown" };
90
+ }
91
+ if (normalized.startsWith("/learn target-set create ")) {
92
+ const payload = splitDelimitedPayload(raw.slice("/learn target-set create ".length));
93
+ if (!payload) {
94
+ return { scope: "unknown" };
95
+ }
96
+ const targetIds = parseTargetIds(payload.body);
97
+ return payload.head && targetIds.length > 0
98
+ ? { scope: "target-set-create", setName: payload.head, targetIds }
99
+ : { scope: "unknown" };
100
+ }
101
+ if (normalized.startsWith("/learn target-set apply ")) {
102
+ const payload = splitDelimitedPayload(raw.slice("/learn target-set apply ".length));
103
+ return payload?.head && payload.body
104
+ ? { scope: "target-set-apply", setName: payload.head, instruction: payload.body }
105
+ : { scope: "unknown" };
106
+ }
107
+ if (normalized.startsWith("/learn target ")) {
108
+ const payload = splitDelimitedPayload(raw.slice("/learn target ".length));
109
+ return payload?.head && payload.body
110
+ ? { scope: "target", targetId: payload.head, instruction: payload.body }
111
+ : { scope: "unknown" };
112
+ }
113
+ if (normalized.startsWith("/learn targets ")) {
114
+ const payload = splitDelimitedPayload(raw.slice("/learn targets ".length));
115
+ if (!payload) {
116
+ return { scope: "unknown" };
117
+ }
118
+ const targetIds = parseTargetIds(payload.head);
119
+ return targetIds.length > 0 && payload.body
120
+ ? { scope: "targets", targetIds, instruction: payload.body }
121
+ : { scope: "unknown" };
122
+ }
123
+ return { scope: "unknown" };
124
+ }
125
+
126
+ function splitDelimitedPayload(raw: string): { head: string; body: string } | null {
127
+ const index = raw.indexOf(TARGET_DELIMITER);
128
+ if (index < 0) {
129
+ return null;
130
+ }
131
+ const head = raw.slice(0, index).trim();
132
+ const body = raw.slice(index + TARGET_DELIMITER.length).trim();
133
+ if (!head || !body) {
134
+ return null;
135
+ }
136
+ return { head, body };
137
+ }
138
+
139
+ function parseTargetIds(raw: string): string[] {
140
+ return raw
141
+ .split(",")
142
+ .map((item) => item.trim())
143
+ .filter(Boolean);
144
+ }
145
+
146
+ export function isLearningOwner(params: {
147
+ cfg?: { commands?: { ownerAllowFrom?: Array<string | number> } };
148
+ config?: DingTalkConfig;
149
+ senderId?: string;
150
+ rawSenderId?: string;
151
+ }): boolean {
152
+ const allow = normalizeAllowFrom(params.cfg?.commands?.ownerAllowFrom as string[] | undefined);
153
+ return isSenderOwner({ allow, senderId: params.senderId, rawSenderId: params.rawSenderId });
154
+ }
155
+
156
+ export function formatWhoAmIReply(params: {
157
+ senderId: string;
158
+ rawSenderId?: string;
159
+ senderStaffId?: string;
160
+ isOwner?: boolean;
161
+ }): string {
162
+ return [
163
+ "这是你当前消息对应的身份信息:",
164
+ "",
165
+ `- senderId: \`${params.senderId || ""}\``,
166
+ `- rawSenderId: \`${params.rawSenderId || ""}\``,
167
+ `- senderStaffId: \`${params.senderStaffId || ""}\``,
168
+ `- isOwner: \`${params.isOwner ? "true" : "false"}\``,
169
+ "",
170
+ "后续如果要配置 owner 或控制命令权限,就以这里返回的 senderId 为准。",
171
+ ].join("\n");
172
+ }
173
+
174
+ export function formatWhereAmIReply(params: {
175
+ conversationId: string;
176
+ conversationType: "group" | "dm";
177
+ peerId?: string;
178
+ }): string {
179
+ return [
180
+ params.conversationType === "group" ? "这是当前群聊信息:" : "这是当前会话信息:",
181
+ "",
182
+ `- conversationId: \`${params.conversationId}\``,
183
+ `- conversationType: \`${params.conversationType}\``,
184
+ params.peerId ? `- peerId: \`${params.peerId}\`` : undefined,
185
+ "",
186
+ "后续如果要定向注入到这里,就使用这个 conversationId。",
187
+ ].filter(Boolean).join("\n");
188
+ }
189
+
190
+ export function formatOwnerStatusReply(params: {
191
+ senderId: string;
192
+ rawSenderId?: string;
193
+ isOwner: boolean;
194
+ }): string {
195
+ return [
196
+ "当前 owner 控制状态:",
197
+ "",
198
+ `- senderId: \`${params.senderId || ""}\``,
199
+ `- rawSenderId: \`${params.rawSenderId || ""}\``,
200
+ `- isOwner: \`${params.isOwner ? "true" : "false"}\``,
201
+ "",
202
+ "如果需要变更 owner,请由宿主修改本机运行配置。",
203
+ ].join("\n");
204
+ }
205
+
206
+ export function formatOwnerOnlyDeniedReply(): string {
207
+ return "这条学习/控制命令仅允许 owner 使用。先发送“我是谁”确认你的 senderId,再由宿主将该 senderId 加入 commands.ownerAllowFrom。";
208
+ }
209
+
210
+ export function formatLearnCommandHelp(): string {
211
+ return [
212
+ "可用的 owner 学习命令:",
213
+ "",
214
+ "- /learn whoami:查看当前 senderId",
215
+ "- /learn owner status:查看当前 owner 权限状态",
216
+ "- /learn whereami:查看当前会话/群 ID",
217
+ "- /learn global <规则>:发布到当前钉钉账号下所有会话",
218
+ "- /learn session <规则>:仅发布到当前私聊会话",
219
+ "- /learn here #@# <规则>:发布到当前群/当前私聊",
220
+ "- /learn target <conversationId> #@# <规则>:发布到单个指定群/私聊",
221
+ "- /learn targets <conversationId1,conversationId2> #@# <规则>:一次发布到多个指定群/私聊",
222
+ "- /learn target-set create <名称> #@# <conversationId1,conversationId2>:保存一组固定目标",
223
+ "- /learn target-set apply <名称> #@# <规则>:向一个目标组批量发布规则",
224
+ "- /learn list:查看当前全局规则、目标规则与目标组摘要",
225
+ "- /learn disable <ruleId>:停用一条规则,停止继续命中,但保留记录",
226
+ "- /learn delete <ruleId>:彻底删除一条规则或目标规则",
227
+ "",
228
+ "可用的 owner 会话控制命令:",
229
+ "- /session-alias show:查看当前会话共享会话别名",
230
+ "- /session-alias set <alias>:将当前会话绑定到指定共享会话别名",
231
+ "- /session-alias clear:清除当前会话共享会话别名,恢复为默认 peerId",
232
+ "- /session-alias bind direct <senderId> <alias>:由 owner 远程绑定某个私聊 senderId",
233
+ "- /session-alias bind group <conversationId> <alias>:由 owner 远程绑定某个群 conversationId",
234
+ "- /session-alias unbind direct <senderId>:由 owner 远程解除某个私聊 senderId 绑定",
235
+ "- /session-alias unbind group <conversationId>:由 owner 远程解除某个群 conversationId 绑定",
236
+ "",
237
+ "权限说明:",
238
+ "- 只有 owner 才能真正执行 /learn 的写操作和控制操作。",
239
+ "- 如果你现在不是 owner,也可以先用 `/learn whoami` 查看 senderId,再由宿主把它加入 commands.ownerAllowFrom。",
240
+ ].join("\n");
241
+ }
242
+ export function formatLearnAppliedReply(params: {
243
+ scope: "global" | "session" | "target" | "targets" | "target-set";
244
+ instruction: string;
245
+ ruleId?: string;
246
+ targetId?: string;
247
+ targetIds?: string[];
248
+ setName?: string;
249
+ }): string {
250
+ const scopeLine = params.scope === "global"
251
+ ? "- 生效范围:同一钉钉账号下所有会话,将在下一条消息进入时自动加载"
252
+ : params.scope === "session"
253
+ ? "- 生效范围:当前会话,将在下一条消息进入时自动加载"
254
+ : params.scope === "target"
255
+ ? `- 生效范围:指定目标 \`${params.targetId}\``
256
+ : params.scope === "targets"
257
+ ? `- 生效范围:${params.targetIds?.length || 0} 个目标`
258
+ : `- 生效范围:目标组 \`${params.setName}\``;
259
+ return [
260
+ params.scope === "global"
261
+ ? "已注入全局知识。"
262
+ : params.scope === "session"
263
+ ? "已注入当前会话知识。"
264
+ : params.scope === "target"
265
+ ? "已注入指定目标知识。"
266
+ : params.scope === "targets"
267
+ ? "已批量注入多个目标。"
268
+ : "已向目标组批量注入规则。",
269
+ "",
270
+ params.ruleId ? `- ruleId: \`${params.ruleId}\`` : undefined,
271
+ `- instruction: ${params.instruction}`,
272
+ scopeLine,
273
+ ].filter(Boolean).join("\n");
274
+ }
275
+
276
+ export function formatLearnListReply(lines: string[]): string {
277
+ if (lines.length === 0) {
278
+ return "当前还没有规则或目标组。";
279
+ }
280
+ return ["当前规则与目标组摘要:", "", ...lines].join("\n");
281
+ }
282
+
283
+ export function formatLearnDisabledReply(params: {
284
+ ruleId: string;
285
+ existed: boolean;
286
+ scope?: "global" | "target";
287
+ targetId?: string;
288
+ }): string {
289
+ if (!params.existed) {
290
+ return [
291
+ "未找到对应规则。",
292
+ "",
293
+ `- ruleId: \`${params.ruleId}\``,
294
+ "可先使用 `/learn list` 查看当前可停用的规则。",
295
+ ].join("\n");
296
+ }
297
+ return [
298
+ "已停用规则。",
299
+ "",
300
+ `- ruleId: \`${params.ruleId}\``,
301
+ params.scope === "target" && params.targetId ? `- scope: target (\`${params.targetId}\`)` : "- scope: global",
302
+ "- 当前效果:这条规则会停止生效,但记录仍会保留,便于后续排查或恢复。",
303
+ ].join("\n");
304
+ }
305
+
306
+ export function formatLearnDeletedReply(params: {
307
+ ruleId: string;
308
+ existed: boolean;
309
+ scope?: "global" | "target";
310
+ targetId?: string;
311
+ }): string {
312
+ if (!params.existed) {
313
+ return [
314
+ "未找到对应规则。",
315
+ "",
316
+ `- ruleId: \`${params.ruleId}\``,
317
+ "可先使用 `/learn list` 查看当前规则。",
318
+ ].join("\n");
319
+ }
320
+ return [
321
+ "已删除规则。",
322
+ "",
323
+ `- ruleId: \`${params.ruleId}\``,
324
+ params.scope === "target" && params.targetId ? `- scope: target (\`${params.targetId}\`)` : "- scope: global",
325
+ ].join("\n");
326
+ }
327
+
328
+ export function formatTargetSetSavedReply(params: {
329
+ setName: string;
330
+ targetIds: string[];
331
+ }): string {
332
+ return [
333
+ "已保存目标组。",
334
+ "",
335
+ `- name: \`${params.setName}\``,
336
+ `- targetCount: \`${params.targetIds.length}\``,
337
+ `- targetIds: ${params.targetIds.map((targetId) => `\`${targetId}\``).join(", ")}`,
338
+ ].join("\n");
339
+ }
@@ -15,7 +15,7 @@ import { BlockList, isIP } from "node:net";
15
15
  import axios from "axios";
16
16
  import FormData from "form-data";
17
17
  import type { DingTalkConfig, Logger } from "./types";
18
- import { formatDingTalkErrorPayloadLog } from "./utils";
18
+ import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
19
19
 
20
20
  /**
21
21
  * Calculate MP3 duration in seconds by parsing MPEG frame headers
@@ -183,8 +183,8 @@ export async function getMp3DurationSeconds(filePath: string, log?: Logger): Pro
183
183
 
184
184
  log?.warn?.(`[DingTalk] Could not parse MP3 duration from ${filePath} (found ${frameCount} frames)`);
185
185
  return 0;
186
- } catch (err: any) {
187
- log?.error?.(`[DingTalk] Failed to get MP3 duration: ${err.message}`);
186
+ } catch (err: unknown) {
187
+ log?.error?.(`[DingTalk] Failed to get MP3 duration: ${err instanceof Error ? err.message : String(err)}`);
188
188
  return 0;
189
189
  }
190
190
  }
@@ -246,6 +246,7 @@ export class RemoteMediaError extends Error {
246
246
 
247
247
  const REMOTE_MEDIA_DOWNLOAD_TIMEOUT_MS = 10_000;
248
248
  const REMOTE_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
249
+ const REMOTE_MEDIA_MAX_REDIRECTS = 5;
249
250
 
250
251
  function normalizeAllowlistEntry(entry: string): string {
251
252
  return entry.trim().toLowerCase();
@@ -462,73 +463,114 @@ export async function prepareMediaInput(
462
463
  return { path: trimmed };
463
464
  }
464
465
 
465
- const parsedUrl = new URL(trimmed);
466
- const isPrivateHost = isPrivateOrLocalHost(parsedUrl.hostname);
467
466
  const allowlist = mediaUrlAllowlist?.filter((entry) => entry.trim().length > 0) || [];
468
467
  const allowlistConfigured = allowlist.length > 0;
469
- const inAllowlist = allowlistConfigured ? isAllowedByMediaUrlAllowlist(parsedUrl, allowlist) : false;
470
468
 
471
- if (allowlistConfigured && !inAllowlist) {
472
- throw new RemoteMediaError(
473
- `remote media URL host is not in mediaUrlAllowlist: ${parsedUrl.hostname}`,
474
- REMOTE_MEDIA_ERROR_CODES.ALLOWLIST_MISS,
475
- );
476
- }
469
+ const assertRemoteUrlAllowed = async (
470
+ url: URL,
471
+ ): Promise<((hostname: string) => Promise<{ address: string; family: number }>) | undefined> => {
472
+ const inAllowlist = allowlistConfigured ? isAllowedByMediaUrlAllowlist(url, allowlist) : false;
477
473
 
478
- if (isPrivateHost && !inAllowlist) {
479
- throw new RemoteMediaError(
480
- `remote media URL points to private or local network host: ${parsedUrl.hostname}`,
481
- REMOTE_MEDIA_ERROR_CODES.PRIVATE_HOST,
482
- );
483
- }
474
+ if (allowlistConfigured && !inAllowlist) {
475
+ throw new RemoteMediaError(
476
+ `remote media URL host is not in mediaUrlAllowlist: ${url.hostname}`,
477
+ REMOTE_MEDIA_ERROR_CODES.ALLOWLIST_MISS,
478
+ );
479
+ }
484
480
 
485
- const isIpLiteralHost = isIP(parsedUrl.hostname) !== 0;
486
- let pinnedResolved: { address: string; family: number } | undefined;
487
- if (!isIpLiteralHost) {
488
- const resolvedRecords = await resolveHostname(parsedUrl.hostname);
481
+ if (isPrivateOrLocalHost(url.hostname) && !inAllowlist) {
482
+ throw new RemoteMediaError(
483
+ `remote media URL points to private or local network host: ${url.hostname}`,
484
+ REMOTE_MEDIA_ERROR_CODES.PRIVATE_HOST,
485
+ );
486
+ }
487
+
488
+ if (isIP(url.hostname) !== 0) {
489
+ return undefined;
490
+ }
491
+
492
+ const resolvedRecords = await resolveHostname(url.hostname);
489
493
  if (resolvedRecords.length === 0) {
490
494
  throw new RemoteMediaError(
491
- `remote media URL host cannot be resolved: ${parsedUrl.hostname}`,
495
+ `remote media URL host cannot be resolved: ${url.hostname}`,
492
496
  REMOTE_MEDIA_ERROR_CODES.DNS_UNRESOLVED,
493
497
  );
494
498
  }
495
499
 
496
500
  if (!inAllowlist && resolvedRecords.some((record) => isPrivateOrLocalHost(record.address))) {
497
501
  throw new RemoteMediaError(
498
- `remote media URL host resolves to private or local network address: ${parsedUrl.hostname}`,
502
+ `remote media URL host resolves to private or local network address: ${url.hostname}`,
499
503
  REMOTE_MEDIA_ERROR_CODES.DNS_PRIVATE,
500
504
  );
501
505
  }
502
506
 
503
- pinnedResolved = resolvedRecords[0];
507
+ const pinnedResolved = resolvedRecords[0];
508
+ return async (hostname: string): Promise<{ address: string; family: number }> => {
509
+ if (hostname === url.hostname) {
510
+ return pinnedResolved;
511
+ }
512
+
513
+ throw new RemoteMediaError(
514
+ `remote media URL redirected to unexpected host: ${hostname}`,
515
+ REMOTE_MEDIA_ERROR_CODES.REDIRECT_HOST,
516
+ );
517
+ };
518
+ };
519
+
520
+ let currentUrl = new URL(trimmed);
521
+ let response: { data: unknown; headers?: Record<string, unknown>; status: number } | null = null;
522
+
523
+ for (let redirectCount = 0; redirectCount <= REMOTE_MEDIA_MAX_REDIRECTS; redirectCount += 1) {
524
+ const lookup = await assertRemoteUrlAllowed(currentUrl);
525
+ const currentResponse = await axios.get(currentUrl.toString(), {
526
+ responseType: "arraybuffer",
527
+ maxBodyLength: REMOTE_MEDIA_MAX_BYTES,
528
+ maxContentLength: REMOTE_MEDIA_MAX_BYTES,
529
+ timeout: REMOTE_MEDIA_DOWNLOAD_TIMEOUT_MS,
530
+ maxRedirects: 0,
531
+ validateStatus: (status) => status >= 200 && status < 400,
532
+ lookup,
533
+ });
534
+
535
+ response = currentResponse;
536
+ const statusCode = typeof currentResponse.status === "number" ? currentResponse.status : 200;
537
+ if (statusCode < 300 || statusCode >= 400) {
538
+ break;
539
+ }
540
+
541
+ const locationHeader =
542
+ typeof currentResponse.headers?.location === "string"
543
+ ? currentResponse.headers.location
544
+ : Array.isArray(currentResponse.headers?.location)
545
+ ? currentResponse.headers.location[0]
546
+ : undefined;
547
+ if (!locationHeader) {
548
+ throw new Error(`redirect response missing location header: ${statusCode}`);
549
+ }
550
+
551
+ if (redirectCount === REMOTE_MEDIA_MAX_REDIRECTS) {
552
+ throw new Error(`too many redirects when downloading remote media: ${currentUrl.toString()}`);
553
+ }
554
+
555
+ const redirectUrl = new URL(locationHeader, currentUrl);
556
+ if (redirectUrl.protocol !== "http:" && redirectUrl.protocol !== "https:") {
557
+ throw new RemoteMediaError(
558
+ `remote media URL redirected to unsupported scheme: ${redirectUrl.protocol}`,
559
+ REMOTE_MEDIA_ERROR_CODES.REDIRECT_HOST,
560
+ );
561
+ }
562
+ currentUrl = redirectUrl;
504
563
  }
505
564
 
506
- const lookup = pinnedResolved
507
- ? async (hostname: string): Promise<{ address: string; family: number }> => {
508
- if (hostname === parsedUrl.hostname) {
509
- return pinnedResolved;
510
- }
565
+ if (!response) {
566
+ throw new Error("remote media download failed: empty response");
567
+ }
511
568
 
512
- throw new RemoteMediaError(
513
- `remote media URL redirected to unexpected host: ${hostname}`,
514
- REMOTE_MEDIA_ERROR_CODES.REDIRECT_HOST,
515
- );
516
- }
517
- : undefined;
518
-
519
- const response = await axios.get(trimmed, {
520
- responseType: "arraybuffer",
521
- maxBodyLength: REMOTE_MEDIA_MAX_BYTES,
522
- maxContentLength: REMOTE_MEDIA_MAX_BYTES,
523
- timeout: REMOTE_MEDIA_DOWNLOAD_TIMEOUT_MS,
524
- maxRedirects: 0,
525
- lookup,
526
- });
527
569
  const contentType =
528
570
  typeof response.headers?.["content-type"] === "string"
529
571
  ? response.headers["content-type"]
530
572
  : undefined;
531
- const urlPath = parsedUrl.pathname;
573
+ const urlPath = currentUrl.pathname;
532
574
  const ext = path.extname(urlPath) || detectExtensionFromContentType(contentType) || ".bin";
533
575
  const tempPath = path.join(os.tmpdir(), `dingtalk_${randomUUID()}${ext}`);
534
576
  const buffer = Buffer.isBuffer(response.data)
@@ -618,6 +660,7 @@ export async function uploadMedia(
618
660
  headers: form.getHeaders(),
619
661
  maxBodyLength: Infinity,
620
662
  maxContentLength: Infinity,
663
+ ...getProxyBypassOption(config),
621
664
  });
622
665
 
623
666
  if (response.data?.errcode === 0 && response.data?.media_id) {
@@ -629,14 +672,15 @@ export async function uploadMedia(
629
672
  log?.error?.(`[DingTalk] Media upload failed: ${JSON.stringify(response.data)}`);
630
673
  return null;
631
674
  }
632
- } catch (err: any) {
675
+ } catch (err: unknown) {
633
676
  // Handle file system errors (e.g., file not found, permission denied)
634
- if (err.code === "ENOENT") {
677
+ const errno = err as NodeJS.ErrnoException;
678
+ if (errno.code === "ENOENT") {
635
679
  log?.error?.(`[DingTalk] Media file not found: ${mediaPath}`);
636
- } else if (err.code === "EACCES") {
680
+ } else if (errno.code === "EACCES") {
637
681
  log?.error?.(`[DingTalk] Permission denied accessing media file: ${mediaPath}`);
638
682
  } else {
639
- log?.error?.(`[DingTalk] Failed to upload media: ${err.message}`);
683
+ log?.error?.(`[DingTalk] Failed to upload media: ${err instanceof Error ? err.message : String(err)}`);
640
684
  if (axios.isAxiosError(err) && err.response) {
641
685
  const status = err.response.status;
642
686
  const statusText = err.response.statusText;