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.
@@ -0,0 +1,239 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ZaloNotifier } from "./notifier.js";
4
+
5
+ /** Create a spy api.sendMessage that records calls */
6
+ function makeSpy() {
7
+ const calls = [];
8
+ const api = {
9
+ sendMessage: async (text, thread, type) => {
10
+ calls.push({ text, thread, type });
11
+ },
12
+ };
13
+ return { api, calls };
14
+ }
15
+
16
+ /** Minimal enabled config for ZaloNotifier */
17
+ function enabledConfig(overrides = {}) {
18
+ return {
19
+ notify: {
20
+ enabled: true,
21
+ thread: "notify_group_123",
22
+ on: ["dm"],
23
+ cooldown: "10ms", // very short for tests
24
+ ...overrides,
25
+ },
26
+ };
27
+ }
28
+
29
+ /** Disabled config */
30
+ function disabledConfig() {
31
+ return {
32
+ notify: {
33
+ enabled: false,
34
+ thread: "notify_group_123",
35
+ on: ["dm"],
36
+ cooldown: "10ms",
37
+ },
38
+ };
39
+ }
40
+
41
+ /** A normalised DM message */
42
+ function dmMsg(text = "hello", overrides = {}) {
43
+ return { threadType: "dm", senderId: "user_1", senderName: "Alice", text, ...overrides };
44
+ }
45
+
46
+ /** Wait for ms milliseconds */
47
+ function wait(ms) {
48
+ return new Promise((r) => setTimeout(r, ms));
49
+ }
50
+
51
+ describe("ZaloNotifier constructor", () => {
52
+ it("is disabled by default when config.notify.enabled=false", () => {
53
+ const { api } = makeSpy();
54
+ const n = new ZaloNotifier(api, disabledConfig());
55
+ assert.equal(n._enabled, false);
56
+ });
57
+
58
+ it("is enabled when config.notify.enabled=true", () => {
59
+ const { api } = makeSpy();
60
+ const n = new ZaloNotifier(api, enabledConfig());
61
+ assert.equal(n._enabled, true);
62
+ });
63
+
64
+ it("stores notify thread and onTypes from config", () => {
65
+ const { api } = makeSpy();
66
+ const n = new ZaloNotifier(api, enabledConfig({ thread: "grp_99", on: ["dm", "group"] }));
67
+ assert.equal(n._notifyThread, "grp_99");
68
+ assert.ok(n._onTypes.has("dm"));
69
+ assert.ok(n._onTypes.has("group"));
70
+ });
71
+
72
+ it("agentConnected starts as false", () => {
73
+ const { api } = makeSpy();
74
+ const n = new ZaloNotifier(api, enabledConfig());
75
+ assert.equal(n._agentConnected, false);
76
+ });
77
+ });
78
+
79
+ describe("ZaloNotifier onMessage - disabled", () => {
80
+ it("does not queue messages when disabled", async () => {
81
+ const { api, calls } = makeSpy();
82
+ const n = new ZaloNotifier(api, disabledConfig());
83
+ n.onMessage(dmMsg());
84
+ await wait(30);
85
+ assert.equal(calls.length, 0);
86
+ assert.equal(n._pending.length, 0);
87
+ n.destroy();
88
+ });
89
+ });
90
+
91
+ describe("ZaloNotifier onMessage - agent connected", () => {
92
+ it("does not notify when agent is connected", async () => {
93
+ const { api, calls } = makeSpy();
94
+ const n = new ZaloNotifier(api, enabledConfig());
95
+ n.setAgentConnected(true);
96
+ n.onMessage(dmMsg());
97
+ await wait(30);
98
+ assert.equal(calls.length, 0);
99
+ n.destroy();
100
+ });
101
+
102
+ it("notifies again after agent disconnects", async () => {
103
+ const { api, calls } = makeSpy();
104
+ const n = new ZaloNotifier(api, enabledConfig());
105
+ n.setAgentConnected(true);
106
+ n.onMessage(dmMsg("ignored"));
107
+ n.setAgentConnected(false);
108
+ n.onMessage(dmMsg("should notify"));
109
+ await wait(30);
110
+ assert.equal(calls.length, 1);
111
+ n.destroy();
112
+ });
113
+ });
114
+
115
+ describe("ZaloNotifier onMessage - type filtering", () => {
116
+ it("queues messages matching onTypes (dm)", async () => {
117
+ const { api, calls } = makeSpy();
118
+ const n = new ZaloNotifier(api, enabledConfig({ on: ["dm"] }));
119
+ n.onMessage(dmMsg("queued"));
120
+ await wait(30);
121
+ assert.equal(calls.length, 1);
122
+ });
123
+
124
+ it("ignores messages not matching onTypes", async () => {
125
+ const { api, calls } = makeSpy();
126
+ const n = new ZaloNotifier(api, enabledConfig({ on: ["dm"] }));
127
+ n.onMessage({ threadType: "group", senderId: "u1", senderName: "Bob", text: "hi" });
128
+ await wait(30);
129
+ assert.equal(calls.length, 0);
130
+ });
131
+
132
+ it("notifies for group type when configured", async () => {
133
+ const { api, calls } = makeSpy();
134
+ const n = new ZaloNotifier(api, enabledConfig({ on: ["group"] }));
135
+ n.onMessage({ threadType: "group", senderId: "u1", senderName: "Bob", text: "hi" });
136
+ await wait(30);
137
+ assert.equal(calls.length, 1);
138
+ });
139
+ });
140
+
141
+ describe("ZaloNotifier _flush notification format", () => {
142
+ it("sends to the configured notify thread", async () => {
143
+ const { api, calls } = makeSpy();
144
+ const n = new ZaloNotifier(api, enabledConfig({ thread: "grp_notify" }));
145
+ n.onMessage(dmMsg("test message"));
146
+ await wait(30);
147
+ assert.equal(calls[0].thread, "grp_notify");
148
+ assert.equal(calls[0].type, 1); // Group conversation type
149
+ });
150
+
151
+ it("notification text includes sender name and message preview", async () => {
152
+ const { api, calls } = makeSpy();
153
+ const n = new ZaloNotifier(api, enabledConfig());
154
+ n.onMessage(dmMsg("hello world", { senderName: "Alice" }));
155
+ await wait(30);
156
+ assert.ok(calls[0].text.includes("Alice"));
157
+ assert.ok(calls[0].text.includes("hello world"));
158
+ });
159
+
160
+ it("shows up to 3 message previews in a single flush", async () => {
161
+ const { api, calls } = makeSpy();
162
+ const n = new ZaloNotifier(api, enabledConfig());
163
+ n.onMessage(dmMsg("msg1", { senderName: "A" }));
164
+ n.onMessage(dmMsg("msg2", { senderName: "B" }));
165
+ n.onMessage(dmMsg("msg3", { senderName: "C" }));
166
+ await wait(30);
167
+ // Only one batched send
168
+ assert.equal(calls.length, 1);
169
+ assert.ok(calls[0].text.includes("msg1"));
170
+ assert.ok(calls[0].text.includes("msg2"));
171
+ assert.ok(calls[0].text.includes("msg3"));
172
+ });
173
+
174
+ it("includes overflow count when more than 3 messages", async () => {
175
+ const { api, calls } = makeSpy();
176
+ const n = new ZaloNotifier(api, enabledConfig());
177
+ for (let i = 1; i <= 5; i++) {
178
+ n.onMessage(dmMsg(`msg${i}`, { senderName: `User${i}` }));
179
+ }
180
+ await wait(30);
181
+ assert.equal(calls.length, 1);
182
+ // Should mention the extra 2 messages
183
+ assert.ok(calls[0].text.includes("2"));
184
+ });
185
+
186
+ it("sends total message count in notification text", async () => {
187
+ const { api, calls } = makeSpy();
188
+ const n = new ZaloNotifier(api, enabledConfig());
189
+ n.onMessage(dmMsg("one"));
190
+ n.onMessage(dmMsg("two"));
191
+ await wait(30);
192
+ assert.ok(calls[0].text.includes("2"));
193
+ });
194
+ });
195
+
196
+ describe("ZaloNotifier cooldown batching", () => {
197
+ it("batches multiple messages within cooldown into a single send", async () => {
198
+ const { api, calls } = makeSpy();
199
+ const n = new ZaloNotifier(api, enabledConfig({ cooldown: "20ms" }));
200
+ n.onMessage(dmMsg("first"));
201
+ n.onMessage(dmMsg("second"));
202
+ n.onMessage(dmMsg("third"));
203
+ await wait(50); // wait for cooldown to fire
204
+ assert.equal(calls.length, 1);
205
+ });
206
+
207
+ it("clears pending after flush so next batch is independent", async () => {
208
+ const { api, calls } = makeSpy();
209
+ const n = new ZaloNotifier(api, enabledConfig({ cooldown: "20ms" }));
210
+ n.onMessage(dmMsg("batch1"));
211
+ await wait(40); // first flush
212
+
213
+ n.onMessage(dmMsg("batch2"));
214
+ await wait(40); // second flush
215
+
216
+ assert.equal(calls.length, 2);
217
+ });
218
+ });
219
+
220
+ describe("ZaloNotifier destroy", () => {
221
+ it("flushes pending messages on destroy", async () => {
222
+ const { api, calls } = makeSpy();
223
+ // Long cooldown so timer wouldn't fire naturally
224
+ const n = new ZaloNotifier(api, enabledConfig({ cooldown: "60000ms" }));
225
+ n.onMessage(dmMsg("urgent"));
226
+ n.destroy(); // should flush immediately
227
+ // _flush is async, give it a tick
228
+ await wait(10);
229
+ assert.equal(calls.length, 1);
230
+ });
231
+
232
+ it("destroy with no pending messages does not send", async () => {
233
+ const { api, calls } = makeSpy();
234
+ const n = new ZaloNotifier(api, enabledConfig());
235
+ n.destroy(); // nothing pending, no timer
236
+ await wait(10);
237
+ assert.equal(calls.length, 0);
238
+ });
239
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Filter which threads to watch and which messages to keep.
3
+ * Supports glob-like patterns for thread matching and noise reduction.
4
+ */
5
+
6
+ /** System message types to drop (join, leave, pin, etc.) */
7
+ const SYSTEM_MSG_TYPES = new Set(["system", "join", "leave", "pin", "unpin", "rename"]);
8
+
9
+ export class ThreadFilter {
10
+ /**
11
+ * @param {object} config - MCP config object
12
+ * @param {string[]} config.watchThreads - Glob patterns like ["dm:*", "group:support"]
13
+ * @param {string[]} [config.triggerKeywords] - Keywords that trigger attention
14
+ */
15
+ constructor(config) {
16
+ this._watchPatterns = config.watchThreads || ["dm:*", "group:*"];
17
+ this._triggerKeywords = (config.triggerKeywords || []).map((k) => k.toLowerCase());
18
+ }
19
+
20
+ /**
21
+ * Check if a thread should be watched based on patterns.
22
+ * Pattern format: "dm:*", "group:*", "group:exact_id", "dm:exact_id"
23
+ * @param {string} threadId
24
+ * @param {"group"|"dm"} threadType
25
+ * @returns {boolean}
26
+ */
27
+ shouldWatch(threadId, threadType) {
28
+ const key = `${threadType}:${threadId}`;
29
+ for (const pattern of this._watchPatterns) {
30
+ if (this._matchPattern(pattern, key, threadType)) return true;
31
+ }
32
+ return false;
33
+ }
34
+
35
+ /**
36
+ * Check if a message passes noise filter (drop stickers, short emoji, system msgs).
37
+ * @param {object} message - Normalized message
38
+ * @returns {boolean}
39
+ */
40
+ shouldKeep(message) {
41
+ // Drop system messages
42
+ if (message.type && SYSTEM_MSG_TYPES.has(message.type)) return false;
43
+ // Drop sticker-only messages
44
+ if (message.type === "sticker") return false;
45
+ // Drop very short emoji-only messages (< 3 chars, all emoji/whitespace)
46
+ if (message.text && message.text.length < 3 && /^[\s\p{Emoji}]*$/u.test(message.text)) {
47
+ return false;
48
+ }
49
+ // Keep everything else (including images, files, links)
50
+ return true;
51
+ }
52
+
53
+ /**
54
+ * Check if a message is a trigger (contains keyword or @mention).
55
+ * @param {object} message - Normalized message
56
+ * @returns {boolean}
57
+ */
58
+ isTrigger(message) {
59
+ if (!message.text || this._triggerKeywords.length === 0) return false;
60
+ const lower = message.text.toLowerCase();
61
+ return this._triggerKeywords.some((kw) => lower.includes(kw));
62
+ }
63
+
64
+ /**
65
+ * Match a pattern against a thread key.
66
+ * @param {string} pattern - e.g. "dm:*", "group:support_123"
67
+ * @param {string} key - e.g. "dm:user_456", "group:support_123"
68
+ * @param {string} threadType - "dm" or "group"
69
+ * @returns {boolean}
70
+ */
71
+ _matchPattern(pattern, key, threadType) {
72
+ // Wildcard: "dm:*" matches all DMs, "group:*" matches all groups
73
+ if (pattern === `${threadType}:*`) return true;
74
+ // Catch-all patterns
75
+ if (pattern === "*" || pattern === "*:*") return true;
76
+ // Exact match
77
+ return pattern === key;
78
+ }
79
+ }
@@ -0,0 +1,206 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ThreadFilter } from "./thread-filter.js";
4
+
5
+ /** Helper: create a ThreadFilter with given patterns and keywords */
6
+ function makeFilter(watchThreads = ["dm:*", "group:*"], triggerKeywords = []) {
7
+ return new ThreadFilter({ watchThreads, triggerKeywords });
8
+ }
9
+
10
+ describe("ThreadFilter shouldWatch - glob patterns", () => {
11
+ it("dm:* matches any DM thread", () => {
12
+ const f = makeFilter(["dm:*"]);
13
+ assert.equal(f.shouldWatch("user_123", "dm"), true);
14
+ assert.equal(f.shouldWatch("abc", "dm"), true);
15
+ });
16
+
17
+ it("group:* matches any group thread", () => {
18
+ const f = makeFilter(["group:*"]);
19
+ assert.equal(f.shouldWatch("support_456", "group"), true);
20
+ assert.equal(f.shouldWatch("anything", "group"), true);
21
+ });
22
+
23
+ it("dm:* does not match group threads", () => {
24
+ const f = makeFilter(["dm:*"]);
25
+ assert.equal(f.shouldWatch("some_group", "group"), false);
26
+ });
27
+
28
+ it("group:* does not match dm threads", () => {
29
+ const f = makeFilter(["group:*"]);
30
+ assert.equal(f.shouldWatch("some_user", "dm"), false);
31
+ });
32
+ });
33
+
34
+ describe("ThreadFilter shouldWatch - exact match", () => {
35
+ it("exact group pattern matches only that thread id", () => {
36
+ const f = makeFilter(["group:support_123"]);
37
+ assert.equal(f.shouldWatch("support_123", "group"), true);
38
+ assert.equal(f.shouldWatch("support_456", "group"), false);
39
+ });
40
+
41
+ it("exact dm pattern matches only that thread id", () => {
42
+ const f = makeFilter(["dm:user_42"]);
43
+ assert.equal(f.shouldWatch("user_42", "dm"), true);
44
+ assert.equal(f.shouldWatch("user_99", "dm"), false);
45
+ });
46
+
47
+ it("exact match requires type prefix to align", () => {
48
+ const f = makeFilter(["group:support_123"]);
49
+ // same id but different type should not match
50
+ assert.equal(f.shouldWatch("support_123", "dm"), false);
51
+ });
52
+ });
53
+
54
+ describe("ThreadFilter shouldWatch - catch-all patterns", () => {
55
+ it("* matches any thread regardless of type", () => {
56
+ const f = makeFilter(["*"]);
57
+ assert.equal(f.shouldWatch("user_1", "dm"), true);
58
+ assert.equal(f.shouldWatch("group_1", "group"), true);
59
+ });
60
+
61
+ it("*:* matches any thread regardless of type", () => {
62
+ const f = makeFilter(["*:*"]);
63
+ assert.equal(f.shouldWatch("user_1", "dm"), true);
64
+ assert.equal(f.shouldWatch("group_1", "group"), true);
65
+ });
66
+ });
67
+
68
+ describe("ThreadFilter shouldWatch - unmatched patterns", () => {
69
+ it("returns false when no pattern matches", () => {
70
+ const f = makeFilter(["group:specific_only"]);
71
+ assert.equal(f.shouldWatch("other_group", "group"), false);
72
+ assert.equal(f.shouldWatch("any_user", "dm"), false);
73
+ });
74
+
75
+ it("empty watchThreads array never matches", () => {
76
+ const f = makeFilter([]);
77
+ assert.equal(f.shouldWatch("x", "dm"), false);
78
+ assert.equal(f.shouldWatch("y", "group"), false);
79
+ });
80
+ });
81
+
82
+ describe("ThreadFilter shouldKeep - system messages dropped", () => {
83
+ const SYSTEM_TYPES = ["system", "join", "leave", "pin", "unpin", "rename"];
84
+
85
+ for (const type of SYSTEM_TYPES) {
86
+ it(`drops message with type="${type}"`, () => {
87
+ const f = makeFilter();
88
+ assert.equal(f.shouldKeep({ type, text: "some text" }), false);
89
+ });
90
+ }
91
+ });
92
+
93
+ describe("ThreadFilter shouldKeep - sticker messages dropped", () => {
94
+ it("drops sticker-only messages", () => {
95
+ const f = makeFilter();
96
+ assert.equal(f.shouldKeep({ type: "sticker" }), false);
97
+ });
98
+ });
99
+
100
+ describe("ThreadFilter shouldKeep - short emoji-only messages dropped", () => {
101
+ it("drops single emoji (< 3 chars)", () => {
102
+ const f = makeFilter();
103
+ assert.equal(f.shouldKeep({ text: "👍" }), false);
104
+ });
105
+
106
+ it("drops two-char emoji message", () => {
107
+ const f = makeFilter();
108
+ assert.equal(f.shouldKeep({ text: "😂" }), false);
109
+ });
110
+
111
+ it("keeps messages with 3+ chars even if emoji", () => {
112
+ const f = makeFilter();
113
+ // Three emojis — length in JS may vary; use text that is clearly >= 3 code units
114
+ assert.equal(f.shouldKeep({ text: "hey" }), true);
115
+ });
116
+
117
+ it("drops whitespace-only short message", () => {
118
+ const f = makeFilter();
119
+ // 1-2 spaces are < 3 chars and match /^[\s\p{Emoji}]*$/u
120
+ assert.equal(f.shouldKeep({ text: " " }), false);
121
+ });
122
+ });
123
+
124
+ describe("ThreadFilter shouldKeep - kept messages", () => {
125
+ it("keeps normal text messages", () => {
126
+ const f = makeFilter();
127
+ assert.equal(f.shouldKeep({ type: "text", text: "Hello there!" }), true);
128
+ });
129
+
130
+ it("keeps image messages (no text)", () => {
131
+ const f = makeFilter();
132
+ assert.equal(f.shouldKeep({ type: "image" }), true);
133
+ });
134
+
135
+ it("keeps file messages", () => {
136
+ const f = makeFilter();
137
+ assert.equal(f.shouldKeep({ type: "file" }), true);
138
+ });
139
+
140
+ it("keeps link messages", () => {
141
+ const f = makeFilter();
142
+ assert.equal(f.shouldKeep({ type: "link", text: "https://example.com" }), true);
143
+ });
144
+
145
+ it("keeps message with no type field", () => {
146
+ const f = makeFilter();
147
+ assert.equal(f.shouldKeep({ text: "plain message" }), true);
148
+ });
149
+ });
150
+
151
+ describe("ThreadFilter isTrigger", () => {
152
+ it("detects keyword in message text (case-insensitive)", () => {
153
+ const f = makeFilter(["dm:*"], ["@bot"]);
154
+ assert.equal(f.isTrigger({ text: "Hey @BOT help me" }), true);
155
+ });
156
+
157
+ it("detects keyword at start of text", () => {
158
+ const f = makeFilter(["dm:*"], ["help"]);
159
+ assert.equal(f.isTrigger({ text: "help me please" }), true);
160
+ });
161
+
162
+ it("returns false when keyword not present", () => {
163
+ const f = makeFilter(["dm:*"], ["@bot"]);
164
+ assert.equal(f.isTrigger({ text: "just a normal message" }), false);
165
+ });
166
+
167
+ it("returns false when no keywords configured", () => {
168
+ const f = makeFilter(["dm:*"], []);
169
+ assert.equal(f.isTrigger({ text: "@bot trigger me" }), false);
170
+ });
171
+
172
+ it("returns false when text is empty string", () => {
173
+ const f = makeFilter(["dm:*"], ["@bot"]);
174
+ assert.equal(f.isTrigger({ text: "" }), false);
175
+ });
176
+
177
+ it("returns false when text property is missing", () => {
178
+ const f = makeFilter(["dm:*"], ["@bot"]);
179
+ assert.equal(f.isTrigger({}), false);
180
+ });
181
+
182
+ it("matches any of multiple keywords", () => {
183
+ const f = makeFilter(["dm:*"], ["urgent", "help", "broken"]);
184
+ assert.equal(f.isTrigger({ text: "system is broken" }), true);
185
+ assert.equal(f.isTrigger({ text: "need urgent attention" }), true);
186
+ assert.equal(f.isTrigger({ text: "everything is fine" }), false);
187
+ });
188
+ });
189
+
190
+ describe("ThreadFilter edge cases", () => {
191
+ it("shouldKeep handles empty message object", () => {
192
+ const f = makeFilter();
193
+ // No type, no text — should keep (default allow)
194
+ assert.equal(f.shouldKeep({}), true);
195
+ });
196
+
197
+ it("shouldKeep handles null text field gracefully", () => {
198
+ const f = makeFilter();
199
+ assert.equal(f.shouldKeep({ type: "text", text: null }), true);
200
+ });
201
+
202
+ it("isTrigger handles null text field", () => {
203
+ const f = makeFilter(["dm:*"], ["keyword"]);
204
+ assert.equal(f.isTrigger({ text: null }), false);
205
+ });
206
+ });
@@ -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
+ }