zalo-agent-cli 1.5.0 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zalo-agent-cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "CLI tool for Zalo automation — multi-account, proxy, bank transfers, QR payments, Official Account API v3.0",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@ import { registerTools } from "../mcp/mcp-tools.js";
14
14
  import { createHTTPServer } from "../mcp/mcp-http-transport.js";
15
15
  import { ZaloNotifier } from "../mcp/notifier.js";
16
16
  import { ThreadNameCache } from "../mcp/thread-name-cache.js";
17
+ import { autoDownloadMedia, isDownloadableMedia } from "../mcp/media-downloader.js";
17
18
 
18
19
  /** Zalo close code for duplicate web session — fatal, do not retry */
19
20
  const CLOSE_DUPLICATE = 3000;
@@ -120,6 +121,15 @@ export function registerMCPCommands(program) {
120
121
  // Apply noise filter (stickers, system msgs, short emoji)
121
122
  if (!filter.shouldKeep(normalized)) return;
122
123
 
124
+ // Auto-download media (images, audio, video) in background
125
+ if (normalized.attachment?.url && isDownloadableMedia(normalized.type)) {
126
+ const threadName = nameCache?.get(normalized.threadId)?.name || null;
127
+ autoDownloadMedia(normalized, {
128
+ downloadDir: config.media?.downloadDir || undefined,
129
+ threadName,
130
+ });
131
+ }
132
+
123
133
  buffer.push(normalized.threadId, normalized);
124
134
  notifier.onMessage(normalized);
125
135
  console.error(`[mcp] Buffered ${normalized.threadType} msg from ${normalized.threadId}`);
@@ -26,8 +26,8 @@ export function getDefaultConfig() {
26
26
  bufferMaxAge: "2h",
27
27
  bufferMaxSize: 500,
28
28
  },
29
- images: {
30
- downloadDir: null, // default: ~/.zalo-agent-cli/images/
29
+ media: {
30
+ downloadDir: null, // default: ~/.zalo-agent-cli/media/
31
31
  autoOpen: true,
32
32
  },
33
33
  };
@@ -48,7 +48,7 @@ export function loadMCPConfig() {
48
48
  ...saved,
49
49
  notify: { ...defaults.notify, ...saved.notify },
50
50
  limits: { ...defaults.limits, ...saved.limits },
51
- images: { ...defaults.images, ...saved.images },
51
+ media: { ...defaults.media, ...saved.media },
52
52
  };
53
53
  } catch {
54
54
  // File doesn't exist or invalid JSON — use defaults
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * MCP tool registrations for Zalo message access and sending.
3
- * Registers 6 tools: zalo_get_messages, zalo_send_message, zalo_list_threads, zalo_search_threads, zalo_mark_read, zalo_view_image.
3
+ * Registers 6 tools: zalo_get_messages, zalo_send_message, zalo_list_threads, zalo_search_threads, zalo_mark_read, zalo_view_media.
4
4
  */
5
5
 
6
6
  import { z } from "zod";
7
- import { downloadImage } from "./image-downloader.js";
7
+ import { downloadMedia, openFile } from "./media-downloader.js";
8
8
 
9
9
  /** Thread type constants matching zca-js ThreadType enum */
10
10
  const THREAD_USER = 0;
@@ -195,45 +195,49 @@ export function registerTools(server, api, buffer, filter, config, nameCache) {
195
195
  },
196
196
  );
197
197
 
