@soimy/dingtalk 3.1.4 → 3.2.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,308 @@
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
18
  import { formatDingTalkErrorPayloadLog } from "./utils";
15
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: any) {
187
+ log?.error?.(`[DingTalk] Failed to get MP3 duration: ${err.message}`);
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
+
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
+
250
+ function normalizeAllowlistEntry(entry: string): string {
251
+ return entry.trim().toLowerCase();
252
+ }
253
+
254
+ function normalizeHostname(hostname: string): string {
255
+ return hostname.replace(/^\[/, "").replace(/\]$/, "").trim().toLowerCase();
256
+ }
257
+
258
+ function isIpInCidr(ip: string, cidr: string): boolean {
259
+ const [network, rawMask] = cidr.split("/");
260
+ const mask = Number.parseInt(rawMask || "", 10);
261
+ const normalizedIp = normalizeHostname(ip);
262
+ const normalizedNetwork = normalizeHostname(network || "");
263
+ if (!normalizedNetwork || Number.isNaN(mask)) {
264
+ return false;
265
+ }
266
+
267
+ const ipVersion = isIP(normalizedIp);
268
+ const networkVersion = isIP(normalizedNetwork);
269
+ if (ipVersion === 0 || ipVersion !== networkVersion) {
270
+ return false;
271
+ }
272
+
273
+ const blockList = new BlockList();
274
+ if (ipVersion === 4) {
275
+ blockList.addSubnet(normalizedNetwork, mask, "ipv4");
276
+ return blockList.check(normalizedIp, "ipv4");
277
+ }
278
+
279
+ blockList.addSubnet(normalizedNetwork, mask, "ipv6");
280
+ return blockList.check(normalizedIp, "ipv6");
281
+ }
282
+
283
+ function matchesAllowlistHost(hostname: string, port: string, entry: string): boolean {
284
+ const normalizedHost = normalizeHostname(hostname);
285
+ const normalizedEntry = normalizeAllowlistEntry(entry);
286
+ if (!normalizedEntry) {
287
+ return false;
288
+ }
289
+
290
+ if (normalizedEntry.includes("/")) {
291
+ return isIpInCidr(normalizedHost, normalizedEntry);
292
+ }
293
+
294
+ if (normalizedEntry.startsWith("*.")) {
295
+ const suffix = normalizedEntry.slice(1);
296
+ return normalizedHost.endsWith(suffix);
297
+ }
298
+
299
+ if (normalizedEntry.includes(":")) {
300
+ return `${normalizedHost}:${port}` === normalizedEntry;
301
+ }
302
+
303
+ return normalizedHost === normalizedEntry;
304
+ }
305
+
306
+ function isAllowedByMediaUrlAllowlist(url: URL, mediaUrlAllowlist: string[]): boolean {
307
+ const port = url.port || (url.protocol === "https:" ? "443" : "80");
308
+ return mediaUrlAllowlist.some((entry) => matchesAllowlistHost(url.hostname, port, entry));
309
+ }
17
310
 
18
311
  /**
19
312
  * Detect media type from file extension
@@ -40,6 +333,227 @@ export function detectMediaTypeFromExtension(filePath: string): DingTalkMediaTyp
40
333
  return "file";
41
334
  }
42
335
 
336
+ function normalizeOutboundMediaType(value?: string | null): DingTalkOutboundMediaType | undefined {
337
+ if (!value) {
338
+ return undefined;
339
+ }
340
+
341
+ const normalized = value.trim().toLowerCase();
342
+ if (normalized === "image" || normalized === "voice" || normalized === "video" || normalized === "file") {
343
+ return normalized;
344
+ }
345
+
346
+ return undefined;
347
+ }
348
+
349
+ export function resolveOutboundMediaType(params: {
350
+ mediaType?: string | null;
351
+ mediaPath: string;
352
+ asVoice: boolean;
353
+ }): DingTalkOutboundMediaType {
354
+ const explicitType = normalizeOutboundMediaType(params.mediaType);
355
+ const detectedType = detectMediaTypeFromExtension(params.mediaPath);
356
+
357
+ if (params.asVoice) {
358
+ if (explicitType && explicitType !== "voice") {
359
+ throw new Error('asVoice requires mediaType="voice" when mediaType is provided.');
360
+ }
361
+
362
+ if (detectedType !== "voice") {
363
+ throw new Error("asVoice requires an audio file (mp3, amr, wav).");
364
+ }
365
+
366
+ return "voice";
367
+ }
368
+
369
+ if (explicitType) {
370
+ return explicitType;
371
+ }
372
+
373
+ return detectedType;
374
+ }
375
+
376
+ function isRemoteMediaUrl(input: string): boolean {
377
+ try {
378
+ const parsed = new URL(input);
379
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
380
+ } catch {
381
+ return false;
382
+ }
383
+ }
384
+
385
+ function isPrivateOrLocalHost(hostname: string): boolean {
386
+ const normalized = normalizeHostname(hostname);
387
+ if (!normalized) {
388
+ return true;
389
+ }
390
+
391
+ if (normalized === "localhost" || normalized.endsWith(".localhost")) {
392
+ return true;
393
+ }
394
+
395
+ const ipVersion = isIP(normalized);
396
+ if (ipVersion === 4) {
397
+ const parts = normalized.split(".").map((part) => Number.parseInt(part, 10));
398
+ if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
399
+ return true;
400
+ }
401
+
402
+ const [a, b] = parts;
403
+ return (
404
+ a === 0 ||
405
+ a === 10 ||
406
+ a === 127 ||
407
+ (a === 169 && b === 254) ||
408
+ (a === 172 && b >= 16 && b <= 31) ||
409
+ (a === 192 && b === 168)
410
+ );
411
+ }
412
+
413
+ if (ipVersion === 6) {
414
+ if (normalized === "::1") {
415
+ return true;
416
+ }
417
+ return normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80");
418
+ }
419
+
420
+ return false;
421
+ }
422
+
423
+ async function resolveHostname(hostname: string): Promise<Array<{ address: string; family: number }>> {
424
+ const records = await dnsLookup(hostname, { all: true, verbatim: true });
425
+ return Array.isArray(records) ? records : [records];
426
+ }
427
+
428
+ function detectExtensionFromContentType(contentType?: string): string {
429
+ const normalized = contentType?.split(";")[0]?.trim().toLowerCase();
430
+ switch (normalized) {
431
+ case "image/jpeg":
432
+ return ".jpg";
433
+ case "image/png":
434
+ return ".png";
435
+ case "image/gif":
436
+ return ".gif";
437
+ case "image/bmp":
438
+ return ".bmp";
439
+ case "audio/amr":
440
+ return ".amr";
441
+ case "audio/mpeg":
442
+ return ".mp3";
443
+ case "audio/wav":
444
+ case "audio/x-wav":
445
+ return ".wav";
446
+ case "video/mp4":
447
+ return ".mp4";
448
+ case "application/pdf":
449
+ return ".pdf";
450
+ default:
451
+ return "";
452
+ }
453
+ }
454
+
455
+ export async function prepareMediaInput(
456
+ input: string,
457
+ log?: Logger,
458
+ mediaUrlAllowlist?: string[],
459
+ ): Promise<PreparedMediaInput> {
460
+ const trimmed = input.trim();
461
+ if (!isRemoteMediaUrl(trimmed)) {
462
+ return { path: trimmed };
463
+ }
464
+
465
+ const parsedUrl = new URL(trimmed);
466
+ const isPrivateHost = isPrivateOrLocalHost(parsedUrl.hostname);
467
+ const allowlist = mediaUrlAllowlist?.filter((entry) => entry.trim().length > 0) || [];
468
+ const allowlistConfigured = allowlist.length > 0;
469
+ const inAllowlist = allowlistConfigured ? isAllowedByMediaUrlAllowlist(parsedUrl, allowlist) : false;
470
+
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
+ }
477
+
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
+ }
484
+
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);
489
+ if (resolvedRecords.length === 0) {
490
+ throw new RemoteMediaError(
491
+ `remote media URL host cannot be resolved: ${parsedUrl.hostname}`,
492
+ REMOTE_MEDIA_ERROR_CODES.DNS_UNRESOLVED,
493
+ );
494
+ }
495
+
496
+ if (!inAllowlist && resolvedRecords.some((record) => isPrivateOrLocalHost(record.address))) {
497
+ throw new RemoteMediaError(
498
+ `remote media URL host resolves to private or local network address: ${parsedUrl.hostname}`,
499
+ REMOTE_MEDIA_ERROR_CODES.DNS_PRIVATE,
500
+ );
501
+ }
502
+
503
+ pinnedResolved = resolvedRecords[0];
504
+ }
505
+
506
+ const lookup = pinnedResolved
507
+ ? async (hostname: string): Promise<{ address: string; family: number }> => {
508
+ if (hostname === parsedUrl.hostname) {
509
+ return pinnedResolved;
510
+ }
511
+
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
+ const contentType =
528
+ typeof response.headers?.["content-type"] === "string"
529
+ ? response.headers["content-type"]
530
+ : undefined;
531
+ const urlPath = parsedUrl.pathname;
532
+ const ext = path.extname(urlPath) || detectExtensionFromContentType(contentType) || ".bin";
533
+ const tempPath = path.join(os.tmpdir(), `dingtalk_${randomUUID()}${ext}`);
534
+ const buffer = Buffer.isBuffer(response.data)
535
+ ? response.data
536
+ : Buffer.from(response.data as ArrayBuffer);
537
+
538
+ await fsPromises.writeFile(tempPath, buffer);
539
+ log?.debug?.(`[DingTalk] Downloaded remote media to temp file: ${tempPath}`);
540
+
541
+ return {
542
+ path: tempPath,
543
+ cleanup: async () => {
544
+ try {
545
+ await fsPromises.unlink(tempPath);
546
+ } catch (err: unknown) {
547
+ const code = typeof err === "object" && err !== null && "code" in err ? err.code : undefined;
548
+ if (code !== "ENOENT") {
549
+ const message = err instanceof Error ? err.message : String(err);
550
+ log?.warn?.(`[DingTalk] Failed to remove temp media ${tempPath}: ${message}`);
551
+ }
552
+ }
553
+ },
554
+ };
555
+ }
556
+
43
557
  /**
44
558
  * File size limits for DingTalk media types (in bytes)
45
559
  */
package/src/onboarding.ts CHANGED
@@ -109,6 +109,10 @@ function applyAccountConfig(params: {
109
109
  ...(typeof input.maxReconnectCycles === "number"
110
110
  ? { maxReconnectCycles: input.maxReconnectCycles }
111
111
  : {}),
112
+ ...(typeof input.useConnectionManager === "boolean"
113
+ ? { useConnectionManager: input.useConnectionManager }
114
+ : {}),
115
+ ...(typeof input.mediaMaxMb === "number" ? { mediaMaxMb: input.mediaMaxMb } : {}),
112
116
  };
113
117
 
114
118
  if (useDefault) {
@@ -271,10 +275,10 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
271
275
  String(
272
276
  await prompter.text({
273
277
  message: "Card Template Key (content field name)",
274
- placeholder: "msgContent",
275
- initialValue: resolved.cardTemplateKey ?? "msgContent",
278
+ placeholder: "content",
279
+ initialValue: resolved.cardTemplateKey ?? "content",
276
280
  }),
277
- ).trim() || "msgContent";
281
+ ).trim() || "content";
278
282
 
279
283
  messageType = "card";
280
284
  }
@@ -298,6 +302,14 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
298
302
  allowFrom = parsed.length > 0 ? parsed : undefined;
299
303
  }
