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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zalo-agent-cli",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
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": {
@@ -12,11 +12,75 @@ export function registerGroupCommands(program) {
12
12
 
13
13
  group
14
14
  .command("list")
15
- .description("List all groups")
16
- .action(async () => {
17
- try {
18
- const result = await getApi().getAllGroups();
19
- output(result, program.opts().json);
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
  }
@@ -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
@@ -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);
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * MCP tool registrations for Zalo message access and sending.
3
- * Registers 4 tools: zalo_get_messages, zalo_send_message, zalo_list_threads, zalo_mark_read.
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
- .string()
51
- .optional()
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
- return { ...t, threadType };
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
+ });
@@ -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(JSON.stringify({
63
- event: "qr",
64
- image: imageB64,
65
- file: QR_PATH,
66
- dataUrl: `data:image/png;base64,${imageB64}`,
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(JSON.stringify({
138
- event: "qr_server",
139
- port: actualPort,
140
- localUrl: `http://localhost:${actualPort}/qr`,
141
- publicUrl: publicIp ? `http://${publicIp}:${actualPort}/qr` : null,
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`);