@manybot/manybot 5.8.0 → 5.9.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 +15 -7
- package/dist/client/store.js +35 -1
- package/dist/download/queue.js +13 -4
- package/dist/drivers/baileys/adapter.js +75 -8
- package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
- package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
- package/dist/drivers/baileys/api/index.js +212 -38
- package/dist/drivers/baileys/index.js +30 -6
- package/dist/drivers/baileys/messageHandler.js +207 -21
- package/dist/drivers/baileys/messageHandler.test.js +256 -14
- package/dist/drivers/baileysAdapter.test.js +97 -0
- package/dist/drivers/jid.js +26 -0
- package/dist/drivers/jid.test.js +35 -1
- package/dist/i18n/index.js +5 -22
- package/dist/kernel/chatOverrides.js +46 -0
- package/dist/kernel/chatOverrides.test.js +59 -0
- package/dist/kernel/commandAccess.test.js +2 -2
- package/dist/kernel/commandDeprecation.js +4 -2
- package/dist/kernel/commandDeprecation.test.js +8 -1
- package/dist/kernel/commandMenu.js +91 -2
- package/dist/kernel/commandMenu.test.js +131 -2
- package/dist/kernel/commandPermissions.js +69 -23
- package/dist/kernel/commandPermissions.test.js +77 -9
- package/dist/kernel/commandRegistry.js +167 -43
- package/dist/kernel/commandRegistry.test.js +4 -2
- package/dist/kernel/commandsConfig.js +470 -38
- package/dist/kernel/commandsConfig.test.js +249 -3
- package/dist/kernel/contactAutoSave.js +6 -6
- package/dist/kernel/coreCommands.js +62 -0
- package/dist/kernel/pluginApi.test.js +20 -3
- package/dist/kernel/pluginGuard.js +5 -3
- package/dist/kernel/pluginLoader.js +73 -10
- package/dist/kernel/pluginLoader.test.js +111 -1
- package/dist/kernel/runCommand.js +57 -18
- package/dist/kernel/runCommand.test.js +269 -7
- package/dist/kernel/settingsDb.js +15 -2
- package/dist/kernel/testConfig.js +9 -0
- package/dist/locales/en.json +14 -1
- package/dist/locales/es.json +17 -4
- package/dist/locales/pt.json +18 -5
- package/dist/plugins/__manybot_integration__/index.js +33 -16
- package/dist/plugins/__manybot_integration__/index.test.js +42 -8
- package/dist/utils/phoneNumber.js +83 -0
- package/dist/utils/phoneNumber.test.js +53 -0
- package/package.json +4 -3
|
@@ -5,6 +5,14 @@ import { handleMessage } from "#drivers/baileys/messageHandler.js";
|
|
|
5
5
|
import { pluginRegistry } from "#kernel/pluginLoader.js";
|
|
6
6
|
import { buildCommandRegistry, __setRegistryForTests } from "#kernel/commandRegistry.js";
|
|
7
7
|
import { getDriverManager, _resetDriverManagerForTests } from "#kernel/driverManager.js";
|
|
8
|
+
import { buildSettingsApi } from "#kernel/settingsDb.js";
|
|
9
|
+
import { CONFIG } from "#config";
|
|
10
|
+
// Pin the "no override" global language independently of whatever
|
|
11
|
+
// ~/.manybot/manybot.toml happens to say on the machine running the
|
|
12
|
+
// suite — CONFIG.LANGUAGE is read from the real config dir unless
|
|
13
|
+
// MANYBOT_CONFIG_DIR is set, and i18n's `currentLang` locks in on the
|
|
14
|
+
// first t()/tFor() call, so this must run before any test does.
|
|
15
|
+
CONFIG.LANGUAGE = "en";
|
|
8
16
|
// ── Minimal spec builders (mirrors kernel/runCommand.test.ts conventions) ──
|
|
9
17
|
function emptySpec(overrides) {
|
|
10
18
|
return {
|
|
@@ -12,7 +20,8 @@ function emptySpec(overrides) {
|
|
|
12
20
|
cmd: overrides.cmd ?? "task",
|
|
13
21
|
aliases: overrides.aliases ?? [],
|
|
14
22
|
plugin: overrides.plugin ?? "taskPlugin",
|
|
15
|
-
|
|
23
|
+
functions: overrides.functions ?? ["runFn"],
|
|
24
|
+
loading: overrides.loading ?? null,
|
|
16
25
|
text: overrides.text ?? null,
|
|
17
26
|
desc: overrides.desc ?? null,
|
|
18
27
|
category: overrides.category ?? null,
|
|
@@ -31,7 +40,8 @@ function emptySub(overrides) {
|
|
|
31
40
|
id: overrides.id ?? "task::list",
|
|
32
41
|
cmd: overrides.cmd ?? "list",
|
|
33
42
|
aliases: overrides.aliases ?? [],
|
|
34
|
-
|
|
43
|
+
functions: overrides.functions ?? null,
|
|
44
|
+
loading: overrides.loading ?? null,
|
|
35
45
|
desc: overrides.desc ?? null,
|
|
36
46
|
manual: overrides.manual ?? null,
|
|
37
47
|
arguments: overrides.arguments ?? [],
|
|
@@ -42,6 +52,11 @@ function emptySub(overrides) {
|
|
|
42
52
|
// ── Mock WaContract — only the surface handleMessage's pipeline touches ────
|
|
43
53
|
function createMockContract() {
|
|
44
54
|
const sentTexts = [];
|
|
55
|
+
const reactions = [];
|
|
56
|
+
const presences = [];
|
|
57
|
+
const deletions = [];
|
|
58
|
+
const edits = [];
|
|
59
|
+
const sentHistory = [];
|
|
45
60
|
let msgSeq = 0;
|
|
46
61
|
const contract = {
|
|
47
62
|
name: "baileys",
|
|
@@ -52,7 +67,18 @@ function createMockContract() {
|
|
|
52
67
|
on: () => () => { },
|
|
53
68
|
sendText: async (jid, text) => {
|
|
54
69
|
sentTexts.push({ jid, text });
|
|
55
|
-
|
|
70
|
+
const id = `msg-${++msgSeq}`;
|
|
71
|
+
sentHistory.push({
|
|
72
|
+
id,
|
|
73
|
+
chatId: jid,
|
|
74
|
+
fromMe: true,
|
|
75
|
+
contentHash: "hash",
|
|
76
|
+
timestamp: Date.now(),
|
|
77
|
+
type: "text",
|
|
78
|
+
body: text,
|
|
79
|
+
fromPn: "5511900000000@s.whatsapp.net",
|
|
80
|
+
});
|
|
81
|
+
return { id, chatId: jid, timestamp: Date.now() };
|
|
56
82
|
},
|
|
57
83
|
sendImage: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
|
|
58
84
|
sendVideo: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
|
|
@@ -60,10 +86,18 @@ function createMockContract() {
|
|
|
60
86
|
sendSticker: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
|
|
61
87
|
sendDocument: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
|
|
62
88
|
sendPoll: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
|
|
63
|
-
react: async () => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
89
|
+
react: async (_jid, _key, emoji) => {
|
|
90
|
+
reactions.push({ jid: _jid, emoji });
|
|
91
|
+
},
|
|
92
|
+
deleteMessage: async (_jid, _key, forEveryone) => {
|
|
93
|
+
deletions.push({ jid: _jid, forEveryone });
|
|
94
|
+
},
|
|
95
|
+
editMessage: async (_jid, key, text) => {
|
|
96
|
+
edits.push({ jid: _jid, id: key.id ?? "", text });
|
|
97
|
+
},
|
|
98
|
+
sendPresenceUpdate: async (state, jid) => {
|
|
99
|
+
presences.push({ state, jid });
|
|
100
|
+
},
|
|
67
101
|
readMessages: async () => { },
|
|
68
102
|
onWhatsApp: async () => [{ exists: true }],
|
|
69
103
|
getBusinessProfile: async () => null,
|
|
@@ -83,9 +117,12 @@ function createMockContract() {
|
|
|
83
117
|
updateProfileStatus: async () => { },
|
|
84
118
|
me: () => ({ id: "5511900000000@s.whatsapp.net" }),
|
|
85
119
|
downloadMedia: async () => null,
|
|
86
|
-
|
|
120
|
+
// Mirrors real Baileys behavior: sendText's result lands in history
|
|
121
|
+
// immediately (sendFallbackGuard's verifyDelivery does a t=0 lookup
|
|
122
|
+
// here to confirm the send before the caller's await resolves).
|
|
123
|
+
getHistory: async (jid) => sentHistory.filter(m => m.chatId === jid),
|
|
87
124
|
};
|
|
88
|
-
return { contract, sentTexts };
|
|
125
|
+
return { contract, sentTexts, reactions, presences, deletions, edits };
|
|
89
126
|
}
|
|
90
127
|
function makeBotMessage(overrides = {}) {
|
|
91
128
|
return {
|
|
@@ -100,13 +137,30 @@ function makeBotMessage(overrides = {}) {
|
|
|
100
137
|
...overrides,
|
|
101
138
|
};
|
|
102
139
|
}
|
|
103
|
-
function buildRegistry(specs) {
|
|
104
|
-
|
|
140
|
+
function buildRegistry(specs, menu) {
|
|
141
|
+
if (!menu)
|
|
142
|
+
return buildCommandRegistry(specs, pluginRegistry);
|
|
143
|
+
return buildCommandRegistry(specs, pluginRegistry, undefined, {
|
|
144
|
+
title: "ManyBot — Menu",
|
|
145
|
+
intro: "Help menu",
|
|
146
|
+
footer: null,
|
|
147
|
+
cmd: "help",
|
|
148
|
+
aliases: ["help", "menu"],
|
|
149
|
+
notFoundFallback: false,
|
|
150
|
+
welcomeMessage: null,
|
|
151
|
+
welcomeWindowDays: 3,
|
|
152
|
+
pageSize: 15,
|
|
153
|
+
...menu,
|
|
154
|
+
});
|
|
105
155
|
}
|
|
106
156
|
describe("drivers/baileys/messageHandler — v6 runCommand dispatch", () => {
|
|
107
157
|
let store;
|
|
108
158
|
let contract;
|
|
109
159
|
let sentTexts;
|
|
160
|
+
let reactions;
|
|
161
|
+
let presences;
|
|
162
|
+
let deletions;
|
|
163
|
+
let edits;
|
|
110
164
|
let runCalls;
|
|
111
165
|
let listCalls;
|
|
112
166
|
beforeEach(() => {
|
|
@@ -115,6 +169,10 @@ describe("drivers/baileys/messageHandler — v6 runCommand dispatch", () => {
|
|
|
115
169
|
const mock = createMockContract();
|
|
116
170
|
contract = mock.contract;
|
|
117
171
|
sentTexts = mock.sentTexts;
|
|
172
|
+
reactions = mock.reactions;
|
|
173
|
+
presences = mock.presences;
|
|
174
|
+
deletions = mock.deletions;
|
|
175
|
+
edits = mock.edits;
|
|
118
176
|
getDriverManager().register(contract, { isPrimary: true });
|
|
119
177
|
runCalls = [];
|
|
120
178
|
listCalls = [];
|
|
@@ -160,7 +218,7 @@ describe("drivers/baileys/messageHandler — v6 runCommand dispatch", () => {
|
|
|
160
218
|
_resetDriverManagerForTests();
|
|
161
219
|
});
|
|
162
220
|
test("routes a matched subcommand to the subcommand handler, not the parent's, and replies", async () => {
|
|
163
|
-
const spec = emptySpec({ subcommands: [emptySub({
|
|
221
|
+
const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
|
|
164
222
|
__setRegistryForTests(buildRegistry([spec]));
|
|
165
223
|
const msg = makeBotMessage({ body: "!task list" });
|
|
166
224
|
await handleMessage(msg, contract, store);
|
|
@@ -175,8 +233,32 @@ describe("drivers/baileys/messageHandler — v6 runCommand dispatch", () => {
|
|
|
175
233
|
assert.equal(sentTexts.length, 1);
|
|
176
234
|
assert.equal(sentTexts[0].text, "done");
|
|
177
235
|
});
|
|
236
|
+
// Regression test for the bug where "core" (ping/status/config/...)
|
|
237
|
+
// was never actually reachable from handleMessage(): it only ever
|
|
238
|
+
// existed in a *local* allPlugins map built inside commandRegistry.ts
|
|
239
|
+
// for registry-building purposes, never in the real pluginRegistry
|
|
240
|
+
// singleton the dispatch loop iterates — so runCommand() was never
|
|
241
|
+
// called for any core.* command, even though the registry itself
|
|
242
|
+
// resolved them fine (only !menu, which has its own separate dispatch
|
|
243
|
+
// path, worked). This must keep passing with 0 real plugins active.
|
|
244
|
+
test("a matched command sourced from the 'core' pseudo-plugin still dispatches and replies", async () => {
|
|
245
|
+
pluginRegistry.delete("taskPlugin"); // 0 real plugins active, same as the reported bug
|
|
246
|
+
const spec = emptySpec({
|
|
247
|
+
id: "core::ping",
|
|
248
|
+
cmd: "ping",
|
|
249
|
+
plugin: "core",
|
|
250
|
+
functions: ["ping"],
|
|
251
|
+
});
|
|
252
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
253
|
+
const msg = makeBotMessage({ body: "!ping" });
|
|
254
|
+
await handleMessage(msg, contract, store);
|
|
255
|
+
assert.equal(sentTexts.length, 1, "core.ping replied");
|
|
256
|
+
assert.equal(sentTexts[0].text, "pong");
|
|
257
|
+
// No restore needed: the next test's beforeEach() re-registers
|
|
258
|
+
// "taskPlugin" fresh regardless of what this test left behind.
|
|
259
|
+
});
|
|
178
260
|
test("a crash inside the matched command's handler is swallowed at the messageHandler boundary", async () => {
|
|
179
|
-
const spec = emptySpec({ id: "task::crash", cmd: "crashcmd",
|
|
261
|
+
const spec = emptySpec({ id: "task::crash", cmd: "crashcmd", functions: ["crashFn"] });
|
|
180
262
|
__setRegistryForTests(buildRegistry([spec]));
|
|
181
263
|
const msg = makeBotMessage({ body: "!crashcmd" });
|
|
182
264
|
// The whole point of this session's fix: runCommand() now genuinely
|
|
@@ -188,7 +270,7 @@ describe("drivers/baileys/messageHandler — v6 runCommand dispatch", () => {
|
|
|
188
270
|
assert.equal(sentTexts.length, 0, "no reply is sent for a crashed handler");
|
|
189
271
|
});
|
|
190
272
|
test("the message loop keeps working for the next message after a handler crash", async () => {
|
|
191
|
-
const crashSpec = emptySpec({ id: "task::crash", cmd: "crashcmd",
|
|
273
|
+
const crashSpec = emptySpec({ id: "task::crash", cmd: "crashcmd", functions: ["crashFn"] });
|
|
192
274
|
__setRegistryForTests(buildRegistry([crashSpec]));
|
|
193
275
|
await handleMessage(makeBotMessage({ body: "!crashcmd" }), contract, store);
|
|
194
276
|
// Swap in a healthy command and confirm a later message still dispatches
|
|
@@ -200,4 +282,164 @@ describe("drivers/baileys/messageHandler — v6 runCommand dispatch", () => {
|
|
|
200
282
|
assert.equal(sentTexts.length, 1);
|
|
201
283
|
assert.equal(sentTexts[0].text, "done");
|
|
202
284
|
});
|
|
285
|
+
// ── Loading indicator wiring ───────────────────────────────────────────
|
|
286
|
+
describe("loading indicator (v6 reaction/typing/spinner)", () => {
|
|
287
|
+
test("reaction: drops the icon then clears on success", async () => {
|
|
288
|
+
const spec = emptySpec({ loading: { type: "reaction", icon: "🛠" } });
|
|
289
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
290
|
+
await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
|
|
291
|
+
assert.equal(reactions.length, 2, "start + final reaction");
|
|
292
|
+
assert.equal(reactions[0].emoji, "🛠");
|
|
293
|
+
assert.equal(reactions[1].emoji, "");
|
|
294
|
+
});
|
|
295
|
+
test("reaction: replaces with onSuccess emoji on success", async () => {
|
|
296
|
+
const spec = emptySpec({ loading: { type: "reaction", icon: "⏳", onSuccess: "✅" } });
|
|
297
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
298
|
+
await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
|
|
299
|
+
assert.equal(reactions.at(-1)?.emoji, "✅");
|
|
300
|
+
});
|
|
301
|
+
test("typing: emits composing presence + paused on completion", async () => {
|
|
302
|
+
const spec = emptySpec({ loading: { type: "typing" } });
|
|
303
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
304
|
+
await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
|
|
305
|
+
assert.ok(presences.some(p => p.state === "composing"), "composing presence was emitted");
|
|
306
|
+
assert.ok(presences.some(p => p.state === "paused"), "paused presence was emitted at end");
|
|
307
|
+
});
|
|
308
|
+
test("none: never calls react or sendPresenceUpdate for the indicator", async () => {
|
|
309
|
+
const spec = emptySpec({ loading: { type: "none" } });
|
|
310
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
311
|
+
await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
|
|
312
|
+
// reaction is never called — the loading indicator itself is a no-op.
|
|
313
|
+
assert.equal(reactions.length, 0, "no reactions emitted");
|
|
314
|
+
// Presence still shows up from simulateState() inside ctx.send.text()
|
|
315
|
+
// (api/index.ts) humanizing every outbound send.
|
|
316
|
+
// A "none" loading spec must add zero presence calls on top of that.
|
|
317
|
+
assert.equal(presences.filter(p => p.state === "paused" && p.jid === "5511888888888@s.whatsapp.net").length, 1, "paused presence came only from simulateState path");
|
|
318
|
+
});
|
|
319
|
+
test("spinner: sends a frame message and edits it on completion", async () => {
|
|
320
|
+
const spec = emptySpec({
|
|
321
|
+
loading: {
|
|
322
|
+
type: "spinner",
|
|
323
|
+
frames: ["⏳", "⌛"],
|
|
324
|
+
intervalMs: 60000, // disable the interval so the test is deterministic
|
|
325
|
+
onSuccess: "ready",
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
329
|
+
await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
|
|
330
|
+
// The first sendText from the spinner carries the first frame.
|
|
331
|
+
const spinnerSent = sentTexts.find(s => s.text === "⏳");
|
|
332
|
+
assert.ok(spinnerSent, "spinner sent the first frame");
|
|
333
|
+
assert.ok(edits.some(e => e.text === "ready"), "spinner was edited with onSuccess text");
|
|
334
|
+
assert.equal(deletions.length, 0, "onSuccess set: spinner is edited, not deleted");
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
// ── Welcome message gating ──────────────────────────────────────────────
|
|
338
|
+
// Regression coverage for the false-positive welcome: history-sync
|
|
339
|
+
// replays the bot's own past outgoing messages as `messages.upsert`
|
|
340
|
+
// (fromMe=true) on every reconnect, and welcome must never fire for a
|
|
341
|
+
// group chat either — only for a genuinely incoming DM.
|
|
342
|
+
describe("welcome message gating", () => {
|
|
343
|
+
const WELCOME_TEXT = "Bem-vindo! Use {prefix}help.";
|
|
344
|
+
function buildWelcomeRegistry() {
|
|
345
|
+
return buildRegistry([emptySpec({})], {
|
|
346
|
+
welcomeMessage: WELCOME_TEXT,
|
|
347
|
+
welcomeWindowDays: 3,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
// Each test uses its own sender so the shared (in-memory) settings DB
|
|
351
|
+
// never leaks "already seen" state between tests, and clears it
|
|
352
|
+
// up front regardless, mirroring kernel/commandMenu.test.ts.
|
|
353
|
+
function freshSender(id) {
|
|
354
|
+
const jid = `55110000${id}@s.whatsapp.net`;
|
|
355
|
+
buildSettingsApi("kernel", jid).delete("last_welcome_seen");
|
|
356
|
+
return { chatId: jid, fromPn: jid };
|
|
357
|
+
}
|
|
358
|
+
test("fires on a genuine first incoming DM", async () => {
|
|
359
|
+
const { chatId, fromPn } = freshSender("0001");
|
|
360
|
+
__setRegistryForTests(buildWelcomeRegistry());
|
|
361
|
+
await handleMessage(makeBotMessage({ chatId, fromPn, fromMe: false, body: "oi" }), contract, store);
|
|
362
|
+
assert.equal(sentTexts.length, 1, "welcome was sent");
|
|
363
|
+
assert.equal(sentTexts[0].text, "Bem-vindo! Use !help.");
|
|
364
|
+
});
|
|
365
|
+
test("does not fire for a history-sync replay of the bot's own message (fromMe=true)", async () => {
|
|
366
|
+
const { chatId, fromPn } = freshSender("0002");
|
|
367
|
+
__setRegistryForTests(buildWelcomeRegistry());
|
|
368
|
+
await handleMessage(makeBotMessage({ chatId, fromPn, fromMe: true, body: "oi" }), contract, store);
|
|
369
|
+
assert.equal(sentTexts.length, 0, "no welcome sent for a fromMe message");
|
|
370
|
+
});
|
|
371
|
+
test("does not fire for a message received in a group chat", async () => {
|
|
372
|
+
const { fromPn } = freshSender("0003");
|
|
373
|
+
const groupChatId = "120363000000000000@g.us";
|
|
374
|
+
buildSettingsApi("kernel", groupChatId).delete("last_welcome_seen");
|
|
375
|
+
__setRegistryForTests(buildWelcomeRegistry());
|
|
376
|
+
await handleMessage(makeBotMessage({ chatId: groupChatId, fromPn, fromMe: false, body: "oi" }), contract, store);
|
|
377
|
+
assert.equal(sentTexts.length, 0, "no welcome sent for a group message");
|
|
378
|
+
});
|
|
379
|
+
test("does not fire twice within the welcome window for the same sender", async () => {
|
|
380
|
+
const { chatId, fromPn } = freshSender("0004");
|
|
381
|
+
__setRegistryForTests(buildWelcomeRegistry());
|
|
382
|
+
await handleMessage(makeBotMessage({ chatId, fromPn, fromMe: false, body: "oi" }), contract, store);
|
|
383
|
+
await handleMessage(makeBotMessage({ chatId, fromPn, fromMe: false, body: "oi de novo" }), contract, store);
|
|
384
|
+
assert.equal(sentTexts.length, 1, "welcome only sent once inside the window");
|
|
385
|
+
});
|
|
386
|
+
test("does not fire when the registry has no welcomeMessage configured", async () => {
|
|
387
|
+
const { chatId, fromPn } = freshSender("0005");
|
|
388
|
+
__setRegistryForTests(buildRegistry([emptySpec({})])); // default menu: welcomeMessage: null
|
|
389
|
+
await handleMessage(makeBotMessage({ chatId, fromPn, fromMe: false, body: "oi" }), contract, store);
|
|
390
|
+
assert.equal(sentTexts.length, 0, "no welcome sent when unconfigured");
|
|
391
|
+
});
|
|
392
|
+
test("does not fire for an offline-flush misclassified receipt message (empty body)", async () => {
|
|
393
|
+
const { chatId, fromPn } = freshSender("0006");
|
|
394
|
+
__setRegistryForTests(buildWelcomeRegistry());
|
|
395
|
+
await handleMessage(makeBotMessage({ chatId, fromPn, fromMe: false, body: "" }), contract, store);
|
|
396
|
+
assert.equal(sentTexts.length, 0, "no welcome sent for an empty body");
|
|
397
|
+
});
|
|
398
|
+
test("does not fire for an offline-flush delayed message (stale timestamp)", async () => {
|
|
399
|
+
const { chatId, fromPn } = freshSender("0007");
|
|
400
|
+
__setRegistryForTests(buildWelcomeRegistry());
|
|
401
|
+
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
402
|
+
await handleMessage(makeBotMessage({ chatId, fromPn, fromMe: false, body: "oi", timestamp: twoHoursAgo }), contract, store);
|
|
403
|
+
assert.equal(sentTexts.length, 0, "no welcome sent for a stale message");
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
// ── Per-chat !config overrides (prefix / language) ─────────────────────
|
|
407
|
+
describe("per-chat overrides (!config prefixo / !config idioma)", () => {
|
|
408
|
+
// makeBotMessage()'s default chatId ("5511888888888@s.whatsapp.net")
|
|
409
|
+
// normalized the same way buildApi() scopes ctx.settings.
|
|
410
|
+
const normalizedChatId = "5511888888888@c.us";
|
|
411
|
+
afterEach(() => {
|
|
412
|
+
buildSettingsApi("core", normalizedChatId).deleteAll();
|
|
413
|
+
});
|
|
414
|
+
test("a saved prefix override changes which prefix the bot actually parses", async () => {
|
|
415
|
+
buildSettingsApi("core", normalizedChatId).set("chat_prefix", "#");
|
|
416
|
+
__setRegistryForTests(buildRegistry([emptySpec({})]));
|
|
417
|
+
await handleMessage(makeBotMessage({ body: "#task" }), contract, store);
|
|
418
|
+
assert.equal(runCalls.length, 1, "the new '#' prefix dispatches the command");
|
|
419
|
+
assert.equal(sentTexts[0].text, "done");
|
|
420
|
+
await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
|
|
421
|
+
assert.equal(runCalls.length, 1, "the old '!' prefix no longer matches for this chat");
|
|
422
|
+
});
|
|
423
|
+
test("without an override the global prefix still works", async () => {
|
|
424
|
+
__setRegistryForTests(buildRegistry([emptySpec({})]));
|
|
425
|
+
await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
|
|
426
|
+
assert.equal(runCalls.length, 1);
|
|
427
|
+
assert.equal(sentTexts[0].text, "done");
|
|
428
|
+
});
|
|
429
|
+
test("a saved language override changes the language of a kernel-authored reply (unknown subcommand)", async () => {
|
|
430
|
+
buildSettingsApi("core", normalizedChatId).set("chat_locale", "pt");
|
|
431
|
+
const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
|
|
432
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
433
|
+
await handleMessage(makeBotMessage({ body: "!task wat" }), contract, store);
|
|
434
|
+
assert.equal(sentTexts.length, 1);
|
|
435
|
+
assert.match(sentTexts[0].text, /Subcomando "wat" desconhecido/);
|
|
436
|
+
});
|
|
437
|
+
test("without an override the unknown-subcommand reply uses the global language", async () => {
|
|
438
|
+
const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
|
|
439
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
440
|
+
await handleMessage(makeBotMessage({ body: "!task wat" }), contract, store);
|
|
441
|
+
assert.equal(sentTexts.length, 1);
|
|
442
|
+
assert.match(sentTexts[0].text, /Unknown subcommand "wat"/);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
203
445
|
});
|
|
@@ -40,6 +40,64 @@ test("Baileys sends messages with the chat's disappearing-message timer", async
|
|
|
40
40
|
await contract.sendText("group@g.us", "group text");
|
|
41
41
|
assert.equal((calls.at(-1)?.[2]).ephemeralExpiration, 604800);
|
|
42
42
|
});
|
|
43
|
+
test("toBotMessage resolves LID/PN by suffix, not by field position (addressingMode 'lid')", async () => {
|
|
44
|
+
// Modern default WhatsApp addressing: `participant` is ALREADY the LID,
|
|
45
|
+
// `participantAlt` carries the PN companion — the reverse of the legacy
|
|
46
|
+
// "pn" mode. See https://baileys.wiki/concepts/jids.
|
|
47
|
+
const store = createStore();
|
|
48
|
+
const sock = { ev: new EventEmitter(), user: { id: "bot@s.whatsapp.net" } };
|
|
49
|
+
const { contract } = createBaileysAdapter({ sock, store });
|
|
50
|
+
const received = [];
|
|
51
|
+
contract.on("messages.upsert", (payload) => received.push(payload));
|
|
52
|
+
sock.ev.emit("messages.upsert", {
|
|
53
|
+
type: "notify",
|
|
54
|
+
messages: [{
|
|
55
|
+
key: {
|
|
56
|
+
remoteJid: "120363999999999999@g.us",
|
|
57
|
+
fromMe: false,
|
|
58
|
+
id: "MSG1",
|
|
59
|
+
participant: "98765@lid", // already LID
|
|
60
|
+
participantAlt: "5511999999999@s.whatsapp.net", // PN companion
|
|
61
|
+
addressingMode: "lid",
|
|
62
|
+
},
|
|
63
|
+
messageTimestamp: 1700000000,
|
|
64
|
+
pushName: "Alice",
|
|
65
|
+
message: { conversation: "oi" },
|
|
66
|
+
}],
|
|
67
|
+
});
|
|
68
|
+
const batch = received[0];
|
|
69
|
+
const msg = batch.messages[0];
|
|
70
|
+
assert.equal(msg.fromLid, "98765@lid", "fromLid must be the value that's actually @lid, regardless of which field it came from");
|
|
71
|
+
assert.equal(msg.fromPn, "5511999999999@s.whatsapp.net", "fromPn must be the value that's actually NOT @lid");
|
|
72
|
+
assert.equal(msg.participantAlt, "98765@lid", "participantAlt (consumed directly by getMsgSender) must also be suffix-verified");
|
|
73
|
+
});
|
|
74
|
+
test("toBotMessage resolves LID/PN by suffix, not by field position (legacy addressingMode 'pn')", async () => {
|
|
75
|
+
const store = createStore();
|
|
76
|
+
const sock = { ev: new EventEmitter(), user: { id: "bot@s.whatsapp.net" } };
|
|
77
|
+
const { contract } = createBaileysAdapter({ sock, store });
|
|
78
|
+
const received = [];
|
|
79
|
+
contract.on("messages.upsert", (payload) => received.push(payload));
|
|
80
|
+
sock.ev.emit("messages.upsert", {
|
|
81
|
+
type: "notify",
|
|
82
|
+
messages: [{
|
|
83
|
+
key: {
|
|
84
|
+
remoteJid: "120363999999999999@g.us",
|
|
85
|
+
fromMe: false,
|
|
86
|
+
id: "MSG2",
|
|
87
|
+
participant: "5511999999999@s.whatsapp.net", // PN
|
|
88
|
+
participantAlt: "98765@lid", // LID companion
|
|
89
|
+
addressingMode: "pn",
|
|
90
|
+
},
|
|
91
|
+
messageTimestamp: 1700000000,
|
|
92
|
+
pushName: "Bob",
|
|
93
|
+
message: { conversation: "oi" },
|
|
94
|
+
}],
|
|
95
|
+
});
|
|
96
|
+
const batch = received[0];
|
|
97
|
+
const msg = batch.messages[0];
|
|
98
|
+
assert.equal(msg.fromLid, "98765@lid");
|
|
99
|
+
assert.equal(msg.fromPn, "5511999999999@s.whatsapp.net");
|
|
100
|
+
});
|
|
43
101
|
test("Baileys adapter getBusinessProfile handles success and failure", async () => {
|
|
44
102
|
const store = createStore();
|
|
45
103
|
const jid = "12345678@s.whatsapp.net";
|
|
@@ -279,3 +337,42 @@ test("Baileys adapter aggregatePollVotes returns empty array when no poll data",
|
|
|
279
337
|
});
|
|
280
338
|
assert.deepStrictEqual(result, []);
|
|
281
339
|
});
|
|
340
|
+
test("Baileys adapter passes through the real group-participants.update shape (author/action, string[] participants) and feeds LID↔PN cache", async () => {
|
|
341
|
+
const store = createStore();
|
|
342
|
+
const sock = {
|
|
343
|
+
ev: new EventEmitter(),
|
|
344
|
+
user: { id: "bot@s.whatsapp.net" },
|
|
345
|
+
};
|
|
346
|
+
const { contract } = createBaileysAdapter({ sock, store });
|
|
347
|
+
const received = [];
|
|
348
|
+
contract.on("group-participants.update", (payload) => received.push(payload));
|
|
349
|
+
// Baileys v7 ships `participants` as GroupParticipant[] (Contact & { admin?… })
|
|
350
|
+
// — each entry carries `id` (LID form, the addressing mode the group uses),
|
|
351
|
+
// `lid?` (explicit LID alias), and `phoneNumber?` (PN form). The adapter
|
|
352
|
+
// projects to a flat JID list for the kernel, but also feeds the LID↔PN
|
|
353
|
+
// cache from the richer data while it has it. See BaileysEventMap.
|
|
354
|
+
sock.ev.emit("group-participants.update", {
|
|
355
|
+
id: "120363402117932687@g.us",
|
|
356
|
+
author: "99999@lid",
|
|
357
|
+
authorPn: "5516999999999@s.whatsapp.net",
|
|
358
|
+
participants: [
|
|
359
|
+
{ id: "69119495901215@lid", phoneNumber: "5516111222333@s.whatsapp.net" },
|
|
360
|
+
{ id: "69119495901216@lid", phoneNumber: "5516111222444@s.whatsapp.net" },
|
|
361
|
+
],
|
|
362
|
+
action: "add",
|
|
363
|
+
});
|
|
364
|
+
assert.deepStrictEqual(received, [{
|
|
365
|
+
id: "120363402117932687@g.us",
|
|
366
|
+
author: "99999@lid",
|
|
367
|
+
participants: [
|
|
368
|
+
"69119495901215@lid",
|
|
369
|
+
"69119495901216@lid",
|
|
370
|
+
],
|
|
371
|
+
action: "add",
|
|
372
|
+
}]);
|
|
373
|
+
// Passive LID↔PN cache filled from the richer payload — both directions
|
|
374
|
+
// (resolveJid returns the PN, resolvePn returns the LID).
|
|
375
|
+
assert.equal(store.resolveJid("69119495901215@lid"), "5516111222333@s.whatsapp.net");
|
|
376
|
+
assert.equal(store.resolvePn("5516111222444@s.whatsapp.net"), "69119495901216@lid");
|
|
377
|
+
assert.equal(store.resolveJid("99999@lid"), "5516999999999@s.whatsapp.net");
|
|
378
|
+
});
|
package/dist/drivers/jid.js
CHANGED
|
@@ -29,3 +29,29 @@ export function toWireJid(id) {
|
|
|
29
29
|
const digits = trimmed.replace(/\D/g, "");
|
|
30
30
|
return `${digits}@s.whatsapp.net`;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Split a Baileys "primary/alt" JID pair (e.g. `key.participant` +
|
|
34
|
+
* `key.participantAlt`, or `key.remoteJid` + `key.remoteJidAlt`) into its
|
|
35
|
+
* LID and PN forms.
|
|
36
|
+
*
|
|
37
|
+
* Baileys only labels the primary field as the PN and the alt field as the
|
|
38
|
+
* LID under the legacy `addressingMode: "pn"`. Under the modern default
|
|
39
|
+
* `addressingMode: "lid"` the roles are reversed — the primary field IS
|
|
40
|
+
* already the LID, and the alt field carries the PN instead (see
|
|
41
|
+
* https://baileys.wiki/concepts/jids: "Group participant fields are
|
|
42
|
+
* typically LIDs; participantAlt carries the matching PN, and vice versa").
|
|
43
|
+
*
|
|
44
|
+
* Branching on `addressingMode` itself isn't reliable either — it's been
|
|
45
|
+
* observed flip-flopping for the same conversation across rc builds (see
|
|
46
|
+
* https://github.com/WhiskeySockets/Baileys/issues/1827). The one thing
|
|
47
|
+
* that's actually trustworthy is the JID suffix itself: whichever of the
|
|
48
|
+
* two values ends in "@lid" IS the LID, regardless of which field it came
|
|
49
|
+
* from or what addressingMode claims.
|
|
50
|
+
*/
|
|
51
|
+
export function splitLidPn(primary, alt) {
|
|
52
|
+
const candidates = [primary, alt].filter((v) => !!v);
|
|
53
|
+
return {
|
|
54
|
+
lid: candidates.find(v => v.endsWith("@lid")),
|
|
55
|
+
pn: candidates.find(v => !v.endsWith("@lid")),
|
|
56
|
+
};
|
|
57
|
+
}
|
package/dist/drivers/jid.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test, { describe } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { normalizeJid, denormalizeJid, toWireJid } from "#drivers/jid.js";
|
|
3
|
+
import { normalizeJid, denormalizeJid, toWireJid, splitLidPn } from "#drivers/jid.js";
|
|
4
4
|
describe("drivers/jid", () => {
|
|
5
5
|
describe("normalizeJid", () => {
|
|
6
6
|
test("handles empty string or nullish input", () => {
|
|
@@ -37,4 +37,38 @@ describe("drivers/jid", () => {
|
|
|
37
37
|
assert.equal(toWireJid("+55 (11) 99999-9999"), "5511999999999@s.whatsapp.net");
|
|
38
38
|
});
|
|
39
39
|
});
|
|
40
|
+
describe("splitLidPn", () => {
|
|
41
|
+
test("addressingMode 'pn' (legacy): primary is PN, alt is LID", () => {
|
|
42
|
+
const { lid, pn } = splitLidPn("5511999999999@s.whatsapp.net", "12345@lid");
|
|
43
|
+
assert.equal(lid, "12345@lid");
|
|
44
|
+
assert.equal(pn, "5511999999999@s.whatsapp.net");
|
|
45
|
+
});
|
|
46
|
+
test("addressingMode 'lid' (modern default): primary is LID, alt is PN — roles reversed", () => {
|
|
47
|
+
const { lid, pn } = splitLidPn("12345@lid", "5511999999999@s.whatsapp.net");
|
|
48
|
+
assert.equal(lid, "12345@lid");
|
|
49
|
+
assert.equal(pn, "5511999999999@s.whatsapp.net");
|
|
50
|
+
});
|
|
51
|
+
test("only primary present, and it's a LID", () => {
|
|
52
|
+
const { lid, pn } = splitLidPn("12345@lid", undefined);
|
|
53
|
+
assert.equal(lid, "12345@lid");
|
|
54
|
+
assert.equal(pn, undefined);
|
|
55
|
+
});
|
|
56
|
+
test("only primary present, and it's a PN", () => {
|
|
57
|
+
const { lid, pn } = splitLidPn("5511999999999@s.whatsapp.net", undefined);
|
|
58
|
+
assert.equal(lid, undefined);
|
|
59
|
+
assert.equal(pn, "5511999999999@s.whatsapp.net");
|
|
60
|
+
});
|
|
61
|
+
test("neither present", () => {
|
|
62
|
+
const { lid, pn } = splitLidPn(undefined, null);
|
|
63
|
+
assert.equal(lid, undefined);
|
|
64
|
+
assert.equal(pn, undefined);
|
|
65
|
+
});
|
|
66
|
+
test("group JID (@g.us) as primary with no alt is treated as pn-shaped, never as lid", () => {
|
|
67
|
+
// Not a real person's PN, but callers only ever consume `.lid` from
|
|
68
|
+
// this pairing for the group case — `.pn` is discarded there.
|
|
69
|
+
const { lid, pn } = splitLidPn("120363999999999999@g.us", undefined);
|
|
70
|
+
assert.equal(lid, undefined);
|
|
71
|
+
assert.equal(pn, "120363999999999999@g.us");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
40
74
|
});
|
package/dist/i18n/index.js
CHANGED
|
@@ -42,27 +42,10 @@ function loadLocale(lang) {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*/
|
|
50
|
-
function detectSystemLang() {
|
|
51
|
-
try {
|
|
52
|
-
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
53
|
-
if (locale)
|
|
54
|
-
return locale.split("-")[0].toLowerCase();
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
// Intl unavailable — fall through to env vars
|
|
58
|
-
}
|
|
59
|
-
const envLocale = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || process.env.LANGUAGE;
|
|
60
|
-
if (envLocale)
|
|
61
|
-
return envLocale.split(/[_.]/)[0].toLowerCase();
|
|
62
|
-
return DEFAULT_LANG;
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Gets configured language or falls back to system locale, then English.
|
|
45
|
+
* Gets configured language or falls back to English. `CONFIG.LANGUAGE` is
|
|
46
|
+
* the single source of truth (set from manybot.toml, defaulted to "en") —
|
|
47
|
+
* this never guesses from the OS locale, so bot output language doesn't
|
|
48
|
+
* depend on the host machine/CI environment it happens to run on.
|
|
66
49
|
* @returns {string}
|
|
67
50
|
*/
|
|
68
51
|
function getConfiguredLang() {
|
|
@@ -75,7 +58,7 @@ function getConfiguredLang() {
|
|
|
75
58
|
// circular import while #config is still bootstrapping)
|
|
76
59
|
}
|
|
77
60
|
if (!lang) {
|
|
78
|
-
lang =
|
|
61
|
+
lang = DEFAULT_LANG;
|
|
79
62
|
}
|
|
80
63
|
const filePath = path.join(LOCALES_DIR, `${lang}.json`);
|
|
81
64
|
if (!fs.existsSync(filePath)) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kernel/chatOverrides.ts
|
|
3
|
+
*
|
|
4
|
+
* Per-chat overrides for the bot's global command prefix and language,
|
|
5
|
+
* set via `!config prefixo` / `!config idioma` (see coreCommands.ts).
|
|
6
|
+
* Both are stored under the "core" plugin namespace in settingsDb —
|
|
7
|
+
* the same storage plugins use via `ctx.settings`, keyed by the same
|
|
8
|
+
* `normalizeJid(msg.chatId)` that `buildApi()` uses to scope
|
|
9
|
+
* `ctx.settings` — so a value written here is read back under the
|
|
10
|
+
* exact same key it was written under.
|
|
11
|
+
*
|
|
12
|
+
* Kept as a standalone leaf module (only #config, jid utils, and
|
|
13
|
+
* settingsDb as deps) so it can be imported from anywhere in the
|
|
14
|
+
* dispatch pipeline (api/index.ts, messageHandler.ts, runCommand.ts,
|
|
15
|
+
* coreCommands.ts) without risking an import cycle.
|
|
16
|
+
*/
|
|
17
|
+
import { getPluginSetting } from "./settingsDb.js";
|
|
18
|
+
import { normalizeJid } from "#drivers/jid.js";
|
|
19
|
+
import { CMD_PREFIX } from "#config";
|
|
20
|
+
const CORE_PLUGIN = "core";
|
|
21
|
+
/**
|
|
22
|
+
* Resolves the effective command prefix for a chat: the chat's saved
|
|
23
|
+
* override (`!config prefixo`) if one was set, else the global
|
|
24
|
+
* `CMD_PREFIX`.
|
|
25
|
+
*
|
|
26
|
+
* @param chatId - the *raw* chat id (e.g. `msg.chatId`), not yet
|
|
27
|
+
* normalized — this function normalizes it itself so callers don't
|
|
28
|
+
* have to know which normalization the settings layer expects.
|
|
29
|
+
*/
|
|
30
|
+
export function getChatPrefix(chatId) {
|
|
31
|
+
const override = getPluginSetting(CORE_PLUGIN, normalizeJid(chatId), "chat_prefix");
|
|
32
|
+
return typeof override === "string" && override.length > 0 ? override : CMD_PREFIX;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolves the effective language code for a chat: the chat's saved
|
|
36
|
+
* override (`!config idioma`) if one was set, else `undefined` so the
|
|
37
|
+
* caller can fall back to the global language (`getCurrentLang()` /
|
|
38
|
+
* plain `t()`).
|
|
39
|
+
*
|
|
40
|
+
* @param chatId - the *raw* chat id (e.g. `msg.chatId`); normalized
|
|
41
|
+
* internally, same as {@link getChatPrefix}.
|
|
42
|
+
*/
|
|
43
|
+
export function getChatLocale(chatId) {
|
|
44
|
+
const override = getPluginSetting(CORE_PLUGIN, normalizeJid(chatId), "chat_locale");
|
|
45
|
+
return typeof override === "string" && override.length > 0 ? override : undefined;
|
|
46
|
+
}
|