@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
@@ -0,0 +1,80 @@
1
+ import test, { describe, beforeEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { sendWithFallback, SendFailedError } from "#kernel/sendFallbackGuard.js";
4
+ import { getDriverManager, _resetDriverManagerForTests } from "#kernel/driverManager.js";
5
+ function createMockDriver(name, ready = true, failsSend = false, failsVerify = false) {
6
+ const mockRef = (id) => ({ id, chatId: "123@c.us", timestamp: Date.now() });
7
+ return {
8
+ name,
9
+ isReady: () => ready,
10
+ sendText: async (jid, text) => {
11
+ if (failsSend)
12
+ throw new Error(`${name} sendText failed`);
13
+ return mockRef(`msg_${name}`);
14
+ },
15
+ getHistory: async () => {
16
+ if (failsVerify)
17
+ return [];
18
+ return [{ id: `msg_${name}`, fromMe: true }];
19
+ },
20
+ connect: async () => { },
21
+ disconnect: async () => { },
22
+ me: () => ({ id: "123@c.us" }),
23
+ sendImage: async () => mockRef("image"),
24
+ sendVideo: async () => mockRef("video"),
25
+ sendAudio: async () => mockRef("audio"),
26
+ sendDocument: async () => mockRef("doc"),
27
+ sendSticker: async () => mockRef("sticker"),
28
+ sendLocation: async () => mockRef("loc"),
29
+ sendContact: async () => mockRef("contact"),
30
+ sendReaction: async () => { },
31
+ sendPoll: async () => mockRef("poll"),
32
+ react: async () => { },
33
+ deleteMessage: async () => { },
34
+ editMessage: async () => { },
35
+ sendPresenceUpdate: async () => { },
36
+ readMessages: async () => { },
37
+ onWhatsApp: async () => null,
38
+ getBusinessProfile: async () => null,
39
+ profilePictureUrl: async () => null,
40
+ fetchStatus: async () => null,
41
+ updateBlockStatus: async () => { },
42
+ addOrEditContact: async () => { },
43
+ removeContact: async () => { },
44
+ groupMetadata: async () => ({ subject: "Test Group", participants: [] }),
45
+ groupParticipantsUpdate: async () => [],
46
+ groupUpdateSubject: async () => { },
47
+ groupUpdateDescription: async () => { },
48
+ groupInviteCode: async () => "",
49
+ groupRevokeInvite: async () => "",
50
+ updateProfilePicture: async () => { },
51
+ updateProfileName: async () => { },
52
+ updateProfileStatus: async () => { },
53
+ downloadMedia: async () => null,
54
+ on: () => () => { },
55
+ };
56
+ }
57
+ describe("kernel/sendFallbackGuard", () => {
58
+ beforeEach(() => {
59
+ _resetDriverManagerForTests();
60
+ });
61
+ test("delivers text via primary driver when healthy", async () => {
62
+ const dm = getDriverManager();
63
+ const primary = createMockDriver("baileys");
64
+ dm.register(primary, { isPrimary: true });
65
+ const ref = await sendWithFallback("5511999999999@c.us", "hello");
66
+ assert.equal(ref.id, "msg_baileys");
67
+ });
68
+ test("throws SendFailedError with no_fallback when send fails", async () => {
69
+ const dm = getDriverManager();
70
+ const primaryFailing = createMockDriver("baileys", true, true);
71
+ dm.register(primaryFailing, { isPrimary: true });
72
+ await assert.rejects(async () => sendWithFallback("5511999999999@c.us", "no fallback"), (err) => err instanceof SendFailedError && err.reason === "no_fallback");
73
+ });
74
+ test("throws SendFailedError with no_fallback when verification fails", async () => {
75
+ const dm = getDriverManager();
76
+ const primaryFailing = createMockDriver("baileys", true, false, true);
77
+ dm.register(primaryFailing, { isPrimary: true });
78
+ await assert.rejects(async () => sendWithFallback("5511999999999@c.us", "verify fail"), (err) => err instanceof SendFailedError && err.reason === "no_fallback");
79
+ });
80
+ });
@@ -10,8 +10,9 @@
10
10
  * 4. Chat-concurrency gate — caps how many different chats the bot can be
11
11
  * actively answering at the same time
12
12
  * 5. Edit throttle — jittered minimum gap + cap on edits per
13
- * message, so things like loading animations
14
- * don't edit on a fixed, bot-like cadence
13
+ * message. Only active at SECURITY_LEVEL
14
+ * "high"; low/medium leave edit timing to the
15
+ * caller.
15
16
  *
16
17
  * All of the above scale with SECURITY_LEVEL ("low" | "medium" | "high").
17
18
  * Higher levels are slower and more conservative — lower risk of WhatsApp's
@@ -28,8 +29,6 @@ const PROFILES = {
28
29
  chatCooldownMs: 100,
29
30
  jitterMs: { min: 30, max: 120 },
30
31
  concurrency: Infinity,
31
- editIntervalMs: { min: 800, max: 2000 },
32
- maxEditsPerMessage: 20,
33
32
  typingMaxMs: 2000,
34
33
  },
35
34
  medium: {
@@ -37,8 +36,6 @@ const PROFILES = {
37
36
  chatCooldownMs: 150,
38
37
  jitterMs: { min: 50, max: 200 },
39
38
  concurrency: 2,
40
- editIntervalMs: { min: 1200, max: 3000 },
41
- maxEditsPerMessage: 12,
42
39
  typingMaxMs: 4000,
43
40
  },
44
41
  high: {
@@ -46,9 +43,11 @@ const PROFILES = {
46
43
  chatCooldownMs: 400,
47
44
  jitterMs: { min: 150, max: 500 },
48
45
  concurrency: 1,
49
- editIntervalMs: { min: 2000, max: 5000 },
50
- maxEditsPerMessage: 6,
51
46
  typingMaxMs: 8000,
47
+ editThrottle: {
48
+ minGapMs: { min: 800, max: 2000 },
49
+ maxEditsPerMessage: 5,
50
+ },
52
51
  },
53
52
  };
54
53
  function currentProfile() {
@@ -147,40 +146,6 @@ export async function acquireChatSlot(jid) {
147
146
  });
148
147
  });
