zalo-agent-cli 1.3.0 → 1.4.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/group.js +69 -5
- package/src/commands/mcp.js +13 -8
- package/src/mcp/mcp-http-transport.js +1 -1
- package/src/mcp/mcp-server.js +3 -2
- package/src/mcp/mcp-tools.js +52 -26
- package/src/mcp/message-buffer.js +1 -3
- package/src/mcp/thread-name-cache.js +162 -0
- package/src/mcp/thread-name-cache.test.js +139 -0
- package/src/utils/qr-display.js +8 -6
- package/src/utils/qr-display.test.js +1 -2
- package/src/utils/qr-http-server.js +24 -6
package/package.json
CHANGED
package/src/commands/group.js
CHANGED
|
@@ -12,11 +12,75 @@ export function registerGroupCommands(program) {
|
|
|
12
12
|
|
|
13
13
|
group
|
|
14
14
|
.command("list")
|
|
15
|
-
.description("List all groups")
|
|
16
|
-
.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
.description("List all groups with names and member counts")
|
|
16
|
+
.option("-q, --query <text>", "Filter groups by name (case-insensitive, accent-insensitive)")
|
|
17
|
+
.action(async (opts) => {
|
|
18
|
+
try {
|
|
19
|
+
const api = getApi();
|
|
20
|
+
const groupsResult = await api.getAllGroups();
|
|
21
|
+
const groupIds = Object.keys(groupsResult?.gridVerMap || {});
|
|
22
|
+
|
|
23
|
+
if (groupIds.length === 0) {
|
|
24
|
+
info("No groups found.");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Batch fetch group info (50 per batch) to get names
|
|
29
|
+
const groups = [];
|
|
30
|
+
const batchSize = 50;
|
|
31
|
+
for (let i = 0; i < groupIds.length; i += batchSize) {
|
|
32
|
+
const batch = groupIds.slice(i, i + batchSize);
|
|
33
|
+
try {
|
|
34
|
+
const groupInfo = await api.getGroupInfo(batch);
|
|
35
|
+
const map = groupInfo?.gridInfoMap || {};
|
|
36
|
+
for (const [gid, g] of Object.entries(map)) {
|
|
37
|
+
groups.push({
|
|
38
|
+
threadId: gid,
|
|
39
|
+
name: g.name || "?",
|
|
40
|
+
memberCount: g.totalMember || 0,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Skip failed batch, add IDs without names
|
|
45
|
+
for (const id of batch) {
|
|
46
|
+
groups.push({ threadId: id, name: "?", memberCount: 0 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Apply name filter if --query provided
|
|
52
|
+
let filtered = groups;
|
|
53
|
+
if (opts.query) {
|
|
54
|
+
const q = opts.query
|
|
55
|
+
.normalize("NFD")
|
|
56
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
57
|
+
.replace(/đ/g, "d")
|
|
58
|
+
.replace(/Đ/g, "D")
|
|
59
|
+
.toLowerCase();
|
|
60
|
+
filtered = groups.filter((g) => {
|
|
61
|
+
const normalized = g.name
|
|
62
|
+
.normalize("NFD")
|
|
63
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
64
|
+
.replace(/đ/g, "d")
|
|
65
|
+
.replace(/Đ/g, "D")
|
|
66
|
+
.toLowerCase();
|
|
67
|
+
return normalized.includes(q);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
output(filtered, program.opts().json, () => {
|
|
72
|
+
info(
|
|
73
|
+
`${filtered.length} group(s)${opts.query ? ` matching "${opts.query}"` : ""} (${groupIds.length} total)`,
|
|
74
|
+
);
|
|
75
|
+
console.log();
|
|
76
|
+
console.log(" THREAD_ID MEMBERS NAME");
|
|
77
|
+
console.log(" " + "-".repeat(65));
|
|
78
|
+
for (const g of filtered) {
|
|
79
|
+
const id = g.threadId.padEnd(22);
|
|
80
|
+
const members = String(g.memberCount).padStart(5);
|
|
81
|
+
console.log(` ${id} ${members} ${g.name}`);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
20
84
|
} catch (e) {
|
|
21
85
|
error(e.message);
|
|
22
86
|
}
|
package/src/commands/mcp.js
CHANGED
|
@@ -13,6 +13,7 @@ import { createMCPServer } from "../mcp/mcp-server.js";
|
|
|
13
13
|
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
|
+
import { ThreadNameCache } from "../mcp/thread-name-cache.js";
|
|
16
17
|
|
|
17
18
|
/** Zalo close code for duplicate web session — fatal, do not retry */
|
|
18
19
|
const CLOSE_DUPLICATE = 3000;
|
|
@@ -31,9 +32,7 @@ function normalizeMessage(msg) {
|
|
|
31
32
|
threadType: msg.type === 0 ? "dm" : "group",
|
|
32
33
|
senderId: msg.data.uidFrom || null,
|
|
33
34
|
senderName: msg.data.dName || null,
|
|
34
|
-
text: isText
|
|
35
|
-
? rawContent
|
|
36
|
-
: rawContent?.title || rawContent?.href || `[${msg.data.msgType || "attachment"}]`,
|
|
35
|
+
text: isText ? rawContent : rawContent?.title || rawContent?.href || `[${msg.data.msgType || "attachment"}]`,
|
|
37
36
|
timestamp: Date.now(),
|
|
38
37
|
type: isText ? "text" : msg.data.msgType || "attachment",
|
|
39
38
|
attachment:
|
|
@@ -75,15 +74,23 @@ export function registerMCPCommands(program) {
|
|
|
75
74
|
const buffer = new MessageBuffer(maxSize, maxAge);
|
|
76
75
|
const filter = new ThreadFilter(config);
|
|
77
76
|
|
|
77
|
+
// Build thread name cache (groups + friends → in-memory index)
|
|
78
|
+
const nameCache = new ThreadNameCache();
|
|
79
|
+
try {
|
|
80
|
+
await nameCache.init(getApi());
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.error("[mcp] Thread name cache init failed (non-fatal):", e.message);
|
|
83
|
+
}
|
|
84
|
+
|
|
78
85
|
// Start MCP server — stdio (default) or HTTP
|
|
79
86
|
try {
|
|
80
87
|
if (opts.http) {
|
|
81
88
|
const port = Number(opts.http);
|
|
82
|
-
const deps = { api: getApi(), buffer, filter, config };
|
|
89
|
+
const deps = { api: getApi(), buffer, filter, config, nameCache };
|
|
83
90
|
createHTTPServer(registerTools, deps, port, opts.auth || null);
|
|
84
91
|
console.error(`[mcp] HTTP server started on port ${port}`);
|
|
85
92
|
} else {
|
|
86
|
-
await createMCPServer(getApi(), buffer, filter, config);
|
|
93
|
+
await createMCPServer(getApi(), buffer, filter, config, nameCache);
|
|
87
94
|
}
|
|
88
95
|
} catch (e) {
|
|
89
96
|
console.error("[mcp] Failed to start MCP server:", e.message);
|
|
@@ -115,9 +122,7 @@ export function registerMCPCommands(program) {
|
|
|
115
122
|
|
|
116
123
|
buffer.push(normalized.threadId, normalized);
|
|
117
124
|
notifier.onMessage(normalized);
|
|
118
|
-
console.error(
|
|
119
|
-
`[mcp] Buffered ${normalized.threadType} msg from ${normalized.threadId}`,
|
|
120
|
-
);
|
|
125
|
+
console.error(`[mcp] Buffered ${normalized.threadType} msg from ${normalized.threadId}`);
|
|
121
126
|
});
|
|
122
127
|
|
|
123
128
|
api.listener.on("connected", () => {
|
|
@@ -35,7 +35,7 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken) {
|
|
|
35
35
|
app.post("/mcp", async (req, res) => {
|
|
36
36
|
try {
|
|
37
37
|
const server = new McpServer({ name: "zalo-agent", version: "1.0.0" });
|
|
38
|
-
registerToolsFn(server, deps.api, deps.buffer, deps.filter, deps.config);
|
|
38
|
+
registerToolsFn(server, deps.api, deps.buffer, deps.filter, deps.config, deps.nameCache);
|
|
39
39
|
|
|
40
40
|
const transport = new StreamableHTTPServerTransport({
|
|
41
41
|
sessionIdGenerator: undefined, // stateless mode
|
package/src/mcp/mcp-server.js
CHANGED
|
@@ -14,15 +14,16 @@ import { registerTools } from "./mcp-tools.js";
|
|
|
14
14
|
* @param {import("./message-buffer.js").MessageBuffer} buffer
|
|
15
15
|
* @param {import("./thread-filter.js").ThreadFilter} filter
|
|
16
16
|
* @param {object} config - MCP config
|
|
17
|
+
* @param {import("./thread-name-cache.js").ThreadNameCache} [nameCache] - Thread name cache
|
|
17
18
|
* @returns {Promise<McpServer>}
|
|
18
19
|
*/
|
|
19
|
-
export async function createMCPServer(api, buffer, filter, config) {
|
|
20
|
+
export async function createMCPServer(api, buffer, filter, config, nameCache) {
|
|
20
21
|
const server = new McpServer({
|
|
21
22
|
name: "zalo-agent",
|
|
22
23
|
version: "1.0.0",
|
|
23
24
|
});
|
|
24
25
|
|
|
25
|
-
registerTools(server, api, buffer, filter, config);
|
|
26
|
+
registerTools(server, api, buffer, filter, config, nameCache);
|
|
26
27
|
|
|
27
28
|
const transport = new StdioServerTransport();
|
|
28
29
|
await server.connect(transport);
|
package/src/mcp/mcp-tools.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP tool registrations for Zalo message access and sending.
|
|
3
|
-
* Registers
|
|
3
|
+
* Registers 5 tools: zalo_get_messages, zalo_send_message, zalo_list_threads, zalo_search_threads, zalo_mark_read.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { z } from "zod";
|
|
@@ -34,8 +34,9 @@ function err(message) {
|
|
|
34
34
|
* @param {import("./message-buffer.js").MessageBuffer} buffer
|
|
35
35
|
* @param {import("./thread-filter.js").ThreadFilter} filter
|
|
36
36
|
* @param {object} config - MCP config
|
|
37
|
+
* @param {import("./thread-name-cache.js").ThreadNameCache} [nameCache] - Thread name cache
|
|
37
38
|
*/
|
|
38
|
-
export function registerTools(server, api, buffer, filter, config) {
|
|
39
|
+
export function registerTools(server, api, buffer, filter, config, nameCache) {
|
|
39
40
|
const maxPerPoll = config.limits?.maxMessagesPerPoll ?? 20;
|
|
40
41
|
|
|
41
42
|
// --- zalo_get_messages ---
|
|
@@ -46,28 +47,21 @@ export function registerTools(server, api, buffer, filter, config) {
|
|
|
46
47
|
description:
|
|
47
48
|
"Get messages from Zalo threads (DMs and groups). Returns buffered messages since last read. Use 'since' cursor from previous response for incremental polling.",
|
|
48
49
|
inputSchema: z.object({
|
|
49
|
-
threadId: z
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
.describe("Thread ID to read from. Omit for all watched threads."),
|
|
53
|
-
since: z
|
|
54
|
-
.number()
|
|
55
|
-
.int()
|
|
56
|
-
.min(0)
|
|
57
|
-
.default(0)
|
|
58
|
-
.describe("Cursor from previous read for incremental polling"),
|
|
59
|
-
limit: z
|
|
60
|
-
.number()
|
|
61
|
-
.int()
|
|
62
|
-
.min(1)
|
|
63
|
-
.max(100)
|
|
64
|
-
.default(maxPerPoll)
|
|
65
|
-
.describe("Max messages to return"),
|
|
50
|
+
threadId: z.string().optional().describe("Thread ID to read from. Omit for all watched threads."),
|
|
51
|
+
since: z.number().int().min(0).default(0).describe("Cursor from previous read for incremental polling"),
|
|
52
|
+
limit: z.number().int().min(1).max(100).default(maxPerPoll).describe("Max messages to return"),
|
|
66
53
|
}),
|
|
67
54
|
},
|
|
68
55
|
async ({ threadId, since, limit }) => {
|
|
69
56
|
try {
|
|
70
57
|
const result = buffer.read(threadId, since, limit);
|
|
58
|
+
// Enrich messages with thread name from cache
|
|
59
|
+
if (nameCache) {
|
|
60
|
+
for (const msg of result.messages) {
|
|
61
|
+
const info = nameCache.get(msg.threadId);
|
|
62
|
+
if (info) msg.threadName = info.name;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
71
65
|
return ok(result);
|
|
72
66
|
} catch (e) {
|
|
73
67
|
console.error("[mcp-tools] zalo_get_messages error:", e.message);
|
|
@@ -81,8 +75,7 @@ export function registerTools(server, api, buffer, filter, config) {
|
|
|
81
75
|
"zalo_send_message",
|
|
82
76
|
{
|
|
83
77
|
title: "Send Zalo Message",
|
|
84
|
-
description:
|
|
85
|
-
"Send a text message to a Zalo thread (DM or group). threadType: 0=DM(User), 1=Group.",
|
|
78
|
+
description: "Send a text message to a Zalo thread (DM or group). threadType: 0=DM(User), 1=Group.",
|
|
86
79
|
inputSchema: z.object({
|
|
87
80
|
threadId: z.string().describe("Thread ID to send message to"),
|
|
88
81
|
text: z.string().min(1).describe("Message text to send"),
|
|
@@ -129,12 +122,15 @@ export function registerTools(server, api, buffer, filter, config) {
|
|
|
129
122
|
const enriched = stats.map((t) => {
|
|
130
123
|
const thread = buffer._threads.get(t.threadId);
|
|
131
124
|
const threadType = thread?.messages?.[0]?.threadType ?? "unknown";
|
|
132
|
-
|
|
125
|
+
const cached = nameCache?.get(t.threadId);
|
|
126
|
+
return {
|
|
127
|
+
...t,
|
|
128
|
+
threadType,
|
|
129
|
+
name: cached?.name ?? null,
|
|
130
|
+
...(cached?.memberCount !== undefined && { memberCount: cached.memberCount }),
|
|
131
|
+
};
|
|
133
132
|
});
|
|
134
|
-
const filtered =
|
|
135
|
-
type === "all"
|
|
136
|
-
? enriched
|
|
137
|
-
: enriched.filter((t) => t.threadType === type);
|
|
133
|
+
const filtered = type === "all" ? enriched : enriched.filter((t) => t.threadType === type);
|
|
138
134
|
return ok({ threads: filtered, total: filtered.length });
|
|
139
135
|
} catch (e) {
|
|
140
136
|
console.error("[mcp-tools] zalo_list_threads error:", e.message);
|
|
@@ -143,6 +139,36 @@ export function registerTools(server, api, buffer, filter, config) {
|
|
|
143
139
|
},
|
|
144
140
|
);
|
|
145
141
|
|
|
142
|
+
// --- zalo_search_threads ---
|
|
143
|
+
server.registerTool(
|
|
144
|
+
"zalo_search_threads",
|
|
145
|
+
{
|
|
146
|
+
title: "Search Zalo Threads",
|
|
147
|
+
description:
|
|
148
|
+
"Search threads (groups/DMs) by name. Uses fuzzy Vietnamese-aware matching. Useful for finding a thread ID by name.",
|
|
149
|
+
inputSchema: z.object({
|
|
150
|
+
query: z.string().min(1).describe("Search keyword (fuzzy match, case-insensitive, accent-insensitive)"),
|
|
151
|
+
type: z
|
|
152
|
+
.enum(["group", "dm", "all"])
|
|
153
|
+
.default("all")
|
|
154
|
+
.describe("Filter by thread type: 'dm', 'group', or 'all'"),
|
|
155
|
+
limit: z.number().int().min(1).max(50).default(10).describe("Max results to return"),
|
|
156
|
+
}),
|
|
157
|
+
},
|
|
158
|
+
async ({ query, type, limit }) => {
|
|
159
|
+
try {
|
|
160
|
+
if (!nameCache?.ready) {
|
|
161
|
+
return err("Thread name cache not initialized yet. Try again shortly.");
|
|
162
|
+
}
|
|
163
|
+
const results = nameCache.search(query, type, limit);
|
|
164
|
+
return ok({ results, total: results.length });
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.error("[mcp-tools] zalo_search_threads error:", e.message);
|
|
167
|
+
return err(e.message);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
|
|
146
172
|
// --- zalo_mark_read ---
|
|
147
173
|
server.registerTool(
|
|
148
174
|
"zalo_mark_read",
|
|
@@ -41,9 +41,7 @@ export class MessageBuffer {
|
|
|
41
41
|
* @returns {{ messages: Array, cursor: number, hasMore: boolean }}
|
|
42
42
|
*/
|
|
43
43
|
read(threadId, since = 0, maxCount = 20) {
|
|
44
|
-
const sources = threadId
|
|
45
|
-
? [this._threads.get(threadId)].filter(Boolean)
|
|
46
|
-
: Array.from(this._threads.values());
|
|
44
|
+
const sources = threadId ? [this._threads.get(threadId)].filter(Boolean) : Array.from(this._threads.values());
|
|
47
45
|
|
|
48
46
|
// Collect all messages after cursor, sorted by cursor
|
|
49
47
|
let all = [];
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cache mapping threadId → {name, type, memberCount}.
|
|
3
|
+
* Built on startup by fetching all groups + friends from Zalo API.
|
|
4
|
+
* Provides fuzzy Vietnamese-aware search for thread discovery.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalize Vietnamese text for fuzzy matching.
|
|
9
|
+
* Strips diacritics and lowercases for accent-insensitive comparison.
|
|
10
|
+
* @param {string} text
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
function normalizeVietnamese(text) {
|
|
14
|
+
return text
|
|
15
|
+
.normalize("NFD")
|
|
16
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
17
|
+
.replace(/đ/g, "d")
|
|
18
|
+
.replace(/Đ/g, "D")
|
|
19
|
+
.toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ThreadNameCache {
|
|
23
|
+
constructor() {
|
|
24
|
+
/** @type {Map<string, { name: string, type: "group"|"dm", memberCount?: number }>} */
|
|
25
|
+
this._cache = new Map();
|
|
26
|
+
this._ready = false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize cache by fetching all groups and friends from Zalo API.
|
|
31
|
+
* Batches group info requests (50 per batch) to avoid rate limits.
|
|
32
|
+
* @param {object} api - zca-js API instance
|
|
33
|
+
*/
|
|
34
|
+
async init(api) {
|
|
35
|
+
const start = Date.now();
|
|
36
|
+
let groupCount = 0;
|
|
37
|
+
let friendCount = 0;
|
|
38
|
+
|
|
39
|
+
// Load groups: getAllGroups() → IDs → batched getGroupInfo()
|
|
40
|
+
try {
|
|
41
|
+
const groupsResult = await api.getAllGroups();
|
|
42
|
+
const groupIds = Object.keys(groupsResult?.gridVerMap || {});
|
|
43
|
+
const batchSize = 50;
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < groupIds.length; i += batchSize) {
|
|
46
|
+
const batch = groupIds.slice(i, i + batchSize);
|
|
47
|
+
try {
|
|
48
|
+
const info = await api.getGroupInfo(batch);
|
|
49
|
+
const map = info?.gridInfoMap || {};
|
|
50
|
+
for (const [gid, g] of Object.entries(map)) {
|
|
51
|
+
this._cache.set(gid, {
|
|
52
|
+
name: g.name || "?",
|
|
53
|
+
type: "group",
|
|
54
|
+
memberCount: g.totalMember || 0,
|
|
55
|
+
});
|
|
56
|
+
groupCount++;
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.error(`[thread-name-cache] Batch getGroupInfo failed (offset ${i}):`, e.message);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error("[thread-name-cache] getAllGroups failed:", e.message);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Load friends (DM threads)
|
|
67
|
+
try {
|
|
68
|
+
const friends = await api.getAllFriends();
|
|
69
|
+
const list = Array.isArray(friends) ? friends : [];
|
|
70
|
+
for (const f of list) {
|
|
71
|
+
if (f.userId) {
|
|
72
|
+
this._cache.set(f.userId, {
|
|
73
|
+
name: f.displayName || f.zaloName || "?",
|
|
74
|
+
type: "dm",
|
|
75
|
+
});
|
|
76
|
+
friendCount++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.error("[thread-name-cache] getAllFriends failed:", e.message);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this._ready = true;
|
|
84
|
+
const elapsed = Date.now() - start;
|
|
85
|
+
console.error(`[thread-name-cache] Ready: ${groupCount} groups, ${friendCount} friends (${elapsed}ms)`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get thread info by ID. Returns null if not cached.
|
|
90
|
+
* @param {string} threadId
|
|
91
|
+
* @returns {{ name: string, type: "group"|"dm", memberCount?: number } | null}
|
|
92
|
+
*/
|
|
93
|
+
get(threadId) {
|
|
94
|
+
return this._cache.get(threadId) || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get thread name by ID. Returns null if not cached.
|
|
99
|
+
* @param {string} threadId
|
|
100
|
+
* @returns {string | null}
|
|
101
|
+
*/
|
|
102
|
+
getName(threadId) {
|
|
103
|
+
return this._cache.get(threadId)?.name || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Search threads by name with Vietnamese-aware fuzzy matching.
|
|
108
|
+
* @param {string} query - Search keyword
|
|
109
|
+
* @param {"group"|"dm"|"all"} [type="all"] - Filter by thread type
|
|
110
|
+
* @param {number} [limit=10] - Max results
|
|
111
|
+
* @returns {Array<{ threadId: string, name: string, type: "group"|"dm", memberCount?: number }>}
|
|
112
|
+
*/
|
|
113
|
+
search(query, type = "all", limit = 10) {
|
|
114
|
+
const normalizedQuery = normalizeVietnamese(query);
|
|
115
|
+
const results = [];
|
|
116
|
+
|
|
117
|
+
for (const [threadId, info] of this._cache) {
|
|
118
|
+
if (type !== "all" && info.type !== type) continue;
|
|
119
|
+
|
|
120
|
+
const normalizedName = normalizeVietnamese(info.name);
|
|
121
|
+
if (normalizedName.includes(normalizedQuery)) {
|
|
122
|
+
results.push({ threadId, ...info });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Sort: exact prefix match first, then alphabetical
|
|
127
|
+
results.sort((a, b) => {
|
|
128
|
+
const aNorm = normalizeVietnamese(a.name);
|
|
129
|
+
const bNorm = normalizeVietnamese(b.name);
|
|
130
|
+
const aPrefix = aNorm.startsWith(normalizedQuery) ? 0 : 1;
|
|
131
|
+
const bPrefix = bNorm.startsWith(normalizedQuery) ? 0 : 1;
|
|
132
|
+
if (aPrefix !== bPrefix) return aPrefix - bPrefix;
|
|
133
|
+
return aNorm.localeCompare(bNorm);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return results.slice(0, limit);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Update a single thread entry (e.g. on group rename event).
|
|
141
|
+
* @param {string} threadId
|
|
142
|
+
* @param {object} info - Partial update: { name?, type?, memberCount? }
|
|
143
|
+
*/
|
|
144
|
+
set(threadId, info) {
|
|
145
|
+
const existing = this._cache.get(threadId);
|
|
146
|
+
if (existing) {
|
|
147
|
+
this._cache.set(threadId, { ...existing, ...info });
|
|
148
|
+
} else {
|
|
149
|
+
this._cache.set(threadId, { name: "?", type: "group", ...info });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @returns {boolean} Whether cache has been initialized */
|
|
154
|
+
get ready() {
|
|
155
|
+
return this._ready;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @returns {number} Total cached entries */
|
|
159
|
+
get size() {
|
|
160
|
+
return this._cache.size;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { ThreadNameCache } from "./thread-name-cache.js";
|
|
4
|
+
|
|
5
|
+
/** Create a mock API that returns predictable group/friend data */
|
|
6
|
+
function createMockApi(groups = {}, friends = []) {
|
|
7
|
+
return {
|
|
8
|
+
getAllGroups: async () => ({
|
|
9
|
+
gridVerMap: Object.fromEntries(Object.keys(groups).map((id) => [id, 1])),
|
|
10
|
+
}),
|
|
11
|
+
getGroupInfo: async (ids) => ({
|
|
12
|
+
gridInfoMap: Object.fromEntries(ids.filter((id) => groups[id]).map((id) => [id, groups[id]])),
|
|
13
|
+
}),
|
|
14
|
+
getAllFriends: async () => friends,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("ThreadNameCache", () => {
|
|
19
|
+
let cache;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
cache = new ThreadNameCache();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("starts empty and not ready", () => {
|
|
26
|
+
assert.equal(cache.ready, false);
|
|
27
|
+
assert.equal(cache.size, 0);
|
|
28
|
+
assert.equal(cache.get("any"), null);
|
|
29
|
+
assert.equal(cache.getName("any"), null);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("loads groups and friends on init", async () => {
|
|
33
|
+
const api = createMockApi(
|
|
34
|
+
{
|
|
35
|
+
g1: { name: "Nhóm Chờ Báo Giá", totalMember: 20 },
|
|
36
|
+
g2: { name: "Soạn hàng Q. Vũ", totalMember: 15 },
|
|
37
|
+
},
|
|
38
|
+
[
|
|
39
|
+
{ userId: "u1", displayName: "Viet Anh", zaloName: "VA" },
|
|
40
|
+
{ userId: "u2", zaloName: "Bob" },
|
|
41
|
+
],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
await cache.init(api);
|
|
45
|
+
|
|
46
|
+
assert.equal(cache.ready, true);
|
|
47
|
+
assert.equal(cache.size, 4);
|
|
48
|
+
assert.deepEqual(cache.get("g1"), { name: "Nhóm Chờ Báo Giá", type: "group", memberCount: 20 });
|
|
49
|
+
assert.equal(cache.getName("g2"), "Soạn hàng Q. Vũ");
|
|
50
|
+
assert.deepEqual(cache.get("u1"), { name: "Viet Anh", type: "dm" });
|
|
51
|
+
assert.equal(cache.getName("u2"), "Bob");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("handles API failures gracefully", async () => {
|
|
55
|
+
const api = {
|
|
56
|
+
getAllGroups: async () => {
|
|
57
|
+
throw new Error("network error");
|
|
58
|
+
},
|
|
59
|
+
getAllFriends: async () => {
|
|
60
|
+
throw new Error("network error");
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
await cache.init(api);
|
|
65
|
+
|
|
66
|
+
assert.equal(cache.ready, true);
|
|
67
|
+
assert.equal(cache.size, 0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("search", () => {
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
const api = createMockApi(
|
|
73
|
+
{
|
|
74
|
+
g1: { name: "Nhóm Chờ Báo Giá", totalMember: 20 },
|
|
75
|
+
g2: { name: "Soạn hàng Q. Vũ - QV", totalMember: 15 },
|
|
76
|
+
g3: { name: "Soạn hàng kho 2", totalMember: 8 },
|
|
77
|
+
g4: { name: "Admin Team", totalMember: 5 },
|
|
78
|
+
},
|
|
79
|
+
[{ userId: "u1", displayName: "Soạn Văn", zaloName: "SV" }],
|
|
80
|
+
);
|
|
81
|
+
await cache.init(api);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("finds groups by Vietnamese name (accent-insensitive)", () => {
|
|
85
|
+
const results = cache.search("soan hang");
|
|
86
|
+
assert.equal(results.length, 2);
|
|
87
|
+
assert.equal(results[0].name, "Soạn hàng kho 2");
|
|
88
|
+
assert.equal(results[1].name, "Soạn hàng Q. Vũ - QV");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("finds with exact Vietnamese diacritics", () => {
|
|
92
|
+
const results = cache.search("Soạn hàng");
|
|
93
|
+
assert.equal(results.length, 2);
|
|
94
|
+
assert.ok(results.every((r) => r.type === "group"));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("filters by type", () => {
|
|
98
|
+
const groups = cache.search("Soạn", "group");
|
|
99
|
+
assert.equal(groups.length, 2);
|
|
100
|
+
assert.ok(groups.every((r) => r.type === "group"));
|
|
101
|
+
|
|
102
|
+
const dms = cache.search("Soạn", "dm");
|
|
103
|
+
assert.equal(dms.length, 1);
|
|
104
|
+
assert.equal(dms[0].name, "Soạn Văn");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("respects limit parameter", () => {
|
|
108
|
+
const results = cache.search("Soạn", "all", 1);
|
|
109
|
+
assert.equal(results.length, 1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns empty for no match", () => {
|
|
113
|
+
const results = cache.search("xyz_nonexistent");
|
|
114
|
+
assert.equal(results.length, 0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("prioritizes prefix matches", () => {
|
|
118
|
+
const results = cache.search("Admin");
|
|
119
|
+
assert.equal(results[0].name, "Admin Team");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("set (update)", () => {
|
|
124
|
+
it("updates existing entry", async () => {
|
|
125
|
+
const api = createMockApi({ g1: { name: "Old Name", totalMember: 5 } }, []);
|
|
126
|
+
await cache.init(api);
|
|
127
|
+
|
|
128
|
+
cache.set("g1", { name: "New Name" });
|
|
129
|
+
assert.equal(cache.getName("g1"), "New Name");
|
|
130
|
+
assert.equal(cache.get("g1").type, "group");
|
|
131
|
+
assert.equal(cache.get("g1").memberCount, 5);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("adds new entry", () => {
|
|
135
|
+
cache.set("new1", { name: "New Group", type: "group", memberCount: 3 });
|
|
136
|
+
assert.deepEqual(cache.get("new1"), { name: "New Group", type: "group", memberCount: 3 });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
package/src/utils/qr-display.js
CHANGED
|
@@ -59,12 +59,14 @@ export function displayQR(event) {
|
|
|
59
59
|
// JSON mode: structured output for AI agents — no terminal escapes, no noise
|
|
60
60
|
if (jsonMode) {
|
|
61
61
|
if (imageB64) {
|
|
62
|
-
console.log(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
console.log(
|
|
63
|
+
JSON.stringify({
|
|
64
|
+
event: "qr",
|
|
65
|
+
image: imageB64,
|
|
66
|
+
file: QR_PATH,
|
|
67
|
+
dataUrl: `data:image/png;base64,${imageB64}`,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
68
70
|
}
|
|
69
71
|
return;
|
|
70
72
|
}
|
|
@@ -9,8 +9,7 @@ import { existsSync, unlinkSync, mkdirSync } from "fs";
|
|
|
9
9
|
import { dirname } from "path";
|
|
10
10
|
|
|
11
11
|
// Tiny valid 1x1 white PNG as base64 (for testing without real QR)
|
|
12
|
-
const TINY_PNG_B64 =
|
|
13
|
-
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
|
|
12
|
+
const TINY_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
|
|
14
13
|
|
|
15
14
|
describe("displayQR", () => {
|
|
16
15
|
let originalLog;
|
|
@@ -99,6 +99,20 @@ setInterval(async()=>{
|
|
|
99
99
|
},2000);
|
|
100
100
|
</script>
|
|
101
101
|
</body></html>`);
|
|
102
|
+
} else if (req.url === "/qr.png") {
|
|
103
|
+
// Raw PNG image endpoint — for agents to send direct image link
|
|
104
|
+
if (!existsSync(qrImagePath)) {
|
|
105
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
106
|
+
res.end("QR not generated yet");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const img = readFileSync(qrImagePath);
|
|
110
|
+
res.writeHead(200, {
|
|
111
|
+
"Content-Type": "image/png",
|
|
112
|
+
"Content-Length": img.length,
|
|
113
|
+
"Cache-Control": "no-cache, no-store",
|
|
114
|
+
});
|
|
115
|
+
res.end(img);
|
|
102
116
|
} else if (req.url === "/status") {
|
|
103
117
|
// Login status endpoint for browser polling
|
|
104
118
|
let loggedIn = false;
|
|
@@ -134,12 +148,16 @@ setInterval(async()=>{
|
|
|
134
148
|
} catch {}
|
|
135
149
|
|
|
136
150
|
if (jsonMode) {
|
|
137
|
-
console.log(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
151
|
+
console.log(
|
|
152
|
+
JSON.stringify({
|
|
153
|
+
event: "qr_server",
|
|
154
|
+
port: actualPort,
|
|
155
|
+
localUrl: `http://localhost:${actualPort}/qr`,
|
|
156
|
+
imageUrl: `http://localhost:${actualPort}/qr.png`,
|
|
157
|
+
publicUrl: publicIp ? `http://${publicIp}:${actualPort}/qr` : null,
|
|
158
|
+
publicImageUrl: publicIp ? `http://${publicIp}:${actualPort}/qr.png` : null,
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
143
161
|
} else {
|
|
144
162
|
info(`QR available at: http://localhost:${actualPort}/qr`);
|
|
145
163
|
if (publicIp) info(`On VPS, open: http://${publicIp}:${actualPort}/qr`);
|