@ascegu/teamily 1.0.22 → 1.0.23
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/package.json +1 -1
- package/src/channel.ts +108 -27
- package/src/monitor.ts +98 -59
- package/src/upload.ts +46 -0
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import {
|
|
2
3
|
applyAccountNameToChannelSection,
|
|
3
4
|
buildChannelConfigSchema,
|
|
@@ -14,6 +15,9 @@ import {
|
|
|
14
15
|
type ChannelOutboundContext,
|
|
15
16
|
type ChannelStatusIssue,
|
|
16
17
|
type HistoryEntry,
|
|
18
|
+
loadOutboundMediaFromUrl,
|
|
19
|
+
resolveOutboundMediaUrls,
|
|
20
|
+
sendMediaWithLeadingCaption,
|
|
17
21
|
} from "openclaw/plugin-sdk";
|
|
18
22
|
import {
|
|
19
23
|
buildAccountScopedDmSecurityPolicy,
|
|
@@ -26,12 +30,13 @@ import {
|
|
|
26
30
|
} from "./accounts.js";
|
|
27
31
|
import { TeamilyConfigSchema } from "./config-schema.js";
|
|
28
32
|
import type { CoreConfig } from "./config-schema.js";
|
|
29
|
-
import { getTeamilyMonitor, startTeamilyMonitoring, stopTeamilyMonitoring } from "./monitor.js";
|
|
33
|
+
import { getTeamilyMonitor, startTeamilyMonitoring, stopTeamilyMonitoring, type TeamilyMonitor } from "./monitor.js";
|
|
30
34
|
import { normalizeTeamilyTarget, normalizeTeamilyAllowEntry } from "./normalize.js";
|
|
31
35
|
import { probeTeamily } from "./probe.js";
|
|
32
36
|
import { getTeamilyRuntime } from "./runtime.js";
|
|
33
37
|
import type { ResolvedTeamilyAccount } from "./types.js";
|
|
34
38
|
import { isGroupSession } from "./types.js";
|
|
39
|
+
import { detectMediaCategory, guessContentType, isLocalMediaPath } from "./upload.js";
|
|
35
40
|
|
|
36
41
|
const meta = {
|
|
37
42
|
id: "teamily",
|
|
@@ -178,27 +183,22 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
|
|
|
178
183
|
const monitor = requireMonitor(accountId);
|
|
179
184
|
const target = normalizeTeamilyTarget(to);
|
|
180
185
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
urlLower.endsWith(".doc") ||
|
|
194
|
-
urlLower.endsWith(".docx") ||
|
|
195
|
-
urlLower.endsWith(".zip")
|
|
196
|
-
) {
|
|
197
|
-
messageId = await monitor.sendFile(target, mediaUrl);
|
|
198
|
-
} else {
|
|
199
|
-
messageId = await monitor.sendImage(target, mediaUrl);
|
|
186
|
+
// Local file path: load buffer and send via SDK's *ByFile methods
|
|
187
|
+
// (SDK handles the upload internally, like Telegram's InputFile).
|
|
188
|
+
if (isLocalMediaPath(mediaUrl)) {
|
|
189
|
+
const media = await loadOutboundMediaFromUrl(mediaUrl, {
|
|
190
|
+
mediaLocalRoots: ctx.mediaLocalRoots,
|
|
191
|
+
});
|
|
192
|
+
const fileName = media.fileName || path.basename(mediaUrl) || "media";
|
|
193
|
+
const contentType = media.contentType || guessContentType(mediaUrl);
|
|
194
|
+
const messageId = await sendMediaBuffer(
|
|
195
|
+
monitor, target, media.buffer, fileName, contentType, mediaUrl,
|
|
196
|
+
);
|
|
197
|
+
return { channel: "teamily" as const, messageId };
|
|
200
198
|
}
|
|
201
199
|
|
|
200
|
+
// Remote URL: use existing *ByURL methods.
|
|
201
|
+
const messageId = await sendMediaByUrl(monitor, target, mediaUrl);
|
|
202
202
|
return { channel: "teamily" as const, messageId };
|
|
203
203
|
},
|
|
204
204
|
},
|
|
@@ -368,16 +368,37 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
|
|
|
368
368
|
ctx: msgCtx,
|
|
369
369
|
cfg: currentCfg,
|
|
370
370
|
dispatcherOptions: {
|
|
371
|
-
deliver: async (payload
|
|
372
|
-
const
|
|
373
|
-
if (
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
371
|
+
deliver: async (payload) => {
|
|
372
|
+
const monitor = getTeamilyMonitor(accountId);
|
|
373
|
+
if (!monitor)
|
|
374
|
+
throw new Error(`Teamily monitor not running for account ${accountId}`);
|
|
375
|
+
const target = normalizeTeamilyTarget(replyTarget);
|
|
376
|
+
const replyText = (payload as Record<string, unknown>)?.text as string | undefined
|
|
377
|
+
?? (payload as Record<string, unknown>)?.body as string | undefined
|
|
378
|
+
?? "";
|
|
379
|
+
|
|
380
|
+
// Send media attachments (first with caption, rest without).
|
|
381
|
+
const mediaUrls = resolveOutboundMediaUrls(payload as Record<string, unknown>);
|
|
382
|
+
const sentMedia = await sendMediaWithLeadingCaption({
|
|
383
|
+
mediaUrls,
|
|
384
|
+
caption: replyText,
|
|
385
|
+
send: async ({ mediaUrl: url, caption }) => {
|
|
386
|
+
await loadAndSendMedia(monitor, target, url);
|
|
387
|
+
// OpenIM doesn't support inline captions on media; send separately.
|
|
388
|
+
if (caption) {
|
|
389
|
+
await monitor.sendText(target, caption);
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
onError: (error) => {
|
|
393
|
+
log?.warn?.(`[${accountId}] Media send failed: ${String(error)}`);
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// If no media was sent, send text only.
|
|
398
|
+
if (!sentMedia && replyText) {
|
|
377
399
|
log?.info?.(
|
|
378
400
|
`[${accountId}] Sending reply to: ${replyTarget} (isGroup=${isGroup})`,
|
|
379
401
|
);
|
|
380
|
-
const target = normalizeTeamilyTarget(replyTarget);
|
|
381
402
|
await monitor.sendText(target, replyText);
|
|
382
403
|
}
|
|
383
404
|
},
|
|
@@ -457,6 +478,66 @@ function applyTeamilyAccountConfig(params: {
|
|
|
457
478
|
} as CoreConfig;
|
|
458
479
|
}
|
|
459
480
|
|
|
481
|
+
/** Send a local media buffer using SDK's *ByFile methods (SDK handles upload). */
|
|
482
|
+
async function sendMediaBuffer(
|
|
483
|
+
monitor: TeamilyMonitor,
|
|
484
|
+
target: ReturnType<typeof normalizeTeamilyTarget>,
|
|
485
|
+
buffer: Buffer,
|
|
486
|
+
fileName: string,
|
|
487
|
+
contentType: string,
|
|
488
|
+
originalPath: string,
|
|
489
|
+
): Promise<string> {
|
|
490
|
+
const category = detectMediaCategory(originalPath);
|
|
491
|
+
switch (category) {
|
|
492
|
+
case "video":
|
|
493
|
+
return monitor.sendVideoBuffer(target, buffer, fileName, contentType);
|
|
494
|
+
case "audio":
|
|
495
|
+
return monitor.sendAudioBuffer(target, buffer, fileName, contentType);
|
|
496
|
+
case "file":
|
|
497
|
+
return monitor.sendFileBuffer(target, buffer, fileName, contentType);
|
|
498
|
+
default:
|
|
499
|
+
return monitor.sendImageBuffer(target, buffer, fileName, contentType);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Send media by remote URL using existing *ByURL methods. */
|
|
504
|
+
async function sendMediaByUrl(
|
|
505
|
+
monitor: TeamilyMonitor,
|
|
506
|
+
target: ReturnType<typeof normalizeTeamilyTarget>,
|
|
507
|
+
url: string,
|
|
508
|
+
): Promise<string> {
|
|
509
|
+
const category = detectMediaCategory(url);
|
|
510
|
+
switch (category) {
|
|
511
|
+
case "video":
|
|
512
|
+
return monitor.sendVideo(target, url);
|
|
513
|
+
case "audio":
|
|
514
|
+
return monitor.sendAudio(target, url);
|
|
515
|
+
case "file":
|
|
516
|
+
return monitor.sendFile(target, url);
|
|
517
|
+
default:
|
|
518
|
+
return monitor.sendImage(target, url);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Load a local media file and send it via SDK buffer upload.
|
|
524
|
+
* For remote URLs, send directly via *ByURL methods.
|
|
525
|
+
*/
|
|
526
|
+
async function loadAndSendMedia(
|
|
527
|
+
monitor: TeamilyMonitor,
|
|
528
|
+
target: ReturnType<typeof normalizeTeamilyTarget>,
|
|
529
|
+
mediaUrl: string,
|
|
530
|
+
): Promise<void> {
|
|
531
|
+
if (isLocalMediaPath(mediaUrl)) {
|
|
532
|
+
const media = await loadOutboundMediaFromUrl(mediaUrl);
|
|
533
|
+
const fileName = media.fileName || path.basename(mediaUrl) || "media";
|
|
534
|
+
const contentType = media.contentType || guessContentType(mediaUrl);
|
|
535
|
+
await sendMediaBuffer(monitor, target, media.buffer, fileName, contentType, mediaUrl);
|
|
536
|
+
} else {
|
|
537
|
+
await sendMediaByUrl(monitor, target, mediaUrl);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
460
541
|
function requireMonitor(accountId?: string | null) {
|
|
461
542
|
const id = accountId || "default";
|
|
462
543
|
const monitor = getTeamilyMonitor(id);
|
package/src/monitor.ts
CHANGED
|
@@ -36,11 +36,6 @@ async function loadSDK() {
|
|
|
36
36
|
* reconnection to the official OpenIM SDK. Also exposes send methods
|
|
37
37
|
* so outbound replies flow through the same WebSocket connection.
|
|
38
38
|
*/
|
|
39
|
-
/** Returns true when the string looks like an HTTP(S) URL rather than a local file path. */
|
|
40
|
-
function isHttpUrl(s: string): boolean {
|
|
41
|
-
return s.startsWith("http://") || s.startsWith("https://");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
39
|
export class TeamilyMonitor {
|
|
45
40
|
private account: ResolvedTeamilyAccount;
|
|
46
41
|
private onMessage: TeamilyMessageHandler;
|
|
@@ -131,29 +126,9 @@ export class TeamilyMonitor {
|
|
|
131
126
|
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
132
127
|
}
|
|
133
128
|
|
|
134
|
-
/**
|
|
135
|
-
* Upload a local file to the OpenIM server and return its download URL.
|
|
136
|
-
* Uses the SDK's built-in uploadFile which handles multipart upload to
|
|
137
|
-
* the server's object storage. Requires Node.js 20+ for global File.
|
|
138
|
-
*/
|
|
139
|
-
async uploadLocalFile(localPath: string, contentType?: string): Promise<string> {
|
|
140
|
-
const sdk = this.requireSdk();
|
|
141
|
-
const { readFileSync } = await import("node:fs");
|
|
142
|
-
const { basename, extname } = await import("node:path");
|
|
143
|
-
const buffer = readFileSync(localPath);
|
|
144
|
-
const fileName = basename(localPath);
|
|
145
|
-
const mime = contentType || guessMimeType(extname(localPath));
|
|
146
|
-
const file = new File([buffer], fileName, { type: mime });
|
|
147
|
-
const result = await sdk.uploadFile({ file, name: fileName, contentType: mime });
|
|
148
|
-
const url = result.data?.url;
|
|
149
|
-
if (!url) throw new Error(`Upload failed for ${localPath}: no URL returned`);
|
|
150
|
-
return url;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
129
|
/** Send an image message through the SDK WebSocket connection. */
|
|
154
|
-
async sendImage(target: TeamilyMessageTarget,
|
|
130
|
+
async sendImage(target: TeamilyMessageTarget, url: string): Promise<string> {
|
|
155
131
|
const sdk = this.requireSdk();
|
|
156
|
-
const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
|
|
157
132
|
const picInfo = { uuid: "", type: "", width: 0, height: 0, size: 0, url };
|
|
158
133
|
const created = await sdk.createImageMessageByURL({
|
|
159
134
|
sourcePicture: picInfo,
|
|
@@ -170,9 +145,8 @@ export class TeamilyMonitor {
|
|
|
170
145
|
}
|
|
171
146
|
|
|
172
147
|
/** Send a video message through the SDK WebSocket connection. */
|
|
173
|
-
async sendVideo(target: TeamilyMessageTarget,
|
|
148
|
+
async sendVideo(target: TeamilyMessageTarget, url: string): Promise<string> {
|
|
174
149
|
const sdk = this.requireSdk();
|
|
175
|
-
const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
|
|
176
150
|
const created = await sdk.createVideoMessageByURL({
|
|
177
151
|
videoPath: "",
|
|
178
152
|
duration: 0,
|
|
@@ -196,9 +170,8 @@ export class TeamilyMonitor {
|
|
|
196
170
|
}
|
|
197
171
|
|
|
198
172
|
/** Send a sound/audio message through the SDK WebSocket connection. */
|
|
199
|
-
async sendAudio(target: TeamilyMessageTarget,
|
|
173
|
+
async sendAudio(target: TeamilyMessageTarget, url: string): Promise<string> {
|
|
200
174
|
const sdk = this.requireSdk();
|
|
201
|
-
const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
|
|
202
175
|
const created = await sdk.createSoundMessageByURL({
|
|
203
176
|
uuid: "",
|
|
204
177
|
soundPath: "",
|
|
@@ -215,12 +188,11 @@ export class TeamilyMonitor {
|
|
|
215
188
|
}
|
|
216
189
|
|
|
217
190
|
/** Send a file message through the SDK WebSocket connection. */
|
|
218
|
-
async sendFile(target: TeamilyMessageTarget,
|
|
191
|
+
async sendFile(target: TeamilyMessageTarget, url: string, fileName?: string): Promise<string> {
|
|
219
192
|
const sdk = this.requireSdk();
|
|
220
|
-
const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
|
|
221
193
|
const created = await sdk.createFileMessageByURL({
|
|
222
194
|
filePath: "",
|
|
223
|
-
fileName: fileName ||
|
|
195
|
+
fileName: fileName || url.split("/").pop() || "file",
|
|
224
196
|
uuid: "",
|
|
225
197
|
sourceUrl: url,
|
|
226
198
|
fileSize: 0,
|
|
@@ -233,6 +205,99 @@ export class TeamilyMonitor {
|
|
|
233
205
|
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
234
206
|
}
|
|
235
207
|
|
|
208
|
+
// ---- Buffer-based send methods (local file → SDK upload) ----
|
|
209
|
+
// Like Telegram's InputFile(buffer), pass a buffer and let the SDK upload.
|
|
210
|
+
|
|
211
|
+
/** Send an image from a local buffer. SDK handles the upload to object storage. */
|
|
212
|
+
async sendImageBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
|
|
213
|
+
const sdk = this.requireSdk();
|
|
214
|
+
const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
|
|
215
|
+
const picInfo = { uuid: "", type: contentType, width: 0, height: 0, size: buffer.length, url: "" };
|
|
216
|
+
const created = await sdk.createImageMessageByFile({
|
|
217
|
+
sourcePicture: picInfo,
|
|
218
|
+
bigPicture: picInfo,
|
|
219
|
+
snapshotPicture: picInfo,
|
|
220
|
+
sourcePath: "",
|
|
221
|
+
file,
|
|
222
|
+
});
|
|
223
|
+
const result = await sdk.sendMessage({
|
|
224
|
+
recvID: target.type === "user" ? target.id : "",
|
|
225
|
+
groupID: target.type === "group" ? target.id : "",
|
|
226
|
+
message: created.data,
|
|
227
|
+
});
|
|
228
|
+
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Send a video from a local buffer. SDK handles the upload to object storage. */
|
|
232
|
+
async sendVideoBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
|
|
233
|
+
const sdk = this.requireSdk();
|
|
234
|
+
const videoFile = new File([new Uint8Array(buffer)], fileName, { type: contentType });
|
|
235
|
+
// snapshotFile is required by the SDK type but we have no thumbnail; use an empty file.
|
|
236
|
+
const snapshotFile = new File([], "snapshot.jpg", { type: "image/jpeg" });
|
|
237
|
+
const created = await sdk.createVideoMessageByFile({
|
|
238
|
+
videoPath: "",
|
|
239
|
+
duration: 0,
|
|
240
|
+
videoType: contentType,
|
|
241
|
+
snapshotPath: "",
|
|
242
|
+
videoUUID: "",
|
|
243
|
+
videoUrl: "",
|
|
244
|
+
videoSize: buffer.length,
|
|
245
|
+
snapshotUUID: "",
|
|
246
|
+
snapshotSize: 0,
|
|
247
|
+
snapshotUrl: "",
|
|
248
|
+
snapshotWidth: 0,
|
|
249
|
+
snapshotHeight: 0,
|
|
250
|
+
videoFile,
|
|
251
|
+
snapshotFile,
|
|
252
|
+
});
|
|
253
|
+
const result = await sdk.sendMessage({
|
|
254
|
+
recvID: target.type === "user" ? target.id : "",
|
|
255
|
+
groupID: target.type === "group" ? target.id : "",
|
|
256
|
+
message: created.data,
|
|
257
|
+
});
|
|
258
|
+
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Send an audio/sound from a local buffer. SDK handles the upload to object storage. */
|
|
262
|
+
async sendAudioBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
|
|
263
|
+
const sdk = this.requireSdk();
|
|
264
|
+
const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
|
|
265
|
+
const created = await sdk.createSoundMessageByFile({
|
|
266
|
+
uuid: "",
|
|
267
|
+
soundPath: "",
|
|
268
|
+
sourceUrl: "",
|
|
269
|
+
dataSize: buffer.length,
|
|
270
|
+
duration: 0,
|
|
271
|
+
file,
|
|
272
|
+
});
|
|
273
|
+
const result = await sdk.sendMessage({
|
|
274
|
+
recvID: target.type === "user" ? target.id : "",
|
|
275
|
+
groupID: target.type === "group" ? target.id : "",
|
|
276
|
+
message: created.data,
|
|
277
|
+
});
|
|
278
|
+
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Send a document/file from a local buffer. SDK handles the upload to object storage. */
|
|
282
|
+
async sendFileBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
|
|
283
|
+
const sdk = this.requireSdk();
|
|
284
|
+
const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
|
|
285
|
+
const created = await sdk.createFileMessageByFile({
|
|
286
|
+
filePath: "",
|
|
287
|
+
fileName,
|
|
288
|
+
uuid: "",
|
|
289
|
+
sourceUrl: "",
|
|
290
|
+
fileSize: buffer.length,
|
|
291
|
+
file,
|
|
292
|
+
});
|
|
293
|
+
const result = await sdk.sendMessage({
|
|
294
|
+
recvID: target.type === "user" ? target.id : "",
|
|
295
|
+
groupID: target.type === "group" ? target.id : "",
|
|
296
|
+
message: created.data,
|
|
297
|
+
});
|
|
298
|
+
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
299
|
+
}
|
|
300
|
+
|
|
236
301
|
private requireSdk(): SdkInstance {
|
|
237
302
|
if (!this.sdk) {
|
|
238
303
|
throw new Error("Teamily SDK not connected");
|
|
@@ -246,32 +311,6 @@ export class TeamilyMonitor {
|
|
|
246
311
|
}
|
|
247
312
|
}
|
|
248
313
|
|
|
249
|
-
// ---- MIME type helper ----
|
|
250
|
-
|
|
251
|
-
const MIME_MAP: Record<string, string> = {
|
|
252
|
-
".jpg": "image/jpeg",
|
|
253
|
-
".jpeg": "image/jpeg",
|
|
254
|
-
".png": "image/png",
|
|
255
|
-
".gif": "image/gif",
|
|
256
|
-
".webp": "image/webp",
|
|
257
|
-
".bmp": "image/bmp",
|
|
258
|
-
".mp4": "video/mp4",
|
|
259
|
-
".mov": "video/quicktime",
|
|
260
|
-
".webm": "video/webm",
|
|
261
|
-
".mp3": "audio/mpeg",
|
|
262
|
-
".m4a": "audio/mp4",
|
|
263
|
-
".wav": "audio/wav",
|
|
264
|
-
".ogg": "audio/ogg",
|
|
265
|
-
".pdf": "application/pdf",
|
|
266
|
-
".doc": "application/msword",
|
|
267
|
-
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
268
|
-
".zip": "application/zip",
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
function guessMimeType(ext: string): string {
|
|
272
|
-
return MIME_MAP[ext.toLowerCase()] || "application/octet-stream";
|
|
273
|
-
}
|
|
274
|
-
|
|
275
314
|
// ---- SDK message conversion helpers ----
|
|
276
315
|
|
|
277
316
|
import type { MessageItem } from "@openim/client-sdk";
|
package/src/upload.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/** Returns true if the media URL is a local file path rather than a remote URL. */
|
|
4
|
+
export function isLocalMediaPath(mediaUrl: string): boolean {
|
|
5
|
+
return !(/^https?:\/\//i.test(mediaUrl));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Detect the media category from a file extension. */
|
|
9
|
+
export type MediaCategory = "image" | "video" | "audio" | "file";
|
|
10
|
+
|
|
11
|
+
export function detectMediaCategory(filePath: string): MediaCategory {
|
|
12
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
13
|
+
if ([".mp4", ".mov", ".webm"].includes(ext)) return "video";
|
|
14
|
+
if ([".mp3", ".m4a", ".wav", ".ogg"].includes(ext)) return "audio";
|
|
15
|
+
if ([".pdf", ".doc", ".docx", ".zip"].includes(ext)) return "file";
|
|
16
|
+
return "image";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Guess a reasonable MIME content type from a file extension.
|
|
21
|
+
* Falls back to application/octet-stream for unknown types.
|
|
22
|
+
*/
|
|
23
|
+
export function guessContentType(filePath: string): string {
|
|
24
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
25
|
+
const map: Record<string, string> = {
|
|
26
|
+
".png": "image/png",
|
|
27
|
+
".jpg": "image/jpeg",
|
|
28
|
+
".jpeg": "image/jpeg",
|
|
29
|
+
".gif": "image/gif",
|
|
30
|
+
".webp": "image/webp",
|
|
31
|
+
".bmp": "image/bmp",
|
|
32
|
+
".svg": "image/svg+xml",
|
|
33
|
+
".mp4": "video/mp4",
|
|
34
|
+
".mov": "video/quicktime",
|
|
35
|
+
".webm": "video/webm",
|
|
36
|
+
".mp3": "audio/mpeg",
|
|
37
|
+
".m4a": "audio/mp4",
|
|
38
|
+
".wav": "audio/wav",
|
|
39
|
+
".ogg": "audio/ogg",
|
|
40
|
+
".pdf": "application/pdf",
|
|
41
|
+
".doc": "application/msword",
|
|
42
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
43
|
+
".zip": "application/zip",
|
|
44
|
+
};
|
|
45
|
+
return map[ext] ?? "application/octet-stream";
|
|
46
|
+
}
|