@manybot/manybot 5.7.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.
Files changed (81) hide show
  1. package/README.md +28 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +91 -6
  5. package/dist/client/store.test.js +170 -0
  6. package/dist/config.js +28 -44
  7. package/dist/config.test.js +26 -0
  8. package/dist/download/queue.js +13 -4
  9. package/dist/drivers/baileys/adapter.js +133 -15
  10. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  11. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  12. package/dist/drivers/baileys/api/index.js +384 -62
  13. package/dist/drivers/baileys/index.js +92 -36
  14. package/dist/drivers/baileys/loginPrompt.js +0 -2
  15. package/dist/drivers/baileys/messageHandler.js +344 -4
  16. package/dist/drivers/baileys/messageHandler.test.js +445 -0
  17. package/dist/drivers/baileysAdapter.test.js +378 -0
  18. package/dist/drivers/jid.js +26 -0
  19. package/dist/drivers/jid.test.js +74 -0
  20. package/dist/drivers/types.js +5 -5
  21. package/dist/i18n/index.js +20 -24
  22. package/dist/kernel/activeDriverSend.js +21 -0
  23. package/dist/kernel/activeDriverSend.test.js +89 -0
  24. package/dist/kernel/alerts.js +3 -9
  25. package/dist/kernel/chatOverrides.js +46 -0
  26. package/dist/kernel/chatOverrides.test.js +59 -0
  27. package/dist/kernel/chatSession.js +65 -0
  28. package/dist/kernel/chatSession.test.js +46 -0
  29. package/dist/kernel/commandAccess.js +66 -0
  30. package/dist/kernel/commandAccess.test.js +74 -0
  31. package/dist/kernel/commandDeprecation.js +170 -0
  32. package/dist/kernel/commandDeprecation.test.js +114 -0
  33. package/dist/kernel/commandMenu.js +357 -0
  34. package/dist/kernel/commandMenu.test.js +363 -0
  35. package/dist/kernel/commandPermissions.js +171 -0
  36. package/dist/kernel/commandPermissions.test.js +227 -0
  37. package/dist/kernel/commandRegistry.js +583 -0
  38. package/dist/kernel/commandRegistry.test.js +158 -0
  39. package/dist/kernel/commandsConfig.js +949 -0
  40. package/dist/kernel/commandsConfig.test.js +482 -0
  41. package/dist/kernel/contactAutoSave.js +6 -6
  42. package/dist/kernel/contactAutoSave.test.js +87 -0
  43. package/dist/kernel/coreCommands.js +62 -0
  44. package/dist/kernel/driverManager.js +10 -6
  45. package/dist/kernel/driverManager.test.js +90 -0
  46. package/dist/kernel/integrationMode.js +88 -0
  47. package/dist/kernel/integrationMode.test.js +95 -0
  48. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  49. package/dist/kernel/pluginApi.test.js +600 -0
  50. package/dist/kernel/pluginGuard.js +18 -13
  51. package/dist/kernel/pluginGuard.test.js +39 -0
  52. package/dist/kernel/pluginLoader.js +169 -11
  53. package/dist/kernel/pluginLoader.test.js +190 -0
  54. package/dist/kernel/runCommand.js +284 -0
  55. package/dist/kernel/runCommand.test.js +497 -0
  56. package/dist/kernel/sendFallbackGuard.js +19 -48
  57. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  58. package/dist/kernel/sendGuard.js +38 -42
  59. package/dist/kernel/sendGuard.test.js +102 -0
  60. package/dist/kernel/settingsDb.js +19 -5
  61. package/dist/kernel/statusServer.js +9 -2
  62. package/dist/kernel/statusServer.test.js +70 -0
  63. package/dist/kernel/testConfig.js +192 -0
  64. package/dist/kernel/testConfig.test.js +181 -0
  65. package/dist/kernel/updateCheck.js +33 -10
  66. package/dist/locales/en.json +77 -13
  67. package/dist/locales/es.json +77 -13
  68. package/dist/locales/pt.json +77 -13
  69. package/dist/logger/logger.js +23 -3
  70. package/dist/logger/logger.test.js +45 -0
  71. package/dist/main.js +5 -76
  72. package/dist/plugins/__manybot_integration__/index.js +184 -0
  73. package/dist/plugins/__manybot_integration__/index.test.js +218 -0
  74. package/dist/utils/phoneNumber.js +83 -0
  75. package/dist/utils/phoneNumber.test.js +53 -0
  76. package/package.json +76 -18
  77. package/dist/drivers/whatsmeow/client.js +0 -252
  78. package/dist/drivers/whatsmeow/index.js +0 -79
  79. package/dist/drivers/whatsmeow/installer.js +0 -86
  80. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  81. package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
package/README.md CHANGED
@@ -18,8 +18,6 @@ Open-source framework for message automation, extensible via community plugins.
18
18
 
19
19
  - Node.js >= 24
20
20
  - npm >= 9
21
- - Go >= 1.23 (for whatsmeow driver)
22
- - protoc (for protobuf codegen)
23
21
 
24
22
  ## Getting started
25
23
 
@@ -39,7 +37,6 @@ On first run, a configuration file is created at `~/.manybot/manybot.toml`. Edit
39
37
  git clone <repo-url>
