@manybot/manybot 5.7.0 → 5.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +28 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +91 -6
  5. package/dist/client/store.test.js +170 -0
  6. package/dist/config.js +28 -44
  7. package/dist/config.test.js +26 -0
  8. package/dist/download/queue.js +13 -4
  9. package/dist/drivers/baileys/adapter.js +133 -15
  10. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  11. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  12. package/dist/drivers/baileys/api/index.js +384 -62
  13. package/dist/drivers/baileys/index.js +92 -36
  14. package/dist/drivers/baileys/loginPrompt.js +0 -2
  15. package/dist/drivers/baileys/messageHandler.js +344 -4
  16. package/dist/drivers/baileys/messageHandler.test.js +445 -0
  17. package/dist/drivers/baileysAdapter.test.js +378 -0
  18. package/dist/drivers/jid.js +26 -0
  19. package/dist/drivers/jid.test.js +74 -0
  20. package/dist/drivers/types.js +5 -5
  21. package/dist/i18n/index.js +20 -24
  22. package/dist/kernel/activeDriverSend.js +21 -0
  23. package/dist/kernel/activeDriverSend.test.js +89 -0
  24. package/dist/kernel/alerts.js +3 -9
  25. package/dist/kernel/chatOverrides.js +46 -0
  26. package/dist/kernel/chatOverrides.test.js +59 -0
  27. package/dist/kernel/chatSession.js +65 -0
  28. package/dist/kernel/chatSession.test.js +46 -0
  29. package/dist/kernel/commandAccess.js +66 -0
  30. package/dist/kernel/commandAccess.test.js +74 -0
  31. package/dist/kernel/commandDeprecation.js +170 -0
  32. package/dist/kernel/commandDeprecation.test.js +114 -0
  33. package/dist/kernel/commandMenu.js +357 -0
  34. package/dist/kernel/commandMenu.test.js +363 -0
  35. package/dist/kernel/commandPermissions.js +171 -0
  36. package/dist/kernel/commandPermissions.test.js +227 -0
  37. package/dist/kernel/commandRegistry.js +583 -0
  38. package/dist/kernel/commandRegistry.test.js +158 -0
  39. package/dist/kernel/commandsConfig.js +949 -0
  40. package/dist/kernel/commandsConfig.test.js +482 -0
  41. package/dist/kernel/contactAutoSave.js +6 -6
  42. package/dist/kernel/contactAutoSave.test.js +87 -0
  43. package/dist/kernel/coreCommands.js +62 -0
  44. package/dist/kernel/driverManager.js +10 -6
  45. package/dist/kernel/driverManager.test.js +90 -0
  46. package/dist/kernel/integrationMode.js +88 -0
  47. package/dist/kernel/integrationMode.test.js +95 -0
  48. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  49. package/dist/kernel/pluginApi.test.js +600 -0
  50. package/dist/kernel/pluginGuard.js +18 -13
  51. package/dist/kernel/pluginGuard.test.js +39 -0
  52. package/dist/kernel/pluginLoader.js +169 -11
  53. package/dist/kernel/pluginLoader.test.js +190 -0
  54. package/dist/kernel/runCommand.js +284 -0
  55. package/dist/kernel/runCommand.test.js +497 -0
  56. package/dist/kernel/sendFallbackGuard.js +19 -48
  57. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  58. package/dist/kernel/sendGuard.js +38 -42
  59. package/dist/kernel/sendGuard.test.js +102 -0
  60. package/dist/kernel/settingsDb.js +19 -5
  61. package/dist/kernel/statusServer.js +9 -2
  62. package/dist/kernel/statusServer.test.js +70 -0
  63. package/dist/kernel/testConfig.js +192 -0
  64. package/dist/kernel/testConfig.test.js +181 -0
  65. package/dist/kernel/updateCheck.js +33 -10
  66. package/dist/locales/en.json +77 -13
  67. package/dist/locales/es.json +77 -13
  68. package/dist/locales/pt.json +77 -13
  69. package/dist/logger/logger.js +23 -3
  70. package/dist/logger/logger.test.js +45 -0
  71. package/dist/main.js +5 -76
  72. package/dist/plugins/__manybot_integration__/index.js +184 -0
  73. package/dist/plugins/__manybot_integration__/index.test.js +218 -0
  74. package/dist/utils/phoneNumber.js +83 -0
  75. package/dist/utils/phoneNumber.test.js +53 -0
  76. package/package.json +76 -18
  77. package/dist/drivers/whatsmeow/client.js +0 -252
  78. package/dist/drivers/whatsmeow/index.js +0 -79
  79. package/dist/drivers/whatsmeow/installer.js +0 -86
  80. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  81. package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
