zalo-agent-cli 1.4.2 → 1.5.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.4.2",
3
+ "version": "1.5.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": {
@@ -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 { autoDownloadImage } from "../mcp/image-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 images in background (non-blocking)
125
+ if (normalized.attachment?.url) {
126
+ const threadName = nameCache?.get(normalized.threadId)?.name || null;
127
+ autoDownloadImage(normalized, {
128
+ downloadDir: config.images?.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}`);
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Download Zalo images to local filesystem, organized by thread name.
3
+ * Folder structure: {downloadDir}/{threadName}/{date}_{time}_{sender}_{msgId}.{ext}
4
+ * Cross-platform: opens images 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 { exec } 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, "images");
15
+
16
+ /** Characters unsafe for filesystem paths — stripped from folder/file names */
17
+ const UNSAFE_CHARS = /[/\\:*?"<>|]/g;
18
+
19
+ /**
20
+ * Sanitize a string for use as a filesystem name.
21
+ * @param {string} name
22
+ * @returns {string}
23
+ */
24
+ function sanitize(name) {
25
+ return (name || "unknown").replace(UNSAFE_CHARS, "_").trim() || "unknown";
26
+ }
27
+
28
+ /**
29
+ * Guess file extension from URL or content-type header.
30
+ * @param {string} url
31
+ * @param {string} [contentType]
32
+ * @returns {string}
33
+ */
34
+ function guessExtension(url, contentType) {
35
+ // Try content-type first
36
+ 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();
39
+ }
40
+ // Try URL path
41
+ const urlMatch = url.match(/\.(jpeg|jpg|png|gif|webp|bmp)(\?|$)/i);
42
+ if (urlMatch) return urlMatch[1] === "jpeg" ? "jpg" : urlMatch[1].toLowerCase();
43
+ return "jpg"; // safe default for Zalo CDN
44
+ }
45
+
46
+ /**
47
+ * Build the folder name for a thread.
48
+ * Groups: thread display name. DMs: "DM_{senderName}".
49
+ * @param {object} message - Normalized message
50
+ * @param {string} [threadName] - Resolved thread name from cache
51
+ * @returns {string}
52
+ */
53
+ function buildFolderName(message, threadName) {
54
+ if (message.threadType === "group") {
55
+ return sanitize(threadName || message.threadId);
56
+ }
57
+ return sanitize(`DM_${threadName || message.senderName || message.senderId}`);
58
+ }
59
+
60
+ /**
61
+ * Build a descriptive filename from message metadata.
62
+ * Format: {YYYY-MM-DD}_{HH-mm}_{sender}_{msgId}.{ext}
63
+ * @param {object} message
64
+ * @param {string} ext - File extension
65
+ * @returns {string}
66
+ */
67
+ function buildFileName(message, ext) {
68
+ const d = new Date(message.timestamp);
69
+ const date = d.toISOString().slice(0, 10); // YYYY-MM-DD
70
+ const time = `${String(d.getHours()).padStart(2, "0")}-${String(d.getMinutes()).padStart(2, "0")}`;
71
+ const sender = sanitize(message.senderName || message.senderId);
72
+ const msgId = (message.id || "noId").slice(-8); // last 8 chars for brevity
73
+ return `${date}_${time}_${sender}_${msgId}.${ext}`;
74
+ }
75
+
76
+ /**
77
+ * Open a file with the system's default viewer (cross-platform).
78
+ * Exported for use by MCP tools.
79
+ * @param {string} filePath
80
+ */
81
+ export function openFile(filePath) {
82
+ const cmds = { darwin: "open", win32: "start", linux: "xdg-open" };
83
+ 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}`);
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Auto-download image in background when a message arrives.
92
+ * Non-blocking — fires and forgets. Mutates message.attachment.localPath on success.
93
+ * @param {object} message - Normalized message (will be mutated with localPath)
94
+ * @param {object} [options]
95
+ * @param {string} [options.downloadDir] - Base download directory
96
+ * @param {string} [options.threadName] - Thread display name from cache
97
+ */
98
+ export function autoDownloadImage(message, options = {}) {
99
+ if (!message.attachment?.url) return;
100
+ // Fire-and-forget: download in background, don't block message processing
101
+ downloadImage(message, { ...options, autoOpen: false }).then(
102
+ (result) => {
103
+ message.attachment.localPath = result.path;
104
+ console.error(`[image-dl] Saved: ${result.path}`);
105
+ },
106
+ (err) => {
107
+ console.error(`[image-dl] Auto-download failed: ${err.message}`);
108
+ },
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Download an image from URL and save to organized local folder.
114
+ * @param {object} message - Normalized message with attachment.url
115
+ * @param {object} [options]
116
+ * @param {string} [options.downloadDir] - Base download directory
117
+ * @param {boolean} [options.autoOpen] - Open image after download
118
+ * @param {string} [options.threadName] - Thread display name from cache
119
+ * @returns {Promise<{ success: boolean, path: string, folder: string, fileName: string }>}
120
+ */
121
+ export async function downloadImage(message, options = {}) {
122
+ const url = message.attachment?.url;
123
+ if (!url) throw new Error("Message has no attachment URL");
124
+
125
+ const baseDir = options.downloadDir || DEFAULT_DIR;
126
+ const folder = buildFolderName(message, options.threadName);
127
+ const folderPath = join(baseDir, folder);
128
+ mkdirSync(folderPath, { recursive: true });
129
+
130
+ // Fetch image from Zalo CDN
131
+ const response = await fetch(url);
132
+ if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
133
+
134
+ const contentType = response.headers.get("content-type") || "";
135
+ const ext = guessExtension(url, contentType);
136
+ const fileName = buildFileName(message, ext);
137
+ const filePath = join(folderPath, fileName);
138
+
139
+ const buffer = Buffer.from(await response.arrayBuffer());
140
+ writeFileSync(filePath, buffer);
141
+
142
+ // Auto-open with system viewer if configured
143
+ if (options.autoOpen) {
144
+ openFile(filePath);
145
+ }
146
+
147
+ return { success: true, path: filePath, folder, fileName };
148
+ }
@@ -26,6 +26,10 @@ export function getDefaultConfig() {
26
26
  bufferMaxAge: "2h",
27
27
  bufferMaxSize: 500,
28
28
  },
29
+ images: {
30
+ downloadDir: null, // default: ~/.zalo-agent-cli/images/
31
+ autoOpen: true,
32
+ },
29
33
  };
30
34
  }
31
35
 
@@ -44,6 +48,7 @@ export function loadMCPConfig() {
44
48
  ...saved,
45
49
  notify: { ...defaults.notify, ...saved.notify },
46
50
  limits: { ...defaults.limits, ...saved.limits },
51
+ images: { ...defaults.images, ...saved.images },
47
52
  };
48
53
  } catch {
49
54
  // File doesn't exist or invalid JSON — use defaults
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * MCP tool registrations for Zalo message access and sending.
3
- * Registers 5 tools: zalo_get_messages, zalo_send_message, zalo_list_threads, zalo_search_threads, zalo_mark_read.
3
+ * Registers 6 tools: zalo_get_messages, zalo_send_message, zalo_list_threads, zalo_search_threads, zalo_mark_read, zalo_view_image.
4
4
  */
5
5
 
6
6
  import { z } from "zod";
7
+ import { downloadImage, openFile } from "./image-downloader.js";
7
8
 
8
9
  /** Thread type constants matching zca-js ThreadType enum */
9
10
  const THREAD_USER = 0;
@@ -193,4 +194,53 @@ export function registerTools(server, api, buffer, filter, config, nameCache) {
193
194
  }
194
195
  },
195
196
  );