40
38
  cd dev
41
39
  npm install
42
- go build -o whatsmeow-service/bin/whatsmeow-service ./whatsmeow-service
43
40
  npm start
44
41
  ```
45
42
 
@@ -55,6 +52,34 @@ manyplug install <plugin-name>
55
52
 
56
53
  Browse available plugins at **[manybot.org/plugins](https://manybot.org/plugins/)**.
57
54
 
55
+ ## Testing
56
+
57
+ ManyBot includes both local unit/contract tests and opt-in real WhatsApp integration tests:
58
+
59
+ ```bash
60
+ # Run unit and mock contract test suite (fast, offline)
61
+ npm test
62
+
63
+ # Run all verification gates (typecheck + lint + unit tests)
64
+ npm run check
65
+
66
+ # Run real WhatsApp integration tests against a live account.
67
+ # Requires: TEST_CHAT, MANYBOT_RUN_WHATSAPP_TESTS=1, and a saved
68
+ # WhatsApp session in your CONFIG_DIR. The bare variant below
69
+ # always skips every test (no live socket). Use `:local` to boot
70
+ # the bot first — it preloads src/main.ts so the driver connects
71
+ # before the test runner kicks in.
72
+ TEST_CHAT="5516999999999" MANYBOT_RUN_WHATSAPP_TESTS=1 npm run test:integration:local
73
+
74
+ # Manual smoke probe: connects, runs a few marker round-trips, and
75
+ # prints the IContact shape (id / number / numberRaw / numberPretty /
76
+ # country / countryCallingCode) returned by contacts.get(...) — useful
77
+ # for verifying the LID-aware contact API against your real account
78
+ # without the full test runner overhead.
79
+ TEST_CHAT="5516999999999" MANYBOT_RUN_WHATSAPP_TESTS=1 \
80
+ node --import ./src/main.ts scripts/probe-contacts.mjs
81
+ ```
82
+
58
83
  ## Contributing
59
84
 
60
85
  All kinds of contributions are welcome:
@@ -10,10 +10,20 @@ const C = {
10
10
  import { readFileSync } from "fs";
11
11
  import { fileURLToPath } from "url";
12
12
  import path from "path";
13
+ import { getLogLevel } from "#logger";
13
14
  const __filename = fileURLToPath(import.meta.url);
14
15
  const __dirname = path.dirname(__filename);
15
16
  const v = JSON.parse(readFileSync(path.join(__dirname, "../../package.json"), "utf8")).version;
17
+ /**
18
+ * Startup ASCII banner. Optional — respects `LOG_LEVEL` (Phase 9): shown
19
+ * only at `"normal"`, silent at `"clean"`/`"minimal"` for users who prefer
20
+ * quiet logs. Caller is responsible for only invoking this once per process
21
+ * (see `drivers/baileys/index.ts`'s `bannerShown` guard) — reconnects
22
+ * shouldn't reprint it.
23
+ */
16
24
  export function printBanner() {
25
+ if (getLogLevel() !== "normal")
26
+ return;
17
27
  const banner = [
18
28
  ` _ _ `,
19
29
  ` | | | | `,
@@ -0,0 +1,31 @@
1
+ import test, { describe, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { printBanner } from "./banner.js";
4
+ import { setLogLevel } from "#logger";
5
+ describe("printBanner LOG_LEVEL gating", () => {
6
+ const originalLog = console.log;
7
+ let lines;
8
+ beforeEach(() => {
9
+ lines = [];
10
+ console.log = (...a) => { lines.push(a.join(" ")); };
11
+ });
12
+ afterEach(() => {
13
+ console.log = originalLog;
14
+ setLogLevel("normal");
15
+ });
16
+ test("prints at normal", () => {
17
+ setLogLevel("normal");
18
+ printBanner();
19
+ assert.ok(lines.length > 0);
20
+ });
21
+ test("stays silent at clean", () => {
22
+ setLogLevel("clean");
23
+ printBanner();
24
+ assert.equal(lines.length, 0);
25
+ });
26
+ test("stays silent at minimal", () => {
27
+ setLogLevel("minimal");
28
+ printBanner();
29
+ assert.equal(lines.length, 0);
30
+ });
31
+ });
@@ -18,6 +18,7 @@
18
18
  * @whiskeysockets/baileys import line never leaks past the driver
19
19
  * boundary.
20
20
  */
21
+ import { normalizeJid } from "#drivers/jid.js";
21
22
  // ── Factory ───────────────────────────────────────────────────────────────────
22
23
  /**
23
24
  * Create a new BotStore instance.
@@ -30,9 +31,23 @@ export function createStore() {
30
31
  // @lid JID → traditional @s.whatsapp.net JID, learned from contact and
31
32
  // message-key pairs (Baileys exposes both forms during the LID rollout).
32
33
  const lidMap = new Map();
34
+ // traditional @s.whatsapp.net JID → @lid JID, reverse mapping for lookups.
35
+ const pnMap = new Map();
33
36
  function learnLid(lid, pn) {
34
- if (lid && pn && lid.endsWith("@lid") && !pn.endsWith("@lid"))
37
+ if (lid && pn && lid.endsWith("@lid") && !pn.endsWith("@lid")) {
38
+ // lidMap keeps the wire-format value as given — resolveJid()
39
+ // consumers (e.g. adapter.ts building self/participant candidates
40
+ // via jidNormalizedUser) expect the raw "@s.whatsapp.net" shape.
35
41
  lidMap.set(lid, pn);
42
+ // pnMap's key is canonicalized to ManyBot's internal "@c.us" form
43
+ // so a lookup matches regardless of which format the caller has on
44
+ // hand — callers deep in the driver (adapter.ts) query it with raw
45
+ // wire JIDs, while callers in the plugin-facing API (index.ts) query
46
+ // it with already-normalized JIDs. Without this, pn entries learned
47
+ // in one format silently never matched a resolvePn() call made in
48
+ // the other.
49
+ pnMap.set(normalizeJid(pn), lid);
50
+ }
36
51
  }
37
52
  function resolveJid(jid) {
38
53
  if (!jid || !jid.endsWith("@lid"))
@@ -40,15 +55,54 @@ export function createStore() {
40
55
  return lidMap.get(jid) ?? jid;
41
56
  }
42
57
  function forgetLid(lid) {
58
+ const pn = lidMap.get(lid);
59
+ if (pn)
60
+ pnMap.delete(normalizeJid(pn));
43
61
  lidMap.delete(lid);
44
62
  }
63
+ function resolvePn(pn) {
64
+ if (!pn)
65
+ return null;
66
+ return pnMap.get(normalizeJid(pn)) ?? null;
67
+ }
68
+ // Coerce the various shapes Baileys delivers `ephemeralExpiration` in
69
+ // (string-numbered from the wire, `null` when the timer was cleared,
70
+ // or absent entirely) into a plain finite number. Anything we can't
71
+ // parse becomes 0 — the wire convention for "no disappearing timer".
72
+ function normalizeExpiration(raw) {
73
+ if (raw === null || raw === undefined)
74
+ return 0;
75
+ const n = Number(raw);
76
+ return Number.isFinite(n) && n >= 0 ? n : 0;
77
+ }
78
+ function setChatEphemeralExpiration(jid, seconds) {
79
+ if (!jid)
80
+ return;
81
+ const existing = chatsMap.get(jid);
82
+ const next = normalizeExpiration(seconds);
83
+ if (existing) {
84
+ existing.ephemeralExpiration = next;
85
+ }
86
+ else {
87
+ chatsMap.set(jid, { id: jid, name: jid.split("@")[0], ephemeralExpiration: next });
88
+ }
89
+ }
45
90
  // Max messages kept per chat (prevents unbounded memory growth)
46
91
  const MAX_MSGS_PER_CHAT = 200;
47
92
  function upsertChat(chat) {
48
93
  if (!chat.id)
49
94
  return;
50
95
  const name = chat.name ?? chat.id.split("@")[0];
51
- chatsMap.set(chat.id, { id: chat.id, name });
96
+ const rawExp = chat.ephemeralExpiration;
97
+ const existing = chatsMap.get(chat.id);
98
+ chatsMap.set(chat.id, {
99
+ id: chat.id,
100
+ name,
101
+ // Preserve a previously-learned timer if this upsert doesn't carry
102
+ // one (chats.upsert often ships without ephemeralExpiration — the
103
+ // value arrives later via chats.update or an ephemeralMessage).
104
+ ephemeralExpiration: rawExp !== undefined ? normalizeExpiration(rawExp) : (existing?.ephemeralExpiration ?? 0),
105
+ });
52
106
  }
53
107
  function upsertContact(contact) {
54
108
  // Don't let a later, poorer sync round (e.g. a bare history-sync stub)
@@ -124,9 +178,17 @@ export function createStore() {
124
178
  if (!update.id)
125
179
  continue;
126
180
  const existing = chatsMap.get(update.id);
127
- const name = update.name;
128
- if (existing && name)
129
- existing.name = name;
181
+ if (!existing)
182
+ continue;
183
+ const u = update;
184
+ if (u.name)
185
+ existing.name = u.name;
186
+ // Baileys sends `ephemeralExpiration: null` to signal the timer
187
+ // was cleared — store 0 in that case so a later `chats.update`
188
+ // carrying a positive value can take over again.
189
+ if ("ephemeralExpiration" in u) {
190
+ existing.ephemeralExpiration = normalizeExpiration(u.ephemeralExpiration);
191
+ }
130
192
  }
131
193
  });
132
194
  ev.on("contacts.upsert", (newContacts) => {
@@ -144,8 +206,19 @@ export function createStore() {
144
206
  }
145
207
  });
146
208
  ev.on("messages.upsert", ({ messages: msgs }) => {
147
- for (const msg of msgs)
209
+ for (const msg of msgs) {
148
210
  storeMessage(msg);
211
+ // Disappearing-message timer: when a message arrives wrapped in
212
+ // an `ephemeralMessage`, the inner contextInfo carries the
213
+ // per-message timer WhatsApp negotiated for the chat. Promote
214
+ // it to the chat-level entry so subsequent sends can pick it up
215
+ // without re-scanning every message.
216
+ const m = msg.message;
217
+ const expiration = m?.ephemeralMessage?.message?.extendedTextMessage?.contextInfo?.expiration;
218
+ if (expiration !== undefined && msg.key.remoteJid) {
219
+ setChatEphemeralExpiration(msg.key.remoteJid, normalizeExpiration(expiration));
220
+ }
221
+ }
149
222
  capturePushNames(msgs);
150
223
  });
151
224
  ev.on("messages.update", (updates) => {
@@ -173,6 +246,7 @@ export function createStore() {
173
246
  chats: [...chatsMap.values()],
174
247
  contacts: { ...contacts },
175
248
  lidMap: [...lidMap.entries()],
249
+ pnMap: [...pnMap.entries()],
176
250
  };
177
251
  }
178
252
  function hydrate(snapshot) {
@@ -181,6 +255,15 @@ export function createStore() {
181
255
  for (const [id, contact] of Object.entries(snapshot.contacts ?? {})) {
182
256
  contacts[id] = { ...contacts[id], ...contact };
183
257
  }
258
+ // learnLid() always mirrors into both lidMap and pnMap, so replaying
259
+ // snapshot.lidMap alone fully reconstructs pnMap too — every pnMap
260
+ // entry has a corresponding lidMap entry, they're never written
261
+ // independently. snapshot.pnMap itself is redundant to replay here:
262
+ // it exists only so `toJSON()`'s output is self-describing/inspectable,
263
+ // and re-learning from it a second time would re-derive pnMap's key
264
+ // (already-canonicalized "@c.us" form) as if it were lidMap's raw
265
+ // wire-format value, corrupting the wire-format invariant lidMap's
266
+ // consumers (e.g. adapter.ts) depend on.
184
267
  for (const [lid, pn] of snapshot.lidMap ?? [])
185
268
  learnLid(lid, pn);
186
269
  }
@@ -194,6 +277,8 @@ export function createStore() {
194
277
  resolveJid,
195
278
  learnLid,
196
279
  forgetLid,
280
+ resolvePn,
281
+ setChatEphemeralExpiration,
197
282
  bind,
198
283
  toJSON,
199
284
  hydrate,
@@ -0,0 +1,170 @@
1
+ import assert from "node:assert/strict";
2
+ import { EventEmitter } from "node:events";
3
+ import test from "node:test";
4
+ import { createStore } from "#client/store.js";
5
+ test("store tracks disappearing-message timers from chats and message context", () => {
6
+ const store = createStore();
7
+ const ev = new EventEmitter();
8
+ store.bind(ev);
9
+ ev.emit("chats.upsert", [{ id: "chat@s.whatsapp.net", name: "Chat", ephemeralExpiration: "86400" }]);
10
+ assert.equal(store.chats.get("chat@s.whatsapp.net")?.ephemeralExpiration, 86400);
11
+ ev.emit("chats.update", [{ id: "chat@s.whatsapp.net", ephemeralExpiration: null }]);
12
+ assert.equal(store.chats.get("chat@s.whatsapp.net")?.ephemeralExpiration, 0);
13
+ ev.emit("messages.upsert", {
14
+ messages: [{
15
+ key: { id: "message", remoteJid: "chat@s.whatsapp.net" },
16
+ message: {
17
+ ephemeralMessage: {
18
+ message: { extendedTextMessage: { contextInfo: { expiration: "604800" } } },
19
+ },
20
+ },
21
+ }],
22
+ });
23
+ assert.equal(store.chats.get("chat@s.whatsapp.net")?.ephemeralExpiration, 604800);
24
+ });
25
+ test("store keeps timers in snapshots and accepts a timer learned by metadata", () => {
26
+ const store = createStore();
27
+ store.setChatEphemeralExpiration("group@g.us", 7776000);
28
+ const snapshot = store.toJSON();
29
+ assert.equal(snapshot.chats[0]?.ephemeralExpiration, 7776000);
30
+ const hydrated = createStore();
31
+ hydrated.hydrate(snapshot);
32
+ assert.equal(hydrated.chats.get("group@g.us")?.ephemeralExpiration, 7776000);
33
+ });
34
+ test("store learns, resolves, and forgets LID to PN mappings", () => {
35
+ const store = createStore();
36
+ // Invalid or identity mappings are ignored
37
+ store.learnLid("invalid_jid", "551199999999@s.whatsapp.net");
38
+ store.learnLid("123@lid", "456@lid");
39
+ assert.equal(store.resolveJid("invalid_jid"), "invalid_jid");
40
+ assert.equal(store.resolveJid("123@lid"), "123@lid");
41
+ // Valid mapping is learned and resolved
42
+ store.learnLid("123456789@lid", "551199999999@s.whatsapp.net");
43
+ assert.equal(store.resolveJid("123456789@lid"), "551199999999@s.whatsapp.net");
44
+ // Non-LID JIDs are returned untouched
45
+ assert.equal(store.resolveJid("551188888888@s.whatsapp.net"), "551188888888@s.whatsapp.net");
46
+ // Forget LID removes mapping
47
+ store.forgetLid("123456789@lid");
48
+ assert.equal(store.resolveJid("123456789@lid"), "123456789@lid");
49
+ });
50
+ test("store preserves rich contact fields during updates", () => {
51
+ const store = createStore();
52
+ const ev = new EventEmitter();
53
+ store.bind(ev);
54
+ // Initial contact with full details
55
+ ev.emit("contacts.upsert", [
56
+ { id: "user1@s.whatsapp.net", name: "Alice", notify: "Alice N", verifiedName: "Alice Verified" }
57
+ ]);
58
+ let contact = store.contacts["user1@s.whatsapp.net"];
59
+ assert.equal(contact?.name, "Alice");
60
+ assert.equal(contact?.notify, "Alice N");
61
+ assert.equal(contact?.verifiedName, "Alice Verified");
62
+ // Poorer update (e.g., from a stub) should not wipe out previous fields
63
+ ev.emit("contacts.upsert", [
64
+ { id: "user1@s.whatsapp.net" }
65
+ ]);
66
+ contact = store.contacts["user1@s.whatsapp.net"];
67
+ assert.equal(contact?.name, "Alice");
68
+ assert.equal(contact?.notify, "Alice N");
69
+ assert.equal(contact?.verifiedName, "Alice Verified");
70
+ });
71
+ test("store captures pushName from messages and respects guards", () => {
72
+ const store = createStore();
73
+ const ev = new EventEmitter();
74
+ store.bind(ev);
75
+ ev.emit("messages.upsert", {
76
+ messages: [
77
+ // DM message from other person -> should capture
78
+ {
79
+ key: { id: "m1", remoteJid: "user2@s.whatsapp.net", fromMe: false },
80
+ pushName: "Bob",
81
+ },
82
+ // Message fromMe -> should NOT capture
83
+ {
84
+ key: { id: "m2", remoteJid: "user3@s.whatsapp.net", fromMe: true },
85
+ pushName: "SelfBot",
86
+ },
87
+ // Message in group -> captures participant
88
+ {
89
+ key: { id: "m3", remoteJid: "group1@g.us", participant: "user4@s.whatsapp.net", fromMe: false },
90
+ pushName: "Charlie",
91
+ },
92
+ ],
93
+ });
94
+ assert.equal(store.contacts["user2@s.whatsapp.net"]?.notify, "Bob");
95
+ assert.equal(store.contacts["user3@s.whatsapp.net"], undefined);
96
+ assert.equal(store.contacts["user4@s.whatsapp.net"]?.notify, "Charlie");
97
+ });
98
+ test("store evicts oldest messages when exceeding MAX_MSGS_PER_CHAT (200)", () => {
99
+ const store = createStore();
100
+ const ev = new EventEmitter();
101
+ store.bind(ev);
102
+ const jid = "chat-flood@s.whatsapp.net";
103
+ const messages = [];
104
+ for (let i = 1; i <= 205; i++) {
105
+ messages.push({
106
+ key: { id: `msg-${i}`, remoteJid: jid },
107
+ message: { conversation: `text ${i}` },
108
+ });
109
+ }
110
+ ev.emit("messages.upsert", { messages });
111
+ const chatMap = store.messages.get(jid);
112
+ assert.ok(chatMap);
113
+ assert.equal(chatMap?.size, 200);
114
+ assert.equal(chatMap?.has("msg-1"), false); // evicted
115
+ assert.equal(chatMap?.has("msg-5"), false); // evicted
116
+ assert.equal(chatMap?.has("msg-6"), true); // kept
117
+ assert.equal(chatMap?.has("msg-205"), true); // kept
118
+ });
119
+ test("store accumulates poll updates on messages.update", () => {
120
+ const store = createStore();
121
+ const ev = new EventEmitter();
122
+ store.bind(ev);
123
+ const jid = "poll-chat@s.whatsapp.net";
124
+ const msgId = "poll-1";
125
+ // Initial poll message
126
+ ev.emit("messages.upsert", {
127
+ messages: [
128
+ {
129
+ key: { id: msgId, remoteJid: jid },
130
+ message: { pollCreationMessage: { name: "Question?" } },
131
+ }
132
+ ]
133
+ });
134
+ // First vote update
135
+ ev.emit("messages.update", [
136
+ {
137
+ key: { id: msgId, remoteJid: jid },
138
+ update: { pollUpdates: [{ voter: "user1" }] },
139
+ }
140
+ ]);
141
+ // Second vote update
142
+ ev.emit("messages.update", [
143
+ {
144
+ key: { id: msgId, remoteJid: jid },
145
+ update: { pollUpdates: [{ voter: "user2" }] },
146
+ }
147
+ ]);
148
+ const stored = store.messages.get(jid)?.get(msgId);
149
+ assert.ok(stored);
150
+ assert.equal(stored.pollUpdates?.length, 2);
151
+ assert.deepEqual(stored.pollUpdates, [{ voter: "user1" }, { voter: "user2" }]);
152
+ });
153
+ test("store processes messaging-history.set backfill", () => {
154
+ const store = createStore();
155
+ const ev = new EventEmitter();
156
+ store.bind(ev);
157
+ ev.emit("messaging-history.set", {
158
+ chats: [{ id: "hist-chat@s.whatsapp.net", name: "History Chat" }],
159
+ contacts: [{ id: "hist-user@s.whatsapp.net", name: "Dave" }],
160
+ messages: [
161
+ {
162
+ key: { id: "m-hist", remoteJid: "hist-chat@s.whatsapp.net", fromMe: false },
163
+ pushName: "Dave Push",
164
+ }
165
+ ],
166
+ });
167
+ assert.equal(store.chats.get("hist-chat@s.whatsapp.net")?.name, "History Chat");
168
+ assert.equal(store.contacts["hist-user@s.whatsapp.net"]?.name, "Dave");
169
+ assert.equal(store.contacts["hist-chat@s.whatsapp.net"]?.notify, "Dave Push");
170
+ });
package/dist/config.js CHANGED
@@ -25,7 +25,7 @@ import { t } from "#i18n";
25
25
  // ---------------------------------------------------------------------------
26
26
  // Paths
27
27
  // ---------------------------------------------------------------------------
28
- export const CONFIG_DIR = path.join(os.homedir(), ".manybot");
28
+ export const CONFIG_DIR = process.env.MANYBOT_CONFIG_DIR ?? path.join(os.homedir(), ".manybot");
29
29
  /** @deprecated Use TOML_CONFIG_FILE. Frozen — no new keys. */
30
30
  export const CONFIG_FILE = path.join(CONFIG_DIR, "manybot.conf");
31
31
  /** @deprecated Use TOML_PLUGIN_FILE. Frozen — no new keys. */
@@ -210,6 +210,11 @@ PHONE_NUMBER = ""
210
210
  # but reduce the risk of WhatsApp flagging the account.
211
211
  SECURITY_LEVEL = "medium"
212
212
 
213
+ # Startup log verbosity: "normal" (everything), "clean" (hides routine
214
+ # chatter, keeps success/warnings/errors), or "minimal" (warnings/errors
215
+ # only — silent on the happy path).
216
+ LOG_LEVEL = "normal"
217
+
213
218
  # ── Local alerts (critical warnings, update notices) ──────────────────────
214
219
  # Phone number (or full JID) that receives WhatsApp alerts when the bot
215
220
  # itself is up — e.g. "5511999999999" or "5511999999999@s.whatsapp.net".
@@ -248,18 +253,6 @@ LOGIN_METHOD = ""
248
253
  STATUS_ENABLED = true
249
254
  STATUS_PORT = 8080
250
255
 
251
- # ── Driver selection (advanced) ────────────────────────────────────────────
252
- # ManyBot can fall back from a primary driver to a secondary one when the
253
- # primary fails to confirm a send. Today only Baileys is wired in; leave
254
- # driver_whatsmeow_enabled = false to disable the fallback entirely (no
255
- # extra processes are spawned, no sockets are opened).
256
- driver_primary = "baileys"
257
- driver_fallback_cooldown_ms = 60000
258
- driver_verify_window_ms = [500, 1000, 1500, 2500, 5000]
259
- driver_baileys_enabled = true
260
- driver_whatsmeow_enabled = false
261
- driver_whatsmeow_grpc_address = "localhost:50051"
262
- driver_whatsmeow_binary_path = ""
263
256
  `;
