@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.
- package/README.md +155 -12
- package/package.json +2 -2
- package/src/card-service.ts +31 -51
- package/src/channel.ts +317 -94
- package/src/config-schema.ts +9 -1
- package/src/connection-manager.ts +14 -1
- package/src/inbound-handler.ts +221 -177
- package/src/media-utils.ts +517 -3
- package/src/onboarding.ts +47 -3
- package/src/send-service.ts +73 -27
- package/src/session-lock.ts +32 -0
- package/src/types.ts +15 -0
package/src/media-utils.ts
CHANGED
|
@@ -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 {
|
|
10
|
-
import * as
|
|
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: "
|
|
275
|
-
initialValue: resolved.cardTemplateKey ?? "
|
|
278
|
+
placeholder: "content",
|
|
279
|
+
initialValue: resolved.cardTemplateKey ?? "content",
|
|
276
280
|
}),
|
|
277
|
-
).trim() || "
|
|
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
|
|