@soimy/dingtalk 3.1.4 → 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.
@@ -5,15 +5,309 @@
5
5
  * Provides functions for media type detection and file upload to DingTalk media servers.
6
6
  */
7
7
 
8
- import * as fs from "fs";
9
- import { promises as fsPromises } from "fs";
10
- import * as path from "path";
8
+ import * as fs from "node:fs";
9
+ import { randomUUID } from "node:crypto";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import { promises as fsPromises } from "node:fs";
13
+ import { lookup as dnsLookup } from "node:dns/promises";
14
+ import { BlockList, isIP } from "node:net";
11
15
  import axios from "axios";
12
16
  import FormData from "form-data";
13
17
  import type { DingTalkConfig, Logger } from "./types";
14
- import { formatDingTalkErrorPayloadLog } from "./utils";
18
+ import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
19
+
20
+ /**
21
+ * Calculate MP3 duration in seconds by parsing MPEG frame headers
22
+ * Supports CBR and VBR MP3 files
23
+ * @param filePath Path to the MP3 file
24
+ * @param log Optional logger
25
+ * @returns Duration in seconds (0 if parsing fails)
26
+ */
27
+ export async function getMp3DurationSeconds(filePath: string, log?: Logger): Promise<number> {
28
+ try {
29
+ const buffer = await fsPromises.readFile(filePath);
30
+ let offset = 0;
31
+
32
+ // Skip ID3v2 tag if present
33
+ if (buffer.length >= 10 && buffer[0] === 0x49 && buffer[1] === 0x44 && buffer[2] === 0x33) {
34
+ const flags = buffer[5];
35
+ const id3Size =
36
+ ((buffer[6] & 0x7f) << 21) |
37
+ ((buffer[7] & 0x7f) << 14) |
38
+ ((buffer[8] & 0x7f) << 7) |
39
+ (buffer[9] & 0x7f);
40
+
41
+ // ID3 size excludes the 10-byte header; footer (if present) adds 10 bytes.
42
+ const footerSize = (flags & 0x10) ? 10 : 0;
43
+ offset = 10 + id3Size + footerSize;
44
+ }
45
+
46
+ // Skip ID3v1 tag at the end (last 128 bytes)
47
+ const endOffset =
48
+ buffer.length > 128 &&
49
+ buffer[buffer.length - 128] === 0x54 &&
50
+ buffer[buffer.length - 127] === 0x41 &&
51
+ buffer[buffer.length - 126] === 0x47
52
+ ? buffer.length - 128
53
+ : buffer.length;
54
+
55
+ let frameCount = 0;
56
+ let totalSamples = 0;
57
+ let lastSampleRate = 0;
58
+
59
+ // Sample rate tables
60
+ const sampleRates: Record<"1" | "2" | "2.5", number[]> = {
61
+ "1": [44100, 48000, 32000, 0],
62
+ "2": [22050, 24000, 16000, 0],
63
+ "2.5": [11025, 12000, 8000, 0],
64
+ };
65
+
66
+ // Bitrate tables (kbps) by (version group -> layer)
67
+ // Note: version group here is MPEG1 vs MPEG2/2.5 for bitrate tables.
68
+ const bitratesLayer1: Record<"1" | "2", number[]> = {
69
+ "1": [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0],
70
+ "2": [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0],
71
+ };
72
+ const bitratesLayer2: Record<"1" | "2", number[]> = {
73
+ "1": [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0],
74
+ "2": [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0],
75
+ };
76
+ const bitratesLayer3: Record<"1" | "2", number[]> = {
77
+ "1": [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
78
+ "2": [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0],
79
+ };
80
+
81
+ while (offset < endOffset - 4) {
82
+ // Frame sync (11 bits set)
83
+ if (buffer[offset] === 0xff && (buffer[offset + 1] & 0xe0) === 0xe0) {
84
+ const versionBits = (buffer[offset + 1] >> 3) & 0x03; // 00=2.5, 01=reserved, 10=2, 11=1
85
+ const layerBits = (buffer[offset + 1] >> 1) & 0x03; // 01=III, 10=II, 11=I, 00=reserved
86
+ const bitrateIndex = (buffer[offset + 2] >> 4) & 0x0f; // 0000/1111 invalid
87
+ const sampleRateIndex = (buffer[offset + 2] >> 2) & 0x03; // 11 invalid
88
+ const paddingBit = (buffer[offset + 2] >> 1) & 0x01;
89
+
90
+ // quick validity checks to reduce false sync hits
91
+ if (layerBits === 0 || bitrateIndex === 0 || bitrateIndex === 15 || sampleRateIndex === 3) {
92
+ offset++;
93
+ continue;
94
+ }
95
+
96
+ // MPEG version
97
+ let mpegVersion: "1" | "2" | "2.5";
98
+ if (versionBits === 0) {
99
+ mpegVersion = "2.5";
100
+ } else if (versionBits === 2) {
101
+ mpegVersion = "2";
102
+ } else if (versionBits === 3) {
103
+ mpegVersion = "1";
104
+ } else {
105
+ offset++;
106
+ continue; // reserved
107
+ }
108
+
109
+ // Layer
110
+ let layer: 1 | 2 | 3;
111
+ if (layerBits === 1) {
112
+ layer = 3; // Layer III
113
+ } else if (layerBits === 2) {
114
+ layer = 2; // Layer II
115
+ } else if (layerBits === 3) {
116
+ layer = 1; // Layer I
117
+ } else {
118
+ offset++;
119
+ continue;
120
+ }
121
+
122
+ const sampleRate = sampleRates[mpegVersion][sampleRateIndex] || 0;
123
+ if (!sampleRate) {
124
+ offset++;
125
+ continue;
126
+ }
127
+
128
+ // bitrate tables use group: MPEG1 vs MPEG2/2.5
129
+ const brGroup: "1" | "2" = mpegVersion === "1" ? "1" : "2";
130
+ let bitrateKbps = 0;
131
+ if (layer === 1) {
132
+ bitrateKbps = bitratesLayer1[brGroup][bitrateIndex] || 0;
133
+ } else if (layer === 2) {
134
+ bitrateKbps = bitratesLayer2[brGroup][bitrateIndex] || 0;
135
+ } else {
136
+ bitrateKbps = bitratesLayer3[brGroup][bitrateIndex] || 0;
137
+ }
138
+
139
+ if (!bitrateKbps) {
140
+ offset++;
141
+ continue;
142
+ }
143
+
144
+ // samples per frame
145
+ let samplesPerFrame: number;
146
+ if (layer === 1) {
147
+ samplesPerFrame = 384;
148
+ } else if (layer === 2) {
149
+ samplesPerFrame = 1152;
150
+ } else {
151
+ samplesPerFrame = mpegVersion === "1" ? 1152 : 576; // Layer III
152
+ }
153
+
154
+ // frame size
155
+ let frameSize = 0;
156
+ if (layer === 1) {
157
+ frameSize = Math.floor(((12 * bitrateKbps * 1000) / sampleRate + paddingBit) * 4);
158
+ } else if (layer === 3 && mpegVersion !== "1") {
159
+ // Layer III + MPEG2/2.5 uses 72, not 144
160
+ frameSize = Math.floor((72 * bitrateKbps * 1000) / sampleRate + paddingBit);
161
+ } else {
162
+ // Layer II OR Layer III MPEG1
163
+ frameSize = Math.floor((144 * bitrateKbps * 1000) / sampleRate + paddingBit);
164
+ }
165
+
166
+ if (frameSize > 0 && frameSize < 10000 && offset + frameSize <= endOffset) {
167
+ frameCount++;
168
+ totalSamples += samplesPerFrame;
169
+ lastSampleRate = sampleRate;
170
+ offset += frameSize;
171
+ continue;
172
+ }
173
+ }
174
+
175
+ offset++;
176
+ }
177
+
178
+ if (frameCount > 0 && lastSampleRate > 0) {
179
+ const duration = totalSamples / lastSampleRate;
180
+ log?.debug?.(`[DingTalk] Parsed ${frameCount} MP3 frames, duration: ${duration.toFixed(3)}s`);
181
+ return Math.floor(duration);
182
+ }
183
+
184
+ log?.warn?.(`[DingTalk] Could not parse MP3 duration from ${filePath} (found ${frameCount} frames)`);
185
+ return 0;
186
+ } catch (err: unknown) {
187
+ log?.error?.(`[DingTalk] Failed to get MP3 duration: ${err instanceof Error ? err.message : String(err)}`);
188
+ return 0;
189
+ }
190
+ }
191
+
192
+ const DEFAULT_VOICE_DURATION_MS = 1000;
193
+
194
+ export async function getVoiceDurationMs(
195
+ filePath: string,
196
+ mediaType: DingTalkMediaType,
197
+ log?: Logger,
198
+ ): Promise<number> {
199
+ if (mediaType !== "voice") {
200
+ return DEFAULT_VOICE_DURATION_MS;
201
+ }
202
+
203
+ const ext = path.extname(filePath).toLowerCase();
204
+
205
+ if (ext === ".mp3") {
206
+ const durationSec = await getMp3DurationSeconds(filePath, log);
207
+ if (durationSec > 0) {
208
+ return Math.max(1, Math.round(durationSec * 1000));
209
+ }
210
+
211
+ log?.warn?.(
212
+ `[DingTalk] MP3 duration parse returned ${durationSec} for ${filePath}; using fallback ${DEFAULT_VOICE_DURATION_MS}ms`,
213
+ );
214
+ return DEFAULT_VOICE_DURATION_MS;
215
+ }
216
+
217
+ return DEFAULT_VOICE_DURATION_MS;
218
+ }
219
+
15
220
 
16
221
  export type DingTalkMediaType = "image" | "voice" | "video" | "file";
222
+ export type DingTalkOutboundMediaType = DingTalkMediaType;
223
+
224
+ export interface PreparedMediaInput {
225
+ path: string;
226
+ cleanup?: () => Promise<void>;
227
+ }
228
+
229
+ export const REMOTE_MEDIA_ERROR_CODES = {
230
+ ALLOWLIST_MISS: "ERR_MEDIA_ALLOWLIST_MISS",
231
+ PRIVATE_HOST: "ERR_MEDIA_PRIVATE_HOST",
232
+ DNS_UNRESOLVED: "ERR_MEDIA_DNS_UNRESOLVED",
233
+ DNS_PRIVATE: "ERR_MEDIA_DNS_PRIVATE",
234
+ REDIRECT_HOST: "ERR_MEDIA_REDIRECT_HOST",
235
+ } as const;
236
+
237
+ export class RemoteMediaError extends Error {
238
+ constructor(
239
+ message: string,
240
+ public readonly code: (typeof REMOTE_MEDIA_ERROR_CODES)[keyof typeof REMOTE_MEDIA_ERROR_CODES],
241
+ ) {
242
+ super(message);
243
+ this.name = "RemoteMediaError";
244
+ }
245
+ }
246
+
247
+ const REMOTE_MEDIA_DOWNLOAD_TIMEOUT_MS = 10_000;
248
+ const REMOTE_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
249
+ const REMOTE_MEDIA_MAX_REDIRECTS = 5;
250
+
251
+ function normalizeAllowlistEntry(entry: string): string {
252
+ return entry.trim().toLowerCase();
253
+ }
254
+
255
+ function normalizeHostname(hostname: string): string {
256
+ return hostname.replace(/^\[/, "").replace(/\]$/, "").trim().toLowerCase();
257
+ }
258
+
259
+ function isIpInCidr(ip: string, cidr: string): boolean {
260
+ const [network, rawMask] = cidr.split("/");
261
+ const mask = Number.parseInt(rawMask || "", 10);
262
+ const normalizedIp = normalizeHostname(ip);
263
+ const normalizedNetwork = normalizeHostname(network || "");
264
+ if (!normalizedNetwork || Number.isNaN(mask)) {
265
+ return false;
266
+ }
267
+
268
+ const ipVersion = isIP(normalizedIp);
269
+ const networkVersion = isIP(normalizedNetwork);
270
+ if (ipVersion === 0 || ipVersion !== networkVersion) {
271
+ return false;
272
+ }
273
+
274
+ const blockList = new BlockList();
275
+ if (ipVersion === 4) {
276
+ blockList.addSubnet(normalizedNetwork, mask, "ipv4");
277
+ return blockList.check(normalizedIp, "ipv4");
278
+ }
279
+
280
+ blockList.addSubnet(normalizedNetwork, mask, "ipv6");
281
+ return blockList.check(normalizedIp, "ipv6");
282
+ }
283
+
284
+ function matchesAllowlistHost(hostname: string, port: string, entry: string): boolean {
285
+ const normalizedHost = normalizeHostname(hostname);
286
+ const normalizedEntry = normalizeAllowlistEntry(entry);
287
+ if (!normalizedEntry) {
288
+ return false;
289
+ }
290
+
291
+ if (normalizedEntry.includes("/")) {
292
+ return isIpInCidr(normalizedHost, normalizedEntry);
293
+ }
294
+
295
+ if (normalizedEntry.startsWith("*.")) {
296
+ const suffix = normalizedEntry.slice(1);
297
+ return normalizedHost.endsWith(suffix);
298
+ }
299
+
300
+ if (normalizedEntry.includes(":")) {
301
+ return `${normalizedHost}:${port}` === normalizedEntry;
302
+ }
303
+
304
+ return normalizedHost === normalizedEntry;
305
+ }
306
+
307
+ function isAllowedByMediaUrlAllowlist(url: URL, mediaUrlAllowlist: string[]): boolean {
308
+ const port = url.port || (url.protocol === "https:" ? "443" : "80");
309
+ return mediaUrlAllowlist.some((entry) => matchesAllowlistHost(url.hostname, port, entry));
310
+ }
17
311
 
18
312
  /**
19
313
  * Detect media type from file extension
@@ -40,6 +334,268 @@ export function detectMediaTypeFromExtension(filePath: string): DingTalkMediaTyp
40
334
  return "file";
41
335
  }
42
336
 
337
+ function normalizeOutboundMediaType(value?: string | null): DingTalkOutboundMediaType | undefined {
338
+ if (!value) {
339
+ return undefined;
340
+ }
341
+
342
+ const normalized = value.trim().toLowerCase();
343
+ if (normalized === "image" || normalized === "voice" || normalized === "video" || normalized === "file") {
344
+ return normalized;
345
+ }
346
+
347
+ return undefined;
348
+ }
349
+
350
+ export function resolveOutboundMediaType(params: {
351
+ mediaType?: string | null;
352
+ mediaPath: string;
353
+ asVoice: boolean;
354
+ }): DingTalkOutboundMediaType {
355
+ const explicitType = normalizeOutboundMediaType(params.mediaType);
356
+ const detectedType = detectMediaTypeFromExtension(params.mediaPath);
357
+
358
+ if (params.asVoice) {
359
+ if (explicitType && explicitType !== "voice") {
360
+ throw new Error('asVoice requires mediaType="voice" when mediaType is provided.');
361
+ }
362
+
363
+ if (detectedType !== "voice") {
364
+ throw new Error("asVoice requires an audio file (mp3, amr, wav).");
365
+ }
366
+
367
+ return "voice";
368
+ }
369
+
370
+ if (explicitType) {
371
+ return explicitType;
372
+ }
373
+
374
+ return detectedType;
375
+ }
376
+
377
+ function isRemoteMediaUrl(input: string): boolean {
378
+ try {
379
+ const parsed = new URL(input);
380
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
381
+ } catch {
382
+ return false;
383
+ }
384
+ }
385
+
386
+ function isPrivateOrLocalHost(hostname: string): boolean {
387
+ const normalized = normalizeHostname(hostname);
388
+ if (!normalized) {
389
+ return true;
390
+ }
391
+
392
+ if (normalized === "localhost" || normalized.endsWith(".localhost")) {
393
+ return true;
394
+ }
395
+
396
+ const ipVersion = isIP(normalized);
397
+ if (ipVersion === 4) {
398
+ const parts = normalized.split(".").map((part) => Number.parseInt(part, 10));
399
+ if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
400
+ return true;
401
+ }
402
+
403
+ const [a, b] = parts;
404
+ return (
405
+ a === 0 ||
406
+ a === 10 ||
407
+ a === 127 ||
408
+ (a === 169 && b === 254) ||
409
+ (a === 172 && b >= 16 && b <= 31) ||
410
+ (a === 192 && b === 168)
411
+ );
412
+ }
413
+
414
+ if (ipVersion === 6) {
415
+ if (normalized === "::1") {
416
+ return true;
417
+ }
418
+ return normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80");
419
+ }
420
+
421
+ return false;
422
+ }
423
+
424
+ async function resolveHostname(hostname: string): Promise<Array<{ address: string; family: number }>> {
425
+ const records = await dnsLookup(hostname, { all: true, verbatim: true });
426
+ return Array.isArray(records) ? records : [records];
427
+ }
428
+
429
+ function detectExtensionFromContentType(contentType?: string): string {
430
+ const normalized = contentType?.split(";")[0]?.trim().toLowerCase();
431
+ switch (normalized) {
432
+ case "image/jpeg":
433
+ return ".jpg";
434
+ case "image/png":
435
+ return ".png";
436
+ case "image/gif":
437
+ return ".gif";
438
+ case "image/bmp":
439
+ return ".bmp";
440
+ case "audio/amr":
441
+ return ".amr";
442
+ case "audio/mpeg":
443
+ return ".mp3";
444
+ case "audio/wav":
445
+ case "audio/x-wav":
446
+ return ".wav";
447
+ case "video/mp4":
448
+ return ".mp4";
449
+ case "application/pdf":
450
+ return ".pdf";
451
+ default:
452
+ return "";
453
+ }
454
+ }
455
+
456
+ export async function prepareMediaInput(
457
+ input: string,
458
+ log?: Logger,
459
+ mediaUrlAllowlist?: string[],
460
+ ): Promise<PreparedMediaInput> {
461
+ const trimmed = input.trim();
462
+ if (!isRemoteMediaUrl(trimmed)) {
463
+ return { path: trimmed };
464
+ }
465
+
466
+ const allowlist = mediaUrlAllowlist?.filter((entry) => entry.trim().length > 0) || [];
467
+ const allowlistConfigured = allowlist.length > 0;
468
+
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;
473
+
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
+ }
480
+
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);
493
+ if (resolvedRecords.length === 0) {
494
+ throw new RemoteMediaError(
495
+ `remote media URL host cannot be resolved: ${url.hostname}`,
496
+ REMOTE_MEDIA_ERROR_CODES.DNS_UNRESOLVED,
497
+ );
498
+ }
499
+
500
+ if (!inAllowlist && resolvedRecords.some((record) => isPrivateOrLocalHost(record.address))) {
501
+ throw new RemoteMediaError(
502
+ `remote media URL host resolves to private or local network address: ${url.hostname}`,
503
+ REMOTE_MEDIA_ERROR_CODES.DNS_PRIVATE,
504
+ );
505
+ }
506
+
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;
563
+ }
564
+
565
+ if (!response) {
566
+ throw new Error("remote media download failed: empty response");
567
+ }
568
+
569
+ const contentType =
570
+ typeof response.headers?.["content-type"] === "string"
571
+ ? response.headers["content-type"]
572
+ : undefined;
573
+ const urlPath = currentUrl.pathname;
574
+ const ext = path.extname(urlPath) || detectExtensionFromContentType(contentType) || ".bin";
575
+ const tempPath = path.join(os.tmpdir(), `dingtalk_${randomUUID()}${ext}`);
576
+ const buffer = Buffer.isBuffer(response.data)
577
+ ? response.data
578
+ : Buffer.from(response.data as ArrayBuffer);
579
+
580
+ await fsPromises.writeFile(tempPath, buffer);
581
+ log?.debug?.(`[DingTalk] Downloaded remote media to temp file: ${tempPath}`);
582
+
583
+ return {
584
+ path: tempPath,
585
+ cleanup: async () => {
586
+ try {
587
+ await fsPromises.unlink(tempPath);
588
+ } catch (err: unknown) {
589
+ const code = typeof err === "object" && err !== null && "code" in err ? err.code : undefined;
590
+ if (code !== "ENOENT") {
591
+ const message = err instanceof Error ? err.message : String(err);
592
+ log?.warn?.(`[DingTalk] Failed to remove temp media ${tempPath}: ${message}`);
593
+ }
594
+ }
595
+ },
596
+ };
597
+ }
598
+
43
599
  /**
44
600
  * File size limits for DingTalk media types (in bytes)
45
601
  */
@@ -104,6 +660,7 @@ export async function uploadMedia(
104
660
  headers: form.getHeaders(),
105
661
  maxBodyLength: Infinity,
106
662
  maxContentLength: Infinity,
663
+ ...getProxyBypassOption(config),
107
664
  });
