zalo-agent-cli 1.5.0 → 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 +10 -0
- package/src/mcp/mcp-config.js +3 -3
- package/src/mcp/mcp-tools.js +29 -25
- package/src/mcp/{image-downloader.js → media-downloader.js} +86 -22
- 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,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 { autoDownloadMedia, isDownloadableMedia } from "../mcp/media-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 media (images, audio, video) in background
|
|
125
|
+
if (normalized.attachment?.url && isDownloadableMedia(normalized.type)) {
|
|
126
|
+
const threadName = nameCache?.get(normalized.threadId)?.name || null;
|
|
127
|
+
autoDownloadMedia(normalized, {
|
|
128
|
+
downloadDir: config.media?.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}`);
|
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,45 +195,49 @@ 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
|
-
"
|
|
206
|
-
"
|
|
207
|
-
"
|
|
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
|
+
"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
|
-
async ({ messageId, threadId,
|
|
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
|
-
//
|
|
226
|
-
|
|
224
|
+
// Use local file if already auto-downloaded, otherwise download now
|
|
225
|
+
let localPath = message.attachment.localPath;
|
|
226
|
+
if (!localPath) {
|
|
227
|
+
const threadName = nameCache?.get(message.threadId)?.name || null;
|
|
228
|
+
const result = await downloadMedia(message, {
|
|
229
|
+
downloadDir: mediaConfig.downloadDir || undefined,
|
|
230
|
+
autoOpen: false,
|
|
231
|
+
threadName,
|
|
232
|
+
});
|
|
233
|
+
localPath = result.path;
|
|
234
|
+
}
|
|
227
235
|
|
|
228
|
-
|
|
229
|
-
downloadDir: imgConfig.downloadDir || undefined,
|
|
230
|
-
autoOpen,
|
|
231
|
-
threadName,
|
|
232
|
-
});
|
|
236
|
+
if (open) openFile(localPath);
|
|
233
237
|
|
|
234
|
-
return ok(
|
|
238
|
+
return ok({ success: true, path: localPath, mediaType: message.type });
|
|
235
239
|
} catch (e) {
|
|
236
|
-
console.error("[mcp-tools]
|
|
240
|
+
console.error("[mcp-tools] zalo_view_media error:", e.message);
|
|
237
241
|
return err(e.message);
|
|
238
242
|
}
|
|
239
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
|
/**
|
|
@@ -75,27 +119,48 @@ function buildFileName(message, ext) {
|
|
|
75
119
|
|
|
76
120
|
/**
|
|
77
121
|
* Open a file with the system's default viewer (cross-platform).
|
|
122
|
+
* Exported for use by MCP tools.
|
|
78
123
|
* @param {string} filePath
|
|
79
124
|
*/
|
|
80
|
-
function
|
|
125
|
+
export function openFile(filePath) {
|
|
81
126
|
const cmds = { darwin: "open", win32: "start", linux: "xdg-open" };
|
|
82
127
|
const cmd = cmds[platform()] || "xdg-open";
|
|
83
|
-
// Use double quotes for paths with spaces; detach so CLI doesn't block
|
|
84
128
|
exec(`${cmd} "${filePath}"`, (err) => {
|
|
85
|
-
if (err) console.error(`[
|
|
129
|
+
if (err) console.error(`[media-dl] Failed to open viewer: ${err.message}`);
|
|
86
130
|
});
|
|
87
131
|
}
|
|
88
132
|
|
|
89
133
|
/**
|
|
90
|
-
*
|
|
134
|
+
* Auto-download media in background when a message arrives.
|
|
135
|
+
* Non-blocking — fires and forgets. Mutates message.attachment.localPath on success.
|
|
136
|
+
* @param {object} message - Normalized message (will be mutated with localPath)
|
|
137
|
+
* @param {object} [options]
|
|
138
|
+
* @param {string} [options.downloadDir] - Base download directory
|
|
139
|
+
* @param {string} [options.threadName] - Thread display name from cache
|
|
140
|
+
*/
|
|
141
|
+
export function autoDownloadMedia(message, options = {}) {
|
|
142
|
+
if (!message.attachment?.url) return;
|
|
143
|
+
downloadMedia(message, { ...options, autoOpen: false }).then(
|
|
144
|
+
(result) => {
|
|
145
|
+
message.attachment.localPath = result.path;
|
|
146
|
+
console.error(`[media-dl] Saved: ${result.path}`);
|
|
147
|
+
},
|
|
148
|
+
(err) => {
|
|
149
|
+
console.error(`[media-dl] Auto-download failed: ${err.message}`);
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Download media from URL and save to organized local folder.
|
|
91
156
|
* @param {object} message - Normalized message with attachment.url
|
|
92
157
|
* @param {object} [options]
|
|
93
158
|
* @param {string} [options.downloadDir] - Base download directory
|
|
94
|
-
* @param {boolean} [options.autoOpen] - Open
|
|
159
|
+
* @param {boolean} [options.autoOpen] - Open media with system viewer after download
|
|
95
160
|
* @param {string} [options.threadName] - Thread display name from cache
|
|
96
|
-
* @returns {Promise<{ success: boolean, path: string, folder: string, fileName: string }>}
|
|
161
|
+
* @returns {Promise<{ success: boolean, path: string, folder: string, fileName: string, mediaType: string }>}
|
|
97
162
|
*/
|
|
98
|
-
export async function
|
|
163
|
+
export async function downloadMedia(message, options = {}) {
|
|
99
164
|
const url = message.attachment?.url;
|
|
100
165
|
if (!url) throw new Error("Message has no attachment URL");
|
|
101
166
|
|
|
@@ -104,22 +169,21 @@ export async function downloadImage(message, options = {}) {
|
|
|
104
169
|
const folderPath = join(baseDir, folder);
|
|
105
170
|
mkdirSync(folderPath, { recursive: true });
|
|
106
171
|
|
|
107
|
-
// Fetch image from Zalo CDN
|
|
108
172
|
const response = await fetch(url);
|
|
109
173
|
if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
|
|
110
174
|
|
|
111
175
|
const contentType = response.headers.get("content-type") || "";
|
|
112
|
-
const
|
|
176
|
+
const msgType = message.type || "file";
|
|
177
|
+
const ext = guessExtension(url, contentType, msgType);
|
|
113
178
|
const fileName = buildFileName(message, ext);
|
|
114
179
|
const filePath = join(folderPath, fileName);
|
|
115
180
|
|
|
116
181
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
117
182
|
writeFileSync(filePath, buffer);
|
|
118
183
|
|
|
119
|
-
// Auto-open with system viewer if configured
|
|
120
184
|
if (options.autoOpen) {
|
|
121
|
-
|
|
185
|
+
openFile(filePath);
|
|
122
186
|
}
|
|
123
187
|
|
|
124
|
-
return { success: true, path: filePath, folder, fileName };
|
|
188
|
+
return { success: true, path: filePath, folder, fileName, mediaType: msgType };
|
|
125
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", () => {
|