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 +1 -1
- package/src/commands/oa-init.js +1 -1
- package/src/commands/oa.js +1 -2
- package/src/commands/poll.js +1 -1
- package/src/core/oa-client.js +0 -1
- package/src/mcp/image-downloader.js +125 -0
- package/src/mcp/mcp-config.js +5 -0
- package/src/mcp/mcp-tools.js +46 -2
- package/src/mcp/message-buffer.js +1 -1
- package/src/mcp/notifier.js +39 -2
package/package.json
CHANGED
package/src/commands/oa-init.js
CHANGED
|
@@ -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" });
|
package/src/commands/oa.js
CHANGED
|
@@ -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
|
|
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);
|
package/src/commands/poll.js
CHANGED
|
@@ -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
|
|
41
|
+
result.options.forEach((o) => console.log(` [${o.option_id}] ${o.content}`));
|
|
42
42
|
}
|
|
43
43
|
});
|
|
44
44
|
} catch (e) {
|
package/src/core/oa-client.js
CHANGED
|
@@ -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
|
+
}
|
package/src/mcp/mcp-config.js
CHANGED
|
@@ -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
|
package/src/mcp/mcp-tools.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP tool registrations for Zalo message access and sending.
|
|
3
|
-
* Registers
|
|
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
|
-
|
|
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);
|
package/src/mcp/notifier.js
CHANGED
|
@@ -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) =>
|
|
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);
|