264
257
  const DEFAULT_TOML_PT = `# Arquivo de configuração do ManyBot
265
258
  # Veja https://manybot.org/docs/config para saber mais
@@ -276,6 +269,11 @@ PHONE_NUMBER = ""
276
269
  # maiores), mas reduzem o risco do WhatsApp sinalizar a conta.
277
270
  SECURITY_LEVEL = "medium"
278
271
 
272
+ # Verbosidade dos logs de inicialização: "normal" (tudo), "clean" (esconde
273
+ # ruído de rotina, mantém sucesso/avisos/erros), ou "minimal" (só avisos
274
+ # e erros — silencioso quando está tudo bem).
275
+ LOG_LEVEL = "normal"
276
+
279
277
  # ── Avisos locais (alertas críticos, aviso de update) ──────────────────────
280
278
  # Número de telefone (ou JID completo) que recebe alertas via WhatsApp
281
279
  # quando o bot está de pé — ex. "5511999999999" ou
@@ -316,18 +314,6 @@ LOGIN_METHOD = ""
316
314
  STATUS_ENABLED = true
317
315
  STATUS_PORT = 8080
318
316
 
319
- # ── Seleção de driver (avançado) ───────────────────────────────────────────
320
- # ManyBot pode cair de um driver primário pra um secundário quando o
321
- # primário não confirma um envio. Hoje só Baileys está ligado; deixe
322
- # driver_whatsmeow_enabled = false pra desligar o fallback por completo
323
- # (nenhum processo extra é spawnado, nenhum socket é aberto).
324
- driver_primary = "baileys"
325
- driver_fallback_cooldown_ms = 60000
326
- driver_verify_window_ms = [500, 1000, 1500, 2500, 5000]
327
- driver_baileys_enabled = true
328
- driver_whatsmeow_enabled = false
329
- driver_whatsmeow_grpc_address = "localhost:50051"
330
- driver_whatsmeow_binary_path = ""
331
317
  `;
