zalo-agent-cli 1.4.0 → 1.5.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.4.0",
3
+ "version": "1.5.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": {
@@ -140,7 +140,7 @@ function createWebhookServer(oaId) {
140
140
  const msg = event.message?.text || "";
141
141
  if (eventName === "user_send_text") info(`[text] ${sender}: ${msg}`);
142
142
  else info(`[${eventName}] from ${sender}`);
143
- } catch (_) {
143
+ } catch {
144
144
  /* test ping */
145
145
  }
146
146
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -10,7 +10,6 @@ import {
10
10
  saveOAToken,
11
11
  saveOACreds,
12
12
  loadOACreds,
13
- loadOAToken,
14
13
  getOAuthUrl,
15
14
  exchangeCode,
16
15
  refreshAccessToken,
@@ -208,7 +207,7 @@ export function registerOACommands(program) {
208
207
  const d = result.data || result;
209
208
  info(`OA: ${d.name || "N/A"} (ID: ${d.oa_id || "N/A"})`);
210
209
  if (d.description) info(`Description: ${d.description}`);
211
- if (d.num_follower != null) info(`Followers: ${d.num_follower}`);
210
+ if (d.num_follower !== null && d.num_follower !== undefined) info(`Followers: ${d.num_follower}`);
212
211
  });
213
212
  } catch (e) {
214
213
  error(e.message);
@@ -38,7 +38,7 @@ export function registerPollCommands(program) {
38
38
  success(`Poll created: "${question}"`);
39
39
  info(`Poll ID: ${result.poll_id}`);
40
40
  if (result.options) {
41
- result.options.forEach((o, i) => console.log(` [${o.option_id}] ${o.content}`));
41
+ result.options.forEach((o) => console.log(` [${o.option_id}] ${o.content}`));
42
42
  }
43
43
  });
