@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
@@ -3,14 +3,34 @@ const c = {
3
3
  green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m",
4
4
  red: "\x1b[31m", blue: "\x1b[34m",
5
5
  };
6
+ let debugEnabled = process.argv.includes("--debug");
7
+ let logLevel = "normal";
8
+ export function setLogLevel(level) { logLevel = level; }
9
+ export function getLogLevel() { return logLevel; }
6
10
  /**
7
11
  * ManyBot central logger.
8
12
  * Each method only handles output — no business logic or external I/O.
13
+ *
14
+ * `debug` is silent by default to keep production logs clean. Pass
15
+ * `--debug` on the command line to enable it. The check is a single
16
+ * `Array.includes` on argv, cheap enough to do per call and avoids
17
+ * requiring logger consumers to know about a global toggle.
9
18
  */
10
19
  export const logger = {
11
- info: (...a) => console.log(`${c.cyan}INFO ${c.reset}`, ...a),
12
- success: (...a) => console.log(`${c.green}OK ${c.reset}`, ...a),
20
+ info: (...a) => {
21
+ if (logLevel === "normal")
22
+ console.log(`${c.cyan}INFO ${c.reset}`, ...a);
23
+ },
24
+ success: (...a) => {
25
+ if (logLevel !== "minimal")
26
+ console.log(`${c.green}OK ${c.reset}`, ...a);
27
+ },
13
28
  warn: (...a) => console.log(`${c.yellow}WARN ${c.reset}`, ...a),
14
29
  error: (...a) => console.log(`${c.red}ERROR ${c.reset}`, ...a),
15
- debug: (...a) => console.log(`${c.blue}DEBUG ${c.reset}`, ...a),
30
+ debug: (...a) => {
31
+ if (debugEnabled)
32
+ console.log(`${c.blue}DEBUG ${c.reset}`, ...a);
33
+ },
16
34
  };
