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.
- package/README.md +20 -0
- package/package.json +5 -2
- package/src/commands/group.js +69 -5
- package/src/commands/mcp.js +191 -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 +34 -0
- package/src/mcp/mcp-tools.js +197 -0
- package/src/mcp/message-buffer.js +117 -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
- package/src/mcp/thread-name-cache.js +162 -0
- package/src/mcp/thread-name-cache.test.js +139 -0
- package/src/utils/qr-display.js +8 -6
- package/src/utils/qr-display.test.js +1 -2
- package/src/utils/qr-http-server.js +24 -6
|
@@ -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
|
+
}
|