@ardennguyen/zalo-agent-cli 1.0.7

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.
Files changed (52) hide show
  1. package/DISCLAIMER.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -0
  4. package/package.json +64 -0
  5. package/src/cli.test.js +98 -0
  6. package/src/commands/account.js +178 -0
  7. package/src/commands/auto-reply.js +83 -0
  8. package/src/commands/catalog.js +143 -0
  9. package/src/commands/conv.js +264 -0
  10. package/src/commands/friend.js +384 -0
  11. package/src/commands/group.js +606 -0
  12. package/src/commands/label.js +39 -0
  13. package/src/commands/listen.js +293 -0
  14. package/src/commands/login.js +165 -0
  15. package/src/commands/mcp.js +219 -0
  16. package/src/commands/msg.js +608 -0
  17. package/src/commands/oa-init.js +503 -0
  18. package/src/commands/oa-listen.js +215 -0
  19. package/src/commands/oa.js +657 -0
  20. package/src/commands/poll.js +159 -0
  21. package/src/commands/profile.js +274 -0
  22. package/src/commands/quick-msg.js +55 -0
  23. package/src/commands/reminder.js +195 -0
  24. package/src/core/accounts.js +93 -0
  25. package/src/core/credentials.js +63 -0
  26. package/src/core/oa-client.js +407 -0
  27. package/src/core/zalo-client.js +201 -0
  28. package/src/index.js +95 -0
  29. package/src/mcp/mcp-config.js +81 -0
  30. package/src/mcp/mcp-http-transport.js +84 -0
  31. package/src/mcp/mcp-server.js +40 -0
  32. package/src/mcp/mcp-tools.js +353 -0
  33. package/src/mcp/media-downloader.js +191 -0
  34. package/src/mcp/media-downloader.test.js +45 -0
  35. package/src/mcp/message-buffer.js +127 -0
  36. package/src/mcp/message-buffer.test.js +308 -0
  37. package/src/mcp/notifier.js +141 -0
  38. package/src/mcp/notifier.test.js +269 -0
  39. package/src/mcp/thread-filter.js +79 -0
  40. package/src/mcp/thread-filter.test.js +206 -0
  41. package/src/mcp/thread-name-cache.js +162 -0
  42. package/src/mcp/thread-name-cache.test.js +139 -0
  43. package/src/utils/bank-helpers.js +233 -0
  44. package/src/utils/bank-helpers.test.js +66 -0
  45. package/src/utils/extract-message-text.js +21 -0
  46. package/src/utils/output.js +21 -0
  47. package/src/utils/proxy-helpers.js +14 -0
  48. package/src/utils/proxy-helpers.test.js +36 -0
  49. package/src/utils/qr-display.js +91 -0
  50. package/src/utils/qr-display.test.js +97 -0
  51. package/src/utils/qr-http-server.js +190 -0
  52. package/src/utils/update-check.js +56 -0
