zalo-agent-cli 1.2.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/README.md +20 -0
- package/package.json +5 -2
- package/src/commands/group.js +69 -5
- package/src/commands/mcp.js +191 -0
- package/src/index.js +5 -2
- package/src/mcp/mcp-config.js +76 -0
- package/src/mcp/mcp-http-transport.js +75 -0
- package/src/mcp/mcp-server.js +34 -0
- package/src/mcp/mcp-tools.js +197 -0
- package/src/mcp/message-buffer.js +117 -0
- package/src/mcp/message-buffer.test.js +282 -0
- package/src/mcp/notifier.js +94 -0
- package/src/mcp/notifier.test.js +239 -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/qr-display.js +8 -6
- package/src/utils/qr-display.test.js +1 -2
- package/src/utils/qr-http-server.js +24 -6
|
@@ -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`);
|