@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.
- package/README.md +28 -3
- package/dist/client/banner.js +10 -0
- package/dist/client/banner.test.js +31 -0
- package/dist/client/store.js +91 -6
- package/dist/client/store.test.js +170 -0
- package/dist/config.js +28 -44
- package/dist/config.test.js +26 -0
- package/dist/download/queue.js +13 -4
- package/dist/drivers/baileys/adapter.js +133 -15
- package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
- package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
- package/dist/drivers/baileys/api/index.js +384 -62
- package/dist/drivers/baileys/index.js +92 -36
- package/dist/drivers/baileys/loginPrompt.js +0 -2
- package/dist/drivers/baileys/messageHandler.js +344 -4
- package/dist/drivers/baileys/messageHandler.test.js +445 -0
- package/dist/drivers/baileysAdapter.test.js +378 -0
- package/dist/drivers/jid.js +26 -0
- package/dist/drivers/jid.test.js +74 -0
- package/dist/drivers/types.js +5 -5
- package/dist/i18n/index.js +20 -24
- package/dist/kernel/activeDriverSend.js +21 -0
- package/dist/kernel/activeDriverSend.test.js +89 -0
- package/dist/kernel/alerts.js +3 -9
- package/dist/kernel/chatOverrides.js +46 -0
- package/dist/kernel/chatOverrides.test.js +59 -0
- package/dist/kernel/chatSession.js +65 -0
- package/dist/kernel/chatSession.test.js +46 -0
- package/dist/kernel/commandAccess.js +66 -0
- package/dist/kernel/commandAccess.test.js +74 -0
- package/dist/kernel/commandDeprecation.js +170 -0
- package/dist/kernel/commandDeprecation.test.js +114 -0
- package/dist/kernel/commandMenu.js +357 -0
- package/dist/kernel/commandMenu.test.js +363 -0
- package/dist/kernel/commandPermissions.js +171 -0
- package/dist/kernel/commandPermissions.test.js +227 -0
- package/dist/kernel/commandRegistry.js +583 -0
- package/dist/kernel/commandRegistry.test.js +158 -0
- package/dist/kernel/commandsConfig.js +949 -0
- package/dist/kernel/commandsConfig.test.js +482 -0
- package/dist/kernel/contactAutoSave.js +6 -6
- package/dist/kernel/contactAutoSave.test.js +87 -0
- package/dist/kernel/coreCommands.js +62 -0
- package/dist/kernel/driverManager.js +10 -6
- package/dist/kernel/driverManager.test.js +90 -0
- package/dist/kernel/integrationMode.js +88 -0
- package/dist/kernel/integrationMode.test.js +95 -0
- package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
- package/dist/kernel/pluginApi.test.js +600 -0
- package/dist/kernel/pluginGuard.js +18 -13
- package/dist/kernel/pluginGuard.test.js +39 -0
- package/dist/kernel/pluginLoader.js +169 -11
- package/dist/kernel/pluginLoader.test.js +190 -0
- package/dist/kernel/runCommand.js +284 -0
- package/dist/kernel/runCommand.test.js +497 -0
- package/dist/kernel/sendFallbackGuard.js +19 -48
- package/dist/kernel/sendFallbackGuard.test.js +80 -0
- package/dist/kernel/sendGuard.js +38 -42
- package/dist/kernel/sendGuard.test.js +102 -0
- package/dist/kernel/settingsDb.js +19 -5
- package/dist/kernel/statusServer.js +9 -2
- package/dist/kernel/statusServer.test.js +70 -0
- package/dist/kernel/testConfig.js +192 -0
- package/dist/kernel/testConfig.test.js +181 -0
- package/dist/kernel/updateCheck.js +33 -10
- package/dist/locales/en.json +77 -13
- package/dist/locales/es.json +77 -13
- package/dist/locales/pt.json +77 -13
- package/dist/logger/logger.js +23 -3
- package/dist/logger/logger.test.js +45 -0
- package/dist/main.js +5 -76
- package/dist/plugins/__manybot_integration__/index.js +184 -0
- package/dist/plugins/__manybot_integration__/index.test.js +218 -0
- package/dist/utils/phoneNumber.js +83 -0
- package/dist/utils/phoneNumber.test.js +53 -0
- package/package.json +76 -18
- package/dist/drivers/whatsmeow/client.js +0 -252
- package/dist/drivers/whatsmeow/index.js +0 -79
- package/dist/drivers/whatsmeow/installer.js +0 -86
- package/dist/drivers/whatsmeow/supervisor.js +0 -328
- package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
package/dist/logger/logger.js
CHANGED
|
@@ -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) =>
|
|
12
|
-
|
|
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) =>
|
|
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 {
|
|
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
|
|
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:
|
|
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
|
+
}
|