zalo-agent-cli 1.3.0 → 1.4.2

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.2",
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", () => {
@@ -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" });
@@ -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 != null) info(`Followers: ${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);
@@ -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, i) => console.log(` [${o.option_id}] ${o.content}`));
41
+ result.options.forEach((o) => console.log(` [${o.option_id}] ${o.content}`));
42
42
  }
43
43
  });
44
44
  } catch (e) {
@@ -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]);
@@ -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,13 +1,12 @@
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";
7
7
 
8
8
  /** Thread type constants matching zca-js ThreadType enum */
9
9
  const THREAD_USER = 0;
10
- const THREAD_GROUP = 1;
11
10
 
12
11
  /**
13
12
  * Wrap a result object into MCP tool content format.
@@ -34,8 +33,9 @@ function err(message) {
34
33
  * @param {import("./message-buffer.js").MessageBuffer} buffer
35
34
  * @param {import("./thread-filter.js").ThreadFilter} filter
36
35
  * @param {object} config - MCP config
36
+ * @param {import("./thread-name-cache.js").ThreadNameCache} [nameCache] - Thread name cache
37
37
  */
38
- export function registerTools(server, api, buffer, filter, config) {
38
+ export function registerTools(server, api, buffer, filter, config, nameCache) {
39
39
  const maxPerPoll = config.limits?.maxMessagesPerPoll ?? 20;
40
40
 
41
41
  // --- zalo_get_messages ---
@@ -46,28 +46,21 @@ export function registerTools(server, api, buffer, filter, config) {
46
46
  description:
47
47
  "Get messages from Zalo threads (DMs and groups). Returns buffered messages since last read. Use 'since' cursor from previous response for incremental polling.",
48
48
  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"),
49
+ threadId: z.string().optional().describe("Thread ID to read from. Omit for all watched threads."),
50
+ since: z.number().int().min(0).default(0).describe("Cursor from previous read for incremental polling"),
51
+ limit: z.number().int().min(1).max(100).default(maxPerPoll).describe("Max messages to return"),
66
52
  }),
67
53
  },
68
54
  async ({ threadId, since, limit }) => {
69
55
  try {
70
56
  const result = buffer.read(threadId, since, limit);
57
+ // Enrich messages with thread name from cache
58
+ if (nameCache) {
59
+ for (const msg of result.messages) {
60
+ const info = nameCache.get(msg.threadId);
61
+ if (info) msg.threadName = info.name;
62
+ }
63
+ }
71
64
  return ok(result);
72
65
  } catch (e) {
73
66
  console.error("[mcp-tools] zalo_get_messages error:", e.message);
@@ -81,8 +74,7 @@ export function registerTools(server, api, buffer, filter, config) {
81
74
  "zalo_send_message",
82
75
  {
83
76
  title: "Send Zalo Message",
84
- description:
85
- "Send a text message to a Zalo thread (DM or group). threadType: 0=DM(User), 1=Group.",
77
+ description: "Send a text message to a Zalo thread (DM or group). threadType: 0=DM(User), 1=Group.",
86
78
  inputSchema: z.object({
87
79
  threadId: z.string().describe("Thread ID to send message to"),
88
80
  text: z.string().min(1).describe("Message text to send"),
@@ -129,12 +121,15 @@ export function registerTools(server, api, buffer, filter, config) {
129
121
  const enriched = stats.map((t) => {
130
122
  const thread = buffer._threads.get(t.threadId);
131
123
  const threadType = thread?.messages?.[0]?.threadType ?? "unknown";
132
- return { ...t, threadType };
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
+ };
133
131
  });
134
- const filtered =
135
- type === "all"
136
- ? enriched
137
- : enriched.filter((t) => t.threadType === type);
132
+ const filtered = type === "all" ? enriched : enriched.filter((t) => t.threadType === type);
138
133
  return ok({ threads: filtered, total: filtered.length });
139
134
  } catch (e) {
140
135
  console.error("[mcp-tools] zalo_list_threads error:", e.message);
@@ -143,6 +138,36 @@ export function registerTools(server, api, buffer, filter, config) {
143
138
  },
144
139
  );
145
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
+
146
171
  // --- zalo_mark_read ---
147
172
  server.registerTool(
148
173
  "zalo_mark_read",
@@ -41,12 +41,10 @@ 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
- let all = [];
47
+ const all = [];
50
48
  for (const thread of sources) {
51
49
  for (const msg of thread.messages) {
52
50
  if (msg._cursor > since) all.push(msg);
@@ -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) => `- ${m.senderName || m.senderId}: ${(m.text || "").slice(0, 50)}`)
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);
@@ -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`);