332
318
  const DEFAULT_TOML = detectSystemLang() === "pt" ? DEFAULT_TOML_PT : DEFAULT_TOML_EN;
333
319
  await fs.mkdir(CONFIG_DIR, { recursive: true });
@@ -368,9 +354,11 @@ const DEFAULTS = {
368
354
  CHATS: [],
369
355
  EXCLUDE_CHATS: [],
370
356
  SECURITY_LEVEL: "medium",
357
+ LOG_LEVEL: "normal",
371
358
  PLUGINS: [],
372
359
  LANGUAGE: "en",
373
360
  PHONE_NUMBER: null,
361
+ OWNER_NUMBER: null,
374
362
  LOGIN_METHOD: null,
375
363
  ADMIN_JID: "",
376
364
  SMTP_HOST: "",
@@ -390,7 +378,6 @@ const DEFAULTS = {
390
378
  fallbackCooldownMs: 60000,
391
379
  verifyWindowMs: [500, 1000, 1500, 2500, 5000],
392
380
  baileys: { enabled: true },
393
- whatsmeow: { enabled: false, grpcAddress: "localhost:50051", binaryPath: "" },
394
381
  },
395
382
  };
396
383
  function normalize(cfg) {
@@ -398,6 +385,13 @@ function normalize(cfg) {
398
385
  // can always do a simple truthiness check regardless of config source.
399
386
  if (cfg.PHONE_NUMBER === "")
400
387
  cfg.PHONE_NUMBER = null;
388
+ const rawOwner = cfg.owner_number ?? cfg.OWNER_NUMBER;
389
+ if (typeof rawOwner === "string" && rawOwner.trim().length > 0) {
390
+ cfg.OWNER_NUMBER = rawOwner.trim();
391
+ }
392
+ else {
393
+ cfg.OWNER_NUMBER = null;
394
+ }
401
395
  // Anything other than "phone"/"qr" is treated as "not configured yet",
402
396
  // so a typo or leftover garbage value falls back to the interactive
403
397
  // prompt instead of silently misbehaving.
@@ -407,6 +401,9 @@ function normalize(cfg) {
407
401
  if (!["low", "medium", "high"].includes(cfg.SECURITY_LEVEL)) {
408
402
  cfg.SECURITY_LEVEL = "medium";
409
403
  }
404
+ if (!["normal", "clean", "minimal"].includes(cfg.LOG_LEVEL)) {
405
+ cfg.LOG_LEVEL = "normal";
406
+ }
410
407
  // Legacy .conf values arrive as strings; TOML gives real types already —
411
408
  // Number()/coercion here is a no-op for the TOML path.
412
409
  cfg.SMTP_PORT = Number(cfg.SMTP_PORT) || 587;
@@ -423,8 +420,8 @@ function normalize(cfg) {
423
420
  cfg.STATUS_PORT = Number(cfg.STATUS_PORT) || 8080;
424
421
  // drivers.* — the fallback guard and DriverManager read these at every
425
422
  // send. The TOML surface is flat lowercase keys (driver_primary,
426
- // driver_baileys_enabled, driver_whatsmeow_*, …) so it matches the
427
- // rest of the file; the nested `cfg.drivers` object is rebuilt here
423
+ // driver_baileys_enabled, …) same style as the
424
+ // rest of this file. `normalize()` rebuilds the nested object below
428
425
  // from those flat keys so callers (main.ts, sendFallbackGuard.ts,
429
426
  // supervisor.ts, client.ts) keep using `CONFIG.drivers.*`.
430
427
  // Coerce strings (legacy .conf arrives as strings) and reject anything
@@ -443,15 +440,11 @@ function normalize(cfg) {
443
440
  // Allow either the new flat keys or (for backward compatibility) the
444
441
  // old nested `[drivers]` block. Flat keys take precedence.
445
442
  const nestedDrv = (cfg.drivers ?? {});
446
- const flatPrimary = cfg.driver_primary ?? nestedDrv.primary;
447
443
  const flatCooldown = cfg.driver_fallback_cooldown_ms ?? nestedDrv.fallbackCooldownMs;
448
444
  const flatVerify = cfg.driver_verify_window_ms ?? nestedDrv.verifyWindowMs;
449
445
  const flatBaileysEnabled = cfg.driver_baileys_enabled ?? nestedDrv.baileys?.enabled;
450
- const flatWhatsmeowEnabled = cfg.driver_whatsmeow_enabled ?? nestedDrv.whatsmeow?.enabled;
451
- const flatWhatsmeowGrpc = cfg.driver_whatsmeow_grpc_address ?? nestedDrv.whatsmeow?.grpcAddress;
452
- const flatWhatsmeowBinary = cfg.driver_whatsmeow_binary_path ?? nestedDrv.whatsmeow?.binaryPath;
453
446
  cfg.drivers = {
454
- primary: flatPrimary === "whatsmeow" ? "whatsmeow" : "baileys",
447
+ primary: "baileys",
455
448
  fallbackCooldownMs: Number(flatCooldown) || 60000,
456
449
  verifyWindowMs: Array.isArray(flatVerify) && flatVerify.length
457
450
  ? flatVerify.map((n) => Number(n)).filter((n) => Number.isFinite(n) && n > 0)
@@ -459,17 +452,6 @@ function normalize(cfg) {
459
452
  baileys: {
460
453
  enabled: coerceEnabled(flatBaileysEnabled, true),
461
454
  },
462
- whatsmeow: {
463
- // MANYBOT_SMOKE=1 force-enables whatsmeow regardless of the TOML
464
- // value — used by scripts/smoke-whatsmeow-supervisor.mjs so the
465
- // smoke test doesn't depend on the local manybot.toml already
466
- // having driver_whatsmeow_enabled = true. Not a documented
467
- // user-facing setting; TOML remains the source of truth outside
468
- // smoke runs.
469
- enabled: process.env.MANYBOT_SMOKE === "1" ? true : isTruthy(flatWhatsmeowEnabled),
470
- grpcAddress: typeof flatWhatsmeowGrpc === "string" ? flatWhatsmeowGrpc : "localhost:50051",
471
- binaryPath: typeof flatWhatsmeowBinary === "string" ? flatWhatsmeowBinary : "",
472
- },
473
455
  };
474
456
  // Drop the flat keys from the merged object so they don't leak as
475
457
  // loose top-level Config keys (the `Config` interface exposes only
@@ -515,9 +497,11 @@ export const CMD_PREFIX = CONFIG.CMD_PREFIX;
515
497
  export const CHATS = CONFIG.CHATS;
516
498
  export const EXCLUDE_CHATS = CONFIG.EXCLUDE_CHATS;
517
499
  export const SECURITY_LEVEL = CONFIG.SECURITY_LEVEL;
500
+ export const LOG_LEVEL = CONFIG.LOG_LEVEL;
518
501
  export const PLUGINS = CONFIG.PLUGINS;
519
502
  export const LANGUAGE = CONFIG.LANGUAGE;
520
503
  export const PHONE_NUMBER = CONFIG.PHONE_NUMBER;
504
+ export const OWNER_NUMBER = CONFIG.OWNER_NUMBER;
521
505
  export const LOGIN_METHOD = CONFIG.LOGIN_METHOD;
522
506
  export const ADMIN_JID = CONFIG.ADMIN_JID;
523
507
  export const SMTP_HOST = CONFIG.SMTP_HOST;
@@ -0,0 +1,26 @@
1
+ import test, { describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import path from "path";
4
+ import os from "os";
5
+ import { CONFIG_DIR, CONFIG } from "#config";
6
+ describe("config", () => {
7
+ test("CONFIG_DIR defaults to ~/.manybot or respects env override", () => {
8
+ if (process.env.MANYBOT_CONFIG_DIR) {
9
+ assert.equal(CONFIG_DIR, process.env.MANYBOT_CONFIG_DIR);
10
+ }
11
+ else {
12
+ assert.equal(CONFIG_DIR, path.join(os.homedir(), ".manybot"));
13
+ }
14
+ });
15
+ test("CONFIG has required structure and default fallback values", () => {
16
+ assert.ok(typeof CONFIG.CMD_PREFIX === "string");
17
+ assert.ok(typeof CONFIG.CLIENT_ID === "string");
18
+ assert.ok(Array.isArray(CONFIG.CHATS));
19
+ assert.ok(Array.isArray(CONFIG.EXCLUDE_CHATS));
20
+ assert.ok(["low", "medium", "high"].includes(CONFIG.SECURITY_LEVEL));
21
+ assert.ok(["normal", "clean", "minimal"].includes(CONFIG.LOG_LEVEL));
22
+ assert.ok(CONFIG.drivers);
23
+ assert.ok(CONFIG.drivers.primary === "baileys");
24
+ assert.ok(typeof CONFIG.drivers.baileys.enabled === "boolean");
25
+ });
26
+ });
@@ -10,6 +10,9 @@
10
10
  * Usage:
11
11
  * import { enqueue } from "#download";
12
12
  * enqueue(async () => { ... all plugin logic ... }, onError);
13
+ *
14
+ * `errorFn` is optional — if omitted, a failure is still logged via
15
+ * `logger.warn` so it's never silently swallowed.
13
16
  */
14
17
  import { logger } from "#logger";
15
18
  import { t } from "#i18n";
@@ -18,7 +21,8 @@ let processing = false;
18
21
  /**
19
22
  * Add a job to the queue and start processing if idle.
20
23
  * @param workFn All plugin logic — runs exclusively until resolved.
21
- * @param errorFn Called with the thrown error if workFn rejects.
24
+ * @param errorFn Called with the thrown error if workFn rejects. If omitted,
25
+ * the error is logged via `logger.warn` instead.
22
26
  */
23
27
  export function enqueue(workFn, errorFn) {
24
28
  queue.push({ workFn, errorFn });
@@ -41,9 +45,14 @@ async function processJob({ workFn, errorFn }) {
41
45
  catch (e) {
42
46
  const err = e instanceof Error ? e : new Error(String(e));
43
47
  logger.error(t("system.downloadJobFailed", { message: err.message }));
44
- try {
45
- await errorFn(err instanceof Error ? err : new Error(String(err)));
48
+ if (errorFn) {
49
+ try {
50
+ await errorFn(err);
51
+ }
52
+ catch { }
53
+ }
54
+ else {
55
+ logger.warn(t("system.downloadJobNoErrorFn"));
46
56
  }
47
- catch { }
48
57
  }
49
58
  }