zalo-agent-cli 1.1.0 → 1.3.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,171 @@
1
+ /**
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.
4
+ */
5
+
6
+ import { z } from "zod";
7
+
8
+ /** Thread type constants matching zca-js ThreadType enum */
9
+ const THREAD_USER = 0;
10
+ const THREAD_GROUP = 1;
11
+
12
+ /**
13
+ * Wrap a result object into MCP tool content format.
14
+ * @param {object} result
15
+ * @returns {{ content: Array }}
16
+ */
17
+ function ok(result) {
18
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
19
+ }
20
+
21
+ /**
22
+ * Wrap an error message into MCP tool error content format.
23
+ * @param {string} message
24
+ * @returns {{ content: Array, isError: true }}
25
+ */
26
+ function err(message) {
27
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
28
+ }
29
+
30
+ /**
31
+ * Register all Zalo MCP tools on the server.
32
+ * @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server
33
+ * @param {object} api - zca-js API instance
34
+ * @param {import("./message-buffer.js").MessageBuffer} buffer
35
+ * @param {import("./thread-filter.js").ThreadFilter} filter
36
+ * @param {object} config - MCP config
37
+ */
38
+ export function registerTools(server, api, buffer, filter, config) {
39
+ const maxPerPoll = config.limits?.maxMessagesPerPoll ?? 20;
40
+
41
+ // --- zalo_get_messages ---
42
+ server.registerTool(
43
+ "zalo_get_messages",
44
+ {
45
+ title: "Get Zalo Messages",
46
+ description:
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
+ 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"),
66
+ }),
67
+ },
68
+ async ({ threadId, since, limit }) => {
69
+ try {
70
+ const result = buffer.read(threadId, since, limit);
71
+ return ok(result);
72
+ } catch (e) {
73
+ console.error("[mcp-tools] zalo_get_messages error:", e.message);
74
+ return err(e.message);
75
+ }
76
+ },
77
+ );
78
+
79
+ // --- zalo_send_message ---
80
+ server.registerTool(
81
+ "zalo_send_message",
82
+ {
83
+ title: "Send Zalo Message",
84
+ description:
85
+ "Send a text message to a Zalo thread (DM or group). threadType: 0=DM(User), 1=Group.",
86
+ inputSchema: z.object({
87
+ threadId: z.string().describe("Thread ID to send message to"),
88
+ text: z.string().min(1).describe("Message text to send"),
89
+ threadType: z
90
+ .number()
91
+ .int()
92
+ .min(0)
93
+ .max(1)
94
+ .default(THREAD_USER)
95
+ .describe("Thread type: 0=DM(User), 1=Group"),
96
+ }),
97
+ },
98
+ async ({ threadId, text, threadType }) => {
99
+ try {
100
+ const result = await api.sendMessage(text, threadId, Number(threadType));
101
+ const messageId = result?.message?.msgId ?? result?.msgId ?? null;
102
+ return ok({ success: true, messageId });
103
+ } catch (e) {
104
+ console.error("[mcp-tools] zalo_send_message error:", e.message);
105
+ return err(e.message);
106
+ }
107
+ },
108
+ );
109
+
110
+ // --- zalo_list_threads ---
111
+ server.registerTool(
112
+ "zalo_list_threads",
113
+ {
114
+ title: "List Zalo Threads",
115
+ description:
116
+ "List all Zalo threads currently buffered with unread message counts. Useful for discovering active conversations.",
117
+ inputSchema: z.object({
118
+ type: z
119
+ .enum(["group", "dm", "all"])
120
+ .default("all")
121
+ .describe("Filter by thread type: 'dm', 'group', or 'all'"),
122
+ }),
123
+ },
124
+ async ({ type }) => {
125
+ try {
126
+ const stats = buffer.getStats(0);
127
+ // Enrich each stat entry with threadType by peeking at the first buffered message.
128
+ // buffer._threads is a Map<threadId, { messages: Array, lastActivity: number }>
129
+ const enriched = stats.map((t) => {
130
+ const thread = buffer._threads.get(t.threadId);
131
+ const threadType = thread?.messages?.[0]?.threadType ?? "unknown";
132
+ return { ...t, threadType };
133
+ });
134
+ const filtered =
135
+ type === "all"
136
+ ? enriched
137
+ : enriched.filter((t) => t.threadType === type);
138
+ return ok({ threads: filtered, total: filtered.length });
139
+ } catch (e) {
140
+ console.error("[mcp-tools] zalo_list_threads error:", e.message);
141
+ return err(e.message);
142
+ }
143
+ },
144
+ );
145
+
146
+ // --- zalo_mark_read ---
147
+ server.registerTool(
148
+ "zalo_mark_read",
149
+ {
150
+ title: "Mark Zalo Messages Read",
151
+ description:
152
+ "Discard buffered messages up to and including the given cursor. Use the cursor returned by zalo_get_messages.",
153
+ inputSchema: z.object({
154
+ cursor: z
155
+ .number()
156
+ .int()
157
+ .min(0)
158
+ .describe("Cursor value returned from a previous zalo_get_messages call"),
159
+ }),
160
+ },
161
+ async ({ cursor }) => {
162
+ try {
163
+ const discarded = buffer.markRead(cursor);
164
+ return ok({ success: true, discarded });
165
+ } catch (e) {
166
+ console.error("[mcp-tools] zalo_mark_read error:", e.message);
167
+ return err(e.message);
168
+ }
169
+ },
170
+ );
171
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Ring buffer storing messages per thread with cursor-based incremental reads.
3
+ * Shared by both stdio and HTTP MCP transports.
4
+ */
5
+
6
+ export class MessageBuffer {
7
+ /**
8
+ * @param {number} maxSize - Max messages per thread before eviction
9
+ * @param {number} maxAge - Max message age in ms before eviction (default 2h)
10
+ */
11
+ constructor(maxSize = 500, maxAge = 2 * 60 * 60 * 1000) {
12
+ /** @type {Map<string, { messages: Array, lastActivity: number }>} */
13
+ this._threads = new Map();
14
+ this._maxSize = maxSize;
15
+ this._maxAge = maxAge;
16
+ this._globalCursor = 0;
17
+ }
18
+
19
+ /**
20
+ * Add message to thread buffer. Auto-evicts stale messages.
21
+ * @param {string} threadId
22
+ * @param {object} message - Normalized message object
23
+ */
24
+ push(threadId, message) {
25
+ if (!this._threads.has(threadId)) {
26
+ this._threads.set(threadId, { messages: [], lastActivity: Date.now() });
27
+ }
28
+ const thread = this._threads.get(threadId);
29
+ // Assign global cursor for incremental reads
30
+ message._cursor = ++this._globalCursor;
31
+ thread.messages.push(message);
32
+ thread.lastActivity = Date.now();
33
+ this._evict(threadId);
34
+ }
35
+
36
+ /**
37
+ * Read messages from a thread, optionally since a cursor.
38
+ * @param {string} [threadId] - If omitted, reads from all threads
39
+ * @param {number} [since=0] - Cursor to read from (exclusive)
40
+ * @param {number} [maxCount=20] - Max messages to return
41
+ * @returns {{ messages: Array, cursor: number, hasMore: boolean }}
42
+ */
43
+ read(threadId, since = 0, maxCount = 20) {
44
+ const sources = threadId
45
+ ? [this._threads.get(threadId)].filter(Boolean)
46
+ : Array.from(this._threads.values());
47
+
48
+ // Collect all messages after cursor, sorted by cursor
49
+ let all = [];
50
+ for (const thread of sources) {
51
+ for (const msg of thread.messages) {
52
+ if (msg._cursor > since) all.push(msg);
53
+ }
54
+ }
55
+ all.sort((a, b) => a._cursor - b._cursor);
56
+
57
+ const hasMore = all.length > maxCount;
58
+ const messages = all.slice(0, maxCount);
59
+ const cursor = messages.length > 0 ? messages[messages.length - 1]._cursor : since;
60
+
61
+ return { messages, cursor, hasMore };
62
+ }
63
+
64
+ /**
65
+ * Advance read cursor — discard messages at or before given cursor.
66
+ * @param {number} cursor
67
+ * @returns {number} Count of discarded messages
68
+ */
69
+ markRead(cursor) {
70
+ let discarded = 0;
71
+ for (const [, thread] of this._threads) {
72
+ const before = thread.messages.length;
73
+ thread.messages = thread.messages.filter((m) => m._cursor > cursor);
74
+ discarded += before - thread.messages.length;
75
+ }
76
+ return discarded;
77
+ }
78
+
79
+ /**
80
+ * Get stats for all threads with buffered messages.
81
+ * @param {number} [readCursor=0] - Messages after this cursor are "unread"
82
+ * @returns {Array<{ threadId: string, unread: number, total: number, lastActivity: number }>}
83
+ */
84
+ getStats(readCursor = 0) {
85
+ const stats = [];
86
+ for (const [threadId, thread] of this._threads) {
87
+ if (thread.messages.length === 0) continue;
88
+ const unread = thread.messages.filter((m) => m._cursor > readCursor).length;
89
+ stats.push({
90
+ threadId,
91
+ unread,
92
+ total: thread.messages.length,
93
+ lastActivity: thread.lastActivity,
94
+ });
95
+ }
96
+ return stats;
97
+ }
98
+
99
+ /**
100
+ * Evict messages that exceed maxSize or maxAge for a given thread.
101
+ * @param {string} threadId
102
+ */
103
+ _evict(threadId) {
104
+ const thread = this._threads.get(threadId);
105
+ if (!thread) return;
106
+
107
+ const now = Date.now();
108
+ // Remove messages older than maxAge
109
+ thread.messages = thread.messages.filter((m) => now - m.timestamp < this._maxAge);
110
+ // Trim to maxSize (keep newest)
111
+ if (thread.messages.length > this._maxSize) {
112
+ thread.messages = thread.messages.slice(thread.messages.length - this._maxSize);
113
+ }
114
+ // Clean up empty threads
115
+ if (thread.messages.length === 0) {
116
+ this._threads.delete(threadId);
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,282 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { MessageBuffer } from "./message-buffer.js";
4
+
5
+ /** Helper: create a message with a timestamp (default = now) */
6
+ function msg(text, timestamp = Date.now()) {
7
+ return { text, timestamp };
8
+ }
9
+
10
+ describe("MessageBuffer constructor", () => {
11
+ it("uses default maxSize=500 and maxAge=2h", () => {
12
+ const buf = new MessageBuffer();
13
+ assert.equal(buf._maxSize, 500);
14
+ assert.equal(buf._maxAge, 2 * 60 * 60 * 1000);
15
+ });
16
+
17
+ it("accepts custom maxSize and maxAge", () => {
18
+ const buf = new MessageBuffer(10, 5000);
19
+ assert.equal(buf._maxSize, 10);
20
+ assert.equal(buf._maxAge, 5000);
21
+ });
22
+
23
+ it("starts with empty threads and cursor at 0", () => {
24
+ const buf = new MessageBuffer();
25
+ assert.equal(buf._threads.size, 0);
26
+ assert.equal(buf._globalCursor, 0);
27
+ });
28
+ });
29
+
30
+ describe("MessageBuffer push + read", () => {
31
+ it("push stores message and read returns it", () => {
32
+ const buf = new MessageBuffer();
33
+ buf.push("thread-1", msg("hello"));
34
+ const { messages, cursor } = buf.read("thread-1");
35
+ assert.equal(messages.length, 1);
36
+ assert.equal(messages[0].text, "hello");
37
+ assert.equal(cursor, 1);
38
+ });
39
+
40
+ it("multiple pushes to same thread are all returned", () => {
41
+ const buf = new MessageBuffer();
42
+ buf.push("t1", msg("a"));
43
+ buf.push("t1", msg("b"));
44
+ buf.push("t1", msg("c"));
45
+ const { messages } = buf.read("t1");
46
+ assert.equal(messages.length, 3);
47
+ });
48
+
49
+ it("assigns monotonically increasing cursors", () => {
50
+ const buf = new MessageBuffer();
51
+ buf.push("t1", msg("x"));
52
+ buf.push("t1", msg("y"));
53
+ const { messages } = buf.read("t1");
54
+ assert.ok(messages[0]._cursor < messages[1]._cursor);
55
+ });
56
+
57
+ it("read without threadId returns messages from all threads", () => {
58
+ const buf = new MessageBuffer();
59
+ buf.push("t1", msg("a"));
60
+ buf.push("t2", msg("b"));
61
+ const { messages } = buf.read();
62
+ assert.equal(messages.length, 2);
63
+ });
64
+
65
+ it("read from non-existent thread returns empty array", () => {
66
+ const buf = new MessageBuffer();
67
+ const { messages, cursor } = buf.read("no-such-thread");
68
+ assert.deepEqual(messages, []);
69
+ assert.equal(cursor, 0);
70
+ });
71
+ });
72
+
73
+ describe("MessageBuffer cursor-based incremental reads", () => {
74
+ it("since parameter excludes already-seen messages", () => {
75
+ const buf = new MessageBuffer();
76
+ buf.push("t1", msg("first"));
77
+ const { cursor: c1 } = buf.read("t1");
78
+
79
+ buf.push("t1", msg("second"));
80
+ const { messages } = buf.read("t1", c1);
81
+ assert.equal(messages.length, 1);
82
+ assert.equal(messages[0].text, "second");
83
+ });
84
+
85
+ it("since=cursor of last message returns empty array", () => {
86
+ const buf = new MessageBuffer();
87
+ buf.push("t1", msg("only"));
88
+ const { cursor } = buf.read("t1");
89
+ const { messages } = buf.read("t1", cursor);
90
+ assert.deepEqual(messages, []);
91
+ });
92
+
93
+ it("respects maxCount limit and sets hasMore=true", () => {
94
+ const buf = new MessageBuffer();
95
+ for (let i = 0; i < 5; i++) buf.push("t1", msg(`m${i}`));
96
+ const { messages, hasMore } = buf.read("t1", 0, 3);
97
+ assert.equal(messages.length, 3);
98
+ assert.equal(hasMore, true);
99
+ });
100
+
101
+ it("hasMore=false when all messages fit", () => {
102
+ const buf = new MessageBuffer();
103
+ buf.push("t1", msg("a"));
104
+ buf.push("t1", msg("b"));
105
+ const { hasMore } = buf.read("t1", 0, 20);
106
+ assert.equal(hasMore, false);
107
+ });
108
+ });
109
+
110
+ describe("MessageBuffer markRead", () => {
111
+ it("discards messages at or before cursor and returns count", () => {
112
+ const buf = new MessageBuffer();
113
+ buf.push("t1", msg("a"));
114
+ buf.push("t1", msg("b"));
115
+ const { cursor } = buf.read("t1");
116
+ const discarded = buf.markRead(cursor);
117
+ assert.equal(discarded, 2);
118
+ });
119
+
120
+ it("messages after cursor are kept", () => {
121
+ const buf = new MessageBuffer();
122
+ buf.push("t1", msg("a"));
123
+ const { cursor: c1 } = buf.read("t1");
124
+ buf.push("t1", msg("b"));
125
+
126
+ buf.markRead(c1);
127
+ const { messages } = buf.read("t1");
128
+ assert.equal(messages.length, 1);
129
+ assert.equal(messages[0].text, "b");
130
+ });
131
+
132
+ it("double markRead at same cursor returns 0 on second call", () => {
133
+ const buf = new MessageBuffer();
134
+ buf.push("t1", msg("a"));
135
+ const { cursor } = buf.read("t1");
136
+ buf.markRead(cursor);
137
+ const second = buf.markRead(cursor);
138
+ assert.equal(second, 0);
139
+ });
140
+
141
+ it("markRead across multiple threads discards from all", () => {
142
+ const buf = new MessageBuffer();
143
+ buf.push("t1", msg("a"));
144
+ buf.push("t2", msg("b"));
145
+ const { cursor } = buf.read(); // reads all
146
+ const discarded = buf.markRead(cursor);
147
+ assert.equal(discarded, 2);
148
+ });
149
+ });
150
+
151
+ describe("MessageBuffer eviction by maxSize", () => {
152
+ it("oldest messages are evicted when maxSize exceeded", () => {
153
+ const buf = new MessageBuffer(3, 99999999);
154
+ for (let i = 1; i <= 5; i++) buf.push("t1", msg(`m${i}`));
155
+ const { messages } = buf.read("t1");
156
+ assert.equal(messages.length, 3);
157
+ // Newest 3 kept
158
+ assert.equal(messages[0].text, "m3");
159
+ assert.equal(messages[2].text, "m5");
160
+ });
161
+ });
162
+
163
+ describe("MessageBuffer eviction by maxAge", () => {
164
+ it("stale messages are removed after maxAge expires", async () => {
165
+ const buf = new MessageBuffer(500, 50); // 50ms maxAge
166
+ buf.push("t1", { text: "old", timestamp: Date.now() });
167
+
168
+ await new Promise((r) => setTimeout(r, 80)); // wait for expiry
169
+
170
+ // Trigger eviction by pushing another message
171
+ buf.push("t1", { text: "new", timestamp: Date.now() });
172
+
173
+ const { messages } = buf.read("t1");
174
+ assert.equal(messages.length, 1);
175
+ assert.equal(messages[0].text, "new");
176
+ });
177
+
178
+ it("thread is removed when all messages evicted by age", async () => {
179
+ const buf = new MessageBuffer(500, 30); // 30ms maxAge
180
+ buf.push("t1", { text: "gone", timestamp: Date.now() });
181
+
182
+ await new Promise((r) => setTimeout(r, 60));
183
+
184
+ // Push to a different thread to avoid triggering eviction on t1
185
+ buf.push("t2", { text: "trigger", timestamp: Date.now() });
186
+
187
+ // t1 still has stale message but won't be cleaned unless _evict("t1") runs
188
+ // Push to t1 to trigger cleanup
189
+ buf.push("t1", { text: "trigger-clean", timestamp: Date.now() });
190
+
191
+ const { messages } = buf.read("t1");
192
+ // Only new message should remain
193
+ assert.equal(messages.length, 1);
194
+ assert.equal(messages[0].text, "trigger-clean");
195
+ });
196
+ });
197
+
198
+ describe("MessageBuffer multi-thread isolation", () => {
199
+ it("messages in different threads are independent", () => {
200
+ const buf = new MessageBuffer();
201
+ buf.push("t1", msg("from-t1"));
202
+ buf.push("t2", msg("from-t2"));
203
+
204
+ const r1 = buf.read("t1");
205
+ const r2 = buf.read("t2");
206
+
207
+ assert.equal(r1.messages.length, 1);
208
+ assert.equal(r1.messages[0].text, "from-t1");
209
+ assert.equal(r2.messages.length, 1);
210
+ assert.equal(r2.messages[0].text, "from-t2");
211
+ });
212
+
213
+ it("markRead on shared cursor only keeps messages after it in each thread", () => {
214
+ const buf = new MessageBuffer();
215
+ buf.push("t1", msg("t1-a"));
216
+ const { cursor: midCursor } = buf.read();
217
+ buf.push("t2", msg("t2-b")); // cursor after midCursor
218
+
219
+ buf.markRead(midCursor);
220
+
221
+ const r1 = buf.read("t1");
222
+ const r2 = buf.read("t2");
223
+ assert.equal(r1.messages.length, 0);
224
+ assert.equal(r2.messages.length, 1);
225
+ });
226
+ });
227
+
228
+ describe("MessageBuffer getStats", () => {
229
+ it("returns unread/total/lastActivity per thread", () => {
230
+ const buf = new MessageBuffer();
231
+ buf.push("t1", msg("a"));
232
+ buf.push("t1", msg("b"));
233
+ buf.push("t2", msg("c"));
234
+
235
+ const stats = buf.getStats(0);
236
+ assert.equal(stats.length, 2);
237
+
238
+ const t1 = stats.find((s) => s.threadId === "t1");
239
+ assert.ok(t1);
240
+ assert.equal(t1.total, 2);
241
+ assert.equal(t1.unread, 2);
242
+ assert.ok(typeof t1.lastActivity === "number");
243
+ });
244
+
245
+ it("respects readCursor when computing unread count", () => {
246
+ const buf = new MessageBuffer();
247
+ buf.push("t1", msg("a"));
248
+ const { cursor } = buf.read("t1");
249
+ buf.push("t1", msg("b")); // unread
250
+
251
+ const stats = buf.getStats(cursor);
252
+ const t1 = stats.find((s) => s.threadId === "t1");
253
+ assert.equal(t1.unread, 1);
254
+ assert.equal(t1.total, 2);
255
+ });
256
+
257
+ it("excludes threads with no messages", () => {
258
+ const buf = new MessageBuffer(500, 999999);
259
+ buf.push("t1", msg("a"));
260
+ const { cursor } = buf.read("t1");
261
+ buf.markRead(cursor); // discards all messages
262
+
263
+ const stats = buf.getStats(0);
264
+ assert.equal(stats.length, 0);
265
+ });
266
+ });
267
+
268
+ describe("MessageBuffer edge cases", () => {
269
+ it("empty read on fresh buffer returns empty array and cursor 0", () => {
270
+ const buf = new MessageBuffer();
271
+ const { messages, cursor } = buf.read("nowhere");
272
+ assert.deepEqual(messages, []);
273
+ assert.equal(cursor, 0);
274
+ });
275
+
276
+ it("global cursor increments across different threads", () => {
277
+ const buf = new MessageBuffer();
278
+ buf.push("t1", msg("x"));
279
+ buf.push("t2", msg("y"));
280
+ assert.equal(buf._globalCursor, 2);
281
+ });
282
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Sends notification to a configured Zalo group when DMs arrive
3
+ * and no MCP agent is connected. Batches messages within a cooldown window.
4
+ */
5
+
6
+ import { parseDuration } from "./mcp-config.js";
7
+
8
+ export class ZaloNotifier {
9
+ /**
10
+ * @param {object} api - zca-js API instance
11
+ * @param {object} config - Full MCP config (uses config.notify section)
12
+ * @param {boolean} config.notify.enabled
13
+ * @param {string|null} config.notify.thread - Group ID to send notifications to
14
+ * @param {string[]} config.notify.on - Event types to notify on (e.g. ["dm"])
15
+ * @param {string} config.notify.cooldown - Debounce window (e.g. "5m")
16
+ */
17
+ constructor(api, config) {
18
+ this._api = api;
19
+ this._enabled = config.notify?.enabled || false;
20
+ this._notifyThread = config.notify?.thread || null;
21
+ this._onTypes = new Set(config.notify?.on || ["dm"]);
22
+ this._cooldownMs = parseDuration(config.notify?.cooldown || "5m");
23
+ this._pending = []; // Messages queued during cooldown window
24
+ this._timer = null;
25
+ this._agentConnected = false;
26
+ }
27
+
28
+ /** Mark agent as connected/disconnected — suppresses notifications when connected */
29
+ setAgentConnected(connected) {
30
+ this._agentConnected = connected;
31
+ }
32
+
33
+ /**
34
+ * Called when a new message arrives. Queues notification if conditions are met.
35
+ * @param {object} message - Normalized message object
36
+ */
37
+ onMessage(message) {
38
+ if (!this._shouldNotify(message)) return;
39
+ this._pending.push(message);
40
+ // Start cooldown timer only once per batch window
41
+ if (!this._timer) {
42
+ this._timer = setTimeout(() => this._flush(), this._cooldownMs);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Determine whether this message warrants a notification.
48
+ * @param {object} message
49
+ * @returns {boolean}
50
+ */
51
+ _shouldNotify(message) {
52
+ if (!this._enabled || !this._notifyThread) return false;
53
+ if (this._agentConnected) return false;
54
+ return this._onTypes.has(message.threadType);
55
+ }
56
+
57
+ /** Flush pending notifications as a single batched message to the notify thread */
58
+ async _flush() {
59
+ this._timer = null;
60
+ if (this._pending.length === 0) return;
61
+
62
+ const count = this._pending.length;
63
+ const preview = this._pending
64
+ .slice(0, 3) // Show at most 3 message previews
65
+ .map((m) => `- ${m.senderName || m.senderId}: ${(m.text || "").slice(0, 50)}`)
66
+ .join("\n");
67
+
68
+ 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}`;
70
+
71
+ try {
72
+ // threadType 1 = Group conversation
73
+ await this._api.sendMessage(text, this._notifyThread, 1);
74
+ } catch (err) {
75
+ console.error("Notifier send failed:", err.message);
76
+ }
77
+
78
+ this._pending = [];
79
+ }
80
+
81
+ /** Format cooldown duration as human-readable string (e.g. "5 phút", "1h") */
82
+ _formatWindow() {
83
+ const mins = Math.round(this._cooldownMs / 60000);
84
+ return mins >= 60 ? `${Math.round(mins / 60)}h` : `${mins} phút`;
85
+ }
86
+
87
+ /** Clean up pending timer on shutdown and attempt final flush */
88
+ destroy() {
89
+ if (this._timer) {
90
+ clearTimeout(this._timer);
91
+ this._flush();
92
+ }
93
+ }
94
+ }