149
148
  }
150
- const editState = new Map();
151
- const EDIT_STATE_STALE_MS = 10 * 60 * 1000;
152
- function cleanupEditState(now) {
153
- for (const [id, s] of editState) {
154
- if (now - s.lastEditAt > EDIT_STATE_STALE_MS)
155
- editState.delete(id);
156
- }
157
- }
158
- /**
159
- * Waits for a safe edit slot for `messageId`, applying a jittered minimum
160
- * gap since its last edit. Returns false once the message has hit its
161
- * per-level edit cap — callers should skip the edit silently in that case.
162
- * @param {string} messageId
163
- * @returns {Promise<boolean>} true if the edit may proceed
164
- */
165
- export async function waitForEditSlot(messageId) {
166
- const now = Date.now();
167
- cleanupEditState(now);
168
- const profile = currentProfile();
169
- const s = editState.get(messageId) ?? { lastEditAt: 0, count: 0 };
170
- if (s.count >= profile.maxEditsPerMessage) {
171
- editState.set(messageId, s);
172
- logger.debug(`[sendGuard] edit cap reached for ${messageId}`);
173
- return false;
174
- }
175
- const minGap = randomBetween(profile.editIntervalMs);
176
- const wait = s.lastEditAt + minGap - Date.now();
177
- if (wait > 0)
178
- await sleep(wait);
179
- s.lastEditAt = Date.now();
180
- s.count += 1;
181
- editState.set(messageId, s);
182
- return true;
183
- }
184
149
  // ── Public API ────────────────────────────────────────────────────────────────