@@ -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,18 +16,17 @@
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";
25
24
  import { handleMessage } from "./messageHandler.js";
26
- import { normalizeJid } from "#drivers/jid.js";
27
- import { loadPlugins, setupPlugins } from "#kernel/pluginLoader.js";
25
+ import { normalizeJid, splitLidPn } from "#drivers/jid.js";
26
+ import { loadPlugins, setupPlugins, loadIntegrationPlugin } from "#kernel/pluginLoader.js";
27
+ import { isIntegrationOptIn } from "#kernel/integrationMode.js";
28
28
  import { runContactRefreshSweep } from "#kernel/contactAutoSave.js";
29
29
  import { registerAlertSockProvider, sendAlert } from "#kernel/alerts.js";
30
- import { getDriverManager } from "#kernel/driverManager.js";
31
30
  import { startUpdateCheckSchedule, stopUpdateCheckSchedule } from "#kernel/updateCheck.js";
32
31
  import { setStatus } from "#kernel/statusServer.js";
33
32
  import { logger } from "#logger";
@@ -53,6 +52,7 @@ let halted = false;
53
52
  let cacheHydrated = false;
54
53
  let cacheSaveTimer = null;
55
54
  let contactRefreshTimer = null;
55
+ let bannerShown = false;
56
56
  registerAlertSockProvider(() => currentSock);
57
57
  // ── Per-chat message queue ──────────────────────────────────────────────────
58
58
  // Messages from the same chat are processed one at a time (in order), but
