@ardennguyen/zalo-agent-cli 1.0.7
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/DISCLAIMER.md +27 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +64 -0
- package/src/cli.test.js +98 -0
- package/src/commands/account.js +178 -0
- package/src/commands/auto-reply.js +83 -0
- package/src/commands/catalog.js +143 -0
- package/src/commands/conv.js +264 -0
- package/src/commands/friend.js +384 -0
- package/src/commands/group.js +606 -0
- package/src/commands/label.js +39 -0
- package/src/commands/listen.js +293 -0
- package/src/commands/login.js +165 -0
- package/src/commands/mcp.js +219 -0
- package/src/commands/msg.js +608 -0
- package/src/commands/oa-init.js +503 -0
- package/src/commands/oa-listen.js +215 -0
- package/src/commands/oa.js +657 -0
- package/src/commands/poll.js +159 -0
- package/src/commands/profile.js +274 -0
- package/src/commands/quick-msg.js +55 -0
- package/src/commands/reminder.js +195 -0
- package/src/core/accounts.js +93 -0
- package/src/core/credentials.js +63 -0
- package/src/core/oa-client.js +407 -0
- package/src/core/zalo-client.js +201 -0
- package/src/index.js +95 -0
- package/src/mcp/mcp-config.js +81 -0
- package/src/mcp/mcp-http-transport.js +84 -0
- package/src/mcp/mcp-server.js +40 -0
- package/src/mcp/mcp-tools.js +353 -0
- package/src/mcp/media-downloader.js +191 -0
- package/src/mcp/media-downloader.test.js +45 -0
- package/src/mcp/message-buffer.js +127 -0
- package/src/mcp/message-buffer.test.js +308 -0
- package/src/mcp/notifier.js +141 -0
- package/src/mcp/notifier.test.js +269 -0
- package/src/mcp/thread-filter.js +79 -0
- package/src/mcp/thread-filter.test.js +206 -0
- package/src/mcp/thread-name-cache.js +162 -0
- package/src/mcp/thread-name-cache.test.js +139 -0
- package/src/utils/bank-helpers.js +233 -0
- package/src/utils/bank-helpers.test.js +66 -0
- package/src/utils/extract-message-text.js +21 -0
- package/src/utils/output.js +21 -0
- package/src/utils/proxy-helpers.js +14 -0
- package/src/utils/proxy-helpers.test.js +36 -0
- package/src/utils/qr-display.js +91 -0
- package/src/utils/qr-display.test.js +97 -0
- package/src/utils/qr-http-server.js +190 -0
- package/src/utils/update-check.js +56 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool registrations for Zalo message access and sending.
|
|
3
|
+
* Registers 7 tools: zalo_get_messages, zalo_get_history, zalo_send_message, zalo_list_threads, zalo_search_threads, zalo_mark_read, zalo_view_media.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { downloadMedia, openFile } from "./media-downloader.js";
|
|
8
|
+
import { extractMessageText } from "../utils/extract-message-text.js";
|
|
9
|
+
|
|
10
|
+
/** Thread type constants matching zca-js ThreadType enum */
|
|
11
|
+
const THREAD_USER = 0;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wrap a result object into MCP tool content format.
|
|
15
|
+
* @param {object} result
|
|
16
|
+
* @returns {{ content: Array }}
|
|
17
|
+
*/
|
|
18
|
+
function ok(result) {
|
|
19
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Wrap an error message into MCP tool error content format.
|
|
24
|
+
* @param {string} message
|
|
25
|
+
* @returns {{ content: Array, isError: true }}
|
|
26
|
+
*/
|
|
27
|
+
function err(message) {
|
|
28
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Register all Zalo MCP tools on the server.
|
|
33
|
+
* @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server
|
|
34
|
+
* @param {object} api - zca-js API instance
|
|
35
|
+
* @param {import("./message-buffer.js").MessageBuffer} buffer
|
|
36
|
+
* @param {import("./thread-filter.js").ThreadFilter} filter
|
|
37
|
+
* @param {object} config - MCP config
|
|
38
|
+
* @param {import("./thread-name-cache.js").ThreadNameCache} [nameCache] - Thread name cache
|
|
39
|
+
*/
|
|
40
|
+
export function registerTools(server, api, buffer, filter, config, nameCache) {
|
|
41
|
+
const maxPerPoll = config.limits?.maxMessagesPerPoll ?? 20;
|
|
42
|
+
|
|
43
|
+
// --- zalo_get_messages ---
|
|
44
|
+
server.registerTool(
|
|
45
|
+
"zalo_get_messages",
|
|
46
|
+
{
|
|
47
|
+
title: "Get Zalo Messages",
|
|
48
|
+
description:
|
|
49
|
+
"Get messages from Zalo threads (DMs and groups). Returns buffered messages since last read. Use 'since' cursor from previous response for incremental polling.",
|
|
50
|
+
inputSchema: z.object({
|
|
51
|
+
threadId: z.string().optional().describe("Thread ID to read from. Omit for all watched threads."),
|
|
52
|
+
since: z.number().int().min(0).default(0).describe("Cursor from previous read for incremental polling"),
|
|
53
|
+
limit: z.number().int().min(1).max(100).default(maxPerPoll).describe("Max messages to return"),
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
async ({ threadId, since, limit }) => {
|
|
57
|
+
try {
|
|
58
|
+
const result = buffer.read(threadId, since, limit);
|
|
59
|
+
// Enrich messages with thread name from cache
|
|
60
|
+
if (nameCache) {
|
|
61
|
+
for (const msg of result.messages) {
|
|
62
|
+
const info = nameCache.get(msg.threadId);
|
|
63
|
+
if (info) msg.threadName = info.name;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return ok(result);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
console.error("[mcp-tools] zalo_get_messages error:", e.message);
|
|
69
|
+
return err(e.message);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// --- zalo_send_message ---
|
|
75
|
+
server.registerTool(
|
|
76
|
+
"zalo_send_message",
|
|
77
|
+
{
|
|
78
|
+
title: "Send Zalo Message",
|
|
79
|
+
description: "Send a text message to a Zalo thread (DM or group). threadType: 0=DM(User), 1=Group.",
|
|
80
|
+
inputSchema: z.object({
|
|
81
|
+
threadId: z.string().describe("Thread ID to send message to"),
|
|
82
|
+
text: z.string().min(1).describe("Message text to send"),
|
|
83
|
+
threadType: z
|
|
84
|
+
.number()
|
|
85
|
+
.int()
|
|
86
|
+
.min(0)
|
|
87
|
+
.max(1)
|
|
88
|
+
.default(THREAD_USER)
|
|
89
|
+
.describe("Thread type: 0=DM(User), 1=Group"),
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
async ({ threadId, text, threadType }) => {
|
|
93
|
+
try {
|
|
94
|
+
const result = await api.sendMessage(text, threadId, Number(threadType));
|
|
95
|
+
const messageId = result?.message?.msgId ?? result?.msgId ?? null;
|
|
96
|
+
return ok({ success: true, messageId });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error("[mcp-tools] zalo_send_message error:", e.message);
|
|
99
|
+
return err(e.message);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// --- zalo_list_threads ---
|
|
105
|
+
server.registerTool(
|
|
106
|
+
"zalo_list_threads",
|
|
107
|
+
{
|
|
108
|
+
title: "List Zalo Threads",
|
|
109
|
+
description:
|
|
110
|
+
"List all Zalo threads currently buffered with unread message counts. Useful for discovering active conversations.",
|
|
111
|
+
inputSchema: z.object({
|
|
112
|
+
type: z
|
|
113
|
+
.enum(["group", "dm", "all"])
|
|
114
|
+
.default("all")
|
|
115
|
+
.describe("Filter by thread type: 'dm', 'group', or 'all'"),
|
|
116
|
+
}),
|
|
117
|
+
},
|
|
118
|
+
async ({ type }) => {
|
|
119
|
+
try {
|
|
120
|
+
const stats = buffer.getStats(0);
|
|
121
|
+
// Enrich each stat entry with threadType and thread name
|
|
122
|
+
const enriched = stats.map((t) => {
|
|
123
|
+
const threadType = buffer.getThreadType(t.threadId) ?? "unknown";
|
|
124
|
+
const cached = nameCache?.get(t.threadId);
|
|
125
|
+
return {
|
|
126
|
+
...t,
|
|
127
|
+
threadType,
|
|
128
|
+
name: cached?.name ?? null,
|
|
129
|
+
...(cached?.memberCount !== undefined && { memberCount: cached.memberCount }),
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
const filtered = type === "all" ? enriched : enriched.filter((t) => t.threadType === type);
|
|
133
|
+
return ok({ threads: filtered, total: filtered.length });
|
|
134
|
+
} catch (e) {
|
|
135
|
+
console.error("[mcp-tools] zalo_list_threads error:", e.message);
|
|
136
|
+
return err(e.message);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// --- zalo_search_threads ---
|
|
142
|
+
server.registerTool(
|
|
143
|
+
"zalo_search_threads",
|
|
144
|
+
{
|
|
145
|
+
title: "Search Zalo Threads",
|
|
146
|
+
description:
|
|
147
|
+
"Search threads (groups/DMs) by name. Uses fuzzy Vietnamese-aware matching. Useful for finding a thread ID by name.",
|
|
148
|
+
inputSchema: z.object({
|
|
149
|
+
query: z.string().min(1).describe("Search keyword (fuzzy match, case-insensitive, accent-insensitive)"),
|
|
150
|
+
type: z
|
|
151
|
+
.enum(["group", "dm", "all"])
|
|
152
|
+
.default("all")
|
|
153
|
+
.describe("Filter by thread type: 'dm', 'group', or 'all'"),
|
|
154
|
+
limit: z.number().int().min(1).max(50).default(10).describe("Max results to return"),
|
|
155
|
+
}),
|
|
156
|
+
},
|
|
157
|
+
async ({ query, type, limit }) => {
|
|
158
|
+
try {
|
|
159
|
+
if (!nameCache?.ready) {
|
|
160
|
+
return err("Thread name cache not initialized yet. Try again shortly.");
|
|
161
|
+
}
|
|
162
|
+
const results = nameCache.search(query, type, limit);
|
|
163
|
+
return ok({ results, total: results.length });
|
|
164
|
+
} catch (e) {
|
|
165
|
+
console.error("[mcp-tools] zalo_search_threads error:", e.message);
|
|
166
|
+
return err(e.message);
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// --- zalo_mark_read ---
|
|
172
|
+
server.registerTool(
|
|
173
|
+
"zalo_mark_read",
|
|
174
|
+
{
|
|
175
|
+
title: "Mark Zalo Messages Read",
|
|
176
|
+
description:
|
|
177
|
+
"Discard buffered messages up to and including the given cursor. Use the cursor returned by zalo_get_messages.",
|
|
178
|
+
inputSchema: z.object({
|
|
179
|
+
cursor: z
|
|
180
|
+
.number()
|
|
181
|
+
.int()
|
|
182
|
+
.min(0)
|
|
183
|
+
.describe("Cursor value returned from a previous zalo_get_messages call"),
|
|
184
|
+
}),
|
|
185
|
+
},
|
|
186
|
+
async ({ cursor }) => {
|
|
187
|
+
try {
|
|
188
|
+
const discarded = buffer.markRead(cursor);
|
|
189
|
+
return ok({ success: true, discarded });
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.error("[mcp-tools] zalo_mark_read error:", e.message);
|
|
192
|
+
return err(e.message);
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// --- zalo_get_history ---
|
|
198
|
+
server.registerTool(
|
|
199
|
+
"zalo_get_history",
|
|
200
|
+
{
|
|
201
|
+
title: "Get Zalo Message History",
|
|
202
|
+
description:
|
|
203
|
+
"Fetch historical messages from a Zalo DM or group conversation (up to ~2 weeks). " +
|
|
204
|
+
"Unlike zalo_get_messages (which reads from the live buffer), this fetches older messages " +
|
|
205
|
+
"from the Zalo server. Use 'lastMsgId' cursor from previous response for pagination. " +
|
|
206
|
+
"WARNING: Large limits may consume significant memory/bandwidth. Start with a small limit and paginate.",
|
|
207
|
+
inputSchema: z.object({
|
|
208
|
+
threadId: z.string().describe("Thread ID to fetch history from"),
|
|
209
|
+
threadType: z
|
|
210
|
+
.number()
|
|
211
|
+
.int()
|
|
212
|
+
.min(0)
|
|
213
|
+
.max(1)
|
|
214
|
+
.default(THREAD_USER)
|
|
215
|
+
.describe("Thread type: 0=DM(User), 1=Group"),
|
|
216
|
+
limit: z.number().int().min(1).max(200).default(50).describe("Max messages to fetch"),
|
|
217
|
+
lastMsgId: z
|
|
218
|
+
.string()
|
|
219
|
+
.optional()
|
|
220
|
+
.nullable()
|
|
221
|
+
.describe("Cursor: last message ID from previous fetch for pagination"),
|
|
222
|
+
}),
|
|
223
|
+
},
|
|
224
|
+
async ({ threadId, threadType, limit, lastMsgId }) => {
|
|
225
|
+
try {
|
|
226
|
+
const allMessages = [];
|
|
227
|
+
let cursor = lastMsgId || null;
|
|
228
|
+
let done = false;
|
|
229
|
+
const maxPages = Math.ceil(limit / 20);
|
|
230
|
+
let page = 0;
|
|
231
|
+
|
|
232
|
+
while (!done && allMessages.length < limit && page < maxPages) {
|
|
233
|
+
page++;
|
|
234
|
+
const pageMessages = await new Promise((resolve) => {
|
|
235
|
+
const handler = (messages) => {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
api.listener.removeListener("old_messages", handler);
|
|
238
|
+
resolve(messages);
|
|
239
|
+
};
|
|
240
|
+
const timer = setTimeout(() => {
|
|
241
|
+
api.listener.removeListener("old_messages", handler);
|
|
242
|
+
resolve([]);
|
|
243
|
+
}, 10000);
|
|
244
|
+
|
|
245
|
+
api.listener.on("old_messages", handler);
|
|
246
|
+
api.listener.requestOldMessages(threadType, cursor);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (!pageMessages || pageMessages.length === 0) {
|
|
250
|
+
done = true;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const msg of pageMessages) {
|
|
255
|
+
if (allMessages.length >= limit) break;
|
|
256
|
+
// API returns messages globally — filter to requested thread
|
|
257
|
+
const msgThread = String(msg.threadId || "");
|
|
258
|
+
const msgSender = String(msg.data?.uidFrom || "");
|
|
259
|
+
const target = String(threadId);
|
|
260
|
+
if (msgThread !== target && msgSender !== target) continue;
|
|
261
|
+
const rawContent = msg.data?.content;
|
|
262
|
+
const isText = typeof rawContent === "string";
|
|
263
|
+
allMessages.push({
|
|
264
|
+
msgId: msg.data?.msgId,
|
|
265
|
+
threadId: msg.threadId,
|
|
266
|
+
senderId: msg.data?.uidFrom || null,
|
|
267
|
+
senderName: msg.data?.dName || null,
|
|
268
|
+
text: isText ? rawContent : extractMessageText(rawContent, msg.data?.msgType),
|
|
269
|
+
timestamp: msg.data?.ts ? Number(msg.data.ts) : null,
|
|
270
|
+
type: isText ? "text" : msg.data?.msgType || "attachment",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const lastMsg = pageMessages[pageMessages.length - 1];
|
|
275
|
+
const nextId = lastMsg?.data?.actionId || lastMsg?.data?.msgId;
|
|
276
|
+
if (!nextId || nextId === cursor) done = true;
|
|
277
|
+
cursor = nextId;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Sort oldest first
|
|
281
|
+
allMessages.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
|
282
|
+
|
|
283
|
+
// Enrich with thread name
|
|
284
|
+
if (nameCache) {
|
|
285
|
+
const info = nameCache.get(threadId);
|
|
286
|
+
if (info) {
|
|
287
|
+
for (const msg of allMessages) msg.threadName = info.name;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return ok({
|
|
292
|
+
threadId,
|
|
293
|
+
threadType: threadType === 0 ? "dm" : "group",
|
|
294
|
+
count: allMessages.length,
|
|
295
|
+
messages: allMessages,
|
|
296
|
+
cursor: cursor,
|
|
297
|
+
hasMore: !done,
|
|
298
|
+
});
|
|
299
|
+
} catch (e) {
|
|
300
|
+
console.error("[mcp-tools] zalo_get_history error:", e.message);
|
|
301
|
+
return err(e.message);
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// --- zalo_view_media ---
|
|
307
|
+
const mediaConfig = config.media || {};
|
|
308
|
+
server.registerTool(
|
|
309
|
+
"zalo_view_media",
|
|
310
|
+
{
|
|
311
|
+
title: "View Zalo Media",
|
|
312
|
+
description:
|
|
313
|
+
"Open a Zalo media file (image, audio, video) with the system viewer. " +
|
|
314
|
+
"Media is auto-downloaded when received, organized by thread folder with date/sender metadata filenames. " +
|
|
315
|
+
"If not yet downloaded, downloads first then opens.",
|
|
316
|
+
inputSchema: z.object({
|
|
317
|
+
messageId: z.string().describe("Message ID from zalo_get_messages that has a media attachment"),
|
|
318
|
+
threadId: z.string().optional().describe("Thread ID to search in. Omit to search all threads."),
|
|
319
|
+
open: z
|
|
320
|
+
.boolean()
|
|
321
|
+
.default(mediaConfig.autoOpen ?? true)
|
|
322
|
+
.describe("Open media with system viewer"),
|
|
323
|
+
}),
|
|
324
|
+
},
|
|
325
|
+
async ({ messageId, threadId, open }) => {
|
|
326
|
+
try {
|
|
327
|
+
const allMessages = buffer.read(threadId, 0, 9999).messages;
|
|
328
|
+
const message = allMessages.find((m) => m.id === messageId);
|
|
329
|
+
if (!message) return err(`Message ${messageId} not found in buffer`);
|
|
330
|
+
if (!message.attachment?.url) return err(`Message ${messageId} has no media attachment`);
|
|
331
|
+
|
|
332
|
+
// Use local file if already auto-downloaded, otherwise download now
|
|
333
|
+
let localPath = message.attachment.localPath;
|
|
334
|
+
if (!localPath) {
|
|
335
|
+
const threadName = nameCache?.get(message.threadId)?.name || null;
|
|
336
|
+
const result = await downloadMedia(message, {
|
|
337
|
+
downloadDir: mediaConfig.downloadDir || undefined,
|
|
338
|
+
autoOpen: false,
|
|
339
|
+
threadName,
|
|
340
|
+
});
|
|
341
|
+
localPath = result.path;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (open) openFile(localPath);
|
|
345
|
+
|
|
346
|
+
return ok({ success: true, path: localPath, mediaType: message.type });
|
|
347
|
+
} catch (e) {
|
|
348
|
+
console.error("[mcp-tools] zalo_view_media error:", e.message);
|
|
349
|
+
return err(e.message);
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download Zalo media (images, audio, video) to local filesystem, organized by thread name.
|
|
3
|
+
* Folder structure: {downloadDir}/{threadName}/{date}_{time}_{sender}_{msgId}.{ext}
|
|
4
|
+
* Cross-platform: opens media 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 { execFile } 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, "media");
|
|
15
|
+
|
|
16
|
+
/** Characters unsafe for filesystem paths — stripped from folder/file names */
|
|
17
|
+
const UNSAFE_CHARS = /[/\\:*?"<>|]/g;
|
|
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
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Sanitize a string for use as a filesystem name.
|
|
54
|
+
* @param {string} name
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
function sanitize(name) {
|
|
58
|
+
return (name || "unknown").replace(UNSAFE_CHARS, "_").trim() || "unknown";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Guess file extension from URL, content-type, or message type.
|
|
63
|
+
* @param {string} url
|
|
64
|
+
* @param {string} [contentType]
|
|
65
|
+
* @param {string} [msgType] - Normalized message type (image, audio, video, etc.)
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function guessExtension(url, contentType, msgType) {
|
|
69
|
+
// Try content-type first
|
|
70
|
+
if (contentType) {
|
|
71
|
+
const ct = contentType.split(";")[0].trim().toLowerCase();
|
|
72
|
+
if (CONTENT_TYPE_MAP[ct]) return CONTENT_TYPE_MAP[ct];
|
|
73
|
+
}
|
|
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);
|
|
76
|
+
if (urlMatch) return urlMatch[1] === "jpeg" ? "jpg" : urlMatch[1].toLowerCase();
|
|
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);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the folder name for a thread.
|
|
92
|
+
* Groups: thread display name. DMs: "DM_{senderName}".
|
|
93
|
+
* @param {object} message - Normalized message
|
|
94
|
+
* @param {string} [threadName] - Resolved thread name from cache
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function buildFolderName(message, threadName) {
|
|
98
|
+
if (message.threadType === "group") {
|
|
99
|
+
return sanitize(threadName || message.threadId);
|
|
100
|
+
}
|
|
101
|
+
return sanitize(`DM_${threadName || message.senderName || message.senderId}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build a descriptive filename from message metadata.
|
|
106
|
+
* Format: {YYYY-MM-DD}_{HH-mm}_{sender}_{msgId}.{ext}
|
|
107
|
+
* @param {object} message
|
|
108
|
+
* @param {string} ext - File extension
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
function buildFileName(message, ext) {
|
|
112
|
+
const d = new Date(message.timestamp);
|
|
113
|
+
const date = d.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
114
|
+
const time = `${String(d.getHours()).padStart(2, "0")}-${String(d.getMinutes()).padStart(2, "0")}`;
|
|
115
|
+
const sender = sanitize(message.senderName || message.senderId);
|
|
116
|
+
const msgId = (message.id || "noId").slice(-8); // last 8 chars for brevity
|
|
117
|
+
return `${date}_${time}_${sender}_${msgId}.${ext}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Open a file with the system's default viewer (cross-platform).
|
|
122
|
+
* Exported for use by MCP tools.
|
|
123
|
+
* @param {string} filePath
|
|
124
|
+
*/
|
|
125
|
+
export function openFile(filePath) {
|
|
126
|
+
const isWin = platform() === "win32";
|
|
127
|
+
const cmds = { darwin: "open", win32: "start", linux: "xdg-open" };
|
|
128
|
+
const cmd = cmds[platform()] || "xdg-open";
|
|
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}`);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Auto-download media in background when a message arrives.
|
|
137
|
+
* Non-blocking — fires and forgets. Mutates message.attachment.localPath on success.
|
|
138
|
+
* @param {object} message - Normalized message (will be mutated with localPath)
|
|
139
|
+
* @param {object} [options]
|
|
140
|
+
* @param {string} [options.downloadDir] - Base download directory
|
|
141
|
+
* @param {string} [options.threadName] - Thread display name from cache
|
|
142
|
+
*/
|
|
143
|
+
export function autoDownloadMedia(message, options = {}) {
|
|
144
|
+
if (!message.attachment?.url) return;
|
|
145
|
+
downloadMedia(message, { ...options, autoOpen: false }).then(
|
|
146
|
+
(result) => {
|
|
147
|
+
message.attachment.localPath = result.path;
|
|
148
|
+
console.error(`[media-dl] Saved: ${result.path}`);
|
|
149
|
+
},
|
|
150
|
+
(err) => {
|
|
151
|
+
console.error(`[media-dl] Auto-download failed: ${err.message}`);
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Download media from URL and save to organized local folder.
|
|
158
|
+
* @param {object} message - Normalized message with attachment.url
|
|
159
|
+
* @param {object} [options]
|
|
160
|
+
* @param {string} [options.downloadDir] - Base download directory
|
|
161
|
+
* @param {boolean} [options.autoOpen] - Open media with system viewer after download
|
|
162
|
+
* @param {string} [options.threadName] - Thread display name from cache
|
|
163
|
+
* @returns {Promise<{ success: boolean, path: string, folder: string, fileName: string, mediaType: string }>}
|
|
164
|
+
*/
|
|
165
|
+
export async function downloadMedia(message, options = {}) {
|
|
166
|
+
const url = message.attachment?.url;
|
|
167
|
+
if (!url) throw new Error("Message has no attachment URL");
|
|
168
|
+
|
|
169
|
+
const baseDir = options.downloadDir || DEFAULT_DIR;
|
|
170
|
+
const folder = buildFolderName(message, options.threadName);
|
|
171
|
+
const folderPath = join(baseDir, folder);
|
|
172
|
+
mkdirSync(folderPath, { recursive: true });
|
|
173
|
+
|
|
174
|
+
const response = await fetch(url);
|
|
175
|
+
if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
|
|
176
|
+
|
|
177
|
+
const contentType = response.headers.get("content-type") || "";
|
|
178
|
+
const msgType = message.type || "file";
|
|
179
|
+
const ext = guessExtension(url, contentType, msgType);
|
|
180
|
+
const fileName = buildFileName(message, ext);
|
|
181
|
+
const filePath = join(folderPath, fileName);
|
|
182
|
+
|
|
183
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
184
|
+
writeFileSync(filePath, buffer);
|
|
185
|
+
|
|
186
|
+
if (options.autoOpen) {
|
|
187
|
+
openFile(filePath);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { success: true, path: filePath, folder, fileName, mediaType: msgType };
|
|
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
|
+
});
|