@ardennguyen/zalo-agent-cli 1.0.7

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.
Files changed (52) hide show
  1. package/DISCLAIMER.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -0
  4. package/package.json +64 -0
  5. package/src/cli.test.js +98 -0
  6. package/src/commands/account.js +178 -0
  7. package/src/commands/auto-reply.js +83 -0
  8. package/src/commands/catalog.js +143 -0
  9. package/src/commands/conv.js +264 -0
  10. package/src/commands/friend.js +384 -0
  11. package/src/commands/group.js +606 -0
  12. package/src/commands/label.js +39 -0
  13. package/src/commands/listen.js +293 -0
  14. package/src/commands/login.js +165 -0
  15. package/src/commands/mcp.js +219 -0
  16. package/src/commands/msg.js +608 -0
  17. package/src/commands/oa-init.js +503 -0
  18. package/src/commands/oa-listen.js +215 -0
  19. package/src/commands/oa.js +657 -0
  20. package/src/commands/poll.js +159 -0
  21. package/src/commands/profile.js +274 -0
  22. package/src/commands/quick-msg.js +55 -0
  23. package/src/commands/reminder.js +195 -0
  24. package/src/core/accounts.js +93 -0
  25. package/src/core/credentials.js +63 -0
  26. package/src/core/oa-client.js +407 -0
  27. package/src/core/zalo-client.js +201 -0
  28. package/src/index.js +95 -0
  29. package/src/mcp/mcp-config.js +81 -0
  30. package/src/mcp/mcp-http-transport.js +84 -0
  31. package/src/mcp/mcp-server.js +40 -0
  32. package/src/mcp/mcp-tools.js +353 -0
  33. package/src/mcp/media-downloader.js +191 -0
  34. package/src/mcp/media-downloader.test.js +45 -0
  35. package/src/mcp/message-buffer.js +127 -0
  36. package/src/mcp/message-buffer.test.js +308 -0
  37. package/src/mcp/notifier.js +141 -0
  38. package/src/mcp/notifier.test.js +269 -0
  39. package/src/mcp/thread-filter.js +79 -0
  40. package/src/mcp/thread-filter.test.js +206 -0
  41. package/src/mcp/thread-name-cache.js +162 -0
  42. package/src/mcp/thread-name-cache.test.js +139 -0
  43. package/src/utils/bank-helpers.js +233 -0
  44. package/src/utils/bank-helpers.test.js +66 -0
  45. package/src/utils/extract-message-text.js +21 -0
  46. package/src/utils/output.js +21 -0
  47. package/src/utils/proxy-helpers.js +14 -0
  48. package/src/utils/proxy-helpers.test.js +36 -0
  49. package/src/utils/qr-display.js +91 -0
  50. package/src/utils/qr-display.test.js +97 -0
  51. package/src/utils/qr-http-server.js +190 -0
  52. package/src/utils/update-check.js +56 -0