@@ -204,11 +204,32 @@ async function startBot() {
204
204
  setStatus(true);
205
205
  logger.success(t("system.connected"));
206
206
  logger.info(t("system.clientId", { id: CLIENT_ID }));
207
- printBanner();
207
+ if (!bannerShown) {
208
+ bannerShown = true;
209
+ printBanner();
210
+ }
208
211
  if (!pluginsReady) {
209
212
  pluginsReady = true;
210
213
  await loadPlugins(PLUGINS);
211
214
  await setupPlugins(contract, store);
215
+ // Opt-in: when the operator is running the integration test
216
+ // suite (`MANYBOT_RUN_WHATSAPP_TESTS=1`), also load the
217
+ // reserved integration plugin so its public API
218
+ // (`waitForMarker`, `testChat`, ...) is available for the
219
+ // test harness. Done after setupPlugins() so the integration
220
+ // plugin's own setup() can subscribe to events already wired
221
+ // up by the regular plugins. Production runs (no opt-in flag)
222
+ // are completely unaffected — `loadIntegrationPlugin()` itself
223
+ // throws on missing opt-in, so we gate it here too for an
224
+ // early, clean skip.
225
+ if (isIntegrationOptIn()) {
226
+ try {
227
+ await loadIntegrationPlugin();
228
+ }
229
+ catch (e) {
230
+ logger.warn(`[baileys] integration plugin failed to load: ${e.message}`);
231
+ }
232
+ }
212
233
  }
213
234
  startCacheAutosave(store);
214
235
  startContactRefreshSweep(contract);
@@ -227,15 +248,22 @@ async function startBot() {
227
248
  if (loggedOut || badSession) {
228
249
  if (badSession) {
229
250
  logger.warn("Session data corrupted (badSession=500). Clearing session dir.");
230
- getDriverManager().markDegraded("baileys", 300_000);
251
+ // No longer mark degraded - halt on failure
252
+ logger.error("Baileys driver failed due to bad session - bot will halt");
253
+ process.exit(1);
231
254
  }
232
255
  else {
233
256
  logger.warn(t("system.sessionExpired"));
257
+ // No longer mark degraded - halt on failure
258
+ logger.error("Baileys driver failed due to session expired - bot will halt");
259
+ process.exit(1);
234
260
  }
235
261
  try {
236
262
  await fs.rm(AUTH_DIR, { recursive: true, force: true });
237
263
  }
238
264
  catch (e) {
265
+ logger.error(`Baileys driver failed due to ${badSession ? "bad session" : "session expired"} - bot will halt`);
266
+ process.exit(1);
239
267
  logger.error(`[whatsapp] Failed to remove session dir: ${e.message}`);
240
268
  }
241
269
  scheduleReconnect(1000);
@@ -245,13 +273,8 @@ async function startBot() {
245
273
  if (restartRequiredCount >= MAX_RESTART_REQUIRED) {
246
274
  halted = true;
247
275
  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;
276
+ logger.error("Baileys driver failed due to repeated restartRequired - bot will halt");
277
+ process.exit(1);
255
278
  }
256
279
  const delay = Math.min(500, RECONNECT_BASE_MS);
257
280
  logger.info(t("system.reconnecting", { secs: Math.round(delay / 1000) }));
@@ -260,12 +283,13 @@ async function startBot() {
260
283
  else if (!shuttingDown) {
261
284
  if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
262
285
  halted = true;
263
- getDriverManager().markDegraded("baileys", 600_000);
264
286
  logger.error(t("system.reconnectHalted", { attempts: reconnectAttempts }));
287
+ logger.error("Baileys driver failed due to exhausted reconnect attempts - bot will halt");
288
+ process.exit(1);
265
289
  sendAlert({
266
290
  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.`,
291
+ title: t("alerts.reconnectHaltedTitle"),
292
+ message: t("alerts.reconnectHaltedMessage", { attempts: reconnectAttempts }),
269
293
  }).catch(() => { });
270
294
  return;
271
295
  }
@@ -390,10 +414,10 @@ export const baileysContract = {
390
414
  me: () => requireReady().me(),
391
415
  // ── media (download) ────────────────────────────────────────────────────
392
416
  downloadMedia: (...args) => requireReady().downloadMedia(...args),
393
- // ── verification primitive ─────────────────────────────────────────────
417
+ // ── history ──────────────────────────────────────────────────────────
394
418
  // 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.
419
+ // store (store.messages). The Baileys adapter always defines
420
+ // getHistory, so calling it unconditionally here is safe.
397
421
  getHistory: (jid, opts) => requireReady().getHistory(jid, opts),
398
422
  /**
399
423
  * Diagnostic mode: connects on its own session (separate from the
@@ -459,22 +483,30 @@ export const baileysContract = {
459
483
  const sorted = chats
460
484
  .map((c) => ({ id: c.id, name: c.name ?? "" }))
461
485
  .sort((a, b) => a.name.localeCompare(b.name));
462
- const picked = await clack.select({
463
- message: t("getid.pickChat"),
486
+ // Allow selecting multiple chats (Space to toggle, Enter to confirm).
487
+ const picked = await clack.multiselect({
488
+ message: t("getid.pickPrompt"),
464
489
  options: sorted.map((c) => ({ label: c.name || c.id, value: c.id })),
465
490
  });
466
- if (clack.isCancel(picked) || typeof picked !== "string") {
491
+ if (clack.isCancel(picked) || !Array.isArray(picked) || picked.length === 0) {
467
492
  teardownSock(sock);
468
493
  process.exit(0);
469
494
  }
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 })}`);
495
+ // Resolve any @lid values to their phone-based JIDs when possible.
496
+ const finalJids = [];
497
+ for (const p of picked) {
498
+ const resolved = await resolveLidForJid(sock, p);
499
+ finalJids.push(resolved ?? p);
500
+ }
501
+ const joined = finalJids.join("\n");
502
+ const copied = await copyToClipboard(joined);
503
+ if (copied) {
504
+ logger.success(`[getid] ${t("getid.copied", { count: finalJids.length, ids: joined })}`);
475
505
  }
476
- catch {
477
- logger.info(`[getid] ${finalJid}`);
506
+ else {
507
+ logger.warn(`[getid] ${t("getid.copyFailed", { count: finalJids.length, ids: joined })}`);
508
+ // Also print to stdout so the user can still see the IDs.
509
+ logger.info(`[getid] \n${joined}`);
478
510
  }
479
511
  teardownSock(sock);
480
512
  },
@@ -556,13 +588,37 @@ export function toBotMessage(msg) {
556
588
  text = "";
557
589
  mimetype = m.stickerMessage.mimetype ?? undefined;
558
590
  }
591
+ else if (m?.templateMessage) {
592
+ type = "text";
593
+ const tpl = m.templateMessage.hydratedTemplate ?? m.templateMessage.hydratedFourRowTemplate;
594
+ const buttonUrls = tpl?.hydratedButtons?.map((b) => b.urlButton?.url).filter(Boolean).join(" ") ?? "";
595
+ text = [tpl?.hydratedContentText ?? "", buttonUrls].filter(Boolean).join(" ");
596
+ }
597
+ else if (m?.interactiveMessage) {
598
+ type = "text";
599
+ const buttonParams = m.interactiveMessage.nativeFlowMessage?.buttons
600
+ ?.map((b) => b.buttonParamsJson).filter(Boolean).join(" ") ?? "";
601
+ text = [m.interactiveMessage.body?.text ?? "", buttonParams].filter(Boolean).join(" ");
602
+ }
603
+ else if (m?.buttonsMessage) {
604
+ type = "text";
605
+ text = [m.buttonsMessage.contentText ?? "", m.buttonsMessage.footerText ?? ""].filter(Boolean).join(" ");
606
+ }
559
607
  const key = msg.key;
560
608
  const contextInfo = m?.extendedTextMessage?.contextInfo ??
561
609
  m?.imageMessage?.contextInfo ??
562
610
  m?.videoMessage?.contextInfo ??
563
611
  m?.audioMessage?.contextInfo ??
564
612
  m?.documentMessage?.contextInfo ??
613
+ m?.templateMessage?.contextInfo ??
614
+ m?.interactiveMessage?.contextInfo ??
615
+ m?.buttonsMessage?.contextInfo ??
565
616
  undefined;
617
+ // See splitLidPn() in #drivers/jid.js — `key.participant` is only the PN
618
+ // form under legacy `addressingMode: "pn"`; under the modern default
619
+ // "lid" mode it's already the LID and `key.participantAlt` carries the
620
+ // PN instead. Resolve by JID suffix, not by field position.
621
+ const participantIds = splitLidPn(key.participant, key.participantAlt);
566
622
  return {
567
623
  id: msg.key?.id ?? "",
568
624
  chatId: normalizeJid(msg.key?.remoteJid ?? ""),
@@ -580,10 +636,10 @@ export function toBotMessage(msg) {
580
636
  fromMe: false,
581
637
  participant: contextInfo.participant ?? undefined,
582
638
  } : undefined,
583
- fromLid: key.participantAlt,
584
- fromPn: key.participant,
585
- participantAlt: key.participantAlt,
586
- remoteJidAlt: key.remoteJidAlt,
639
+ fromLid: participantIds.lid,
640
+ fromPn: participantIds.pn,
641
+ participantAlt: participantIds.lid,
642
+ remoteJidAlt: splitLidPn(key.remoteJid, key.remoteJidAlt).lid,
587
643
  _raw: {
588
644
  pollEncKeyRaw: m?.messageContextInfo?.messageSecret ?? undefined,
589
645
  },
@@ -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();
@@ -10,16 +10,156 @@
10
10
  * 3. Pass context to all active plugins
11
11
  *
12
12
  * Each plugin decides whether to act or ignore.
13
+ *
14
+ * v6 loading indicators: a matched command may declare a `loading:` spec
15
+ * (or reference a top-level `loading_presets:` preset) which controls
16
+ * the user-visible "processando..." signal — `reaction`, `typing`,
17
+ * `recording_audio`, `spinner`, or `none`. The spec is resolved at
18
+ * registry build time (defaults → category → command → sub), so by the
19
+ * time the dispatch path reads it we only have to apply it.
13
20
  */
14
21
  import { CHATS, EXCLUDE_CHATS } from "#config";
22
+ import { getChatPrefix, getChatLocale } from "#kernel/chatOverrides.js";
15
23
  import { buildApi, buildChatFromMsg, buildMessageContext } from "./api/index.js";
16
24
  import { pluginRegistry } from "#kernel/pluginLoader.js";
25
+ import { getCommandByInvocation, getCommandRegistry } from "#kernel/commandRegistry.js";
26
+ import { resolveDispatch, runCommand } from "#kernel/runCommand.js";
27
+ import { getActiveDeprecation, formatDeprecationMessage } from "#kernel/commandDeprecation.js";
28
+ import { checkPermission } from "#kernel/commandPermissions.js";
29
+ import { handleMenuCommand, renderNotFound, resolveLocalizedString, checkAndTriggerWelcomeMessage } from "#kernel/commandMenu.js";
17
30
  import { runPlugin } from "#kernel/pluginGuard.js";
18
31
  import { acquireChatSlot } from "#sendguard";
19
32
  import { trackIncomingForContactSave } from "#kernel/contactAutoSave.js";
20
33
  import { normalizeJid } from "#drivers/jid.js";
34
+ import { logger } from "#logger";
21
35
  const INCOMING_DEBOUNCE_MS = 0;
22
36
  const lastProcessedAt = new Map();
37
+ /**
38
+ * Extract the bare command token from a raw body string.
39
+ * Mirrors the prefix/parsing logic in `buildMessageContext` and `buildApi`
40
+ * (kept local to avoid coupling to internal helpers of api/index.ts).
41
+ * Returns "" when the body does not start with the configured prefix.
42
+ */
43
+ function extractCommand(body, prefix) {
44
+ const first = body.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
45
+ return first.startsWith(prefix) ? first.slice(prefix.length) : "";
46
+ }
47
+ /**
48
+ * Drive the per-command loading indicator described by the registry.
49
+ *
50
+ * - `reaction` : drop an emoji reaction on the source message,
51
+ * update with onSuccess/onError on completion.
52
+ * - `typing` : native WhatsApp "typing..." presence, refreshed
53
+ * on a 4s interval; cleared on completion.
54
+ * - `recording_audio` : native WhatsApp "recording audio..." presence,
55
+ * same interval/clear semantics as typing.
56
+ * - `spinner` : self-sent message showing a frame sequence,
57
+ * edited every `intervalMs` (default 1500); last
58
+ * frame stays on completion.
59
+ * - `none` : no-op — caller doesn't see a difference.
60
+ *
61
+ * All best-effort: any driver-level failure is logged at `warn` level
62
+ * and swallowed so a broken indicator never breaks a command.
63
+ */
64
+ function startLoadingIndicator(spec, contract, rawJid, msgKey) {
65
+ const noop = async () => { };
66
+ if (!spec)
67
+ return { stop: noop };
68
+ if (spec.type === "reaction") {
69
+ const icon = spec.icon ?? "⏳";
70
+ if (msgKey) {
71
+ contract.react(rawJid, msgKey, icon).catch((e) => {
72
+ const err = e instanceof Error ? e : new Error(String(e));
73
+ logger.warn(`[messageHandler] loading.reaction send failed: ${err.message}`);
74
+ });
75
+ }
76
+ return {
77
+ stop: async (outcome) => {
78
+ if (!msgKey)
79
+ return;
80
+ const next = outcome === "success" ? spec.onSuccess : spec.onError;
81
+ try {
82
+ await contract.react(rawJid, msgKey, next ?? "");
83
+ }
84
+ catch (e) {
85
+ const err = e instanceof Error ? e : new Error(String(e));
86
+ logger.warn(`[messageHandler] loading.reaction clear failed: ${err.message}`);
87
+ }
88
+ },
89
+ };
90
+ }
91
+ if (spec.type === "typing" || spec.type === "recording_audio") {
92
+ const presence = spec.type === "typing" ? "composing" : "recording";
93
+ const interval = setInterval(() => {
94
+ contract.sendPresenceUpdate(presence, rawJid).catch(() => { });
95
+ }, 4000);
96
+ contract.sendPresenceUpdate(presence, rawJid).catch(() => { });
97
+ return {
98
+ stop: async () => {
99
+ clearInterval(interval);
100
+ contract.sendPresenceUpdate("paused", rawJid).catch(() => { });
101
+ },
102
+ };
103
+ }
104
+ if (spec.type === "spinner") {
105
+ const frames = spec.frames && spec.frames.length > 0 ? spec.frames : ["⏳"];
106
+ const intervalMs = Math.max(1000, spec.intervalMs ?? 1500);
107
+ let frameIdx = 0;
108
+ let sentMsgId = null;
109
+ const cycle = async () => {
110
+ try {
111
+ if (sentMsgId === null) {
112
+ const sent = await contract.sendText(rawJid, frames[0]);
113
+ sentMsgId = sent.id;
114
+ }
115
+ else {
116
+ frameIdx = (frameIdx + 1) % frames.length;
117
+ await contract.editMessage(rawJid, { id: sentMsgId, remoteJid: rawJid, fromMe: true }, frames[frameIdx]);
118
+ }
119
+ }
120
+ catch {
121
+ // Spinner is best-effort — leave the previous frame in place
122
+ // when an edit fails rather than aborting the whole indicator.
123
+ }
124
+ };
125
+ cycle().catch(() => { });
126
+ const interval = setInterval(() => { cycle().catch(() => { }); }, intervalMs);
127
+ return {
128
+ stop: async (outcome) => {
129
+ clearInterval(interval);
130
+ if (sentMsgId !== null) {
131
+ const finalText = outcome === "success" ? spec.onSuccess : spec.onError;
132
+ try {
133
+ if (finalText !== undefined) {
134
+ await contract.editMessage(rawJid, { id: sentMsgId, remoteJid: rawJid, fromMe: true }, finalText);
135
+ }
136
+ else {
137
+ await contract.deleteMessage(rawJid, { id: sentMsgId, remoteJid: rawJid, fromMe: true }, true);
138
+ }
139
+ }
140
+ catch (e) {
141
+ const err = e instanceof Error ? e : new Error(String(e));
142
+ logger.warn(`[messageHandler] loading.spinner finalize failed: ${err.message}`);
143
+ }
144
+ }
145
+ },
146
+ };
147
+ }
148
+ // `none` or any future variant — nothing to do.
149
+ return { stop: noop };
150
+ }
151
+ /**
152
+ * Resolve the loading spec for the matched command/sub. Subcommands win
153
+ * (their own chain inheritance ran at registry build time); falls back
154
+ * to the parent's resolved spec.
155
+ */
156
+ function resolveLoadingForDispatch(entry, resolution) {
157
+ if (resolution.target.kind === "sub")
158
+ return resolution.target.sub.loading ?? entry.loading;
159
+ if (resolution.target.kind === "parent")
160
+ return entry.loading;
161
+ return null;
162
+ }
23
163
  // ── Dedup of already-processed messages ────────────────────────────────────
24
164
  // WhatsApp resends messages without a delivery/read confirmation (the
25
165
  // protocol's own retry, usually up to 3 times) when the socket reconnects.
@@ -91,13 +231,167 @@ export async function handleMessage(msg, contract, store) {
91
231
  // Caps how many chats get answered at the same time — see SECURITY_LEVEL.
92
232
  const releaseChatSlot = await acquireChatSlot(jid);
93
233
  try {
94
- await runPluginsForMessage(msg, chat, contract, store, rawJid);
234
+ await runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid, rawKey);
95
235
  }
96
236
  finally {
97
237
  releaseChatSlot();
98
238
  }
99
239
  }
100
- async function runPluginsForMessage(msg, chat, contract, store, rawJid) {
240
+ async function runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid, rawKey) {
241
+ const chatPrefix = getChatPrefix(msg.chatId);
242
+ const chatLocale = getChatLocale(msg.chatId);
243
+ const command = extractCommand(msgCtx.body, chatPrefix);
244
+ const registry = getCommandRegistry();
245
+ // 0. Welcome message (first message within the configured window)
246
+ //
247
+ // Two gates before we even consider firing the welcome:
248
+ //
249
+ // - `!msg.fromMe`: skip when the bot itself "sent" the message
250
+ // that triggered this dispatch. Baileys' history-sync replays
251
+ // the bot's own outgoing messages as `messages.upsert` with
252
+ // `fromMe=true` on every reconnect — without this gate, the
253
+ // bot would greet itself in its own DM/group on every restart
254
+ // the moment history-sync completes. The welcome is for
255
+ // *incoming* messages only.
256
+ //
257
+ // - `!chat.isGroup`: skip when the message arrived in a group.
258
+ // A new member joining a group shouldn't get a per-member
259
+ // welcome reply inside the group's conversation — it's noise
260
+ // and reads weird in front of everyone else. The welcome is
261
+ // only meaningful in a 1:1 (DM) chat where the message
262
+ // originates from the user being greeted, and where the reply
263
+ // goes back to the same chat (`msgCtx.reply` already targets
264
+ // the source chat).
265
+ if (registry && !msg.fromMe && !chat.isGroup) {
266
+ const welcomeMsg = checkAndTriggerWelcomeMessage(msgCtx.sender ?? msg.chatId, registry, { body: msgCtx.body, timestamp: msg.timestamp }, chatLocale, chatPrefix);
267
+ if (welcomeMsg) {
268
+ try {
269
+ await msgCtx.reply.text(welcomeMsg);
270
+ }
271
+ catch (e) {
272
+ const err = e instanceof Error ? e : new Error(String(e));
273
+ logger.warn(`[messageHandler] welcome reply failed: ${err.message}`);
274
+ }
275
+ }
276
+ }
277
+ // 1. Menu aliases match (overview / category / manual / notFound)
278
+ if (command && registry && registry.menuAliases.has(command)) {
279
+ const rawArgs = msgCtx.body.trim().slice(chatPrefix.length + command.length).trim();
280
+ const scope = chat.isGroup ? "group" : "dm";
281
+ const menuResponse = handleMenuCommand(command, rawArgs, registry, chatLocale, scope);
282
+ try {
283
+ await msgCtx.reply.text(menuResponse);
284
+ }
285
+ catch (e) {
286
+ const err = e instanceof Error ? e : new Error(String(e));
287
+ logger.warn(`[messageHandler] menu reply failed: ${err.message}`);
288
+ }
289
+ return;
290
+ }
291
+ const matched = command ? getCommandByInvocation(command) : null;
292
+ // Full v6 dispatch resolution (parent vs. subcommand) for the matched
293
+ // entry, if any — feeds `runCommand()` below so subcommand routing,
294
+ // required-argument validation, and the Phase-8 crash-alert hook are
295
+ // active in production. Kept separate from the `matched` top-level
296
+ // permission pre-check further down (unchanged, still gates the whole
297
+ // per-message plugin loop exactly as before `runCommand` existed).
298
+ const rawArgsForDispatch = command ? msgCtx.body.trim().slice(chatPrefix.length + command.length).trim() : "";
299
+ const resolution = command ? resolveDispatch(command, rawArgsForDispatch) : { target: { kind: "none" } };
300
+ if (matched) {
301
+ const permApi = buildApi({
302
+ msg,
303
+ chat,
304
+ contract,
305
+ store,
306
+ pluginRegistry,
307
+ pluginName: matched.pluginName ?? "system",
308
+ guardOptions: {},
309
+ });
310
+ const permResult = await checkPermission(matched, {
311
+ isGroup: permApi.chat.isGroup,
312
+ chatId: permApi.chat.id,
313
+ sender: { lid: msgCtx.sender, pn: msgCtx.senderPn },
314
+ isSenderAdmin: () => permApi.chat.isSenderAdmin(),
315
+ isBotAdmin: () => permApi.chat.isBotAdmin(),
316
+ });
317
+ if (!permResult.allowed) {
318
+ if (permResult.message) {
319
+ try {
320
+ await msgCtx.reply.text(permResult.message);
321
+ }
322
+ catch (e) {
323
+ const err = e instanceof Error ? e : new Error(String(e));
324
+ logger.warn(`[messageHandler] permission reply failed: ${err.message}`);
325
+ }
326
+ }
327
+ return;
328
+ }
329
+ }
330
+ // Fixed-text command: reply with the literal text and stop — do not
331
+ // invoke any plugin (legacy or migrated) for this message.
332
+ if (matched && matched.source === "text" && matched.text !== null) {
333
+ try {
334
+ const textContent = resolveLocalizedString(matched.text, chatLocale) ?? "";
335
+ await msgCtx.reply.text(textContent);
336
+ }
337
+ catch (e) {
338
+ const err = e instanceof Error ? e : new Error(String(e));
339
+ logger.warn(`[messageHandler] fixed-text reply failed: ${err.message}`);
340
+ }
341
+ return;
342
+ }
343
+ // Deprecated old name: notify the user and stop. We do NOT redirect
344
+ // to the new command and do NOT fall through to the legacy run(ctx).
345
+ if (!matched && command) {
346
+ const defaults = registry?.defaults ?? {
347
+ notifyChanges: true,
348
+ notifyPeriodDays: 7,
349
+ notifyMessage: null,
350
+ };
351
+ const dep = defaults.notifyChanges ? getActiveDeprecation(command) : null;
352
+ if (dep) {
353
+ try {
354
+ await msgCtx.reply.text(formatDeprecationMessage(dep, defaults));
355
+ }
356
+ catch (e) {
357
+ const err = e instanceof Error ? e : new Error(String(e));
358
+ logger.warn(`[messageHandler] deprecation reply failed: ${err.message}`);
359
+ }
360
+ return;
361
+ }
362
+ }
363
+ const matchedPlugin = matched && matched.source === "plugin" ? matched : null;
364
+ // "core" (ping/status/config/...) is a synthetic pseudo-plugin: it only
365
+ // exists in a *local* `allPlugins` map built inside commandRegistry.ts
366
+ // for registry-building purposes — it is never inserted into the real
367
+ // `pluginRegistry` singleton that the loop below iterates. Left as-is,
368
+ // no "core"-sourced command could ever be dispatched (runCommand() would
369
+ // never be called for it, plugin count or not), so it's handled here
370
+ // explicitly, once, before the real-plugin loop.
371
+ if (matchedPlugin && matchedPlugin.pluginName === "core") {
372
+ const coreCtx = buildApi({
373
+ msg,
374
+ chat,
375
+ contract,
376
+ store,
377
+ pluginRegistry,
378
+ pluginName: "core",
379
+ guardOptions: {},
380
+ });
381
+ const coreLoading = startLoadingIndicator(resolveLoadingForDispatch(matchedPlugin, resolution), contract, rawJid, rawKey);
382
+ let coreOutcome = "success";
383
+ try {
384
+ await runCommand({ pluginName: "core", ctx: coreCtx, resolution, reply: msgCtx.reply, chatId: msg.chatId });
385
+ }
386
+ catch (e) {
387
+ coreOutcome = "error";
388
+ const err = e instanceof Error ? e : new Error(String(e));
389
+ logger.warn(`[messageHandler] runCommand crashed for core: ${err.message}`);
390
+ }
391
+ finally {
392
+ await coreLoading.stop(coreOutcome);
393
+ }
394
+ }
101
395
  for (const plugin of pluginRegistry.values()) {
102
396
  const ctx = buildApi({
103
397
  msg,
@@ -108,7 +402,7 @@ async function runPluginsForMessage(msg, chat, contract, store, rawJid) {
108
402
  pluginName: plugin.name,
109
403
  guardOptions: plugin.guardOptions,
110
404
  });
111
- const useTyping = plugin.guardOptions?.typing !== false;
405
+ const useTyping = !matchedPlugin && plugin.guardOptions?.typing !== false;
112
406
  let typingInterval;
113
407
  if (useTyping) {
114
408
  // Refresh presence every 4s so WhatsApp doesn't auto-clear it
@@ -117,7 +411,40 @@ async function runPluginsForMessage(msg, chat, contract, store, rawJid) {
117
411
  }, 4000);
118
412
  }
119
413
  try {
120
- await runPlugin(plugin, ctx);
414
+ // If this plugin owns the matched registry entry, skip the legacy
415
+ // run(ctx) and go through the unified v6 dispatcher instead — avoids
416
+ // double-firing for a migrated command and activates subcommand
417
+ // routing, required-argument validation, and the Phase-8 crash-alert
418
+ // hook (`runCommand.ts`), none of which the direct `runPlugin` call
419
+ // below it used to provide. Other plugins (and other invocations of
420
+ // this same plugin that did NOT match the registry) keep their legacy
421
+ // run(ctx).
422
+ if (matchedPlugin && matchedPlugin.pluginName === plugin.name && matchedPlugin.handler) {
423
+ // Loading indicator: the resolved spec is read once per dispatch
424
+ // and drives start/stop around the runCommand call.
425
+ const loading = startLoadingIndicator(resolveLoadingForDispatch(matchedPlugin, resolution), contract, rawJid, rawKey);
426
+ let outcome = "success";
427
+ try {
428
+ // runCommand() opts into rethrow so its Phase-8 fireAlert("plugin_crash")
429
+ // catch actually runs (runPlugin() swallows by default — see pluginGuard.ts).
430
+ // Swallow here too, at the boundary: this loop must never crash the bot,
431
+ // same guarantee the legacy runPlugin(plugin, ctx) branch below already has.
432
+ try {
433
+ await runCommand({ pluginName: plugin.name, ctx, resolution, reply: msgCtx.reply, chatId: msg.chatId });
434
+ }
435
+ catch (e) {
436
+ outcome = "error";
437
+ const err = e instanceof Error ? e : new Error(String(e));
438
+ logger.warn(`[messageHandler] runCommand crashed for plugin "${plugin.name}": ${err.message}`);
439
+ }
440
+ }
441
+ finally {
442
+ await loading.stop(outcome);
443
+ }
444
+ }
445
+ else {
446
+ await runPlugin(plugin, ctx);
447
+ }
121
448
  }
122
449
  finally {
123
450
  if (useTyping) {
@@ -126,4 +453,17 @@ async function runPluginsForMessage(msg, chat, contract, store, rawJid) {
126
453
  }
127
454
  }
128
455
  }
456
+ // Legacy plugins do not report whether they handled a message. Keep the
457
+ // generic fallback opt-in and send it only after they have all had a
458
+ // chance to respond; a legacy plugin may therefore still produce a reply
459
+ // alongside this fallback.
460
+ if (!matched && command && registry?.menu.notFoundFallback) {
461
+ try {
462
+ await msgCtx.reply.text(renderNotFound(command, registry, chatLocale));
463
+ }
464
+ catch (e) {
465
+ const err = e instanceof Error ? e : new Error(String(e));
466
+ logger.warn(`[messageHandler] notFoundFallback reply failed: ${err.message}`);
467
+ }
468
+ }
129
469
  }