@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.
- package/LICENSE +21 -0
- package/README.md +808 -40
- package/index.ts +62 -0
- package/package.json +4 -2
- package/src/access-control.ts +18 -0
- package/src/ack-reaction-classifier.ts +62 -0
- package/src/ack-reaction-service.ts +135 -0
- package/src/attachment-text-extractor.ts +147 -0
- package/src/card-callback-service.ts +119 -0
- package/src/card-draft-controller.ts +114 -0
- package/src/card-service.ts +794 -62
- package/src/channel.ts +675 -179
- package/src/config-schema.ts +49 -5
- package/src/config.ts +136 -4
- package/src/connection-manager.ts +356 -36
- package/src/dedup.ts +1 -0
- package/src/docs-service.ts +198 -0
- package/src/draft-stream-loop.ts +119 -0
- package/src/feedback-learning-service.ts +643 -0
- package/src/feedback-learning-store.ts +543 -0
- package/src/group-members-store.ts +48 -14
- package/src/inbound-handler.ts +1191 -206
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +566 -8
- package/src/message-utils.ts +301 -39
- package/src/onboarding.ts +85 -3
- package/src/peer-id-registry.ts +102 -0
- package/src/persistence-store.ts +131 -0
- package/src/quote-journal.ts +242 -0
- package/src/quoted-file-service.ts +385 -0
- package/src/quoted-msg-cache.ts +226 -0
- package/src/send-service.ts +239 -59
- package/src/session-command-service.ts +147 -0
- package/src/session-lock.ts +34 -0
- package/src/session-peer-store.ts +77 -0
- package/src/session-routing.ts +33 -0
- package/src/types.ts +165 -22
- package/src/utils.ts +231 -12
package/src/media-utils.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
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:
|
|
675
|
+
} catch (err: unknown) {
|
|
119
676
|
// Handle file system errors (e.g., file not found, permission denied)
|
|
120
|
-
|
|
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 (
|
|
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;
|