@@ -0,0 +1,127 @@
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 ? [this._threads.get(threadId)].filter(Boolean) : Array.from(this._threads.values());
45
+
46
+ // Collect all messages after cursor, sorted by cursor
47
+ const all = [];
48
+ for (const thread of sources) {
49
+ for (const msg of thread.messages) {
50
+ if (msg._cursor > since) all.push(msg);
51
+ }
52
+ }
53
+ all.sort((a, b) => a._cursor - b._cursor);
54
+
55
+ const hasMore = all.length > maxCount;
56
+ const messages = all.slice(0, maxCount);
57
+ const cursor = messages.length > 0 ? messages[messages.length - 1]._cursor : since;
58
+
59
+ return { messages, cursor, hasMore };
60
+ }
61
+
62
+ /**
63
+ * Advance read cursor — discard messages at or before given cursor.
64
+ * @param {number} cursor
65
+ * @returns {number} Count of discarded messages
66
+ */
67
+ markRead(cursor) {
68
+ let discarded = 0;
69
+ for (const [, thread] of this._threads) {
70
+ const before = thread.messages.length;
71
+ thread.messages = thread.messages.filter((m) => m._cursor > cursor);
72
+ discarded += before - thread.messages.length;
73
+ }
74
+ return discarded;
75
+ }
76
+
77
+ /**
78
+ * Get stats for all threads with buffered messages.
79
+ * @param {number} [readCursor=0] - Messages after this cursor are "unread"
80
+ * @returns {Array<{ threadId: string, unread: number, total: number, lastActivity: number }>}
81
+ */
82
+ getStats(readCursor = 0) {
83
+ const stats = [];
84
+ for (const [threadId, thread] of this._threads) {
85
+ if (thread.messages.length === 0) continue;
86
+ const unread = thread.messages.filter((m) => m._cursor > readCursor).length;
87
+ stats.push({
88
+ threadId,
89
+ unread,
90
+ total: thread.messages.length,
91
+ lastActivity: thread.lastActivity,
92
+ });
93
+ }
94
+ return stats;
95
+ }
96
+
97
+ /**
98
+ * Get thread type from first buffered message.
99
+ * @param {string} threadId
100
+ * @returns {string|null}
101
+ */
102
+ getThreadType(threadId) {
103
+ const thread = this._threads.get(threadId);
104
+ return thread?.messages?.[0]?.threadType ?? null;
105
+ }
106
+
107
+ /**
108
+ * Evict messages that exceed maxSize or maxAge for a given thread.
109
+ * @param {string} threadId
110
+ */
111
+ _evict(threadId) {
112
+ const thread = this._threads.get(threadId);
113
+ if (!thread) return;
114
+
115
+ const now = Date.now();
116
+ // Remove messages older than maxAge
117
+ thread.messages = thread.messages.filter((m) => now - m.timestamp < this._maxAge);
118
+ // Trim to maxSize (keep newest)
119
+ if (thread.messages.length > this._maxSize) {
120
+ thread.messages = thread.messages.slice(thread.messages.length - this._maxSize);
121
+ }
122
+ // Clean up empty threads
123
+ if (thread.messages.length === 0) {
124
+ this._threads.delete(threadId);
125
+ }
126
+ }
127
+ }
@@ -0,0 +1,308 @@
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 getThreadType", () => {
269
+ it("returns threadType from first buffered message", () => {
270
+ const buf = new MessageBuffer();
271
+ buf.push("t1", { text: "hi", threadType: "dm", timestamp: Date.now() });
272
+ buf.push("t1", { text: "hey", threadType: "dm", timestamp: Date.now() });
273
+ assert.equal(buf.getThreadType("t1"), "dm");
274
+ });
275
+
276
+ it("returns null for unknown thread", () => {
277
+ const buf = new MessageBuffer();
278
+ assert.equal(buf.getThreadType("nonexistent"), null);
279
+ });
280
+
281
+ it("returns null when thread exists but messages have no threadType", () => {
282
+ const buf = new MessageBuffer();
283
+ buf.push("t1", { text: "hi", timestamp: Date.now() });
284
+ assert.equal(buf.getThreadType("t1"), null);
285
+ });
286
+
287
+ it("returns group for group thread", () => {
288
+ const buf = new MessageBuffer();
289
+ buf.push("g1", { text: "hello", threadType: "group", timestamp: Date.now() });
290
+ assert.equal(buf.getThreadType("g1"), "group");
291
+ });
292
+ });
293
+
294
+ describe("MessageBuffer edge cases", () => {
295
+ it("empty read on fresh buffer returns empty array and cursor 0", () => {
296
+ const buf = new MessageBuffer();
297
+ const { messages, cursor } = buf.read("nowhere");
298
+ assert.deepEqual(messages, []);
299
+ assert.equal(cursor, 0);
300
+ });
301
+
302
+ it("global cursor increments across different threads", () => {
303
+ const buf = new MessageBuffer();
304
+ buf.push("t1", msg("x"));
305
+ buf.push("t2", msg("y"));
306
+ assert.equal(buf._globalCursor, 2);
307
+ });
308
+ });
@@ -0,0 +1,141 @@
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
+ /** Emoji prefix per message type for notification previews */
9
+ const TYPE_EMOJI = {
10
+ text: "💬",
11
+ image: "📷",
12
+ file: "📎",
13
+ link: "🔗",
14
+ video: "🎬",
15
+ audio: "🎵",
16
+ voice: "🎤",
17
+ gif: "🎞️",
18
+ };
19
+
20
+ /** Vietnamese label per message type for notification breakdown */
21
+ const TYPE_LABEL = {
22
+ text: "text",
23
+ image: "ảnh",
24
+ file: "file",
25
+ link: "link",
26
+ video: "video",
27
+ audio: "audio",
28
+ voice: "voice",
29
+ gif: "gif",
30
+ };
31
+
32
+ export class ZaloNotifier {
33
+ /**
34
+ * @param {object} api - zca-js API instance
35
+ * @param {object} config - Full MCP config (uses config.notify section)
36
+ * @param {boolean} config.notify.enabled
37
+ * @param {string|null} config.notify.thread - Group ID to send notifications to
38
+ * @param {string[]} config.notify.on - Event types to notify on (e.g. ["dm"])
39
+ * @param {string} config.notify.cooldown - Debounce window (e.g. "5m")
40
+ */
41
+ constructor(api, config) {
42
+ this._api = api;
43
+ this._enabled = config.notify?.enabled || false;
44
+ this._notifyThread = config.notify?.thread || null;
45
+ this._onTypes = new Set(config.notify?.on || ["dm"]);
46
+ this._cooldownMs = parseDuration(config.notify?.cooldown || "5m");
47
+ this._pending = []; // Messages queued during cooldown window
48
+ this._timer = null;
49
+ this._agentConnected = false;
50
+ }
51
+
52
+ /** Mark agent as connected/disconnected — suppresses notifications when connected */
53
+ setAgentConnected(connected) {
54
+ this._agentConnected = connected;
55
+ }
56
+
57
+ /**
58
+ * Called when a new message arrives. Queues notification if conditions are met.
59
+ * @param {object} message - Normalized message object
60
+ */
61
+ onMessage(message) {
62
+ if (!this._shouldNotify(message)) return;
63
+ this._pending.push(message);
64
+ // Start cooldown timer only once per batch window
65
+ if (!this._timer) {
66
+ this._timer = setTimeout(() => this._flush(), this._cooldownMs);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Determine whether this message warrants a notification.
72
+ * @param {object} message
73
+ * @returns {boolean}
74
+ */
75
+ _shouldNotify(message) {
76
+ if (!this._enabled || !this._notifyThread) return false;
77
+ if (this._agentConnected) return false;
78
+ return this._onTypes.has(message.threadType);
79
+ }
80
+
81
+ /** Flush pending notifications as a single batched message to the notify thread */
82
+ async _flush() {
83
+ this._timer = null;
84
+ if (this._pending.length === 0) return;
85
+
86
+ const count = this._pending.length;
87
+ const typeBreakdown = this._buildTypeBreakdown(this._pending);
88
+ const preview = this._pending
89
+ .slice(0, 3) // Show at most 3 message previews
90
+ .map((m) => {
91
+ const type = m.type || "text";
92
+ const prefix = TYPE_EMOJI[type] || "💬";
93
+ const sender = m.senderName || m.senderId;
94
+ const body = type === "text" ? (m.text || "").slice(0, 50) : `[${TYPE_LABEL[type] || type}]`;
95
+ return `- ${sender}: ${prefix} ${body}`;
96
+ })
97
+ .join("\n");
98
+
99
+ const suffix = count > 3 ? `\n...và ${count - 3} tin nhắn khác` : "";
100
+ const text = `🔔 ${count} tin nhắn mới [${typeBreakdown}] trong ${this._formatWindow()}:\n${preview}${suffix}`;
101
+
102
+ try {
103
+ // threadType 1 = Group conversation
104
+ await this._api.sendMessage(text, this._notifyThread, 1);
105
+ } catch (err) {
106
+ console.error("Notifier send failed:", err.message);
107
+ }
108
+
109
+ this._pending = [];
110
+ }
111
+
112
+ /**
113
+ * Build a human-readable type breakdown string, e.g. "2 text, 1 ảnh".
114
+ * @param {object[]} messages - Array of normalized messages
115
+ * @returns {string}
116
+ */
117
+ _buildTypeBreakdown(messages) {
118
+ const counts = {};
119
+ for (const m of messages) {
120
+ const t = m.type || "text";
121
+ counts[t] = (counts[t] || 0) + 1;
122
+ }
123
+ return Object.entries(counts)
124
+ .map(([type, n]) => `${n} ${TYPE_LABEL[type] || type}`)
125
+ .join(", ");
126
+ }
127
+
128
+ /** Format cooldown duration as human-readable string (e.g. "5 phút", "1h") */
129
+ _formatWindow() {
130
+ const mins = Math.round(this._cooldownMs / 60000);
131
+ return mins >= 60 ? `${Math.round(mins / 60)}h` : `${mins} phút`;
132
+ }
133
+
134
+ /** Clean up pending timer on shutdown and attempt final flush */
135
+ destroy() {
136
+ if (this._timer) {
137
+ clearTimeout(this._timer);
138
+ this._flush();
139
+ }
140
+ }
141
+ }