zalo-agent-cli 1.2.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.
- package/README.md +20 -0
- package/package.json +5 -2
- package/src/commands/mcp.js +186 -0
- package/src/index.js +5 -2
- package/src/mcp/mcp-config.js +76 -0
- package/src/mcp/mcp-http-transport.js +75 -0
- package/src/mcp/mcp-server.js +33 -0
- package/src/mcp/mcp-tools.js +171 -0
- package/src/mcp/message-buffer.js +119 -0
- package/src/mcp/message-buffer.test.js +282 -0
- package/src/mcp/notifier.js +94 -0
- package/src/mcp/notifier.test.js +239 -0
- package/src/mcp/thread-filter.js +79 -0
- package/src/mcp/thread-filter.test.js +206 -0
|
@@ -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
|
+
}
|