@manybot/manybot 5.7.0 → 5.8.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 +20 -3
- package/dist/client/banner.js +10 -0
- package/dist/client/banner.test.js +31 -0
- package/dist/client/store.js +56 -5
- package/dist/client/store.test.js +170 -0
- package/dist/config.js +28 -44
- package/dist/config.test.js +26 -0
- package/dist/drivers/baileys/adapter.js +58 -7
- package/dist/drivers/baileys/api/index.js +172 -24
- package/dist/drivers/baileys/index.js +62 -30
- package/dist/drivers/baileys/loginPrompt.js +0 -2
- package/dist/drivers/baileys/messageHandler.js +158 -4
- package/dist/drivers/baileys/messageHandler.test.js +203 -0
- package/dist/drivers/baileysAdapter.test.js +281 -0
- package/dist/drivers/jid.test.js +40 -0
- package/dist/drivers/types.js +5 -5
- package/dist/i18n/index.js +15 -2
- 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/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 +168 -0
- package/dist/kernel/commandDeprecation.test.js +107 -0
- package/dist/kernel/commandMenu.js +268 -0
- package/dist/kernel/commandMenu.test.js +234 -0
- package/dist/kernel/commandPermissions.js +125 -0
- package/dist/kernel/commandPermissions.test.js +159 -0
- package/dist/kernel/commandRegistry.js +459 -0
- package/dist/kernel/commandRegistry.test.js +156 -0
- package/dist/kernel/commandsConfig.js +517 -0
- package/dist/kernel/commandsConfig.test.js +236 -0
- package/dist/kernel/contactAutoSave.test.js +87 -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 +583 -0
- package/dist/kernel/pluginGuard.js +15 -12
- package/dist/kernel/pluginGuard.test.js +39 -0
- package/dist/kernel/pluginLoader.js +96 -1
- package/dist/kernel/pluginLoader.test.js +80 -0
- package/dist/kernel/runCommand.js +245 -0
- package/dist/kernel/runCommand.test.js +235 -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 +4 -3
- package/dist/kernel/statusServer.js +9 -2
- package/dist/kernel/statusServer.test.js +70 -0
- package/dist/kernel/testConfig.js +183 -0
- package/dist/kernel/testConfig.test.js +181 -0
- package/dist/kernel/updateCheck.js +33 -10
- package/dist/locales/en.json +64 -13
- package/dist/locales/es.json +64 -13
- package/dist/locales/pt.json +64 -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 +167 -0
- package/dist/plugins/__manybot_integration__/index.test.js +184 -0
- package/package.json +74 -17
- 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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Owns the socket state machine (connect / disconnect / reconnect /
|
|
7
7
|
* circuit breaker) and exposes the live `WaContract` adapter to the
|
|
8
|
-
* rest of the kernel. Plugins,
|
|
8
|
+
* rest of the kernel. Plugins, activeDriverSend, messageHandler,
|
|
9
9
|
* pluginLoader, contactAutoSave and sendGuard all consume the
|
|
10
10
|
* `WaContract` returned here — they never touch the raw Baileys socket.
|
|
11
11
|
*
|
|
@@ -16,9 +16,8 @@
|
|
|
16
16
|
* registration, and the reconnect circuit breaker — none of which
|
|
17
17
|
* the adapter should know about.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* Baileys store.messages map on demand.
|
|
19
|
+
* The contract also exposes `getHistory?` — which the adapter
|
|
20
|
+
* populates from the Baileys store.messages map on demand.
|
|
22
21
|
*/
|
|
23
22
|
import { createSocket, AUTH_DIR, store as sharedStore } from "./sdk/baileysSock.js";
|
|
24
23
|
import { createBaileysAdapter } from "./adapter.js";
|
|
@@ -27,7 +26,6 @@ import { normalizeJid } from "#drivers/jid.js";
|
|
|
27
26
|
import { loadPlugins, setupPlugins } from "#kernel/pluginLoader.js";
|
|
28
27
|
import { runContactRefreshSweep } from "#kernel/contactAutoSave.js";
|
|
29
28
|
import { registerAlertSockProvider, sendAlert } from "#kernel/alerts.js";
|
|
30
|
-
import { getDriverManager } from "#kernel/driverManager.js";
|
|
31
29
|
import { startUpdateCheckSchedule, stopUpdateCheckSchedule } from "#kernel/updateCheck.js";
|
|
32
30
|
import { setStatus } from "#kernel/statusServer.js";
|
|
33
31
|
import { logger } from "#logger";
|
|
@@ -53,6 +51,7 @@ let halted = false;
|
|
|
53
51
|
let cacheHydrated = false;
|
|
54
52
|
let cacheSaveTimer = null;
|
|
55
53
|
let contactRefreshTimer = null;
|
|
54
|
+
let bannerShown = false;
|
|
56
55
|
registerAlertSockProvider(() => currentSock);
|
|
57
56
|
// ── Per-chat message queue ──────────────────────────────────────────────────
|
|
58
57
|
// Messages from the same chat are processed one at a time (in order), but
|
|
@@ -204,7 +203,10 @@ async function startBot() {
|
|
|
204
203
|
setStatus(true);
|
|
205
204
|
logger.success(t("system.connected"));
|
|
206
205
|
logger.info(t("system.clientId", { id: CLIENT_ID }));
|
|
207
|
-
|
|
206
|
+
if (!bannerShown) {
|
|
207
|
+
bannerShown = true;
|
|
208
|
+
printBanner();
|
|
209
|
+
}
|
|
208
210
|
if (!pluginsReady) {
|
|
209
211
|
pluginsReady = true;
|
|
210
212
|
await loadPlugins(PLUGINS);
|
|
@@ -227,15 +229,22 @@ async function startBot() {
|
|
|
227
229
|
if (loggedOut || badSession) {
|
|
228
230
|
if (badSession) {
|
|
229
231
|
logger.warn("Session data corrupted (badSession=500). Clearing session dir.");
|
|
230
|
-
|
|
232
|
+
// No longer mark degraded - halt on failure
|
|
233
|
+
logger.error("Baileys driver failed due to bad session - bot will halt");
|
|
234
|
+
process.exit(1);
|
|
231
235
|
}
|
|
232
236
|
else {
|
|
233
237
|
logger.warn(t("system.sessionExpired"));
|
|
238
|
+
// No longer mark degraded - halt on failure
|
|
239
|
+
logger.error("Baileys driver failed due to session expired - bot will halt");
|
|
240
|
+
process.exit(1);
|
|
234
241
|
}
|
|
235
242
|
try {
|
|
236
243
|
await fs.rm(AUTH_DIR, { recursive: true, force: true });
|
|
237
244
|
}
|
|
238
245
|
catch (e) {
|
|
246
|
+
logger.error(`Baileys driver failed due to ${badSession ? "bad session" : "session expired"} - bot will halt`);
|
|
247
|
+
process.exit(1);
|
|
239
248
|
logger.error(`[whatsapp] Failed to remove session dir: ${e.message}`);
|
|
240
249
|
}
|
|
241
250
|
scheduleReconnect(1000);
|
|
@@ -245,13 +254,8 @@ async function startBot() {
|
|
|
245
254
|
if (restartRequiredCount >= MAX_RESTART_REQUIRED) {
|
|
246
255
|
halted = true;
|
|
247
256
|
logger.error(`restartRequired (515) recurring — protocol drift suspected. Halting.`);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
level: "critical",
|
|
251
|
-
title: "manybot — restartRequired recurring",
|
|
252
|
-
message: `Protocol drift suspected after ${restartRequiredCount}x restartRequired. Bot halted on Baileys. Run connect() manually.`,
|
|
253
|
-
}).catch(() => { });
|
|
254
|
-
return;
|
|
257
|
+
logger.error("Baileys driver failed due to repeated restartRequired - bot will halt");
|
|
258
|
+
process.exit(1);
|
|
255
259
|
}
|
|
256
260
|
const delay = Math.min(500, RECONNECT_BASE_MS);
|
|
257
261
|
logger.info(t("system.reconnecting", { secs: Math.round(delay / 1000) }));
|
|
@@ -260,12 +264,13 @@ async function startBot() {
|
|
|
260
264
|
else if (!shuttingDown) {
|
|
261
265
|
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
262
266
|
halted = true;
|
|
263
|
-
getDriverManager().markDegraded("baileys", 600_000);
|
|
264
267
|
logger.error(t("system.reconnectHalted", { attempts: reconnectAttempts }));
|
|
268
|
+
logger.error("Baileys driver failed due to exhausted reconnect attempts - bot will halt");
|
|
269
|
+
process.exit(1);
|
|
265
270
|
sendAlert({
|
|
266
271
|
level: "critical",
|
|
267
|
-
title: "
|
|
268
|
-
message:
|
|
272
|
+
title: t("alerts.reconnectHaltedTitle"),
|
|
273
|
+
message: t("alerts.reconnectHaltedMessage", { attempts: reconnectAttempts }),
|
|
269
274
|
}).catch(() => { });
|
|
270
275
|
return;
|
|
271
276
|
}
|
|
@@ -390,10 +395,10 @@ export const baileysContract = {
|
|
|
390
395
|
me: () => requireReady().me(),
|
|
391
396
|
// ── media (download) ────────────────────────────────────────────────────
|
|
392
397
|
downloadMedia: (...args) => requireReady().downloadMedia(...args),
|
|
393
|
-
// ──
|
|
398
|
+
// ── history ──────────────────────────────────────────────────────────
|
|
394
399
|
// Delegates to the adapter, which reads from the in-memory Baileys
|
|
395
|
-
// store (store.messages). The adapter
|
|
396
|
-
//
|
|
400
|
+
// store (store.messages). The Baileys adapter always defines
|
|
401
|
+
// getHistory, so calling it unconditionally here is safe.
|
|
397
402
|
getHistory: (jid, opts) => requireReady().getHistory(jid, opts),
|
|
398
403
|
/**
|
|
399
404
|
* Diagnostic mode: connects on its own session (separate from the
|
|
@@ -459,22 +464,30 @@ export const baileysContract = {
|
|
|
459
464
|
const sorted = chats
|
|
460
465
|
.map((c) => ({ id: c.id, name: c.name ?? "" }))
|
|
461
466
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
462
|
-
|
|
463
|
-
|
|
467
|
+
// Allow selecting multiple chats (Space to toggle, Enter to confirm).
|
|
468
|
+
const picked = await clack.multiselect({
|
|
469
|
+
message: t("getid.pickPrompt"),
|
|
464
470
|
options: sorted.map((c) => ({ label: c.name || c.id, value: c.id })),
|
|
465
471
|
});
|
|
466
|
-
if (clack.isCancel(picked) ||
|
|
472
|
+
if (clack.isCancel(picked) || !Array.isArray(picked) || picked.length === 0) {
|
|
467
473
|
teardownSock(sock);
|
|
468
474
|
process.exit(0);
|
|
469
475
|
}
|
|
470
|
-
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
await
|
|
474
|
-
|
|
476
|
+
// Resolve any @lid values to their phone-based JIDs when possible.
|
|
477
|
+
const finalJids = [];
|
|
478
|
+
for (const p of picked) {
|
|
479
|
+
const resolved = await resolveLidForJid(sock, p);
|
|
480
|
+
finalJids.push(resolved ?? p);
|
|
481
|
+
}
|
|
482
|
+
const joined = finalJids.join("\n");
|
|
483
|
+
const copied = await copyToClipboard(joined);
|
|
484
|
+
if (copied) {
|
|
485
|
+
logger.success(`[getid] ${t("getid.copied", { count: finalJids.length, ids: joined })}`);
|
|
475
486
|
}
|
|
476
|
-
|
|
477
|
-
logger.
|
|
487
|
+
else {
|
|
488
|
+
logger.warn(`[getid] ${t("getid.copyFailed", { count: finalJids.length, ids: joined })}`);
|
|
489
|
+
// Also print to stdout so the user can still see the IDs.
|
|
490
|
+
logger.info(`[getid] \n${joined}`);
|
|
478
491
|
}
|
|
479
492
|
teardownSock(sock);
|
|
480
493
|
},
|
|
@@ -556,12 +569,31 @@ export function toBotMessage(msg) {
|
|
|
556
569
|
text = "";
|
|
557
570
|
mimetype = m.stickerMessage.mimetype ?? undefined;
|
|
558
571
|
}
|
|
572
|
+
else if (m?.templateMessage) {
|
|
573
|
+
type = "text";
|
|
574
|
+
const tpl = m.templateMessage.hydratedTemplate ?? m.templateMessage.hydratedFourRowTemplate;
|
|
575
|
+
const buttonUrls = tpl?.hydratedButtons?.map((b) => b.urlButton?.url).filter(Boolean).join(" ") ?? "";
|
|
576
|
+
text = [tpl?.hydratedContentText ?? "", buttonUrls].filter(Boolean).join(" ");
|
|
577
|
+
}
|
|
578
|
+
else if (m?.interactiveMessage) {
|
|
579
|
+
type = "text";
|
|
580
|
+
const buttonParams = m.interactiveMessage.nativeFlowMessage?.buttons
|
|
581
|
+
?.map((b) => b.buttonParamsJson).filter(Boolean).join(" ") ?? "";
|
|
582
|
+
text = [m.interactiveMessage.body?.text ?? "", buttonParams].filter(Boolean).join(" ");
|
|
583
|
+
}
|
|
584
|
+
else if (m?.buttonsMessage) {
|
|
585
|
+
type = "text";
|
|
586
|
+
text = [m.buttonsMessage.contentText ?? "", m.buttonsMessage.footerText ?? ""].filter(Boolean).join(" ");
|
|
587
|
+
}
|
|
559
588
|
const key = msg.key;
|
|
560
589
|
const contextInfo = m?.extendedTextMessage?.contextInfo ??
|
|
561
590
|
m?.imageMessage?.contextInfo ??
|
|
562
591
|
m?.videoMessage?.contextInfo ??
|
|
563
592
|
m?.audioMessage?.contextInfo ??
|
|
564
593
|
m?.documentMessage?.contextInfo ??
|
|
594
|
+
m?.templateMessage?.contextInfo ??
|
|
595
|
+
m?.interactiveMessage?.contextInfo ??
|
|
596
|
+
m?.buttonsMessage?.contextInfo ??
|
|
565
597
|
undefined;
|
|
566
598
|
return {
|
|
567
599
|
id: msg.key?.id ?? "",
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
import * as clack from "@clack/prompts";
|
|
12
12
|
import { CONFIG, persistConfigValue } from "#config";
|
|
13
13
|
import { t } from "#i18n";
|
|
14
|
-
import { promptWhatsmeowInstall } from "#drivers/whatsmeow/installer.js";
|
|
15
14
|
function cancelAndExit() {
|
|
16
15
|
clack.cancel(t("onboarding.cancelled"));
|
|
17
16
|
process.exit(1);
|
|
@@ -73,7 +72,6 @@ export async function resolveLoginMethod() {
|
|
|
73
72
|
clack.intro(t("onboarding.intro"));
|
|
74
73
|
if (needsMethod) {
|
|
75
74
|
method = await promptLoginMethod();
|
|
76
|
-
await promptWhatsmeowInstall();
|
|
77
75
|
}
|
|
78
76
|
if (method === "phone" && !phone) {
|
|
79
77
|
phone = await promptPhoneNumber();
|
|
@@ -11,15 +11,31 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Each plugin decides whether to act or ignore.
|
|
13
13
|
*/
|
|
14
|
-
import { CHATS, EXCLUDE_CHATS } from "#config";
|
|
14
|
+
import { CHATS, EXCLUDE_CHATS, CMD_PREFIX } from "#config";
|
|
15
15
|
import { buildApi, buildChatFromMsg, buildMessageContext } from "./api/index.js";
|
|
16
16
|
import { pluginRegistry } from "#kernel/pluginLoader.js";
|
|
17
|
+
import { getCommandByInvocation, getCommandRegistry } from "#kernel/commandRegistry.js";
|
|
18
|
+
import { resolveDispatch, runCommand } from "#kernel/runCommand.js";
|
|
19
|
+
import { getActiveDeprecation, formatDeprecationMessage } from "#kernel/commandDeprecation.js";
|
|
20
|
+
import { checkPermission } from "#kernel/commandPermissions.js";
|
|
21
|
+
import { handleMenuCommand, renderNotFound, resolveLocalizedString, checkAndTriggerWelcomeMessage } from "#kernel/commandMenu.js";
|
|
17
22
|
import { runPlugin } from "#kernel/pluginGuard.js";
|
|
18
23
|
import { acquireChatSlot } from "#sendguard";
|
|
19
24
|
import { trackIncomingForContactSave } from "#kernel/contactAutoSave.js";
|
|
20
25
|
import { normalizeJid } from "#drivers/jid.js";
|
|
26
|
+
import { logger } from "#logger";
|
|
21
27
|
const INCOMING_DEBOUNCE_MS = 0;
|
|
22
28
|
const lastProcessedAt = new Map();
|
|
29
|
+
/**
|
|
30
|
+
* Extract the bare command token from a raw body string.
|
|
31
|
+
* Mirrors the prefix/parsing logic in `buildMessageContext` and `buildApi`
|
|
32
|
+
* (kept local to avoid coupling to internal helpers of api/index.ts).
|
|
33
|
+
* Returns "" when the body does not start with the configured prefix.
|
|
34
|
+
*/
|
|
35
|
+
function extractCommand(body, prefix) {
|
|
36
|
+
const first = body.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
37
|
+
return first.startsWith(prefix) ? first.slice(prefix.length) : "";
|
|
38
|
+
}
|
|
23
39
|
// ── Dedup of already-processed messages ────────────────────────────────────
|
|
24
40
|
// WhatsApp resends messages without a delivery/read confirmation (the
|
|
25
41
|
// protocol's own retry, usually up to 3 times) when the socket reconnects.
|
|
@@ -91,13 +107,115 @@ export async function handleMessage(msg, contract, store) {
|
|
|
91
107
|
// Caps how many chats get answered at the same time — see SECURITY_LEVEL.
|
|
92
108
|
const releaseChatSlot = await acquireChatSlot(jid);
|
|
93
109
|
try {
|
|
94
|
-
await runPluginsForMessage(msg, chat, contract, store, rawJid);
|
|
110
|
+
await runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid);
|
|
95
111
|
}
|
|
96
112
|
finally {
|
|
97
113
|
releaseChatSlot();
|
|
98
114
|
}
|
|
99
115
|
}
|
|
100
|
-
async function runPluginsForMessage(msg, chat, contract, store, rawJid) {
|
|
116
|
+
async function runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid) {
|
|
117
|
+
const command = extractCommand(msgCtx.body, CMD_PREFIX);
|
|
118
|
+
const registry = getCommandRegistry();
|
|
119
|
+
// 0. Welcome message (first message within the configured window)
|
|
120
|
+
if (registry) {
|
|
121
|
+
const welcomeMsg = checkAndTriggerWelcomeMessage(msgCtx.sender, registry);
|
|
122
|
+
if (welcomeMsg) {
|
|
123
|
+
try {
|
|
124
|
+
await msgCtx.reply.text(welcomeMsg);
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
128
|
+
logger.warn(`[messageHandler] welcome reply failed: ${err.message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// 1. Menu aliases match (overview / category / manual / notFound)
|
|
133
|
+
if (command && registry && registry.menuAliases.has(command)) {
|
|
134
|
+
const rawArgs = msgCtx.body.trim().slice(CMD_PREFIX.length + command.length).trim();
|
|
135
|
+
const scope = chat.isGroup ? "group" : "dm";
|
|
136
|
+
const menuResponse = handleMenuCommand(command, rawArgs, registry, undefined, scope);
|
|
137
|
+
try {
|
|
138
|
+
await msgCtx.reply.text(menuResponse);
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
142
|
+
logger.warn(`[messageHandler] menu reply failed: ${err.message}`);
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const matched = command ? getCommandByInvocation(command) : null;
|
|
147
|
+
// Full v6 dispatch resolution (parent vs. subcommand) for the matched
|
|
148
|
+
// entry, if any — feeds `runCommand()` below so subcommand routing,
|
|
149
|
+
// required-argument validation, and the Phase-8 crash-alert hook are
|
|
150
|
+
// active in production. Kept separate from the `matched` top-level
|
|
151
|
+
// permission pre-check further down (unchanged, still gates the whole
|
|
152
|
+
// per-message plugin loop exactly as before `runCommand` existed).
|
|
153
|
+
const rawArgsForDispatch = command ? msgCtx.body.trim().slice(CMD_PREFIX.length + command.length).trim() : "";
|
|
154
|
+
const resolution = command ? resolveDispatch(command, rawArgsForDispatch) : { target: { kind: "none" } };
|
|
155
|
+
if (matched) {
|
|
156
|
+
const permApi = buildApi({
|
|
157
|
+
msg,
|
|
158
|
+
chat,
|
|
159
|
+
contract,
|
|
160
|
+
store,
|
|
161
|
+
pluginRegistry,
|
|
162
|
+
pluginName: matched.pluginName ?? "system",
|
|
163
|
+
guardOptions: {},
|
|
164
|
+
});
|
|
165
|
+
const permResult = await checkPermission(matched, {
|
|
166
|
+
isGroup: permApi.chat.isGroup,
|
|
167
|
+
chatId: permApi.chat.id,
|
|
168
|
+
senderId: msgCtx.sender,
|
|
169
|
+
isSenderAdmin: () => permApi.chat.isSenderAdmin(),
|
|
170
|
+
isBotAdmin: () => permApi.chat.isBotAdmin(),
|
|
171
|
+
});
|
|
172
|
+
if (!permResult.allowed) {
|
|
173
|
+
if (permResult.message) {
|
|
174
|
+
try {
|
|
175
|
+
await msgCtx.reply.text(permResult.message);
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
179
|
+
logger.warn(`[messageHandler] permission reply failed: ${err.message}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Fixed-text command: reply with the literal text and stop — do not
|
|
186
|
+
// invoke any plugin (legacy or migrated) for this message.
|
|
187
|
+
if (matched && matched.source === "text" && matched.text !== null) {
|
|
188
|
+
try {
|
|
189
|
+
const textContent = resolveLocalizedString(matched.text) ?? "";
|
|
190
|
+
await msgCtx.reply.text(textContent);
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
194
|
+
logger.warn(`[messageHandler] fixed-text reply failed: ${err.message}`);
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// Deprecated old name: notify the user and stop. We do NOT redirect
|
|
199
|
+
// to the new command and do NOT fall through to the legacy run(ctx).
|
|
200
|
+
if (!matched && command) {
|
|
201
|
+
const defaults = registry?.defaults ?? {
|
|
202
|
+
notifyChanges: true,
|
|
203
|
+
notifyPeriodDays: 7,
|
|
204
|
+
notifyMessage: null,
|
|
205
|
+
};
|
|
206
|
+
const dep = defaults.notifyChanges ? getActiveDeprecation(command) : null;
|
|
207
|
+
if (dep) {
|
|
208
|
+
try {
|
|
209
|
+
await msgCtx.reply.text(formatDeprecationMessage(dep, defaults));
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
213
|
+
logger.warn(`[messageHandler] deprecation reply failed: ${err.message}`);
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const matchedPlugin = matched && matched.source === "plugin" ? matched : null;
|
|
101
219
|
for (const plugin of pluginRegistry.values()) {
|
|
102
220
|
const ctx = buildApi({
|
|
103
221
|
msg,
|
|
@@ -117,7 +235,30 @@ async function runPluginsForMessage(msg, chat, contract, store, rawJid) {
|
|
|
117
235
|
}, 4000);
|
|
118
236
|
}
|
|
119
237
|
try {
|
|
120
|
-
|
|
238
|
+
// If this plugin owns the matched registry entry, skip the legacy
|
|
239
|
+
// run(ctx) and go through the unified v6 dispatcher instead — avoids
|
|
240
|
+
// double-firing for a migrated command and activates subcommand
|
|
241
|
+
// routing, required-argument validation, and the Phase-8 crash-alert
|
|
242
|
+
// hook (`runCommand.ts`), none of which the direct `runPlugin` call
|
|
243
|
+
// below it used to provide. Other plugins (and other invocations of
|
|
244
|
+
// this same plugin that did NOT match the registry) keep their legacy
|
|
245
|
+
// run(ctx).
|
|
246
|
+
if (matchedPlugin && matchedPlugin.pluginName === plugin.name && matchedPlugin.handler) {
|
|
247
|
+
// runCommand() opts into rethrow so its Phase-8 fireAlert("plugin_crash")
|
|
248
|
+
// catch actually runs (runPlugin() swallows by default — see pluginGuard.ts).
|
|
249
|
+
// Swallow here too, at the boundary: this loop must never crash the bot,
|
|
250
|
+
// same guarantee the legacy runPlugin(plugin, ctx) branch below already has.
|
|
251
|
+
try {
|
|
252
|
+
await runCommand({ pluginName: plugin.name, ctx, resolution, reply: msgCtx.reply });
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
256
|
+
logger.warn(`[messageHandler] runCommand crashed for plugin "${plugin.name}": ${err.message}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
await runPlugin(plugin, ctx);
|
|
261
|
+
}
|
|
121
262
|
}
|
|
122
263
|
finally {
|
|
123
264
|
if (useTyping) {
|
|
@@ -126,4 +267,17 @@ async function runPluginsForMessage(msg, chat, contract, store, rawJid) {
|
|
|
126
267
|
}
|
|
127
268
|
}
|
|
128
269
|
}
|
|
270
|
+
// Legacy plugins do not report whether they handled a message. Keep the
|
|
271
|
+
// generic fallback opt-in and send it only after they have all had a
|
|
272
|
+
// chance to respond; a legacy plugin may therefore still produce a reply
|
|
273
|
+
// alongside this fallback.
|
|
274
|
+
if (!matched && command && registry?.menu.notFoundFallback) {
|
|
275
|
+
try {
|
|
276
|
+
await msgCtx.reply.text(renderNotFound(command, registry));
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
280
|
+
logger.warn(`[messageHandler] notFoundFallback reply failed: ${err.message}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
129
283
|
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test, beforeEach, afterEach } from "node:test";
|
|
3
|
+
import { createStore } from "#client/store.js";
|
|
4
|
+
import { handleMessage } from "#drivers/baileys/messageHandler.js";
|
|
5
|
+
import { pluginRegistry } from "#kernel/pluginLoader.js";
|
|
6
|
+
import { buildCommandRegistry, __setRegistryForTests } from "#kernel/commandRegistry.js";
|
|
7
|
+
import { getDriverManager, _resetDriverManagerForTests } from "#kernel/driverManager.js";
|
|
8
|
+
// ── Minimal spec builders (mirrors kernel/runCommand.test.ts conventions) ──
|
|
9
|
+
function emptySpec(overrides) {
|
|
10
|
+
return {
|
|
11
|
+
id: overrides.id ?? "task::run",
|
|
12
|
+
cmd: overrides.cmd ?? "task",
|
|
13
|
+
aliases: overrides.aliases ?? [],
|
|
14
|
+
plugin: overrides.plugin ?? "taskPlugin",
|
|
15
|
+
function: overrides.function ?? "runFn",
|
|
16
|
+
text: overrides.text ?? null,
|
|
17
|
+
desc: overrides.desc ?? null,
|
|
18
|
+
category: overrides.category ?? null,
|
|
19
|
+
group: overrides.group ?? null,
|
|
20
|
+
manual: overrides.manual ?? null,
|
|
21
|
+
deprecatedMessage: overrides.deprecatedMessage ?? null,
|
|
22
|
+
notifyChanges: overrides.notifyChanges ?? null,
|
|
23
|
+
permissions: overrides.permissions ?? null,
|
|
24
|
+
messages: overrides.messages ?? null,
|
|
25
|
+
arguments: overrides.arguments ?? [],
|
|
26
|
+
subcommands: overrides.subcommands ?? [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function emptySub(overrides) {
|
|
30
|
+
return {
|
|
31
|
+
id: overrides.id ?? "task::list",
|
|
32
|
+
cmd: overrides.cmd ?? "list",
|
|
33
|
+
aliases: overrides.aliases ?? [],
|
|
34
|
+
function: overrides.function ?? null,
|
|
35
|
+
desc: overrides.desc ?? null,
|
|
36
|
+
manual: overrides.manual ?? null,
|
|
37
|
+
arguments: overrides.arguments ?? [],
|
|
38
|
+
permissions: overrides.permissions ?? null,
|
|
39
|
+
messages: overrides.messages ?? null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// ── Mock WaContract — only the surface handleMessage's pipeline touches ────
|
|
43
|
+
function createMockContract() {
|
|
44
|
+
const sentTexts = [];
|
|
45
|
+
let msgSeq = 0;
|
|
46
|
+
const contract = {
|
|
47
|
+
name: "baileys",
|
|
48
|
+
connect: async () => { },
|
|
49
|
+
disconnect: async () => { },
|
|
50
|
+
isReady: () => true,
|
|
51
|
+
resolveLid: async () => null,
|
|
52
|
+
on: () => () => { },
|
|
53
|
+
sendText: async (jid, text) => {
|
|
54
|
+
sentTexts.push({ jid, text });
|
|
55
|
+
return { id: `msg-${++msgSeq}`, chatId: jid, timestamp: Date.now() };
|
|
56
|
+
},
|
|
57
|
+
sendImage: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
|
|
58
|
+
sendVideo: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
|
|
59
|
+
sendAudio: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
|
|
60
|
+
sendSticker: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
|
|
61
|
+
sendDocument: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
|
|
62
|
+
sendPoll: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
|
|
63
|
+
react: async () => { },
|
|
64
|
+
deleteMessage: async () => { },
|
|
65
|
+
editMessage: async () => { },
|
|
66
|
+
sendPresenceUpdate: async () => { },
|
|
67
|
+
readMessages: async () => { },
|
|
68
|
+
onWhatsApp: async () => [{ exists: true }],
|
|
69
|
+
getBusinessProfile: async () => null,
|
|
70
|
+
profilePictureUrl: async () => null,
|
|
71
|
+
fetchStatus: async () => null,
|
|
72
|
+
updateBlockStatus: async () => { },
|
|
73
|
+
addOrEditContact: async () => { },
|
|
74
|
+
removeContact: async () => { },
|
|
75
|
+
groupMetadata: async () => ({ subject: "Group", participants: [] }),
|
|
76
|
+
groupParticipantsUpdate: async () => [],
|
|
77
|
+
groupUpdateSubject: async () => { },
|
|
78
|
+
groupUpdateDescription: async () => { },
|
|
79
|
+
groupInviteCode: async () => "",
|
|
80
|
+
groupRevokeInvite: async () => "",
|
|
81
|
+
updateProfilePicture: async () => { },
|
|
82
|
+
updateProfileName: async () => { },
|
|
83
|
+
updateProfileStatus: async () => { },
|
|
84
|
+
me: () => ({ id: "5511900000000@s.whatsapp.net" }),
|
|
85
|
+
downloadMedia: async () => null,
|
|
86
|
+
getHistory: async () => [],
|
|
87
|
+
};
|
|
88
|
+
return { contract, sentTexts };
|
|
89
|
+
}
|
|
90
|
+
function makeBotMessage(overrides = {}) {
|
|
91
|
+
return {
|
|
92
|
+
id: `in-${Math.random().toString(36).slice(2)}`,
|
|
93
|
+
chatId: "5511888888888@s.whatsapp.net",
|
|
94
|
+
fromMe: false,
|
|
95
|
+
contentHash: "hash",
|
|
96
|
+
timestamp: Date.now(),
|
|
97
|
+
type: "text",
|
|
98
|
+
body: "!task",
|
|
99
|
+
fromPn: "5511888888888@s.whatsapp.net",
|
|
100
|
+
...overrides,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function buildRegistry(specs) {
|
|
104
|
+
return buildCommandRegistry(specs, pluginRegistry);
|
|
105
|
+
}
|
|
106
|
+
describe("drivers/baileys/messageHandler — v6 runCommand dispatch", () => {
|
|
107
|
+
let store;
|
|
108
|
+
let contract;
|
|
109
|
+
let sentTexts;
|
|
110
|
+
let runCalls;
|
|
111
|
+
let listCalls;
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
_resetDriverManagerForTests();
|
|
114
|
+
store = createStore();
|
|
115
|
+
const mock = createMockContract();
|
|
116
|
+
contract = mock.contract;
|
|
117
|
+
sentTexts = mock.sentTexts;
|
|
118
|
+
getDriverManager().register(contract, { isPrimary: true });
|
|
119
|
+
runCalls = [];
|
|
120
|
+
listCalls = [];
|
|
121
|
+
const plugin = {
|
|
122
|
+
name: "taskPlugin",
|
|
123
|
+
status: "active",
|
|
124
|
+
run: null,
|
|
125
|
+
setup: null,
|
|
126
|
+
exports: {},
|
|
127
|
+
error: null,
|
|
128
|
+
guardOptions: {},
|
|
129
|
+
commands: {
|
|
130
|
+
runFn: {
|
|
131
|
+
cmd: "task",
|
|
132
|
+
aliases: [],
|
|
133
|
+
handler: async (ctx, input) => {
|
|
134
|
+
runCalls.push(input);
|
|
135
|
+
const c = ctx;
|
|
136
|
+
await c.send.text("done");
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
listFn: {
|
|
140
|
+
cmd: "list",
|
|
141
|
+
aliases: [],
|
|
142
|
+
handler: async (_ctx, input) => {
|
|
143
|
+
listCalls.push(input);
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
crashFn: {
|
|
147
|
+
cmd: "task",
|
|
148
|
+
aliases: [],
|
|
149
|
+
handler: async () => {
|
|
150
|
+
throw new Error("plugin exploded");
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
pluginRegistry.set("taskPlugin", plugin);
|
|
156
|
+
});
|
|
157
|
+
afterEach(() => {
|
|
158
|
+
pluginRegistry.delete("taskPlugin");
|
|
159
|
+
__setRegistryForTests(null);
|
|
160
|
+
_resetDriverManagerForTests();
|
|
161
|
+
});
|
|
162
|
+
test("routes a matched subcommand to the subcommand handler, not the parent's, and replies", async () => {
|
|
163
|
+
const spec = emptySpec({ subcommands: [emptySub({ function: "listFn" })] });
|
|
164
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
165
|
+
const msg = makeBotMessage({ body: "!task list" });
|
|
166
|
+
await handleMessage(msg, contract, store);
|
|
167
|
+
assert.equal(listCalls.length, 1, "subcommand handler was invoked");
|
|
168
|
+
assert.equal(runCalls.length, 0, "parent handler was not invoked for the subcommand token");
|
|
169
|
+
});
|
|
170
|
+
test("matched parent command dispatches through runCommand and sends its reply", async () => {
|
|
171
|
+
__setRegistryForTests(buildRegistry([emptySpec({})]));
|
|
172
|
+
const msg = makeBotMessage({ body: "!task" });
|
|
173
|
+
await handleMessage(msg, contract, store);
|
|
174
|
+
assert.equal(runCalls.length, 1, "parent handler ran");
|
|
175
|
+
assert.equal(sentTexts.length, 1);
|
|
176
|
+
assert.equal(sentTexts[0].text, "done");
|
|
177
|
+
});
|
|
178
|
+
test("a crash inside the matched command's handler is swallowed at the messageHandler boundary", async () => {
|
|
179
|
+
const spec = emptySpec({ id: "task::crash", cmd: "crashcmd", function: "crashFn" });
|
|
180
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
181
|
+
const msg = makeBotMessage({ body: "!crashcmd" });
|
|
182
|
+
// The whole point of this session's fix: runCommand() now genuinely
|
|
183
|
+
// throws (rethrow: true) so Phase-8's fireAlert runs, but
|
|
184
|
+
// messageHandler.ts wraps that call in its own try/catch so the
|
|
185
|
+
// per-message plugin loop — and handleMessage() itself — must never
|
|
186
|
+
// propagate the error to the caller.
|
|
187
|
+
await assert.doesNotReject(() => handleMessage(msg, contract, store));
|
|
188
|
+
assert.equal(sentTexts.length, 0, "no reply is sent for a crashed handler");
|
|
189
|
+
});
|
|
190
|
+
test("the message loop keeps working for the next message after a handler crash", async () => {
|
|
191
|
+
const crashSpec = emptySpec({ id: "task::crash", cmd: "crashcmd", function: "crashFn" });
|
|
192
|
+
__setRegistryForTests(buildRegistry([crashSpec]));
|
|
193
|
+
await handleMessage(makeBotMessage({ body: "!crashcmd" }), contract, store);
|
|
194
|
+
// Swap in a healthy command and confirm a later message still dispatches
|
|
195
|
+
// normally — the crash must not have left the plugin loop, the driver
|
|
196
|
+
// manager, or the registry in a broken state.
|
|
197
|
+
__setRegistryForTests(buildRegistry([emptySpec({})]));
|
|
198
|
+
await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
|
|
199
|
+
assert.equal(runCalls.length, 1);
|
|
200
|
+
assert.equal(sentTexts.length, 1);
|
|
201
|
+
assert.equal(sentTexts[0].text, "done");
|
|
202
|
+
});
|
|
203
|
+
});
|