@@ -0,0 +1,353 @@
1
+ /**
2
+ * MCP tool registrations for Zalo message access and sending.
3
+ * Registers 7 tools: zalo_get_messages, zalo_get_history, zalo_send_message, zalo_list_threads, zalo_search_threads, zalo_mark_read, zalo_view_media.
4
+ */
5
+
6
+ import { z } from "zod";
7
+ import { downloadMedia, openFile } from "./media-downloader.js";
8
+ import { extractMessageText } from "../utils/extract-message-text.js";
9
+
10
+ /** Thread type constants matching zca-js ThreadType enum */
11
+ const THREAD_USER = 0;
12
+
13
+ /**
14
+ * Wrap a result object into MCP tool content format.
15
+ * @param {object} result
16
+ * @returns {{ content: Array }}
17
+ */
18
+ function ok(result) {
19
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
20
+ }
21
+
22
+ /**
23
+ * Wrap an error message into MCP tool error content format.
24
+ * @param {string} message
25
+ * @returns {{ content: Array, isError: true }}
26
+ */
27
+ function err(message) {
28
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
29
+ }
30
+
31
+ /**
32
+ * Register all Zalo MCP tools on the server.
33
+ * @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server
34
+ * @param {object} api - zca-js API instance
35
+ * @param {import("./message-buffer.js").MessageBuffer} buffer
36
+ * @param {import("./thread-filter.js").ThreadFilter} filter
37
+ * @param {object} config - MCP config
38
+ * @param {import("./thread-name-cache.js").ThreadNameCache} [nameCache] - Thread name cache
39
+ */
40
+ export function registerTools(server, api, buffer, filter, config, nameCache) {
41
+ const maxPerPoll = config.limits?.maxMessagesPerPoll ?? 20;
42
+
43
+ // --- zalo_get_messages ---
44
+ server.registerTool(
45
+ "zalo_get_messages",
46
+ {
47
+ title: "Get Zalo Messages",
48
+ description:
49
+ "Get messages from Zalo threads (DMs and groups). Returns buffered messages since last read. Use 'since' cursor from previous response for incremental polling.",
50
+ inputSchema: z.object({
51
+ threadId: z.string().optional().describe("Thread ID to read from. Omit for all watched threads."),
52
+ since: z.number().int().min(0).default(0).describe("Cursor from previous read for incremental polling"),
53
+ limit: z.number().int().min(1).max(100).default(maxPerPoll).describe("Max messages to return"),
54
+ }),
55
+ },
56
+ async ({ threadId, since, limit }) => {
57
+ try {
58
+ const result = buffer.read(threadId, since, limit);
59
+ // Enrich messages with thread name from cache
60
+ if (nameCache) {
61
+ for (const msg of result.messages) {
62
+ const info = nameCache.get(msg.threadId);
63
+ if (info) msg.threadName = info.name;
64
+ }
65
+ }
66
+ return ok(result);
67
+ } catch (e) {
68
+ console.error("[mcp-tools] zalo_get_messages error:", e.message);
69
+ return err(e.message);
70
+ }
71
+ },
72
+ );
73
+
74
+ // --- zalo_send_message ---
75
+ server.registerTool(
76
+ "zalo_send_message",
77
+ {
78
+ title: "Send Zalo Message",
79
+ description: "Send a text message to a Zalo thread (DM or group). threadType: 0=DM(User), 1=Group.",
80
+ inputSchema: z.object({
81
+ threadId: z.string().describe("Thread ID to send message to"),
82
+ text: z.string().min(1).describe("Message text to send"),
83
+ threadType: z
84
+ .number()
85
+ .int()
86
+ .min(0)
87
+ .max(1)
88
+ .default(THREAD_USER)
89
+ .describe("Thread type: 0=DM(User), 1=Group"),
90
+ }),
91
+ },
92
+ async ({ threadId, text, threadType }) => {
93
+ try {
94
+ const result = await api.sendMessage(text, threadId, Number(threadType));
95
+ const messageId = result?.message?.msgId ?? result?.msgId ?? null;
96
+ return ok({ success: true, messageId });
97
+ } catch (e) {
98
+ console.error("[mcp-tools] zalo_send_message error:", e.message);
99
+ return err(e.message);
100
+ }
101
+ },
102
+ );
103
+
104
+ // --- zalo_list_threads ---
105
+ server.registerTool(
106
+ "zalo_list_threads",
107
+ {
108
+ title: "List Zalo Threads",
109
+ description:
110
+ "List all Zalo threads currently buffered with unread message counts. Useful for discovering active conversations.",
111
+ inputSchema: z.object({
112
+ type: z
113
+ .enum(["group", "dm", "all"])
114
+ .default("all")
115
+ .describe("Filter by thread type: 'dm', 'group', or 'all'"),
116
+ }),
117
+ },
118
+ async ({ type }) => {
119
+ try {
120
+ const stats = buffer.getStats(0);
121
+ // Enrich each stat entry with threadType and thread name
122
+ const enriched = stats.map((t) => {
123
+ const threadType = buffer.getThreadType(t.threadId) ?? "unknown";
124
+ const cached = nameCache?.get(t.threadId);
125
+ return {
126
+ ...t,
127
+ threadType,
128
+ name: cached?.name ?? null,
129
+ ...(cached?.memberCount !== undefined && { memberCount: cached.memberCount }),
130
+ };
131
+ });
132
+ const filtered = type === "all" ? enriched : enriched.filter((t) => t.threadType === type);
133
+ return ok({ threads: filtered, total: filtered.length });
134
+ } catch (e) {
135
+ console.error("[mcp-tools] zalo_list_threads error:", e.message);
136
+ return err(e.message);
137
+ }
138
+ },
139
+ );
140
+
141
+ // --- zalo_search_threads ---
142
+ server.registerTool(
143
+ "zalo_search_threads",
144
+ {
145
+ title: "Search Zalo Threads",
146
+ description:
147
+ "Search threads (groups/DMs) by name. Uses fuzzy Vietnamese-aware matching. Useful for finding a thread ID by name.",
148
+ inputSchema: z.object({
149
+ query: z.string().min(1).describe("Search keyword (fuzzy match, case-insensitive, accent-insensitive)"),
150
+ type: z
151
+ .enum(["group", "dm", "all"])
152
+ .default("all")
153
+ .describe("Filter by thread type: 'dm', 'group', or 'all'"),
154
+ limit: z.number().int().min(1).max(50).default(10).describe("Max results to return"),
155
+ }),
156
+ },
157
+ async ({ query, type, limit }) => {
158
+ try {
159
+ if (!nameCache?.ready) {
160
+ return err("Thread name cache not initialized yet. Try again shortly.");
161
+ }
162
+ const results = nameCache.search(query, type, limit);
163
+ return ok({ results, total: results.length });
164
+ } catch (e) {
165
+ console.error("[mcp-tools] zalo_search_threads error:", e.message);
166
+ return err(e.message);
167
+ }
168
+ },
169
+ );
170
+
171
+ // --- zalo_mark_read ---
172
+ server.registerTool(
173
+ "zalo_mark_read",
174
+ {
175
+ title: "Mark Zalo Messages Read",
176
+ description:
177
+ "Discard buffered messages up to and including the given cursor. Use the cursor returned by zalo_get_messages.",
178
+ inputSchema: z.object({
179
+ cursor: z
180
+ .number()
181
+ .int()
182
+ .min(0)
183
+ .describe("Cursor value returned from a previous zalo_get_messages call"),
184
+ }),
185
+ },
186
+ async ({ cursor }) => {
187
+ try {
188
+ const discarded = buffer.markRead(cursor);
189
+ return ok({ success: true, discarded });
190
+ } catch (e) {
191
+ console.error("[mcp-tools] zalo_mark_read error:", e.message);
192
+ return err(e.message);
193
+ }
194
+ },
195
+ );
196
+
197
+ // --- zalo_get_history ---
198
+ server.registerTool(
199
+ "zalo_get_history",
200
+ {
201
+ title: "Get Zalo Message History",
202
+ description:
203
+ "Fetch historical messages from a Zalo DM or group conversation (up to ~2 weeks). " +
204
+ "Unlike zalo_get_messages (which reads from the live buffer), this fetches older messages " +
205
+ "from the Zalo server. Use 'lastMsgId' cursor from previous response for pagination. " +
206
+ "WARNING: Large limits may consume significant memory/bandwidth. Start with a small limit and paginate.",
207
+ inputSchema: z.object({
208
+ threadId: z.string().describe("Thread ID to fetch history from"),
209
+ threadType: z
210
+ .number()
211
+ .int()
212
+ .min(0)
213
+ .max(1)
214
+ .default(THREAD_USER)
215
+ .describe("Thread type: 0=DM(User), 1=Group"),
216
+ limit: z.number().int().min(1).max(200).default(50).describe("Max messages to fetch"),
217
+ lastMsgId: z
218
+ .string()
219
+ .optional()
220
+ .nullable()
221
+ .describe("Cursor: last message ID from previous fetch for pagination"),
222
+ }),
223
+ },
224
+ async ({ threadId, threadType, limit, lastMsgId }) => {
225
+ try {
226
+ const allMessages = [];
227
+ let cursor = lastMsgId || null;
228
+ let done = false;
229
+ const maxPages = Math.ceil(limit / 20);
230
+ let page = 0;
231
+
232
+ while (!done && allMessages.length < limit && page < maxPages) {
233
+ page++;
234
+ const pageMessages = await new Promise((resolve) => {
235
+ const handler = (messages) => {
236
+ clearTimeout(timer);
237
+ api.listener.removeListener("old_messages", handler);
238
+ resolve(messages);
239
+ };
240
+ const timer = setTimeout(() => {
241
+ api.listener.removeListener("old_messages", handler);
242
+ resolve([]);
243
+ }, 10000);
244
+
245
+ api.listener.on("old_messages", handler);
246
+ api.listener.requestOldMessages(threadType, cursor);
247
+ });
248
+
249
+ if (!pageMessages || pageMessages.length === 0) {
250
+ done = true;
251
+ break;
252
+ }
253
+
254
+ for (const msg of pageMessages) {
255
+ if (allMessages.length >= limit) break;
256
+ // API returns messages globally — filter to requested thread
257
+ const msgThread = String(msg.threadId || "");
258
+ const msgSender = String(msg.data?.uidFrom || "");
259
+ const target = String(threadId);
260
+ if (msgThread !== target && msgSender !== target) continue;
261
+ const rawContent = msg.data?.content;
262
+ const isText = typeof rawContent === "string";
263
+ allMessages.push({
264
+ msgId: msg.data?.msgId,
265
+ threadId: msg.threadId,
266
+ senderId: msg.data?.uidFrom || null,
267
+ senderName: msg.data?.dName || null,
268
+ text: isText ? rawContent : extractMessageText(rawContent, msg.data?.msgType),
269
+ timestamp: msg.data?.ts ? Number(msg.data.ts) : null,
270
+ type: isText ? "text" : msg.data?.msgType || "attachment",
271
+ });
272
+ }
273
+
274
+ const lastMsg = pageMessages[pageMessages.length - 1];
275
+ const nextId = lastMsg?.data?.actionId || lastMsg?.data?.msgId;
276
+ if (!nextId || nextId === cursor) done = true;
277
+ cursor = nextId;
278
+ }
279
+
280
+ // Sort oldest first
281
+ allMessages.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
282
+
283
+ // Enrich with thread name
284
+ if (nameCache) {
285
+ const info = nameCache.get(threadId);
286
+ if (info) {
287
+ for (const msg of allMessages) msg.threadName = info.name;
288
+ }
289
+ }
290
+
291
+ return ok({
292
+ threadId,
293
+ threadType: threadType === 0 ? "dm" : "group",
294
+ count: allMessages.length,
295
+ messages: allMessages,
296
+ cursor: cursor,
297
+ hasMore: !done,
298
+ });
299
+ } catch (e) {
300
+ console.error("[mcp-tools] zalo_get_history error:", e.message);
301
+ return err(e.message);
302
+ }
303
+ },
304
+ );
305
+
306
+ // --- zalo_view_media ---
307
+ const mediaConfig = config.media || {};
308
+ server.registerTool(
309
+ "zalo_view_media",
310
+ {
311
+ title: "View Zalo Media",
312
+ description:
313
+ "Open a Zalo media file (image, audio, video) with the system viewer. " +
314
+ "Media is auto-downloaded when received, organized by thread folder with date/sender metadata filenames. " +
315
+ "If not yet downloaded, downloads first then opens.",
316
+ inputSchema: z.object({
317
+ messageId: z.string().describe("Message ID from zalo_get_messages that has a media attachment"),
318
+ threadId: z.string().optional().describe("Thread ID to search in. Omit to search all threads."),
319
+ open: z
320
+ .boolean()
321
+ .default(mediaConfig.autoOpen ?? true)
322
+ .describe("Open media with system viewer"),
323
+ }),
324
+ },
325
+ async ({ messageId, threadId, open }) => {
326
+ try {
327
+ const allMessages = buffer.read(threadId, 0, 9999).messages;
328
+ const message = allMessages.find((m) => m.id === messageId);
329
+ if (!message) return err(`Message ${messageId} not found in buffer`);
330
+ if (!message.attachment?.url) return err(`Message ${messageId} has no media attachment`);
331
+
332
+ // Use local file if already auto-downloaded, otherwise download now
333
+ let localPath = message.attachment.localPath;
334
+ if (!localPath) {
335
+ const threadName = nameCache?.get(message.threadId)?.name || null;
336
+ const result = await downloadMedia(message, {
337
+ downloadDir: mediaConfig.downloadDir || undefined,
338
+ autoOpen: false,
339
+ threadName,
340
+ });
341
+ localPath = result.path;
342
+ }
343
+
344
+ if (open) openFile(localPath);
345
+
346
+ return ok({ success: true, path: localPath, mediaType: message.type });
347
+ } catch (e) {
348
+ console.error("[mcp-tools] zalo_view_media error:", e.message);
349
+ return err(e.message);
350
+ }
351
+ },
352
+ );
353
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Download Zalo media (images, audio, video) to local filesystem, organized by thread name.
3
+ * Folder structure: {downloadDir}/{threadName}/{date}_{time}_{sender}_{msgId}.{ext}
4
+ * Cross-platform: opens media with system viewer (open/xdg-open/start).
5
+ */
6
+
7
+ import { mkdirSync, writeFileSync } from "fs";
8
+ import { join } from "path";
9
+ import { platform } from "os";
10
+ import { execFile } from "child_process";
11
+ import { CONFIG_DIR } from "../core/credentials.js";
12
+
13
+ /** Default download directory when not configured */
14
+ const DEFAULT_DIR = join(CONFIG_DIR, "media");
15
+
16
+ /** Characters unsafe for filesystem paths — stripped from folder/file names */
17
+ const UNSAFE_CHARS = /[/\\:*?"<>|]/g;
18
+
19
+ /** Message types that have downloadable attachments */
20
+ const DOWNLOADABLE_TYPES = new Set(["image", "video", "audio", "voice", "gif", "file"]);
21
+
22
+ /** Extension lookup by content-type prefix */
23
+ const CONTENT_TYPE_MAP = {
24
+ "image/jpeg": "jpg",
25
+ "image/jpg": "jpg",
26
+ "image/png": "png",
27
+ "image/gif": "gif",
28
+ "image/webp": "webp",
29
+ "image/bmp": "bmp",
30
+ "audio/mpeg": "mp3",
31
+ "audio/mp3": "mp3",
32
+ "audio/mp4": "m4a",
33
+ "audio/aac": "aac",
34
+ "audio/ogg": "ogg",
35
+ "audio/wav": "wav",
36
+ "audio/x-wav": "wav",
37
+ "video/mp4": "mp4",
38
+ "video/webm": "webm",
39
+ "video/quicktime": "mov",
40
+ };
41
+
42
+ /** Fallback extension by message type when URL and content-type give no clue */
43
+ const TYPE_DEFAULT_EXT = {
44
+ image: "jpg",
45
+ audio: "mp3",
46
+ voice: "mp3",
47
+ video: "mp4",
48
+ gif: "gif",
49
+ file: "bin",
50
+ };
51
+
52
+ /**
53
+ * Sanitize a string for use as a filesystem name.
54
+ * @param {string} name
55
+ * @returns {string}
56
+ */
57
+ function sanitize(name) {
58
+ return (name || "unknown").replace(UNSAFE_CHARS, "_").trim() || "unknown";
59
+ }
60
+
61
+ /**
62
+ * Guess file extension from URL, content-type, or message type.
63
+ * @param {string} url
64
+ * @param {string} [contentType]
65
+ * @param {string} [msgType] - Normalized message type (image, audio, video, etc.)
66
+ * @returns {string}
67
+ */
68
+ function guessExtension(url, contentType, msgType) {
69
+ // Try content-type first
70
+ if (contentType) {
71
+ const ct = contentType.split(";")[0].trim().toLowerCase();
72
+ if (CONTENT_TYPE_MAP[ct]) return CONTENT_TYPE_MAP[ct];
73
+ }
74
+ // Try URL path extension
75
+ const urlMatch = url.match(/\.(jpeg|jpg|png|gif|webp|bmp|mp3|m4a|aac|ogg|wav|mp4|webm|mov)(\?|$)/i);
76
+ if (urlMatch) return urlMatch[1] === "jpeg" ? "jpg" : urlMatch[1].toLowerCase();
77
+ // Fallback by message type
78
+ return TYPE_DEFAULT_EXT[msgType] || "bin";
79
+ }
80
+
81
+ /**
82
+ * Check if a message type is downloadable media.
83
+ * @param {string} type - Normalized message type
84
+ * @returns {boolean}
85
+ */
86
+ export function isDownloadableMedia(type) {
87
+ return DOWNLOADABLE_TYPES.has(type);
88
+ }
89
+
90
+ /**
91
+ * Build the folder name for a thread.
92
+ * Groups: thread display name. DMs: "DM_{senderName}".
93
+ * @param {object} message - Normalized message
94
+ * @param {string} [threadName] - Resolved thread name from cache
95
+ * @returns {string}
96
+ */
97
+ function buildFolderName(message, threadName) {
98
+ if (message.threadType === "group") {
99
+ return sanitize(threadName || message.threadId);
100
+ }
101
+ return sanitize(`DM_${threadName || message.senderName || message.senderId}`);
102
+ }
103
+
104
+ /**
105
+ * Build a descriptive filename from message metadata.
106
+ * Format: {YYYY-MM-DD}_{HH-mm}_{sender}_{msgId}.{ext}
107
+ * @param {object} message
108
+ * @param {string} ext - File extension
109
+ * @returns {string}
110
+ */
111
+ function buildFileName(message, ext) {
112
+ const d = new Date(message.timestamp);
113
+ const date = d.toISOString().slice(0, 10); // YYYY-MM-DD
114
+ const time = `${String(d.getHours()).padStart(2, "0")}-${String(d.getMinutes()).padStart(2, "0")}`;
115
+ const sender = sanitize(message.senderName || message.senderId);
116
+ const msgId = (message.id || "noId").slice(-8); // last 8 chars for brevity
117
+ return `${date}_${time}_${sender}_${msgId}.${ext}`;
118
+ }
119
+
120
+ /**
121
+ * Open a file with the system's default viewer (cross-platform).
122
+ * Exported for use by MCP tools.
123
+ * @param {string} filePath
124
+ */
125
+ export function openFile(filePath) {
126
+ const isWin = platform() === "win32";
127
+ const cmds = { darwin: "open", win32: "start", linux: "xdg-open" };
128
+ const cmd = cmds[platform()] || "xdg-open";
129
+ // Windows "start" is a cmd.exe builtin — needs shell: true
130
+ execFile(cmd, isWin ? ["", filePath] : [filePath], { shell: isWin }, (err) => {
131
+ if (err) console.error(`[media-dl] Failed to open viewer: ${err.message}`);
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Auto-download media in background when a message arrives.
137
+ * Non-blocking — fires and forgets. Mutates message.attachment.localPath on success.
138
+ * @param {object} message - Normalized message (will be mutated with localPath)
139
+ * @param {object} [options]
140
+ * @param {string} [options.downloadDir] - Base download directory
141
+ * @param {string} [options.threadName] - Thread display name from cache
142
+ */
143
+ export function autoDownloadMedia(message, options = {}) {
144
+ if (!message.attachment?.url) return;
145
+ downloadMedia(message, { ...options, autoOpen: false }).then(
146
+ (result) => {
147
+ message.attachment.localPath = result.path;
148
+ console.error(`[media-dl] Saved: ${result.path}`);
149
+ },
150
+ (err) => {
151
+ console.error(`[media-dl] Auto-download failed: ${err.message}`);
152
+ },
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Download media from URL and save to organized local folder.
158
+ * @param {object} message - Normalized message with attachment.url
159
+ * @param {object} [options]
160
+ * @param {string} [options.downloadDir] - Base download directory
161
+ * @param {boolean} [options.autoOpen] - Open media with system viewer after download
162
+ * @param {string} [options.threadName] - Thread display name from cache
163
+ * @returns {Promise<{ success: boolean, path: string, folder: string, fileName: string, mediaType: string }>}
164
+ */
165
+ export async function downloadMedia(message, options = {}) {
166
+ const url = message.attachment?.url;
167
+ if (!url) throw new Error("Message has no attachment URL");
168
+
169
+ const baseDir = options.downloadDir || DEFAULT_DIR;
170
+ const folder = buildFolderName(message, options.threadName);
171
+ const folderPath = join(baseDir, folder);
172
+ mkdirSync(folderPath, { recursive: true });
173
+
174
+ const response = await fetch(url);
175
+ if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
176
+
177
+ const contentType = response.headers.get("content-type") || "";
178
+ const msgType = message.type || "file";
179
+ const ext = guessExtension(url, contentType, msgType);
180
+ const fileName = buildFileName(message, ext);
181
+ const filePath = join(folderPath, fileName);
182
+
183
+ const buffer = Buffer.from(await response.arrayBuffer());
184
+ writeFileSync(filePath, buffer);
185
+
186
+ if (options.autoOpen) {
187
+ openFile(filePath);
188
+ }
189
+
190
+ return { success: true, path: filePath, folder, fileName, mediaType: msgType };
191
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { isDownloadableMedia } from "./media-downloader.js";
4
+
5
+ describe("isDownloadableMedia", () => {
6
+ it("returns true for image type", () => {
7
+ assert.ok(isDownloadableMedia("image"));
8
+ });
9
+
10
+ it("returns true for audio type", () => {
11
+ assert.ok(isDownloadableMedia("audio"));
12
+ });
13
+
14
+ it("returns true for voice type", () => {
15
+ assert.ok(isDownloadableMedia("voice"));
16
+ });
17
+
18
+ it("returns true for video type", () => {
19
+ assert.ok(isDownloadableMedia("video"));
20
+ });
21
+
22
+ it("returns true for gif type", () => {
23
+ assert.ok(isDownloadableMedia("gif"));
24
+ });
25
+
26
+ it("returns true for file type", () => {
27
+ assert.ok(isDownloadableMedia("file"));
28
+ });
29
+
30
+ it("returns false for text type", () => {
31
+ assert.equal(isDownloadableMedia("text"), false);
32
+ });
33
+
34
+ it("returns false for link type", () => {
35
+ assert.equal(isDownloadableMedia("link"), false);
36
+ });
37
+
38
+ it("returns false for sticker type", () => {
39
+ assert.equal(isDownloadableMedia("sticker"), false);
40
+ });
41
+
42
+ it("returns false for undefined type", () => {
43
+ assert.equal(isDownloadableMedia(undefined), false);
44
+ });
45
+ });