197
+
198
+ // --- zalo_view_image ---
199
+ const imgConfig = config.images || {};
200
+ server.registerTool(
201
+ "zalo_view_image",
202
+ {
203
+ title: "View Zalo Image",
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. " +
207
+ "If not yet downloaded, downloads first then opens.",
208
+ inputSchema: z.object({
209
+ messageId: z.string().describe("Message ID from zalo_get_messages that has an image attachment"),
210
+ threadId: z.string().optional().describe("Thread ID to search in. Omit to search all threads."),
211
+ open: z
212
+ .boolean()
213
+ .default(imgConfig.autoOpen ?? true)
214
+ .describe("Open image with system viewer"),
215
+ }),
216
+ },
217
+ async ({ messageId, threadId, open }) => {
218
+ try {
219
+ // Find the message in the buffer by ID
220
+ const allMessages = buffer.read(threadId, 0, 9999).messages;
221
+ const message = allMessages.find((m) => m.id === messageId);
222
+ if (!message) return err(`Message ${messageId} not found in buffer`);
223
+ if (!message.attachment?.url) return err(`Message ${messageId} has no image attachment`);
224
+
225
+ // Use local file if already auto-downloaded, otherwise download now
226
+ let localPath = message.attachment.localPath;
227
+ if (!localPath) {
228
+ const threadName = nameCache?.get(message.threadId)?.name || null;
229
+ const result = await downloadImage(message, {
230
+ downloadDir: imgConfig.downloadDir || undefined,
231
+ autoOpen: false,
232
+ threadName,
233
+ });
234
+ localPath = result.path;
235
+ }
236
+
237
+ if (open) openFile(localPath);
238
+
239
+ return ok({ success: true, path: localPath });
240
+ } catch (e) {
241
+ console.error("[mcp-tools] zalo_view_image error:", e.message);
242
+ return err(e.message);
243
+ }
244
+ },
245
+ );
196
246
  }