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 +1 -1
- package/src/commands/mcp.js +5 -5
- package/src/mcp/mcp-config.js +3 -3
- package/src/mcp/mcp-tools.js +16 -17
- package/src/mcp/{image-downloader.js → media-downloader.js} +67 -26
- package/src/mcp/media-downloader.test.js +45 -0
- package/src/mcp/notifier.js +11 -1
- package/src/mcp/notifier.test.js +30 -0
package/package.json
CHANGED
package/src/commands/mcp.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
128
|
-
downloadDir: config.
|
|
127
|
+
autoDownloadMedia(normalized, {
|
|
128
|
+
downloadDir: config.media?.downloadDir || undefined,
|
|
129
129
|
threadName,
|
|
130
130
|
});
|
|
131
131
|
}
|
package/src/mcp/mcp-config.js
CHANGED
|
@@ -26,8 +26,8 @@ export function getDefaultConfig() {
|
|
|
26
26
|
bufferMaxAge: "2h",
|
|
27
27
|
bufferMaxSize: 500,
|
|
28
28
|
},
|
|
29
|
-
|
|
30
|
-
downloadDir: null, // default: ~/.zalo-agent-cli/
|
|
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
|
-
|
|
51
|
+
media: { ...defaults.media, ...saved.media },
|
|
52
52
|
};
|
|
53
53
|
} catch {
|
|
54
54
|
// File doesn't exist or invalid JSON — use defaults
|
package/src/mcp/mcp-tools.js
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
-
// ---
|
|
199
|
-
const
|
|
198
|
+
// --- zalo_view_media ---
|
|
199
|
+
const mediaConfig = config.media || {};
|
|
200
200
|
server.registerTool(
|
|
201
|
-
"
|
|
201
|
+
"zalo_view_media",
|
|
202
202
|
{
|
|
203
|
-
title: "View Zalo
|
|
203
|
+
title: "View Zalo Media",
|
|
204
204
|
description:
|
|
205
|
-
"Open a Zalo image with the system viewer.
|
|
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
|
|
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(
|
|
214
|
-
.describe("Open
|
|
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
|
|
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
|
|
230
|
-
downloadDir:
|
|
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]
|
|
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
|
|
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, "
|
|
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
|
|
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
|
|
38
|
-
if (
|
|
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
|
-
|
|
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(`[
|
|
129
|
+
if (err) console.error(`[media-dl] Failed to open viewer: ${err.message}`);
|
|
87
130
|
});
|
|
88
131
|
}
|
|
89
132
|
|
|
90
133
|
/**
|
|
91
|
-
* Auto-download
|
|
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
|
|
141
|
+
export function autoDownloadMedia(message, options = {}) {
|
|
99
142
|
if (!message.attachment?.url) return;
|
|
100
|
-
|
|
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(`[
|
|
146
|
+
console.error(`[media-dl] Saved: ${result.path}`);
|
|
105
147
|
},
|
|
106
148
|
(err) => {
|
|
107
|
-
console.error(`[
|
|
149
|
+
console.error(`[media-dl] Auto-download failed: ${err.message}`);
|
|
108
150
|
},
|
|
109
151
|
);
|
|
110
152
|
}
|
|
111
153
|
|
|
112
154
|
/**
|
|
113
|
-
* Download
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|
package/src/mcp/notifier.js
CHANGED
|
@@ -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 = {
|
|
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
|
|
package/src/mcp/notifier.test.js
CHANGED
|
@@ -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", () => {
|