185
150
  /**
186
151
  * Wait for a safe send slot: global rate → per-chat cooldown → jitter.
@@ -208,6 +173,37 @@ export async function waitForSendSlot(jid, { cooldown = true, jitter = true } =
208
173
  await sleep(randomBetween(currentProfile().jitterMs));
209
174
  recordSend(jid);
210
175
  }
176
+ // ── Edit throttle ─────────────────────────────────────────────────────────────
177
+ // Only enforced when the active profile defines `editThrottle` (currently
178
+ // just "high"). low/medium always allow immediately.
179
+ const editState = new Map();
180
+ /**
181
+ * Wait for a safe edit slot for `messageId`, then record the edit.
182
+ * Returns `false` if the per-message edit cap has been reached — the
183
+ * caller should drop the edit instead of sending it.
184
+ *
185
+ * @param {string} messageId
186
+ * @returns {Promise<boolean>} whether the edit is allowed to proceed
187
+ */
188
+ export async function waitForEditSlot(messageId) {
189
+ const throttle = currentProfile().editThrottle;
190
+ if (!throttle)
191
+ return true;
192
+ const state = editState.get(messageId) ?? { count: 0, lastEditAt: 0 };
193
+ if (state.count >= throttle.maxEditsPerMessage) {
194
+ logger.debug(`[sendGuard] edit cap (${throttle.maxEditsPerMessage}) reached for message ${messageId} — dropping edit`);
195
+ return false;
196
+ }
197
+ const gap = randomBetween(throttle.minGapMs);
198
+ const elapsed = Date.now() - state.lastEditAt;
199
+ if (state.lastEditAt > 0 && elapsed < gap) {
200
+ await sleep(gap - elapsed);
201
+ }
202
+ state.count++;
203
+ state.lastEditAt = Date.now();
204
+ editState.set(messageId, state);
205
+ return true;
206
+ }
211
207
  /**
212
208
  * Show a presence indicator for `ms` milliseconds, then clear it.
213
209
  * Best-effort — errors are swallowed.
@@ -0,0 +1,102 @@
1
+ import test, { describe, beforeEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { CONFIG } from "#config";
4
+ import { typingDuration, mediaDuration, acquireChatSlot, simulateState, waitForSendSlot } from "#kernel/sendGuard.js";
5
+ describe("kernel/sendGuard", () => {
6
+ beforeEach(() => {
7
+ CONFIG.SECURITY_LEVEL = "medium";
8
+ });
9
+ describe("typingDuration", () => {
10
+ test("returns 0 for empty or invalid text", () => {
11
+ assert.equal(typingDuration(""), 0);
12
+ assert.equal(typingDuration(null), 0);
13
+ });
14
+ test("calculates duration based on CPS and caps at profile typingMaxMs", () => {
15
+ CONFIG.SECURITY_LEVEL = "medium"; // typingMaxMs = 4000
16
+ // 90 chars at 90 CPS = 1000 ms
17
+ const text90 = "a".repeat(90);
18
+ assert.equal(typingDuration(text90), 1000);
19
+ // 900 chars at 90 CPS = 10000 ms -> capped at 4000 ms
20
+ const text900 = "a".repeat(900);
21
+ assert.equal(typingDuration(text900), 4000);
22
+ });
23
+ test("respects SECURITY_LEVEL profile caps", () => {
24
+ const text900 = "a".repeat(900);
25
+ CONFIG.SECURITY_LEVEL = "low"; // cap = 2000
26
+ assert.equal(typingDuration(text900), 2000);
27
+ CONFIG.SECURITY_LEVEL = "high"; // cap = 8000
28
+ assert.equal(typingDuration(text900), 8000);
29
+ });
30
+ });
31
+ describe("mediaDuration", () => {
32
+ test("returns base jitter range when no caption is provided", () => {
33
+ const duration = mediaDuration();
34
+ assert.ok(duration >= 400 && duration <= 1000);
35
+ });
36
+ test("adds typing duration when caption is provided", () => {
37
+ CONFIG.SECURITY_LEVEL = "medium";
38
+ const text90 = "a".repeat(90); // 1000ms typing
39
+ const duration = mediaDuration(text90);
40
+ assert.ok(duration >= 1400 && duration <= 2000);
41
+ });
42
+ });
43
+ describe("acquireChatSlot (concurrency gate)", () => {
44
+ test("allows up to profile concurrency before blocking", async () => {
45
+ CONFIG.SECURITY_LEVEL = "high"; // concurrency = 1
46
+ const release1 = await acquireChatSlot("chat1");
47
+ let slot2Acquired = false;
48
+ const promise2 = acquireChatSlot("chat2").then(rel => {
49
+ slot2Acquired = true;
50
+ return rel;
51
+ });
52
+ // Give microtask tick to verify slot2 is waiting
53
+ await new Promise(r => setImmediate(r));
54
+ assert.equal(slot2Acquired, false);
55
+ // Release slot 1 allows slot 2 to proceed
56
+ release1();
57
+ const release2 = await promise2;
58
+ assert.equal(slot2Acquired, true);
59
+ release2();
60
+ });
61
+ });
62
+ describe("simulateState", () => {
63
+ test("sends presence update composing/recording, waits, then sends paused", async (t) => {
64
+ t.mock.timers.enable({ apis: ["setTimeout"] });
65
+ const updates = [];
66
+ const mockContract = {
67
+ sendPresenceUpdate: async (state, jid) => {
68
+ updates.push({ state, jid });
69
+ }
70
+ };
71
+ const simPromise = simulateState(mockContract, "123@c.us", 1000, "typing");
72
+ // simulateState awaits the first presence update before scheduling its
73
+ // timeout, so let that continuation run before advancing mock time.
74
+ await Promise.resolve();
75
+ t.mock.timers.tick(1000);
76
+ await simPromise;
77
+ assert.deepEqual(updates, [
78
+ { state: "composing", jid: "123@c.us" },
79
+ { state: "paused", jid: "123@c.us" }
80
+ ]);
81
+ });
82
+ test("handles non-fatal contract errors gracefully", async () => {
83
+ const failingContract = {
84
+ sendPresenceUpdate: async () => {
85
+ throw new Error("Network error");
86
+ }
87
+ };
88
+ // Should not throw exception
89
+ await assert.doesNotReject(async () => {
90
+ await simulateState(failingContract, "123@c.us", 100, "typing");
91
+ });
92
+ });
93
+ });
94
+ describe("waitForSendSlot", () => {
95
+ test("completes send throttle without errors", async (t) => {
96
+ t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
97
+ const sendPromise = waitForSendSlot("123@c.us", { cooldown: false, jitter: false });
98
+ t.mock.timers.tick(500);
99
+ await sendPromise;
100
+ });
101
+ });
102
+ });
@@ -14,9 +14,10 @@ import { DatabaseSync } from "node:sqlite";
14
14
  import path from "path";
15
15
  import { mkdirSync } from "fs";
16
16
  import { CONFIG_DIR } from "#config";
17
- // ── DB init ───────────────────────────────────────────────────────────────────
18
- const DB_PATH = path.join(CONFIG_DIR, "settings.db");
19
- mkdirSync(path.dirname(DB_PATH), { recursive: true });
17
+ const DB_PATH = process.env.NODE_ENV === "test" ? ":memory:" : path.join(CONFIG_DIR, "settings.db");
18
+ if (DB_PATH !== ":memory:") {
19
+ mkdirSync(path.dirname(DB_PATH), { recursive: true });
20
+ }
20
21
  const db = new DatabaseSync(DB_PATH);
21
22
  db.exec("PRAGMA journal_mode = WAL");
22
23
  db.exec("PRAGMA foreign_keys = ON");
@@ -93,6 +94,17 @@ function dbDelete(pluginName, chatId, key) {
93
94
  function dbDeleteAll(pluginName, chatId) {
94
95
  stmts.deleteAll.run(pluginName, chatId);
95
96
  }
97
+ /**
98
+ * Direct read of a single (plugin, chat, key) setting, bypassing the
99
+ * `ctx.settings` scoped-accessor pattern. For call sites that need a
100
+ * value before a `PluginContext` exists yet — e.g. resolving the
101
+ * per-chat command prefix while still parsing the incoming message,
102
+ * or resolving the per-chat language from outside the "core" plugin's
103
+ * own context. See `kernel/chatOverrides.ts`.
104
+ */
105
+ export function getPluginSetting(pluginName, chatId, key) {
106
+ return dbGet(pluginName, chatId, key);
107
+ }
96
108
  // ── Scoped accessor factory ───────────────────────────────────────────────────
