zalo-agent-cli 1.5.1 → 1.6.1

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.1",
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": {
@@ -18,7 +18,8 @@
18
18
  "lint:fix": "eslint src/ --fix",
19
19
  "format": "prettier --write src/",
20
20
  "format:check": "prettier --check src/",
21
- "release": "npm version patch -m 'release: v%s' && git push origin main --tags"
21
+ "release": "npm version patch -m 'release: v%s' && git push origin main --tags",
22
+ "prepublishOnly": "npm run lint && npm test"
22
23
  },
23
24
  "keywords": [
24
25
  "zalo",
@@ -65,7 +65,10 @@ export function registerListenCommand(program) {
65
65
  method: "POST",
66
66
  headers: { "Content-Type": "application/json" },
67
67
  body: JSON.stringify(data),
68
- }).catch(() => {});
68
+ signal: AbortSignal.timeout(5000),
69
+ }).catch((e) => {
70
+ console.error(`[listen] Webhook failed: ${e.message}`);
71
+ });
69
72
  }
70
73
 
71
74
  // Setup save directory if --save flag provided
@@ -84,7 +87,9 @@ export function registerListenCommand(program) {
84
87
  const line = JSON.stringify({ ...data, savedAt: new Date().toISOString() }) + "\n";
85
88
  try {
86
89
  appendFileSync(filepath, line, "utf-8");
87
- } catch {}
90
+ } catch (e) {
91
+ console.error(`[listen] Save failed: ${e.message}`);
92
+ }
88
93
  }
89
94
 
90
95
  /** Output event as JSON or human-readable, save locally, then post to webhook */
