@manybot/manybot 5.6.1 → 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.
Files changed (71) hide show
  1. package/README.md +20 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +56 -5
  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/drivers/baileys/adapter.js +207 -8
  9. package/dist/drivers/baileys/api/index.js +300 -130
  10. package/dist/drivers/baileys/index.js +62 -30
  11. package/dist/drivers/baileys/loginPrompt.js +0 -2
  12. package/dist/drivers/baileys/messageHandler.js +158 -4
  13. package/dist/drivers/baileys/messageHandler.test.js +203 -0
  14. package/dist/drivers/baileysAdapter.test.js +281 -0
  15. package/dist/drivers/jid.test.js +40 -0
  16. package/dist/drivers/types.js +5 -5
  17. package/dist/i18n/index.js +15 -2
  18. package/dist/kernel/activeDriverSend.js +21 -0
  19. package/dist/kernel/activeDriverSend.test.js +89 -0
  20. package/dist/kernel/alerts.js +3 -9
  21. package/dist/kernel/chatSession.js +65 -0
  22. package/dist/kernel/chatSession.test.js +46 -0
  23. package/dist/kernel/commandAccess.js +66 -0
  24. package/dist/kernel/commandAccess.test.js +74 -0
  25. package/dist/kernel/commandDeprecation.js +168 -0
  26. package/dist/kernel/commandDeprecation.test.js +107 -0
  27. package/dist/kernel/commandMenu.js +268 -0
  28. package/dist/kernel/commandMenu.test.js +234 -0
  29. package/dist/kernel/commandPermissions.js +125 -0
  30. package/dist/kernel/commandPermissions.test.js +159 -0
  31. package/dist/kernel/commandRegistry.js +459 -0
  32. package/dist/kernel/commandRegistry.test.js +156 -0
  33. package/dist/kernel/commandsConfig.js +517 -0
  34. package/dist/kernel/commandsConfig.test.js +236 -0
  35. package/dist/kernel/contactAutoSave.test.js +87 -0
  36. package/dist/kernel/driverManager.js +10 -6
  37. package/dist/kernel/driverManager.test.js +90 -0
  38. package/dist/kernel/integrationMode.js +88 -0
  39. package/dist/kernel/integrationMode.test.js +95 -0
  40. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  41. package/dist/kernel/pluginApi.test.js +583 -0
  42. package/dist/kernel/pluginGuard.js +15 -12
  43. package/dist/kernel/pluginGuard.test.js +39 -0
  44. package/dist/kernel/pluginLoader.js +96 -1
  45. package/dist/kernel/pluginLoader.test.js +80 -0
  46. package/dist/kernel/runCommand.js +245 -0
  47. package/dist/kernel/runCommand.test.js +235 -0
  48. package/dist/kernel/sendFallbackGuard.js +19 -48
  49. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  50. package/dist/kernel/sendGuard.js +38 -42
  51. package/dist/kernel/sendGuard.test.js +102 -0
  52. package/dist/kernel/settingsDb.js +4 -3
  53. package/dist/kernel/statusServer.js +9 -2
  54. package/dist/kernel/statusServer.test.js +70 -0
  55. package/dist/kernel/testConfig.js +183 -0
  56. package/dist/kernel/testConfig.test.js +181 -0
  57. package/dist/kernel/updateCheck.js +33 -10
  58. package/dist/locales/en.json +64 -13
  59. package/dist/locales/es.json +64 -13
  60. package/dist/locales/pt.json +64 -13
  61. package/dist/logger/logger.js +23 -3
  62. package/dist/logger/logger.test.js +45 -0
  63. package/dist/main.js +5 -76
  64. package/dist/plugins/__manybot_integration__/index.js +167 -0
  65. package/dist/plugins/__manybot_integration__/index.test.js +184 -0
  66. package/package.json +75 -18
  67. package/dist/drivers/whatsmeow/client.js +0 -252
  68. package/dist/drivers/whatsmeow/index.js +0 -79
  69. package/dist/drivers/whatsmeow/installer.js +0 -86
  70. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  71. 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, sendFallbackGuard, messageHandler,
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
- * For the verification-required path (sendFallbackGuard), the contract
20
- * also exposes `getHistory?` which the adapter populates from the
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
- printBanner();
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
- getDriverManager().markDegraded("baileys", 300_000);
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
- getDriverManager().markDegraded("baileys", 600_000);
249
- sendAlert({
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: "manybot parou de tentar reconectar",
268
- message: `Desisti após ${reconnectAttempts} tentativas possível restrição de conta. Rode connect() manualmente pra tentar de novo.`,
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
- // ── verification primitive ─────────────────────────────────────────────
398
+ // ── history ──────────────────────────────────────────────────────────
394
399
  // Delegates to the adapter, which reads from the in-memory Baileys
395
- // store (store.messages). The adapter guarantees getHistory is defined
396
- // (the Baileys adapter guarantees getHistory is defined), so the defensive fallback is gone.
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
- const picked = await clack.select({
463
- message: t("getid.pickChat"),
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) || typeof picked !== "string") {
472
+ if (clack.isCancel(picked) || !Array.isArray(picked) || picked.length === 0) {
467
473
  teardownSock(sock);
468
474
  process.exit(0);
469
475
  }
470
- const resolved = await resolveLidForJid(sock, picked);
471
- const finalJid = resolved ?? picked;
472
- try {
473
- await copyToClipboard(finalJid);
474
- logger.success(`[getid] ${t("getid.copied", { id: finalJid })}`);
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
- catch {
477
- logger.info(`[getid] ${finalJid}`);
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
- await runPlugin(plugin, ctx);
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
+ });