zalo-agent-cli 1.5.1 → 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.1",
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,7 +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 { autoDownloadImage } from "../mcp/image-downloader.js";
17
+ import { autoDownloadMedia, isDownloadableMedia } from "../mcp/media-downloader.js";
18
18
 
19
19
  /** Zalo close code for duplicate web session — fatal, do not retry */
20
20
  const CLOSE_DUPLICATE = 3000;
@@ -121,11 +121,11 @@ export function registerMCPCommands(program) {
121
121
  // Apply noise filter (stickers, system msgs, short emoji)
122
122
  if (!filter.shouldKeep(normalized)) return;
123
123
 
124
- // Auto-download images in background (non-blocking)
125
- if (normalized.attachment?.url) {
124
+ // Auto-download media (images, audio, video) in background
125
+ if (normalized.attachment?.url && isDownloadableMedia(normalized.type)) {
126
126
  const threadName = nameCache?.get(normalized.threadId)?.name || null;
127
- autoDownloadImage(normalized, {
128
- downloadDir: config.images?.downloadDir || undefined,
127
+ autoDownloadMedia(normalized, {
128
+ downloadDir: config.media?.downloadDir || undefined,
129
129
  threadName,
130
130
  });
131
131
  }
@@ -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, openFile } 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,39 +195,38 @@ 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
- "Open a Zalo image with the system viewer. Images are auto-downloaded when received, " +
206
- "organized by thread folder with date/sender metadata filenames. " +
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
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
211
  open: z
212
212
  .boolean()
213
- .default(imgConfig.autoOpen ?? true)
214
- .describe("Open image with system viewer"),
213
+ .default(mediaConfig.autoOpen ?? true)
214
+ .describe("Open media with system viewer"),
215
215
  }),
216
216
  },
217
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
224
  // Use local file if already auto-downloaded, otherwise download now
226
225
  let localPath = message.attachment.localPath;
227
226
  if (!localPath) {
228
227
  const threadName = nameCache?.get(message.threadId)?.name || null;
229
- const result = await downloadImage(message, {
230
- downloadDir: imgConfig.downloadDir || undefined,
228
+ const result = await downloadMedia(message, {
229
+ downloadDir: mediaConfig.downloadDir || undefined,
231
230
  autoOpen: false,
232
231
  threadName,
233
232
  });
@@ -236,9 +235,9 @@ export function registerTools(server, api, buffer, filter, config, nameCache) {
236
235
 
237
236
  if (open) openFile(localPath);
238
237
 
239
- return ok({ success: true, path: localPath });
238
+ return ok({ success: true, path: localPath, mediaType: message.type });
240
239
  } catch (e) {
241
- console.error("[mcp-tools] zalo_view_image error:", e.message);
240
+ console.error("[mcp-tools] zalo_view_media error:", e.message);
242
241
  return err(e.message);
243
242
  }
244
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
  /**
@@ -81,44 +125,42 @@ function buildFileName(message, ext) {
81
125
  export function openFile(filePath) {
82
126
  const cmds = { darwin: "open", win32: "start", linux: "xdg-open" };
83
127
  const cmd = cmds[platform()] || "xdg-open";
84
- // Use double quotes for paths with spaces; detach so CLI doesn't block
85
128
  exec(`${cmd} "${filePath}"`, (err) => {
86
- 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}`);
87
130
  });
88
131
  }
89
132
 
90
133
  /**
91
- * Auto-download image in background when a message arrives.
134
+ * Auto-download media in background when a message arrives.
92
135
  * Non-blocking — fires and forgets. Mutates message.attachment.localPath on success.
93
136
  * @param {object} message - Normalized message (will be mutated with localPath)
94
137
  * @param {object} [options]
95
138
  * @param {string} [options.downloadDir] - Base download directory
96
139
  * @param {string} [options.threadName] - Thread display name from cache
97
140
  */
98
- export function autoDownloadImage(message, options = {}) {
141
+ export function autoDownloadMedia(message, options = {}) {
99
142
  if (!message.attachment?.url) return;
100
- // Fire-and-forget: download in background, don't block message processing
101
- downloadImage(message, { ...options, autoOpen: false }).then(
143
+ downloadMedia(message, { ...options, autoOpen: false }).then(
102
144
  (result) => {
103
145
  message.attachment.localPath = result.path;
104
- console.error(`[image-dl] Saved: ${result.path}`);
146
+ console.error(`[media-dl] Saved: ${result.path}`);
105
147
  },
106
148
  (err) => {
107
- console.error(`[image-dl] Auto-download failed: ${err.message}`);
149
+ console.error(`[media-dl] Auto-download failed: ${err.message}`);
108
150
  },
109
151
  );
110
152
  }
111
153
 
112
154
  /**
113
- * Download an image from URL and save to organized local folder.
155
+ * Download media from URL and save to organized local folder.
114
156
  * @param {object} message - Normalized message with attachment.url
115
157
  * @param {object} [options]
116
158
  * @param {string} [options.downloadDir] - Base download directory
117
- * @param {boolean} [options.autoOpen] - Open image after download
159
+ * @param {boolean} [options.autoOpen] - Open media with system viewer after download
118
160
  * @param {string} [options.threadName] - Thread display name from cache
119
- * @returns {Promise<{ success: boolean, path: string, folder: string, fileName: string }>}
161
+ * @returns {Promise<{ success: boolean, path: string, folder: string, fileName: string, mediaType: string }>}
120
162
  */
121
- export async function downloadImage(message, options = {}) {
163
+ export async function downloadMedia(message, options = {}) {
122
164
  const url = message.attachment?.url;
123
165
  if (!url) throw new Error("Message has no attachment URL");
124
166
 
@@ -127,22 +169,21 @@ export async function downloadImage(message, options = {}) {
127
169
  const folderPath = join(baseDir, folder);
128
170
  mkdirSync(folderPath, { recursive: true });
129
171
 
130
- // Fetch image from Zalo CDN
131
172
  const response = await fetch(url);
132
173
  if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
133
174
 
134
175
  const contentType = response.headers.get("content-type") || "";
135
- const ext = guessExtension(url, contentType);
176
+ const msgType = message.type || "file";
177
+ const ext = guessExtension(url, contentType, msgType);
136
178
  const fileName = buildFileName(message, ext);
137
179
  const filePath = join(folderPath, fileName);
138
180
 
139
181
  const buffer = Buffer.from(await response.arrayBuffer());
140
182
  writeFileSync(filePath, buffer);
141
183
 
142
- // Auto-open with system viewer if configured
143
184
  if (options.autoOpen) {
144
185
  openFile(filePath);
145
186
  }
146
187
 
147
- return { success: true, path: filePath, folder, fileName };
188
+ return { success: true, path: filePath, folder, fileName, mediaType: msgType };
148
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", () => {