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 +3 -2
- package/src/commands/listen.js +10 -3
- package/src/commands/mcp.js +21 -6
- package/src/mcp/mcp-config.js +3 -3
- package/src/mcp/mcp-http-transport.js +15 -6
- package/src/mcp/mcp-server.js +7 -1
- package/src/mcp/mcp-tools.js +18 -21
- package/src/mcp/{image-downloader.js → media-downloader.js} +71 -28
- package/src/mcp/media-downloader.test.js +45 -0
- package/src/mcp/message-buffer.js +10 -0
- package/src/mcp/message-buffer.test.js +26 -0
- package/src/mcp/notifier.js +11 -1
- package/src/mcp/notifier.test.js +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zalo-agent-cli",
|
|
3
|
-
"version": "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",
|
package/src/commands/listen.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
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;
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
128
|
-
downloadDir: config.
|
|
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
|
});
|
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
|
|
@@ -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)
|
|
27
|
-
|
|
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:
|
|
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,
|
|
73
|
-
console.error(`MCP HTTP server listening on
|
|
81
|
+
return app.listen(port, host, () => {
|
|
82
|
+
console.error(`MCP HTTP server listening on ${host}:${port}`);
|
|
74
83
|
});
|
|
75
84
|
}
|
package/src/mcp/mcp-server.js
CHANGED
|
@@ -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:
|
|
29
|
+
version: pkg.version,
|
|
24
30
|
});
|
|
25
31
|
|
|
26
32
|
registerTools(server, api, buffer, filter, config, nameCache);
|
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;
|
|
@@ -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
|
|
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
|
|
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
|
-
// ---
|
|
199
|
-
const
|
|
196
|
+
// --- zalo_view_media ---
|
|
197
|
+
const mediaConfig = config.media || {};
|
|
200
198
|
server.registerTool(
|
|
201
|
-
"
|
|
199
|
+
"zalo_view_media",
|
|
202
200
|
{
|
|
203
|
-
title: "View Zalo
|
|
201
|
+
title: "View Zalo Media",
|
|
204
202
|
description:
|
|
205
|
-
"Open a Zalo image with the system viewer.
|
|
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
|
|
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(
|
|
214
|
-
.describe("Open
|
|
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
|
|
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
|
|
230
|
-
downloadDir:
|
|
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]
|
|
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
|
|
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 {
|
|
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, "
|
|
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
|
/**
|
|
@@ -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
|
-
//
|
|
85
|
-
|
|
86
|
-
if (err) console.error(`[
|
|
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
|
|
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
|
|
143
|
+
export function autoDownloadMedia(message, options = {}) {
|
|
99
144
|
if (!message.attachment?.url) return;
|
|
100
|
-
|
|
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(`[
|
|
148
|
+
console.error(`[media-dl] Saved: ${result.path}`);
|
|
105
149
|
},
|
|
106
150
|
(err) => {
|
|
107
|
-
console.error(`[
|
|
151
|
+
console.error(`[media-dl] Auto-download failed: ${err.message}`);
|
|
108
152
|
},
|
|
109
153
|
);
|
|
110
154
|
}
|
|
111
155
|
|
|
112
156
|
/**
|
|
113
|
-
* Download
|
|
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
|
|
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
|
|
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
|
|
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();
|
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", () => {
|