108
665
 
109
666
  if (response.data?.errcode === 0 && response.data?.media_id) {
@@ -115,14 +672,15 @@ export async function uploadMedia(
115
672
  log?.error?.(`[DingTalk] Media upload failed: ${JSON.stringify(response.data)}`);
116
673
  return null;
117
674
  }
118
- } catch (err: any) {
675
+ } catch (err: unknown) {
119
676
  // Handle file system errors (e.g., file not found, permission denied)
120
- if (err.code === "ENOENT") {
677
+ const errno = err as NodeJS.ErrnoException;
678
+ if (errno.code === "ENOENT") {
121
679
  log?.error?.(`[DingTalk] Media file not found: ${mediaPath}`);
122
- } else if (err.code === "EACCES") {
680
+ } else if (errno.code === "EACCES") {
123
681
  log?.error?.(`[DingTalk] Permission denied accessing media file: ${mediaPath}`);
124
682
  } else {
125
- log?.error?.(`[DingTalk] Failed to upload media: ${err.message}`);
683
+ log?.error?.(`[DingTalk] Failed to upload media: ${err instanceof Error ? err.message : String(err)}`);
126
684
  if (axios.isAxiosError(err) && err.response) {
127
685
  const status = err.response.status;
128
686
  const statusText = err.response.statusText;