97
109
  /**
98
110
  * Returns a settings accessor for a specific (pluginName, chatId) pair.
@@ -105,9 +117,11 @@ function scopedAccessor(pluginName, chatId) {
105
117
  * @param {string} key
106
118
  * @param {*} [defaultValue]
107
119
  */
108
- get(key, defaultValue = undefined) {
120
+ get(key, defaultValue) {
109
121
  const val = dbGet(pluginName, chatId, key);
110
- return val !== undefined ? val : defaultValue;
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `get<T>` is a
123
+ // type-level convenience for callers; the store itself is untyped JSON.
124
+ return (val !== undefined ? val : defaultValue);
111
125
  },
112
126
  /**
113
127
  * Get all settings for this chat as a plain object.
@@ -11,11 +11,17 @@ let status = {
11
11
  since: new Date().toISOString(),
12
12
  };
13
13
  export function setStatus(online, lastError) {
14
- if (status.online === online)
14
+ // Same-state updates are a no-op for `online` / `since` so polling the
15
+ // status page doesn't see the timestamp flicker on every redundant
16
+ // call (the connection.update listener fires more than once per
17
+ // reconnect). An explicit `lastError` always wins — if the caller is
18
+ // reporting a new failure while we're already marked offline, that
19
+ // message is more useful than the stale one from before.
20
+ if (status.online === online && !lastError)
15
21
  return;
16
22
  status = {
17
23
  online,
18
- since: new Date().toISOString(),
24
+ since: status.online === online ? status.since : new Date().toISOString(),
19
25
  ...(lastError ? { lastError } : {}),
20
26
  };
21
27
  }
@@ -36,4 +42,5 @@ export function startStatusServer(port) {
36
42
  server.listen(port, () => {
37
43
  logger.info(`[status] JSON endpoint em http://localhost:${port}`);
38
44
  });
45
+ return server;
39
46
  }
@@ -0,0 +1,70 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, test } from "node:test";
3
+ import { getStatus, setStatus, startStatusServer } from "#kernel/statusServer.js";
4
+ describe("kernel/statusServer", () => {
5
+ let activeServer = null;
6
+ afterEach(async () => {
7
+ if (activeServer) {
8
+ await new Promise((resolve) => activeServer.close(() => resolve()));
9
+ activeServer = null;
10
+ }
11
+ // Reset status back to offline
12
+ setStatus(false);
13
+ });
14
+ test("getStatus returns initial state", () => {
15
+ const status = getStatus();
16
+ assert.equal(typeof status.online, "boolean");
17
+ assert.equal(typeof status.since, "string");
18
+ });
19
+ test("setStatus updates online state and timestamps", async () => {
20
+ setStatus(false, "Initial error");
21
+ const initial = getStatus();
22
+ assert.equal(initial.online, false);
23
+ assert.equal(initial.lastError, "Initial error");
24
+ // Allow timestamp to advance
25
+ await new Promise((r) => setTimeout(r, 10));
26
+ setStatus(true);
27
+ const updated = getStatus();
28
+ assert.equal(updated.online, true);
29
+ assert.equal(updated.lastError, undefined);
30
+ assert.notEqual(updated.since, initial.since);
31
+ // Setting same status is a no-op for since timestamp
32
+ const sinceBefore = updated.since;
33
+ setStatus(true);
34
+ assert.equal(getStatus().since, sinceBefore);
35
+ });
36
+ test("setStatus captures error message when going offline", () => {
37
+ setStatus(true);
38
+ setStatus(false, "Stream closed");
39
+ const status = getStatus();
40
+ assert.equal(status.online, false);
41
+ assert.equal(status.lastError, "Stream closed");
42
+ });
43
+ test("startStatusServer responds with JSON status and CORS headers", async () => {
44
+ setStatus(true);
45
+ activeServer = startStatusServer(0);
46
+ // Wait until the server is listening
47
+ await new Promise((resolve) => {
48
+ if (activeServer.listening)
49
+ resolve();
50
+ else
51
+ activeServer.once("listening", () => resolve());
52
+ });
53
+ const addr = activeServer.address();
54
+ const url = `http://127.0.0.1:${addr.port}/status`;
55
+ const res = await fetch(url);
56
+ assert.equal(res.status, 200);
57
+ assert.equal(res.headers.get("content-type"), "application/json");
58
+ assert.equal(res.headers.get("access-control-allow-origin"), "*");
59
+ const body = (await res.json());
60
+ assert.equal(body.online, true);
61
+ assert.equal(typeof body.since, "string");
62
+ assert.equal(body.lastError, undefined);
63
+ // Mutate status and verify dynamic response on subsequent request
64
+ setStatus(false, "Socket hung up");
65
+ const res2 = await fetch(url);
66
+ const body2 = (await res2.json());
67
+ assert.equal(body2.online, false);
68
+ assert.equal(body2.lastError, "Socket hung up");
69
+ });
70
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * kernel/testConfig.ts
3
+ *
4
+ * Reads the configuration that gates the WhatsApp integration test suite.
5
+ *
6
+ * Two values are exposed:
7
+ *
8
+ * 1. `chat` — the JID (or bare phone number) of the chat the integration
9
+ * suite is allowed to exercise. Comes from, in precedence order:
10
+ * a. environment variable `TEST_CHAT`
11
+ * b. `TEST_CHAT` key in `manybot.toml`
12
+ * c. otherwise absent (`chat === null`) — integration tests skip
13
+ * with an explanatory message instead of crashing.
14
+ *
15
+ * This module accepts any chat shape `normalizeTestChat()` allows,
16
+ * including a group (`@g.us`) — it has no opinion on what a given
17
+ * test file actually needs. Individual suites are the ones with
18
+ * that requirement: contacts.integration.test.ts, for instance,
19
+ * needs an individual chat (a phone number, or a JID ending in
20
+ * `@s.whatsapp.net`/`@c.us`/`@lid`) because it asserts on
21
+ * per-person contact fields (LID, number, country) that a group
22
+ * simply doesn't have — see that file's own header for details.
23
+ *
24
+ * 2. `runWhatsApp` — explicit opt-in flag (env `MANYBOT_RUN_WHATSAPP_TESTS=1`).
25
+ * A saved WhatsApp session plus a `TEST_CHAT` is NOT enough to fire real
26
+ * messages; this is the single, deliberate signal that the operator
27
+ * actually wants the integration suite to run.
28
+ *
29
+ * The module never throws on its own. `getTestConfig()` returns the
30
+ * resolved state and lets the caller decide what to do (skip vs run vs
31
+ * fail). `requireTestConfig()` is the hard version for code paths that
32
+ * must not run without a configured chat + opt-in.
33
+ *
34
+ * Deliberately kept out of `CONFIG` — `TEST_CHAT` is a test-time
35
+ * concern and shouldn't pollute the runtime config object's shape.
36
+ */
37
+ import fs from "fs/promises";
38
+ import { parse as parseToml } from "smol-toml";
39
+ import { CONFIG_DIR, TOML_CONFIG_FILE } from "#config";
40
+ import { logger } from "#logger";
41
+ // ── Constants ───────────────────────────────────────────────────────────────
42
+ /** Env var that signals "yes, the integration suite should really run". */
43
+ export const RUN_WHATSAPP_TESTS_ENV = "MANYBOT_RUN_WHATSAPP_TESTS";
44
+ /** Env var that overrides any value set in manybot.toml. */
45
+ export const TEST_CHAT_ENV = "TEST_CHAT";
46
+ /** Key read from manybot.toml as a fallback when the env var is unset. */
47
+ export const TEST_CHAT_TOML_KEY = "TEST_CHAT";
48
+ // ── JID normalization ──────────────────────────────────────────────────────
49
+ /**
50
+ * Acceptable chat-id shapes:
51
+ * - bare number: "5516999999999"
52
+ * - WhatsApp PN JID: "5516999999999@s.whatsapp.net"
53
+ * - legacy framework PN JID: "5516999999999@c.us"
54
+ * - LID JID: "1234@lid"
55
+ * - group JID: "120363…@g.us"
56
+ *
57
+ * Any other suffix is rejected so the integration plugin can rely on
58
+ * `chat.endsWith(...)` checks and not silently mismatch. Bare numbers
59
+ * are normalized to the WhatsApp PN JID form (the form the bot's own
60
+ * contract uses to reach that contact).
61
+ */
62
+ export function normalizeTestChat(raw) {
63
+ if (typeof raw !== "string") {
64
+ throw new TypeError(`TEST_CHAT must be a string, got ${typeof raw}`);
65
+ }
66
+ const trimmed = raw.trim();
67
+ if (trimmed === "") {
68
+ throw new Error("TEST_CHAT is empty");
69
+ }
70
+ const ALLOWED_SUFFIXES = ["@s.whatsapp.net", "@c.us", "@lid", "@g.us"];
71
+ for (const suffix of ALLOWED_SUFFIXES) {
72
+ if (trimmed.endsWith(suffix)) {
73
+ const local = trimmed.slice(0, -suffix.length);
74
+ if (local === "" || /[^\dA-Za-z._-]/.test(local)) {
75
+ throw new Error(`TEST_CHAT has invalid local part: "${raw}"`);
76
+ }
77
+ return trimmed;
78
+ }
79
+ }
80
+ // Bare number — accept digits, plus, and the leading "+".
81
+ if (/^\+?\d+$/.test(trimmed)) {
82
+ return `${trimmed.replace(/^\+/, "")}@s.whatsapp.net`;
83
+ }
84
+ throw new Error(`TEST_CHAT must be a bare phone number or a JID with one of: ` +
85
+ `${ALLOWED_SUFFIXES.join(", ")} — got "${raw}"`);
86
+ }
87
+ // ── Resolution ──────────────────────────────────────────────────────────────
88
+ async function readTomlTestChat() {
89
+ let raw;
90
+ try {
91
+ raw = await fs.readFile(TOML_CONFIG_FILE, "utf-8");
92
+ }
93
+ catch (e) {
94
+ if (e.code !== "ENOENT") {
95
+ logger.warn(`[testConfig] could not read ${TOML_CONFIG_FILE}: ${e.message}`);
96
+ }
97
+ return null;
98
+ }
99
+ let parsed;
100
+ try {
101
+ parsed = parseToml(raw);
102
+ }
103
+ catch (e) {
104
+ logger.warn(`[testConfig] invalid TOML in ${TOML_CONFIG_FILE}: ${e.message}`);
105
+ return null;
106
+ }
107
+ const value = parsed[TEST_CHAT_TOML_KEY];
108
+ if (value === undefined || value === null)
109
+ return null;
110
+ if (typeof value !== "string") {
111
+ logger.warn(`[testConfig] ${TEST_CHAT_TOML_KEY} in TOML is not a string, ignoring`);
112
+ return null;
113
+ }
114
+ return value.trim() === "" ? null : value;
115
+ }
116
+ let cached = null;
117
+ /**
118
+ * Resolve the test configuration. Result is cached after the first call
119
+ * because the env and `manybot.toml` don't change mid-process; the
120
+ * cache gives every test a stable view without re-reading disk.
121
+ */
122
+ export async function getTestConfig() {
123
+ if (cached)
124
+ return cached;
125
+ const envValue = process.env[TEST_CHAT_ENV];
126
+ let raw = null;
127
+ let source = null;
128
+ if (typeof envValue === "string" && envValue.trim() !== "") {
129
+ raw = envValue;
130
+ source = "env";
131
+ }
132
+ else {
133
+ const tomlValue = await readTomlTestChat();
134
+ if (tomlValue) {
135
+ raw = tomlValue;
136
+ source = "toml";
137
+ }
138
+ }
139
+ let chat = null;
140
+ if (raw !== null) {
141
+ try {
142
+ chat = normalizeTestChat(raw);
143
+ }
144
+ catch (e) {
145
+ logger.warn(`[testConfig] ${e.message}`);
146
+ }
147
+ }
148
+ const runWhatsApp = process.env[RUN_WHATSAPP_TESTS_ENV] === "1";
149
+ let skipReason = null;
150
+ if (chat === null) {
151
+ skipReason =
152
+ `TEST_CHAT is not set (env ${TEST_CHAT_ENV} or key ` +
153
+ `${TEST_CHAT_TOML_KEY} in ${TOML_CONFIG_FILE})`;
154
+ }
155
+ else if (!runWhatsApp) {
156
+ skipReason =
157
+ `${RUN_WHATSAPP_TESTS_ENV}=1 is required to run the WhatsApp ` +
158
+ `integration suite (TEST_CHAT alone is not enough)`;
159
+ }
160
+ cached = { chat, source, runWhatsApp, skipReason };
161
+ return cached;
162
+ }
163
+ /**
164
+ * Hard version of {@link getTestConfig}: throws if the chat is not
165
+ * configured or the opt-in flag is missing. Use this in code paths that
166
+ * should never run unless the operator has consciously opted in.
167
+ */
168
+ export async function requireTestConfig() {
169
+ const cfg = await getTestConfig();
170
+ if (cfg.chat === null) {
171
+ throw new Error(`[testConfig] cannot run: ${cfg.skipReason}. ` +
172
+ `Set ${TEST_CHAT_ENV} or add '${TEST_CHAT_TOML_KEY} = "…"' to ${TOML_CONFIG_FILE}.`);
173
+ }
174
+ if (!cfg.runWhatsApp) {
175
+ throw new Error(`[testConfig] cannot run without opt-in: ${cfg.skipReason}. ` +
176
+ `Re-run with ${RUN_WHATSAPP_TESTS_ENV}=1.`);
177
+ }
178
+ return cfg;
179
+ }
180
+ /**
181
+ * Test-only — drops the cached value so the next `getTestConfig()`
182
+ * call re-reads env and disk. Use this after mutating env vars in a
183
+ * test; production code must not call it.
184
+ */
185
+ export function _resetTestConfigForTests() {
186
+ cached = null;
187
+ }
188
+ // Keep `CONFIG_DIR` referenced so this module participates in the
189
+ // project's path-resolution behavior the same way other kernel modules
190
+ // do, and so future test fixtures that need to point at a different
191
+ // config dir (e.g. MANYBOT_CONFIG_DIR) keep working.
192
+ void CONFIG_DIR;