35
+ export function enableDebug() { debugEnabled = true; }
36
+ export function isDebugEnabled() { return debugEnabled; }
@@ -0,0 +1,45 @@
1
+ import test, { describe, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { logger, setLogLevel, getLogLevel } from "./logger.js";
4
+ describe("logger levels", () => {
5
+ const originalLog = console.log;
6
+ let lines;
7
+ beforeEach(() => {
8
+ lines = [];
9
+ console.log = (...a) => { lines.push(a.join(" ")); };
10
+ });
11
+ afterEach(() => {
12
+ console.log = originalLog;
13
+ setLogLevel("normal");
14
+ });
15
+ test("defaults to normal", () => {
16
+ assert.equal(getLogLevel(), "normal");
17
+ });
18
+ test("normal shows info, success, warn, error", () => {
19
+ setLogLevel("normal");
20
+ logger.info("a");
21
+ logger.success("b");
22
+ logger.warn("c");
23
+ logger.error("d");
24
+ assert.equal(lines.length, 4);
25
+ });
26
+ test("clean hides info but keeps success, warn, error", () => {
27
+ setLogLevel("clean");
28
+ logger.info("a");
29
+ logger.success("b");
30
+ logger.warn("c");
31
+ logger.error("d");
32
+ assert.equal(lines.length, 3);
33
+ assert.ok(!lines.some(l => l.includes("a")));
34
+ });
35
+ test("minimal hides info and success, keeps warn and error", () => {
36
+ setLogLevel("minimal");
37
+ logger.info("a");
38
+ logger.success("b");
39
+ logger.warn("c");
40
+ logger.error("d");
41
+ assert.equal(lines.length, 2);
42
+ assert.ok(lines.some(l => l.includes("c")));
43
+ assert.ok(lines.some(l => l.includes("d")));
44
+ });
45
+ });
package/dist/main.js CHANGED
@@ -10,49 +10,20 @@ import path from "path";
10
10
  process.env.NODE_PATH = path.resolve(process.cwd(), "node_modules");
11
11
  Module._initPaths();
12
12
  import { baileysContract } from "#drivers/baileys/index.js";
13
- import { whatsmeowContract, startWhatsmeowSupervisor, wrapWithSupervisor } from "#drivers/whatsmeow/index.js";
14
- import { promptWhatsmeowInstall } from "#drivers/whatsmeow/installer.js";
15
13
  import { cleanupPlugins } from "#kernel/pluginLoader.js";
16
14
  import { stopAll as stopScheduler } from "#kernel/scheduler.js";
17
15
  import { sendAlert } from "#kernel/alerts.js";
18
16
  import { startStatusServer } from "#kernel/statusServer.js";
19
17
  import { getDriverManager } from "#kernel/driverManager.js";
20
- import { CONFIG, STATUS_ENABLED, STATUS_PORT } from "#config";
21
- import { logger } from "#logger";
18
+ import { STATUS_ENABLED, STATUS_PORT, LOG_LEVEL } from "#config";
19
+ import { logger, setLogLevel } from "#logger";
22
20
  import { t } from "#i18n";
21
+ setLogLevel(LOG_LEVEL);
23
22
  let shuttingDown = false;
24
- // DriverManager registration: only register drivers that are enabled in
25
- // the config. whatsmeow.enabled = false means no gRPC
26
- // subprocess, no Go binary lookup, no extra work at boot — the manager
27
- // simply doesn't know about it and sendFallbackGuard sees a missing
28
- // secondary and fires send_failed_no_fallback if needed.
23
+ // DriverManager registration: only Baileys driver now
29
24
  const driverManager = getDriverManager();
30
- driverManager.register(baileysContract, { isPrimary: CONFIG.drivers.primary === "baileys" });
31
- // Whatsmeow supervisor: spawns the Go subprocess when enabled=true,
32
- // owns the restart/backoff/circuit-breaker logic, and gates the
33
- // driver's connect()/isReady() until HealthCheck{ready:true}. When
34
- // enabled=false or the binary can't be located, `supervisor` is null
35
- // and the whatsmeow driver is simply not registered — ManyBot keeps
36
- // running on Baileys alone, no fallback.
37
- let supervisor = null;
38
- if (CONFIG.drivers.whatsmeow.enabled) {
39
- logger.info("[driverManager] whatsmeow enabled — spawning supervisor");
40
- supervisor = await startWhatsmeowSupervisor();
41
- if (supervisor) {
42
- const wrapped = wrapWithSupervisor(whatsmeowContract, supervisor);
43
- driverManager.register(wrapped, { isPrimary: CONFIG.drivers.primary === "whatsmeow" });
44
- logger.info(`[driverManager] whatsmeow registered (primary=${CONFIG.drivers.primary === "whatsmeow"})`);
45
- }
46
- else {
47
- logger.warn("[driverManager] whatsmeow supervisor failed to start — fallback disabled");
48
- }
49
- }
50
- else {
51
- logger.info("[driverManager] whatsmeow disabled by config — no fallback");
52
- }
25
+ driverManager.register(baileysContract, { isPrimary: true });
53
26
  const activeDriver = driverManager.active();
54
- const secondaryName = (activeDriver.name === "baileys" ? "whatsmeow" : "baileys");
55
- const secondaryDriver = driverManager.get(secondaryName);
56
27
  async function shutdown(reason, isError = false) {
57
28
  if (shuttingDown)
58
29
  return;
@@ -87,16 +58,6 @@ async function shutdown(reason, isError = false) {
87
58
  catch (err) {
88
59
  logger.error(`Error disconnecting driver: ${err.message}`);
89
60
  }
90
- // Belt-and-suspenders: driverManager.shutdown() should already have
91
- // disconnected the wrapped contract, which in turn calls
92
- // supervisor.shutdown(). This catches the case where the supervisor
93
- // was started but the driver wasn't registered (binary missing).
94
- if (supervisor) {
95
- try {
96
- await supervisor.shutdown();
97
- }
98
- catch { }
99
- }
100
61
  process.exit(isError ? 1 : 0);
101
62
  }
102
63
  // Global error listeners
@@ -116,11 +77,6 @@ process.on("SIGINT", () => shutdown("SIGINT"));
116
77
  // the JID to the console, to paste into CHATS in manybot.toml.
117
78
  // Does not enter the normal bot flow (plugins are not loaded).
118
79
  if (process.argv.includes("--getid")) {
119
- // getId? is a Baileys-only diagnostic method. Look it
120
- // up on the registered Baileys driver regardless of which one is
121
- // active — --getid always uses Baileys, even in a whatsmeow-primary
122
- // configuration, because it needs the diagnostic session, not the
123
- // bot's normal one.
124
80
  const baileys = driverManager.get("baileys");
125
81
  const getIdFn = baileys?.getId;
126
82
  if (!getIdFn) {
@@ -134,16 +90,6 @@ if (process.argv.includes("--getid")) {
134
90
  process.exit(1);
135
91
  });
136
92
  }
137
- else if (process.argv.includes("--install-whatsmeow")) {
138
- // Re-run the whatsmeow installer outside the normal setup flow.
139
- // Useful when the initial install failed or the binary was moved.
140
- promptWhatsmeowInstall()
141
- .then(() => process.exit(0))
142
- .catch((err) => {
143
- logger.error(`--install-whatsmeow failed: ${err.message}`);
144
- process.exit(1);
145
- });
146
- }
147
93
  else {
148
94
  // Start bot
149
95
  logger.info(t("bot.initialized"));
@@ -153,23 +99,6 @@ else {
153
99
  activeDriver.connect()
154
100
  .then(() => {
155
101
  logger.success(t("bot.ready"));
156
- // The secondary driver is connected and paired in the
157
- // background so sendFallbackGuard can reach for it without first
158
- // having to wait through a connect() round-trip when the primary
159
- // fails. The secondary does NOT register `messages.upsert` handlers
160
- // (no kernel code subscribes on it — only the primary path does),
161
- // so connecting it does not duplicate inbound processing. A failure
162
- // here is non-fatal: the primary keeps running, fallback just stays
163
- // unavailable (sendFallbackGuard's `isReady()` check covers that).
164
- if (secondaryDriver) {
165
- logger.info(`[driverManager] connecting secondary "${secondaryName}" in background…`);
166
- secondaryDriver.connect()
167
- .then(() => logger.info(`[driverManager] secondary "${secondaryName}" connected — fallback available`))
168
- .catch((err) => logger.warn(`[driverManager] secondary "${secondaryName}" connect failed: ${err.message} — fallback unavailable`));
169
- }
170
- else {
171
- logger.info(`[driverManager] no secondary driver registered — running on ${activeDriver.name} only`);
172
- }
173
102
  })
174
103
  .catch((err) => {
175
104
  shutdown(`Failed to connect driver: ${err.message}`, true);
@@ -0,0 +1,184 @@
1
+ /**
2
+ * plugins/__manybot_integration__/index.ts
3
+ *
4
+ * Internal integration plugin for the WhatsApp test suite. NOT a
5
+ * user-facing plugin — only the test harness loads it, and only when
6
+ * integration mode is enabled (see `kernel/integrationMode.ts`).
7
+ *
8
+ * Responsibilities:
9
+ * - Refuse to act on any chat other than the configured TEST_CHAT.
10
+ * Anything arriving in another chat is dropped with a warning,
11
+ * never replied to.
12
+ * - Expose a small public API the test harness drives:
13
+ * - `testChat` — the JID we will respond in.
14
+ * - `isTestChat(jid)` — true iff `jid` matches the test chat.
15
+ * - `waitForMarker(marker, timeoutMs)`
16
+ * — resolves with the message id of the
17
+ * first inbound message in the test chat
18
+ * whose body starts with `marker`. Used
19
+ * by tests to observe real round-trips
20
+ * (e.g. "send a message, wait until the
21
+ * contact echoes it back").
22
+ * - Keep a bounded ring buffer of recent message bodies in the
23
+ * test chat so tests can also assert on what was sent earlier
24
+ * in the same run.
25
+ *
26
+ * This plugin is intentionally minimal — it does NOT register user
27
+ * commands, does NOT participate in the `commands.yaml` registry,
28
+ * and shuts down cleanly via the normal plugin cleanup path
29
+ * (cleanupPluginEvents is driven by the kernel, not by us).
30
+ *
31
+ * Imports are restricted to types and the `events` module from the
32
+ * kernel — never a driver package, never the raw socket. The
33
+ * `ctx.wa.contract` neutral access is the only driver surface used
34
+ * (and only for `me()` and any helper the test harness invokes).
35
+ */
36
+ import { EventEmitter } from "node:events";
37
+ import { logger } from "#logger";
38
+ import { INTEGRATION_PLUGIN_NAME } from "#kernel/integrationMode.js";
39
+ const RING_BUFFER_LIMIT = 50;
40
+ const DEFAULT_TIMEOUT_MS = 30_000;
41
+ let configuredTestChat = null;
42
+ const recent = new Map();
43
+ let recentSeq = 0;
44
+ const waiter = new EventEmitter();
45
+ function pushRecent(body, from) {
46
+ const id = ++recentSeq;
47
+ recent.set(id, { body, from, ts: Date.now() });
48
+ // Cap the ring buffer; eviction keeps insertion order.
49
+ while (recent.size > RING_BUFFER_LIMIT) {
50
+ const firstKey = recent.keys().next().value;
51
+ if (firstKey === undefined)
52
+ break;
53
+ recent.delete(firstKey);
54
+ }
55
+ }
56
+ function findByMarker(marker) {
57
+ for (const entry of recent.values()) {
58
+ if (entry.body.startsWith(marker))
59
+ return entry;
60
+ }
61
+ return null;
62
+ }
63
+ export const api = {
64
+ testChat: "", // populated in setup()
65
+ isTestChat(jid) {
66
+ if (!jid)
67
+ return false;
68
+ if (!configuredTestChat)
69
+ return false;
70
+ return jid === configuredTestChat;
71
+ },
72
+ async waitForMarker(marker, timeoutMs = DEFAULT_TIMEOUT_MS) {
73
+ const existing = findByMarker(marker);
74
+ if (existing) {
75
+ // Return a synthetic id; tests that need the real id should
76
+ // observe the `messages.upsert` payload directly.
77
+ return `recent:${existing.ts}`;
78
+ }
79
+ return new Promise((resolve, reject) => {
80
+ const onHit = (entry) => {
81
+ if (!entry.body.startsWith(marker))
82
+ return;
83
+ waiter.off("hit", onHit);
84
+ clearTimeout(timer);
85
+ resolve(`recent:${entry.ts}`);
86
+ };
87
+ const timer = setTimeout(() => {
88
+ waiter.off("hit", onHit);
89
+ reject(new Error(`[${INTEGRATION_PLUGIN_NAME}] waitForMarker("${marker}") timed out after ${timeoutMs}ms`));
90
+ }, timeoutMs);
91
+ waiter.on("hit", onHit);
92
+ });
93
+ },
94
+ recentBodies() {
95
+ return [...recent.values()].map((e) => e.body);
96
+ },
97
+ };
98
+ /**
99
+ * setup() runs once after the bot connects. We capture the test
100
+ * chat here so the runtime API can refuse any other chat.
101
+ */
102
+ export async function setup(ctx) {
103
+ // The integration chat is decided by the harness BEFORE
104
+ // setupPlugins() is called. Historically the harness set
105
+ // MANYBOT_TEST_CHAT, but users often set TEST_CHAT (per docs) —
106
+ // either as an env var or as a `TEST_CHAT` key in manybot.toml.
107
+ //
108
+ // `kernel/integrationMode.ts`'s own gate (`getIntegrationModeStatus`,
109
+ // which is what decided integration mode was "ready" and logged as
110
+ // much before this plugin's setup() ever ran) resolves TEST_CHAT via
111
+ // `getTestConfig()` — env var first, then manybot.toml. Reading
112
+ // straight off `process.env.TEST_CHAT` here (as this used to)
113
+ // silently diverged from that: the gate would report ready off a
114
+ // toml-only value while this threw "no TEST_CHAT set", because it
115
+ // never looked at the toml. Importing normalizeTestChat/getTestConfig
116
+ // here (rather than at module top) keeps the module load cheap when
117
+ // the plugin isn't used in production code paths.
118
+ //
119
+ // MANYBOT_TEST_CHAT remains a preferred override on top of that, for
120
+ // callers that want to point this specific plugin at a different
121
+ // chat than the gate's own resolution.
122
+ const { getTestConfig, normalizeTestChat } = await import("#kernel/testConfig.js");
123
+ let normalized;
124
+ const overrideEnv = process.env.MANYBOT_TEST_CHAT;
125
+ if (overrideEnv) {
126
+ try {
127
+ normalized = normalizeTestChat(overrideEnv);
128
+ }
129
+ catch (e) {
130
+ throw new Error(`[${INTEGRATION_PLUGIN_NAME}] invalid MANYBOT_TEST_CHAT provided: ${e.message}`);
131
+ }
132
+ }
133
+ else {
134
+ const cfg = await getTestConfig();
135
+ if (!cfg.chat) {
136
+ throw new Error(`[${INTEGRATION_PLUGIN_NAME}] setup() called without MANYBOT_TEST_CHAT or TEST_CHAT — ` +
137
+ `set MANYBOT_TEST_CHAT (preferred), or TEST_CHAT (env var or manybot.toml), before setupPlugins().`);
138
+ }
139
+ normalized = cfg.chat;
140
+ }
141
+ configuredTestChat = normalized;
142
+ api.testChat = normalized;
143
+ // Subscribe to the relevant events so waitForMarker works
144
+ // without requiring a polling loop. `messages.upsert` is the
145
+ // single source of truth for any new incoming message; `on()`
146
+ // returns an unsubscribe handle, but cleanupPluginEvents takes
147
+ // care of it for us.
148
+ ctx.events.on("messages.upsert", (payload) => {
149
+ const p = payload;
150
+ const messages = p.messages ?? [];
151
+ for (const m of messages) {
152
+ if (!m.chatId || m.chatId !== configuredTestChat)
153
+ continue;
154
+ const body = m.body ?? "";
155
+ const from = m.from ?? "";
156
+ pushRecent(body, from);
157
+ waiter.emit("hit", { body, from, ts: Date.now() });
158
+ }
159
+ });
160
+ logger.info(`[${INTEGRATION_PLUGIN_NAME}] loaded — locked to test chat ${configuredTestChat}`);
161
+ // ctx reserved for future use (e.g. registering a scheduler)
162
+ return void ctx;
163
+ }
164
+ /**
165
+ * default(ctx) runs on every inbound message. We are deliberately
166
+ * quiet outside the test chat so a stray message in a production
167
+ * chat never gets a reply from the test plugin; we never throw,
168
+ * because throwing would propagate up to messageHandler and
169
+ * potentially break unrelated plugins.
170
+ */
171
+ export default async function handle(ctx) {
172
+ if (!configuredTestChat) {
173
+ logger.warn(`[${INTEGRATION_PLUGIN_NAME}] default() called before setup() — ignoring`);
174
+ return;
175
+ }
176
+ if (ctx.chat.id !== configuredTestChat) {
177
+ logger.warn(`[${INTEGRATION_PLUGIN_NAME}] ignoring message from non-test chat ${ctx.chat.id}`);
178
+ return;
179
+ }
180
+ // No automatic reply: the test harness drives every send through
181
+ // `ctx.send.*` or `contract.send*` and waits on `waitForMarker`
182
+ // for the round-trip. Keeping the plugin passive here is what
183
+ // makes the suite easy to reason about.
184
+ }
@@ -0,0 +1,218 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, beforeEach, describe, test } from "node:test";
3
+ import { _resetTestConfigForTests } from "#kernel/testConfig.js";
4
+ const integrationPluginUrl = new URL("../../plugins/__manybot_integration__/index.ts", import.meta.url).href;
5
+ describe("plugins/__manybot_integration__", () => {
6
+ let mod;
7
+ beforeEach(async () => {
8
+ // Use a cache-busting query so each test gets a fresh module
9
+ // (and therefore a fresh ring buffer + EventEmitter).
10
+ mod = await import(`${integrationPluginUrl}?t=${Date.now()}-${Math.random()}`);
11
+ // setup() now falls back to kernel/testConfig.js's getTestConfig()
12
+ // for the TEST_CHAT-only case (see setup()'s comment), and that
13
+ // module caches its result in-process after the first read. Tests
14
+ // in this file mutate TEST_CHAT/MANYBOT_TEST_CHAT freely between
15
+ // cases, so the cache must be dropped before every test or a
16
+ // stale resolution from an earlier test leaks in.
17
+ _resetTestConfigForTests();
18
+ });
19
+ after(async () => {
20
+ // Best-effort cleanup; nothing in the plugin subscribes to
21
+ // process-level resources, but we re-clear the configured
22
+ // chat (and the testConfig cache it now feeds through) so a
23
+ // subsequent suite (e.g. loadIntegrationPlugin.test.ts) starts
24
+ // blank.
25
+ process.env.MANYBOT_TEST_CHAT = "";
26
+ delete process.env.TEST_CHAT;
27
+ _resetTestConfigForTests();
28
+ });
29
+ test("exports default, setup, and api", () => {
30
+ assert.equal(typeof mod.default, "function");
31
+ assert.equal(typeof mod.setup, "function");
32
+ assert.ok(mod.api);
33
+ });
34
+ test("api.isTestChat is false before setup() is called", () => {
35
+ assert.equal(mod.api.isTestChat("5516999999999@s.whatsapp.net"), false);
36
+ assert.equal(mod.api.isTestChat(null), false);
37
+ });
38
+ test("api.recentBodies() starts empty", () => {
39
+ assert.deepEqual(mod.api.recentBodies(), []);
40
+ });
41
+ // Note: the previous "setup() throws when MANYBOT_TEST_CHAT is unset" test
42
+ // was removed when the resolution path was widened to env-or-toml (see the
43
+ // `setup() agrees with integrationMode's own gate` regression test below).
44
+ // With toml resolution in scope, "unset" can only be asserted by also
45
+ // clearing the toml — that's testConfig.test.ts's territory, not this
46
+ // plugin's. The plugin-specific throw cases (invalid format, missing
47
+ // MANYBOT_TEST_CHAT override while TEST_CHAT is empty, etc.) are covered
48
+ // by the remaining tests below.
49
+ test("setup() captures the test chat and isTestChat accepts it", async () => {
50
+ process.env.MANYBOT_TEST_CHAT = "5516999999999@s.whatsapp.net";
51
+ const { ctx } = makeSetupCtx();
52
+ await mod.setup(ctx);
53
+ assert.equal(mod.api.testChat, "5516999999999@s.whatsapp.net");
54
+ assert.equal(mod.api.isTestChat("5516999999999@s.whatsapp.net"), true);
55
+ assert.equal(mod.api.isTestChat("5516000000000@s.whatsapp.net"), false);
56
+ });
57
+ test("default() refuses to act on a non-test chat", async () => {
58
+ process.env.MANYBOT_TEST_CHAT = "5516999999999@s.whatsapp.net";
59
+ const { ctx: setupCtx } = makeSetupCtx();
60
+ await mod.setup(setupCtx);
61
+ const { ctx, replyCalls } = makeMessageCtx("120363012345678@g.us");
62
+ // Should not throw and should not produce a reply.
63
+ await mod.default(ctx);
64
+ assert.equal(replyCalls.length, 0);
65
+ });
66
+ test("default() does not act even in the test chat (plugin is passive)", async () => {
67
+ process.env.MANYBOT_TEST_CHAT = "5516999999999@s.whatsapp.net";
68
+ const { ctx: setupCtx } = makeSetupCtx();
69
+ await mod.setup(setupCtx);
70
+ const { ctx, replyCalls } = makeMessageCtx("5516999999999@s.whatsapp.net");
71
+ await mod.default(ctx);
72
+ assert.equal(replyCalls.length, 0);
73
+ });
74
+ test("default() before setup() is a no-op, not a crash", async () => {
75
+ delete process.env.MANYBOT_TEST_CHAT;
76
+ const { ctx, replyCalls } = makeMessageCtx("5516999999999@s.whatsapp.net");
77
+ await mod.default(ctx); // must not throw
78
+ assert.equal(replyCalls.length, 0);
79
+ });
80
+ test("waitForMarker resolves when a matching messages.upsert is delivered via setup()", async () => {
81
+ process.env.MANYBOT_TEST_CHAT = "5516999999999@s.whatsapp.net";
82
+ const { ctx, handlers } = makeSetupCtx();
83
+ await mod.setup(ctx);
84
+ // Fire the wait first, then deliver the event.
85
+ const pending = mod.api.waitForMarker("PING:", 1000);
86
+ const handlerList = handlers.get("messages.upsert");
87
+ assert.ok(handlerList && handlerList.length > 0, "setup() must register a messages.upsert handler");
88
+ handlerList[0]({
89
+ messages: [{ chatId: "5516999999999@s.whatsapp.net", body: "PING:hello", from: "5516000000000@s.whatsapp.net" }],
90
+ });
91
+ const id = await pending;
92
+ assert.match(id, /^recent:\d+$/);
93
+ assert.ok(mod.api.recentBodies().some((b) => b === "PING:hello"));
94
+ });
95
+ test("waitForMarker ignores messages from a different chat", async () => {
96
+ process.env.MANYBOT_TEST_CHAT = "5516999999999@s.whatsapp.net";
97
+ const { ctx, handlers } = makeSetupCtx();
98
+ await mod.setup(ctx);
99
+ const pending = mod.api.waitForMarker("PING:", 200);
100
+ const handlerList = handlers.get("messages.upsert");
101
+ assert.ok(handlerList);
102
+ // Send from a different chat — must not resolve the wait.
103
+ handlerList[0]({
104
+ messages: [{ chatId: "120363012345678@g.us", body: "PING:should-not-match", from: "120363012345678@g.us" }],
105
+ });
106
+ await assert.rejects(pending, /timed out/);
107
+ });
108
+ test("waitForMarker resolves immediately when a matching message is already in the buffer", async () => {
109
+ process.env.MANYBOT_TEST_CHAT = "5516999999999@s.whatsapp.net";
110
+ const { ctx, handlers } = makeSetupCtx();
111
+ await mod.setup(ctx);
112
+ const handlerList = handlers.get("messages.upsert");
113
+ assert.ok(handlerList);
114
+ handlerList[0]({
115
+ messages: [{ chatId: "5516999999999@s.whatsapp.net", body: "PING:already-here", from: "5516000000000@s.whatsapp.net" }],
116
+ });
117
+ const id = await mod.api.waitForMarker("PING:", 200);
118
+ assert.match(id, /^recent:\d+$/);
119
+ });
120
+ test("waitForMarker does not match a body that does not start with the marker", async () => {
121
+ process.env.MANYBOT_TEST_CHAT = "5516999999999@s.whatsapp.net";
122
+ const { ctx, handlers } = makeSetupCtx();
123
+ await mod.setup(ctx);
124
+ const handlerList = handlers.get("messages.upsert");
125
+ assert.ok(handlerList);
126
+ handlerList[0]({
127
+ messages: [{ chatId: "5516999999999@s.whatsapp.net", body: "PONG:no-match", from: "5516000000000@s.whatsapp.net" }],
128
+ });
129
+ await assert.rejects(mod.api.waitForMarker("PING:", 150), /timed out/);
130
+ });
131
+ test("setup() respects TEST_CHAT env var when MANYBOT_TEST_CHAT is unset", async () => {
132
+ delete process.env.MANYBOT_TEST_CHAT;
133
+ process.env.TEST_CHAT = "5516999998888@s.whatsapp.net";
134
+ const { ctx } = makeSetupCtx();
135
+ await mod.setup(ctx);
136
+ assert.equal(mod.api.testChat, "5516999998888@s.whatsapp.net");
137
+ assert.equal(mod.api.isTestChat("5516999998888@s.whatsapp.net"), true);
138
+ });
139
+ test("setup() agrees with integrationMode's own gate (both resolve TEST_CHAT via getTestConfig)", async () => {
140
+ // Regression test: setup() used to read process.env.TEST_CHAT
141
+ // directly, which diverged from kernel/integrationMode.ts's gate
142
+ // (getIntegrationModeStatus -> getTestConfig, env-or-toml). That
143
+ // let the gate report "ready" off a value setup() couldn't see —
144
+ // this pins the two to the same resolution instead.
145
+ delete process.env.MANYBOT_TEST_CHAT;
146
+ process.env.TEST_CHAT = "5516999997777@s.whatsapp.net";
147
+ const { getTestConfig } = await import("#kernel/testConfig.js");
148
+ const { getIntegrationModeStatus } = await import("#kernel/integrationMode.js");
149
+ _resetTestConfigForTests();
150
+ const gateStatus = await getIntegrationModeStatus();
151
+ const { ctx } = makeSetupCtx();
152
+ await mod.setup(ctx);
153
+ assert.equal(gateStatus.chat, mod.api.testChat, "gate and setup() must resolve the same chat");
154
+ assert.equal(mod.api.testChat, "5516999997777@s.whatsapp.net");
155
+ // Sanity: both are backed by the same cached getTestConfig() call.
156
+ const cfg = await getTestConfig();
157
+ assert.equal(cfg.chat, mod.api.testChat);
158
+ });
159
+ test("setup() throws when given an invalid test chat format", async () => {
160
+ process.env.MANYBOT_TEST_CHAT = "not_a_valid_jid";
161
+ const { ctx } = makeSetupCtx();
162
+ await assert.rejects(mod.setup(ctx), /invalid MANYBOT_TEST_CHAT provided/);
163
+ });
164
+ test("ring buffer caps at 50 messages and evicts oldest", async () => {
165
+ process.env.MANYBOT_TEST_CHAT = "5516999999999@s.whatsapp.net";
166
+ const { ctx, handlers } = makeSetupCtx();
167
+ await mod.setup(ctx);
168
+ const handlerList = handlers.get("messages.upsert");
169
+ assert.ok(handlerList);
170
+ // Push 55 messages
171
+ const messages = Array.from({ length: 55 }, (_, i) => ({
172
+ chatId: "5516999999999@s.whatsapp.net",
173
+ body: `MSG_${i}`,
174
+ from: "5516000000000@s.whatsapp.net",
175
+ }));
176
+ handlerList[0]({ messages });
177
+ const recent = mod.api.recentBodies();
178
+ assert.equal(recent.length, 50);
179
+ // Oldest 5 (MSG_0 to MSG_4) should have been evicted
180
+ assert.equal(recent[0], "MSG_5");
181
+ assert.equal(recent.at(-1), "MSG_54");
182
+ });
183
+ });
184
+ function makeSetupCtx() {
185
+ const handlers = new Map();
186
+ const mock = {
187
+ handlers,
188
+ events: {
189
+ on(event, handler) {
190
+ const list = handlers.get(event) ?? [];
191
+ list.push(handler);
192
+ handlers.set(event, list);
193
+ return () => {
194
+ const arr = handlers.get(event) ?? [];
195
+ const idx = arr.indexOf(handler);
196
+ if (idx >= 0)
197
+ arr.splice(idx, 1);
198
+ };
199
+ },
200
+ },
201
+ };
202
+ // The plugin only touches `events` in setup(); the rest of the
203
+ // SetupContext surface is unused, so we type-cast through `unknown`
204
+ // rather than build a full fake.
205
+ return { ctx: mock, handlers };
206
+ }
207
+ function makeMessageCtx(chatId) {
208
+ const mock = {
209
+ chat: {
210
+ id: chatId,
211
+ name: "test",
212
+ isGroup: chatId.endsWith("@g.us"),
213
+ },
214
+ msg: { id: "msg-1", body: "hello" },
215
+ replyCalls: [],
216
+ };
217
+ return { ctx: mock, replyCalls: mock.replyCalls };
218
+ }