300
304
 
305
+ const mediaUrlAllowlistEntry = await prompter.text({
306
+ message: "Media URL allowlist (comma-separated host/IP/CIDR, optional)",
307
+ placeholder: "cdn.example.com, 192.168.1.23, 10.0.0.0/8",
308
+ initialValue: (resolved.mediaUrlAllowlist || []).join(", ") || undefined,
309
+ });
310
+ const mediaUrlAllowlistParsed = parseList(String(mediaUrlAllowlistEntry ?? ""));
311
+ const mediaUrlAllowlist = mediaUrlAllowlistParsed.length > 0 ? mediaUrlAllowlistParsed : undefined;
312
+
301
313
  const groupPolicyValue = await prompter.select({
302
314
  message: "Group message policy",
303
315
  options: [
@@ -336,6 +348,36 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
336
348
  maxReconnectCycles = Number.isInteger(parsedCycles) && parsedCycles > 0 ? parsedCycles : 10;
337
349
  }
338
350
 
351
+ let mediaMaxMb: number | undefined;
352
+ const wantsMediaMax = await prompter.confirm({
353
+ message: "Configure inbound media max size in MB? (optional)",
354
+ initialValue: typeof resolved.mediaMaxMb === "number",
355
+ });
356
+ if (wantsMediaMax) {
357
+ const parsedMediaMax = Number(
358
+ String(
359
+ await prompter.text({
360
+ message: "Max inbound media size (MB)",
361
+ placeholder: "20",
362
+ initialValue:
363
+ typeof resolved.mediaMaxMb === "number" ? String(resolved.mediaMaxMb) : "20",
364
+ validate: (value) => {
365
+ const raw = String(value ?? "").trim();
366
+ const num = Number(raw);
367
+ if (!raw) {
368
+ return "Required";
369
+ }
370
+ if (!Number.isInteger(num) || num < 1) {
371
+ return "Must be an integer >= 1";
372
+ }
373
+ return undefined;
374
+ },
375
+ }),
376
+ ).trim(),
377
+ );
378
+ mediaMaxMb = Number.isInteger(parsedMediaMax) && parsedMediaMax > 0 ? parsedMediaMax : 20;
379
+ }
380
+
339
381
  const next = applyAccountConfig({
340
382
  cfg,
341
383
  accountId,
@@ -348,10 +390,12 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
348
390
  dmPolicy: dmPolicyValue as "open" | "allowlist",
349
391
  groupPolicy: groupPolicyValue as "open" | "allowlist",
350
392
  allowFrom,
393
+ mediaUrlAllowlist,
351
394
  messageType,
352
395
  cardTemplateId,
353
396
  cardTemplateKey,
354
397
  maxReconnectCycles,
398
+ mediaMaxMb,
355
399
  },
356
400
  });
357
401