198
- // --- zalo_view_image ---
199
- const imgConfig = config.images || {};
198
+ // --- zalo_view_media ---
199
+ const mediaConfig = config.media || {};
200
200
  server.registerTool(
201
- "zalo_view_image",
201
+ "zalo_view_media",
202
202
  {
203
- title: "View Zalo Image",
203
+ title: "View Zalo Media",
204
204
  description:
205
- "Download a Zalo image to local filesystem and optionally open it with system viewer. " +
206
- "Images are organized by thread folder and named with date/sender metadata. " +
207
- "Pass the message ID from zalo_get_messages to download its attached image.",
205
+ "Open a Zalo media file (image, audio, video) with the system viewer. " +
206
+ "Media is auto-downloaded when received, organized by thread folder with date/sender metadata filenames. " +
207
+ "If not yet downloaded, downloads first then opens.",
208
208
  inputSchema: z.object({
209
- messageId: z.string().describe("Message ID from zalo_get_messages that has an image attachment"),
209
+ messageId: z.string().describe("Message ID from zalo_get_messages that has a media attachment"),
210
210
  threadId: z.string().optional().describe("Thread ID to search in. Omit to search all threads."),
211
- autoOpen: z
211
+ open: z
212
212
  .boolean()
213
- .default(imgConfig.autoOpen ?? true)
214
- .describe("Open image with system viewer after download"),
213
+ .default(mediaConfig.autoOpen ?? true)
214
+ .describe("Open media with system viewer"),
215
215
  }),
216
216
  },
217
- async ({ messageId, threadId, autoOpen }) => {
217
+ async ({ messageId, threadId, open }) => {
218
218
  try {
219
- // Find the message in the buffer by ID
220
219
  const allMessages = buffer.read(threadId, 0, 9999).messages;
221
220
  const message = allMessages.find((m) => m.id === messageId);
222
221
  if (!message) return err(`Message ${messageId} not found in buffer`);
223
- if (!message.attachment?.url) return err(`Message ${messageId} has no image attachment`);
222
+ if (!message.attachment?.url) return err(`Message ${messageId} has no media attachment`);
224
223
 
225
- // Resolve thread name for folder organization
226
- const threadName = nameCache?.get(message.threadId)?.name || null;
224
+ // Use local file if already auto-downloaded, otherwise download now
225
+ let localPath = message.attachment.localPath;
226
+ if (!localPath) {
227
+ const threadName = nameCache?.get(message.threadId)?.name || null;
228
+ const result = await downloadMedia(message, {
229
+ downloadDir: mediaConfig.downloadDir || undefined,
230
+ autoOpen: false,
231
+ threadName,
232
+ });
233
+ localPath = result.path;
234
+ }
227
235
 
228
- const result = await downloadImage(message, {
229
- downloadDir: imgConfig.downloadDir || undefined,
230
- autoOpen,
231
- threadName,
232
- });
236
+ if (open) openFile(localPath);
233
237
 
234
- return ok(result);
238
+ return ok({ success: true, path: localPath, mediaType: message.type });
235
239
  } catch (e) {
236
- console.error("[mcp-tools] zalo_view_image error:", e.message);
240
+ console.error("[mcp-tools] zalo_view_media error:", e.message);
237
241
  return err(e.message);
238
242
  }
239
243
  },
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Download Zalo images to local filesystem, organized by thread name.
2
+ * Download Zalo media (images, audio, video) to local filesystem, organized by thread name.
3
3
  * Folder structure: {downloadDir}/{threadName}/{date}_{time}_{sender}_{msgId}.{ext}
4
- * Cross-platform: opens images with system viewer (open/xdg-open/start).
4
+ * Cross-platform: opens media with system viewer (open/xdg-open/start).
5
5
  */
6
6
 
7
7
  import { mkdirSync, writeFileSync } from "fs";
@@ -11,11 +11,44 @@ import { exec } from "child_process";
11
11
  import { CONFIG_DIR } from "../core/credentials.js";
12
12
 
13
13
  /** Default download directory when not configured */
14
- const DEFAULT_DIR = join(CONFIG_DIR, "images");
14
+ const DEFAULT_DIR = join(CONFIG_DIR, "media");
15
15
 
16
16
  /** Characters unsafe for filesystem paths — stripped from folder/file names */
17
17
  const UNSAFE_CHARS = /[/\\:*?"<>|]/g;
18
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
+
19
52
  /**
20
53
  * Sanitize a string for use as a filesystem name.
21
54
  * @param {string} name
@@ -26,21 +59,32 @@ function sanitize(name) {
26
59
  }
27
60
 
28
61
  /**
29
- * Guess file extension from URL or content-type header.
62
+ * Guess file extension from URL, content-type, or message type.
30
63
  * @param {string} url
31
64
  * @param {string} [contentType]
65
+ * @param {string} [msgType] - Normalized message type (image, audio, video, etc.)
32
66
  * @returns {string}
33
67
  */
34
- function guessExtension(url, contentType) {
68
+ function guessExtension(url, contentType, msgType) {
35
69
  // Try content-type first
36
70
  if (contentType) {
37
- const match = contentType.match(/image\/(jpeg|jpg|png|gif|webp|bmp)/i);
38
- if (match) return match[1] === "jpeg" ? "jpg" : match[1].toLowerCase();
71
+ const ct = contentType.split(";")[0].trim().toLowerCase();
72
+ if (CONTENT_TYPE_MAP[ct]) return CONTENT_TYPE_MAP[ct];
39
73
  }
40
- // Try URL path
41
- const urlMatch = url.match(/\.(jpeg|jpg|png|gif|webp|bmp)(\?|$)/i);
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);
42
76
  if (urlMatch) return urlMatch[1] === "jpeg" ? "jpg" : urlMatch[1].toLowerCase();
43
- return "jpg"; // safe default for Zalo CDN
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);
44
88
  }
45
89
 
46
90
  /**
@@ -75,27 +119,48 @@ function buildFileName(message, ext) {
75
119
 
76
120
  /**
77
121
  * Open a file with the system's default viewer (cross-platform).
122
+ * Exported for use by MCP tools.
78
123
  * @param {string} filePath
79
124
  */
80
- function openWithSystemViewer(filePath) {
125
+ export function openFile(filePath) {
81
126
  const cmds = { darwin: "open", win32: "start", linux: "xdg-open" };
82
127
  const cmd = cmds[platform()] || "xdg-open";
83
- // Use double quotes for paths with spaces; detach so CLI doesn't block
84
128
  exec(`${cmd} "${filePath}"`, (err) => {
85
- if (err) console.error(`[image-dl] Failed to open viewer: ${err.message}`);
129
+ if (err) console.error(`[media-dl] Failed to open viewer: ${err.message}`);
86
130
  });
87
131
  }
88
132
 
89
133
  /**
90
- * Download an image from URL and save to organized local folder.
134
+ * Auto-download media in background when a message arrives.
135
+ * Non-blocking — fires and forgets. Mutates message.attachment.localPath on success.
136
+ * @param {object} message - Normalized message (will be mutated with localPath)
137
+ * @param {object} [options]
138
+ * @param {string} [options.downloadDir] - Base download directory
139
+ * @param {string} [options.threadName] - Thread display name from cache
140
+ */
141
+ export function autoDownloadMedia(message, options = {}) {
142
+ if (!message.attachment?.url) return;
143
+ downloadMedia(message, { ...options, autoOpen: false }).then(
144
+ (result) => {
145
+ message.attachment.localPath = result.path;
146
+ console.error(`[media-dl] Saved: ${result.path}`);
147
+ },
148
+ (err) => {
149
+ console.error(`[media-dl] Auto-download failed: ${err.message}`);
150
+ },
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Download media from URL and save to organized local folder.
91
156
  * @param {object} message - Normalized message with attachment.url
92
157
  * @param {object} [options]
93
158
  * @param {string} [options.downloadDir] - Base download directory
94
- * @param {boolean} [options.autoOpen] - Open image after download
159
+ * @param {boolean} [options.autoOpen] - Open media with system viewer after download
95
160
  * @param {string} [options.threadName] - Thread display name from cache
96
- * @returns {Promise<{ success: boolean, path: string, folder: string, fileName: string }>}
161
+ * @returns {Promise<{ success: boolean, path: string, folder: string, fileName: string, mediaType: string }>}
97
162
  */
98
- export async function downloadImage(message, options = {}) {
163
+ export async function downloadMedia(message, options = {}) {
99
164
  const url = message.attachment?.url;
100
165
  if (!url) throw new Error("Message has no attachment URL");
101
166
 
@@ -104,22 +169,21 @@ export async function downloadImage(message, options = {}) {
104
169
  const folderPath = join(baseDir, folder);
105
170
  mkdirSync(folderPath, { recursive: true });
106
171
 
107
- // Fetch image from Zalo CDN
108
172
  const response = await fetch(url);
109
173
  if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
110
174
 
111
175
  const contentType = response.headers.get("content-type") || "";
112
- const ext = guessExtension(url, contentType);
176
+ const msgType = message.type || "file";
177
+ const ext = guessExtension(url, contentType, msgType);
113
178
  const fileName = buildFileName(message, ext);
114
179
  const filePath = join(folderPath, fileName);
115
180
 
116
181
  const buffer = Buffer.from(await response.arrayBuffer());
117
182
  writeFileSync(filePath, buffer);
118
183
 
119
- // Auto-open with system viewer if configured
120
184
  if (options.autoOpen) {
121
- openWithSystemViewer(filePath);
185
+ openFile(filePath);
122
186
  }
123
187
 
124
- return { success: true, path: filePath, folder, fileName };
188
+ return { success: true, path: filePath, folder, fileName, mediaType: msgType };
125
189
  }
@@ -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
+ });
@@ -6,7 +6,16 @@
6
6
  import { parseDuration } from "./mcp-config.js";
7
7
 
8
8
  /** Emoji prefix per message type for notification previews */
9
- const TYPE_EMOJI = { text: "💬", image: "📷", file: "📎", link: "🔗", video: "🎬", audio: "🎵", gif: "🎞️" };
9
+ const TYPE_EMOJI = {
10
+ text: "💬",
11
+ image: "📷",
12
+ file: "📎",
13
+ link: "🔗",
14
+ video: "🎬",
15
+ audio: "🎵",
16
+ voice: "🎤",
17
+ gif: "🎞️",
18
+ };
10
19
 
11
20
  /** Vietnamese label per message type for notification breakdown */
12
21
  const TYPE_LABEL = {
@@ -16,6 +25,7 @@ const TYPE_LABEL = {
16
25
  link: "link",
17
26
  video: "video",
18
27
  audio: "audio",
28
+ voice: "voice",
19
29
  gif: "gif",
20
30
  };
21
31
 
@@ -191,6 +191,36 @@ describe("ZaloNotifier _flush notification format", () => {
191
191
  await wait(30);
192
192
  assert.ok(calls[0].text.includes("2"));
193
193
  });
194
+
195
+ it("shows type breakdown with mixed media types", async () => {
196
+ const { api, calls } = makeSpy();
197
+ const n = new ZaloNotifier(api, enabledConfig());
198
+ n.onMessage(dmMsg("hello", { type: "text" }));
199
+ n.onMessage(dmMsg("[image]", { type: "image" }));
200
+ n.onMessage(dmMsg("[voice]", { type: "voice" }));
201
+ await wait(30);
202
+ assert.ok(calls[0].text.includes("1 text"));
203
+ assert.ok(calls[0].text.includes("1 ảnh"));
204
+ assert.ok(calls[0].text.includes("1 voice"));
205
+ });
206
+
207
+ it("uses emoji prefix for audio and video types", async () => {
208
+ const { api, calls } = makeSpy();
209
+ const n = new ZaloNotifier(api, enabledConfig());
210
+ n.onMessage(dmMsg("[audio]", { type: "audio", senderName: "Bob" }));
211
+ n.onMessage(dmMsg("[video]", { type: "video", senderName: "Eve" }));
212
+ await wait(30);
213
+ assert.ok(calls[0].text.includes("🎵"));
214
+ assert.ok(calls[0].text.includes("🎬"));
215
+ });
216
+
217
+ it("uses microphone emoji for voice messages", async () => {
218
+ const { api, calls } = makeSpy();
219
+ const n = new ZaloNotifier(api, enabledConfig());
220
+ n.onMessage(dmMsg("[voice]", { type: "voice" }));
221
+ await wait(30);
222
+ assert.ok(calls[0].text.includes("🎤"));
223
+ });
194
224
  });
195
225
 
196
226
  describe("ZaloNotifier cooldown batching", () => {