44
44
  } catch (e) {
@@ -271,7 +271,6 @@ export async function removeFollowerFromTag(userId, tagName, oaId = "default") {
271
271
  /** Upload image to OA (returns attachment_id). */
272
272
  export async function uploadImage(filePath, oaId = "default") {
273
273
  const token = getToken(oaId);
274
- const { default: FormData } = await import("node-fetch");
275
274
  // Use native FormData-like via node-fetch's Blob
276
275
  const fileData = fs.readFileSync(filePath);
277
276
  const blob = new (await import("node:buffer")).Blob([fileData]);
@@ -0,0 +1,125 @@
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
+ * @param {string} filePath
79
+ */
80
+ function openWithSystemViewer(filePath) {
81
+ const cmds = { darwin: "open", win32: "start", linux: "xdg-open" };
82
+ const cmd = cmds[platform()] || "xdg-open";
83
+ // Use double quotes for paths with spaces; detach so CLI doesn't block
84
+ exec(`${cmd} "${filePath}"`, (err) => {
85
+ if (err) console.error(`[image-dl] Failed to open viewer: ${err.message}`);
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Download an image from URL and save to organized local folder.
91
+ * @param {object} message - Normalized message with attachment.url
92
+ * @param {object} [options]
93
+ * @param {string} [options.downloadDir] - Base download directory
94
+ * @param {boolean} [options.autoOpen] - Open image after download
95
+ * @param {string} [options.threadName] - Thread display name from cache
96
+ * @returns {Promise<{ success: boolean, path: string, folder: string, fileName: string }>}
97
+ */
98
+ export async function downloadImage(message, options = {}) {
99
+ const url = message.attachment?.url;
100
+ if (!url) throw new Error("Message has no attachment URL");
101
+
102
+ const baseDir = options.downloadDir || DEFAULT_DIR;
103
+ const folder = buildFolderName(message, options.threadName);
104
+ const folderPath = join(baseDir, folder);
105
+ mkdirSync(folderPath, { recursive: true });
106
+
107
+ // Fetch image from Zalo CDN
108
+ const response = await fetch(url);
109
+ if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
110
+
111
+ const contentType = response.headers.get("content-type") || "";
112
+ const ext = guessExtension(url, contentType);
113
+ const fileName = buildFileName(message, ext);
114
+ const filePath = join(folderPath, fileName);
115
+
116
+ const buffer = Buffer.from(await response.arrayBuffer());
117
+ writeFileSync(filePath, buffer);
118
+
119
+ // Auto-open with system viewer if configured
120
+ if (options.autoOpen) {
121
+ openWithSystemViewer(filePath);
122
+ }
123
+
124
+ return { success: true, path: filePath, folder, fileName };
125
+ }
@@ -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,13 +1,13 @@
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 } from "./image-downloader.js";
7
8
 
8
9
  /** Thread type constants matching zca-js ThreadType enum */
9
10
  const THREAD_USER = 0;
10
- const THREAD_GROUP = 1;
11
11
 
12
12
  /**
13
13
  * Wrap a result object into MCP tool content format.
@@ -194,4 +194,48 @@ export function registerTools(server, api, buffer, filter, config, nameCache) {
194
194
  }
195
195
  },
196
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
+ "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.",
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
+ autoOpen: z
212
+ .boolean()
213
+ .default(imgConfig.autoOpen ?? true)
214
+ .describe("Open image with system viewer after download"),
215
+ }),
216
+ },
217
+ async ({ messageId, threadId, autoOpen }) => {
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
+ // Resolve thread name for folder organization
226
+ const threadName = nameCache?.get(message.threadId)?.name || null;
227
+
228
+ const result = await downloadImage(message, {
229
+ downloadDir: imgConfig.downloadDir || undefined,
230
+ autoOpen,
231
+ threadName,
232
+ });
233
+
234
+ return ok(result);
235
+ } catch (e) {
236
+ console.error("[mcp-tools] zalo_view_image error:", e.message);
237
+ return err(e.message);
238
+ }
239
+ },
240
+ );
197
241
  }
@@ -44,7 +44,7 @@ export class MessageBuffer {
44
44
  const sources = threadId ? [this._threads.get(threadId)].filter(Boolean) : Array.from(this._threads.values());
45
45
 
46
46
  // Collect all messages after cursor, sorted by cursor
47
- let all = [];
47
+ const all = [];
48
48
  for (const thread of sources) {
49
49
  for (const msg of thread.messages) {
50
50
  if (msg._cursor > since) all.push(msg);
@@ -5,6 +5,20 @@
5
5
 
6
6
  import { parseDuration } from "./mcp-config.js";
7
7
 
8
+ /** Emoji prefix per message type for notification previews */
9
+ const TYPE_EMOJI = { text: "💬", image: "📷", file: "📎", link: "🔗", video: "🎬", audio: "🎵", gif: "🎞️" };
10
+
11
+ /** Vietnamese label per message type for notification breakdown */
12
+ const TYPE_LABEL = {
13
+ text: "text",
14
+ image: "ảnh",
15
+ file: "file",
16
+ link: "link",
17
+ video: "video",
18
+ audio: "audio",
19
+ gif: "gif",
20
+ };
21
+
8
22
  export class ZaloNotifier {
9
23
  /**
10
24
  * @param {object} api - zca-js API instance
@@ -60,13 +74,20 @@ export class ZaloNotifier {
60
74
  if (this._pending.length === 0) return;
61
75
 
62
76
  const count = this._pending.length;
77
+ const typeBreakdown = this._buildTypeBreakdown(this._pending);
63
78
  const preview = this._pending
64
79
  .slice(0, 3) // Show at most 3 message previews
65
- .map((m) => `- ${m.senderName || m.senderId}: ${(m.text || "").slice(0, 50)}`)
80
+ .map((m) => {
81
+ const type = m.type || "text";
82
+ const prefix = TYPE_EMOJI[type] || "💬";
83
+ const sender = m.senderName || m.senderId;
84
+ const body = type === "text" ? (m.text || "").slice(0, 50) : `[${TYPE_LABEL[type] || type}]`;
85
+ return `- ${sender}: ${prefix} ${body}`;
86
+ })
66
87
  .join("\n");
67
88
 
68
89
  const suffix = count > 3 ? `\n...và ${count - 3} tin nhắn khác` : "";
69
- const text = `🔔 ${count} tin nhắn mới trong ${this._formatWindow()}:\n${preview}${suffix}`;
90
+ const text = `🔔 ${count} tin nhắn mới [${typeBreakdown}] trong ${this._formatWindow()}:\n${preview}${suffix}`;
70
91
 
71
92
  try {
72
93
  // threadType 1 = Group conversation
@@ -78,6 +99,22 @@ export class ZaloNotifier {
78
99
  this._pending = [];
79
100
  }
80
101
 
102
+ /**
103
+ * Build a human-readable type breakdown string, e.g. "2 text, 1 ảnh".
104
+ * @param {object[]} messages - Array of normalized messages
105
+ * @returns {string}
106
+ */
107
+ _buildTypeBreakdown(messages) {
108
+ const counts = {};
109
+ for (const m of messages) {
110
+ const t = m.type || "text";
111
+ counts[t] = (counts[t] || 0) + 1;
112
+ }
113
+ return Object.entries(counts)
114
+ .map(([type, n]) => `${n} ${TYPE_LABEL[type] || type}`)
115
+ .join(", ");
116
+ }
117
+
81
118
  /** Format cooldown duration as human-readable string (e.g. "5 phút", "1h") */
82
119
  _formatWindow() {
83
120
  const mins = Math.round(this._cooldownMs / 60000);