@@ -276,7 +281,9 @@ export function registerListenCommand(program) {
276
281
  process.on("SIGINT", () => {
277
282
  try {
278
283
  getApi().listener.stop();
279
- } catch {}
284
+ } catch (e) {
285
+ console.error(`[listen] Stop failed: ${e.message}`);
286
+ }
280
287
  info(`Stopped. Uptime: ${uptime()}, events: ${eventCount}, reconnects: ${reconnectCount}`);
281
288
  if (saveDir) info(`Messages saved to: ${saveDir}`);
282
289
  resolve();
@@ -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;
@@ -56,6 +56,7 @@ export function registerMCPCommands(program) {
56
56
  .option("--config <path>", "Config file path (default: ~/.zalo-agent-cli/mcp-config.json)")
57
57
  .option("--http <port>", "Use HTTP transport on specified port (default: stdio)")
58
58
  .option("--auth <token>", "Bearer token for HTTP auth (only with --http)")
59
+ .option("--host <address>", "HTTP bind address (default: 127.0.0.1, only with --http)")
59
60
  .action(async (opts) => {
60
61
  // Perform login explicitly here — preAction hook skips "mcp"
61
62
  try {
@@ -84,11 +85,17 @@ export function registerMCPCommands(program) {
84
85
  }
85
86
 
86
87
  // Start MCP server — stdio (default) or HTTP
88
+ let httpServer = null;
87
89
  try {
88
90
  if (opts.http) {
89
91
  const port = Number(opts.http);
92
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
93
+ console.error(`[mcp] Invalid port: ${opts.http}. Must be 1-65535.`);
94
+ process.exit(1);
95
+ }
90
96
  const deps = { api: getApi(), buffer, filter, config, nameCache };
91
- createHTTPServer(registerTools, deps, port, opts.auth || null);
97
+ const authToken = opts.auth?.trim() || null;
98
+ httpServer = createHTTPServer(registerTools, deps, port, authToken, opts.host || "127.0.0.1");
92
99
  console.error(`[mcp] HTTP server started on port ${port}`);
93
100
  } else {
94
101
  await createMCPServer(getApi(), buffer, filter, config, nameCache);
@@ -121,11 +128,11 @@ export function registerMCPCommands(program) {
121
128
  // Apply noise filter (stickers, system msgs, short emoji)
122
129
  if (!filter.shouldKeep(normalized)) return;
123
130
 
124
- // Auto-download images in background (non-blocking)
125
- if (normalized.attachment?.url) {
131
+ // Auto-download media (images, audio, video) in background
132
+ if (normalized.attachment?.url && isDownloadableMedia(normalized.type)) {
126
133
  const threadName = nameCache?.get(normalized.threadId)?.name || null;
127
- autoDownloadImage(normalized, {
128
- downloadDir: config.images?.downloadDir || undefined,
134
+ autoDownloadMedia(normalized, {
135
+ downloadDir: config.media?.downloadDir || undefined,
129
136
  threadName,
130
137
  });
131
138
  }
@@ -195,6 +202,14 @@ export function registerMCPCommands(program) {
195
202
  process.exit(1);
196
203
  }
197
204
 
205
+ // Graceful shutdown on SIGINT
206
+ process.on("SIGINT", () => {
207
+ try { getApi().listener.stop(); } catch {}
208
+ notifier?.destroy();
209
+ httpServer?.close();
210
+ process.exit(0);
211
+ });
212
+
198
213
  // Keep process alive (MCP server runs on stdio — process must not exit)
199
214
  await new Promise(() => {});
200
215
  });
@@ -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
@@ -4,9 +4,16 @@
4
4
  */
5
5
 
6
6
  import express from "express";
7
+ import { timingSafeEqual } from "crypto";
8
+ import { readFileSync } from "fs";
9
+ import { fileURLToPath } from "url";
10
+ import { dirname, join } from "path";
7
11
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
12
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9
13
 
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8"));
16
+
10
17
  /**
11
18
  * Create HTTP MCP server with optional bearer token auth.
12
19
  * @param {Function} registerToolsFn - Registers tools on a McpServer instance
@@ -15,7 +22,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
15
22
  * @param {string|null} authToken - Bearer token (null = no auth)
16
23
  * @returns {import("http").Server}
17
24
  */
18
- export function createHTTPServer(registerToolsFn, deps, port, authToken) {
25
+ export function createHTTPServer(registerToolsFn, deps, port, authToken, host = "127.0.0.1") {
19
26
  const app = express();
20
27
  app.use(express.json());
21
28
 
@@ -23,8 +30,10 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken) {
23
30
  if (authToken) {
24
31
  app.use((req, res, next) => {
25
32
  if (req.path === "/health") return next();
26
- const token = req.headers.authorization?.slice(7); // strip "Bearer "
27
- if (token !== authToken) {
33
+ const token = req.headers.authorization?.slice(7) || "";
34
+ const expected = Buffer.from(authToken, "utf8");
35
+ const received = Buffer.from(token, "utf8");
36
+ if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
28
37
  return res.status(401).json({ error: "Unauthorized" });
29
38
  }
30
39
  next();
@@ -34,7 +43,7 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken) {
34
43
  // MCP endpoint — stateless, fresh server+transport per request
35
44
  app.post("/mcp", async (req, res) => {
36
45
  try {
37
- const server = new McpServer({ name: "zalo-agent", version: "1.0.0" });
46
+ const server = new McpServer({ name: "zalo-agent", version: pkg.version });
38
47
  registerToolsFn(server, deps.api, deps.buffer, deps.filter, deps.config, deps.nameCache);
39
48
 
40
49
  const transport = new StreamableHTTPServerTransport({
@@ -69,7 +78,7 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken) {
69
78
  });
70
79
  });
71
80
 
72
- return app.listen(port, "0.0.0.0", () => {
73
- console.error(`MCP HTTP server listening on port ${port}`);
81
+ return app.listen(port, host, () => {
82
+ console.error(`MCP HTTP server listening on ${host}:${port}`);
74
83
  });
75
84
  }
@@ -3,10 +3,16 @@
3
3
  * Creates and connects the McpServer instance for Claude Code integration.
4
4
  */
5
5
 
6
+ import { readFileSync } from "fs";
7
+ import { fileURLToPath } from "url";
8
+ import { dirname, join } from "path";
6
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
11
  import { registerTools } from "./mcp-tools.js";
9
12
 
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8"));
15
+
10
16
  /**
11
17
  * Create and start MCP server with stdio transport.
12
18
  * All logs MUST use console.error() — stdout is reserved for MCP protocol.
@@ -20,7 +26,7 @@ import { registerTools } from "./mcp-tools.js";
20
26
  export async function createMCPServer(api, buffer, filter, config, nameCache) {
21
27
  const server = new McpServer({
22
28
  name: "zalo-agent",
23
- version: "1.0.0",
29
+ version: pkg.version,
24
30
  });
25
31
 
26
32
  registerTools(server, api, buffer, filter, config, nameCache);
@@ -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;
@@ -117,11 +117,9 @@ export function registerTools(server, api, buffer, filter, config, nameCache) {
117
117
  async ({ type }) => {
118
118
  try {
119
119
  const stats = buffer.getStats(0);
120
- // Enrich each stat entry with threadType by peeking at the first buffered message.
121
- // buffer._threads is a Map<threadId, { messages: Array, lastActivity: number }>
120
+ // Enrich each stat entry with threadType and thread name
122
121
  const enriched = stats.map((t) => {
123
- const thread = buffer._threads.get(t.threadId);
124
- const threadType = thread?.messages?.[0]?.threadType ?? "unknown";
122
+ const threadType = buffer.getThreadType(t.threadId) ?? "unknown";
125
123
  const cached = nameCache?.get(t.threadId);
126
124
  return {
127
125
  ...t,
@@ -195,39 +193,38 @@ export function registerTools(server, api, buffer, filter, config, nameCache) {
195
193
  },
196
194
  );
197
195
 
198
- // --- zalo_view_image ---
199
- const imgConfig = config.images || {};
196
+ // --- zalo_view_media ---
197
+ const mediaConfig = config.media || {};
200
198
  server.registerTool(
201
- "zalo_view_image",
199
+ "zalo_view_media",
202
200
  {
203
- title: "View Zalo Image",
201
+ title: "View Zalo Media",
204
202
  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. " +
203
+ "Open a Zalo media file (image, audio, video) with the system viewer. " +
204
+ "Media is auto-downloaded when received, organized by thread folder with date/sender metadata filenames. " +
207
205
  "If not yet downloaded, downloads first then opens.",
208
206
  inputSchema: z.object({
209
- messageId: z.string().describe("Message ID from zalo_get_messages that has an image attachment"),
207
+ messageId: z.string().describe("Message ID from zalo_get_messages that has a media attachment"),
210
208
  threadId: z.string().optional().describe("Thread ID to search in. Omit to search all threads."),
211
209
  open: z
212
210
  .boolean()
213
- .default(imgConfig.autoOpen ?? true)
214
- .describe("Open image with system viewer"),
211
+ .default(mediaConfig.autoOpen ?? true)
212
+ .describe("Open media with system viewer"),
215
213
  }),
216
214
  },
217
215
  async ({ messageId, threadId, open }) => {
218
216
  try {
219
- // Find the message in the buffer by ID
220
217
  const allMessages = buffer.read(threadId, 0, 9999).messages;
221
218
  const message = allMessages.find((m) => m.id === messageId);
222
219
  if (!message) return err(`Message ${messageId} not found in buffer`);
223
- if (!message.attachment?.url) return err(`Message ${messageId} has no image attachment`);
220
+ if (!message.attachment?.url) return err(`Message ${messageId} has no media attachment`);
224
221
 
225
222
  // Use local file if already auto-downloaded, otherwise download now
226
223
  let localPath = message.attachment.localPath;
227
224
  if (!localPath) {
228
225
  const threadName = nameCache?.get(message.threadId)?.name || null;
229
- const result = await downloadImage(message, {
230
- downloadDir: imgConfig.downloadDir || undefined,
226
+ const result = await downloadMedia(message, {
227
+ downloadDir: mediaConfig.downloadDir || undefined,
231
228
  autoOpen: false,
232
229
  threadName,
233
230
  });
@@ -236,9 +233,9 @@ export function registerTools(server, api, buffer, filter, config, nameCache) {
236
233
 
237
234
  if (open) openFile(localPath);
238
235
 
239
- return ok({ success: true, path: localPath });
236
+ return ok({ success: true, path: localPath, mediaType: message.type });
240
237
  } catch (e) {
241
- console.error("[mcp-tools] zalo_view_image error:", e.message);
238
+ console.error("[mcp-tools] zalo_view_media error:", e.message);
242
239
  return err(e.message);
243
240
  }
244
241
  },
@@ -1,21 +1,54 @@
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";
8
8
  import { join } from "path";
9
9
  import { platform } from "os";
10
- import { exec } from "child_process";
10
+ import { execFile } 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
  /**
@@ -79,46 +123,46 @@ function buildFileName(message, ext) {
79
123
  * @param {string} filePath
80
124
  */
81
125
  export function openFile(filePath) {
126
+ const isWin = platform() === "win32";
82
127
  const cmds = { darwin: "open", win32: "start", linux: "xdg-open" };
83
128
  const cmd = cmds[platform()] || "xdg-open";
84
- // Use double quotes for paths with spaces; detach so CLI doesn't block
85
- exec(`${cmd} "${filePath}"`, (err) => {
86
- if (err) console.error(`[image-dl] Failed to open viewer: ${err.message}`);
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}`);
87
132
  });
88
133
  }
89
134
 
90
135
  /**
91
- * Auto-download image in background when a message arrives.
136
+ * Auto-download media in background when a message arrives.
92
137
  * Non-blocking — fires and forgets. Mutates message.attachment.localPath on success.
93
138
  * @param {object} message - Normalized message (will be mutated with localPath)
94
139
  * @param {object} [options]
95
140
  * @param {string} [options.downloadDir] - Base download directory
96
141
  * @param {string} [options.threadName] - Thread display name from cache
97
142
  */
98
- export function autoDownloadImage(message, options = {}) {
143
+ export function autoDownloadMedia(message, options = {}) {
99
144
  if (!message.attachment?.url) return;
100
- // Fire-and-forget: download in background, don't block message processing
101
- downloadImage(message, { ...options, autoOpen: false }).then(
145
+ downloadMedia(message, { ...options, autoOpen: false }).then(
102
146
  (result) => {
103
147
  message.attachment.localPath = result.path;
104
- console.error(`[image-dl] Saved: ${result.path}`);
148
+ console.error(`[media-dl] Saved: ${result.path}`);
105
149
  },
106
150
  (err) => {
107
- console.error(`[image-dl] Auto-download failed: ${err.message}`);
151
+ console.error(`[media-dl] Auto-download failed: ${err.message}`);
108
152
  },
109
153
  );
110
154
  }
111
155
 
112
156
  /**
113
- * Download an image from URL and save to organized local folder.
157
+ * Download media from URL and save to organized local folder.
114
158
  * @param {object} message - Normalized message with attachment.url
115
159
  * @param {object} [options]
116
160
  * @param {string} [options.downloadDir] - Base download directory
117
- * @param {boolean} [options.autoOpen] - Open image after download
161
+ * @param {boolean} [options.autoOpen] - Open media with system viewer after download
118
162
  * @param {string} [options.threadName] - Thread display name from cache
119
- * @returns {Promise<{ success: boolean, path: string, folder: string, fileName: string }>}
163
+ * @returns {Promise<{ success: boolean, path: string, folder: string, fileName: string, mediaType: string }>}
120
164
  */
121
- export async function downloadImage(message, options = {}) {
165
+ export async function downloadMedia(message, options = {}) {
122
166
  const url = message.attachment?.url;
123
167
  if (!url) throw new Error("Message has no attachment URL");
124
168
 
@@ -127,22 +171,21 @@ export async function downloadImage(message, options = {}) {
127
171
  const folderPath = join(baseDir, folder);
128
172
  mkdirSync(folderPath, { recursive: true });
129
173
 
130
- // Fetch image from Zalo CDN
131
174
  const response = await fetch(url);
132
175
  if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
133
176
 
134
177
  const contentType = response.headers.get("content-type") || "";
135
- const ext = guessExtension(url, contentType);
178
+ const msgType = message.type || "file";
179
+ const ext = guessExtension(url, contentType, msgType);
136
180
  const fileName = buildFileName(message, ext);
137
181
  const filePath = join(folderPath, fileName);
138
182
 
139
183
  const buffer = Buffer.from(await response.arrayBuffer());
140
184
  writeFileSync(filePath, buffer);
141
185
 
142
- // Auto-open with system viewer if configured
143
186
  if (options.autoOpen) {
144
187
  openFile(filePath);
145
188
  }
146
189
 
147
- return { success: true, path: filePath, folder, fileName };
190
+ return { success: true, path: filePath, folder, fileName, mediaType: msgType };
148
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
+ });
@@ -94,6 +94,16 @@ export class MessageBuffer {
94
94
  return stats;
95
95
  }
96
96
 
97
+ /**
98
+ * Get thread type from first buffered message.
99
+ * @param {string} threadId
100
+ * @returns {string|null}
101
+ */
102
+ getThreadType(threadId) {
103
+ const thread = this._threads.get(threadId);
104
+ return thread?.messages?.[0]?.threadType ?? null;
105
+ }
106
+
97
107
  /**
98
108
  * Evict messages that exceed maxSize or maxAge for a given thread.
99
109
  * @param {string} threadId
@@ -265,6 +265,32 @@ describe("MessageBuffer getStats", () => {
265
265
  });
266
266
  });
267
267
 
268
+ describe("MessageBuffer getThreadType", () => {
269
+ it("returns threadType from first buffered message", () => {
270
+ const buf = new MessageBuffer();
271
+ buf.push("t1", { text: "hi", threadType: "dm", timestamp: Date.now() });
272
+ buf.push("t1", { text: "hey", threadType: "dm", timestamp: Date.now() });
273
+ assert.equal(buf.getThreadType("t1"), "dm");
274
+ });
275
+
276
+ it("returns null for unknown thread", () => {
277
+ const buf = new MessageBuffer();
278
+ assert.equal(buf.getThreadType("nonexistent"), null);
279
+ });
280
+
281
+ it("returns null when thread exists but messages have no threadType", () => {
282
+ const buf = new MessageBuffer();
283
+ buf.push("t1", { text: "hi", timestamp: Date.now() });
284
+ assert.equal(buf.getThreadType("t1"), null);
285
+ });
286
+
287
+ it("returns group for group thread", () => {
288
+ const buf = new MessageBuffer();
289
+ buf.push("g1", { text: "hello", threadType: "group", timestamp: Date.now() });
290
+ assert.equal(buf.getThreadType("g1"), "group");
291
+ });
292
+ });
293
+
268
294
  describe("MessageBuffer edge cases", () => {
269
295
  it("empty read on fresh buffer returns empty array and cursor 0", () => {
270
296
  const buf = new MessageBuffer();
@@ -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", () => {