@manybot/manybot 5.7.0 → 5.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -3
- package/dist/client/banner.js +10 -0
- package/dist/client/banner.test.js +31 -0
- package/dist/client/store.js +91 -6
- package/dist/client/store.test.js +170 -0
- package/dist/config.js +28 -44
- package/dist/config.test.js +26 -0
- package/dist/download/queue.js +13 -4
- package/dist/drivers/baileys/adapter.js +133 -15
- package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
- package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
- package/dist/drivers/baileys/api/index.js +384 -62
- package/dist/drivers/baileys/index.js +92 -36
- package/dist/drivers/baileys/loginPrompt.js +0 -2
- package/dist/drivers/baileys/messageHandler.js +344 -4
- package/dist/drivers/baileys/messageHandler.test.js +445 -0
- package/dist/drivers/baileysAdapter.test.js +378 -0
- package/dist/drivers/jid.js +26 -0
- package/dist/drivers/jid.test.js +74 -0
- package/dist/drivers/types.js +5 -5
- package/dist/i18n/index.js +20 -24
- package/dist/kernel/activeDriverSend.js +21 -0
- package/dist/kernel/activeDriverSend.test.js +89 -0
- package/dist/kernel/alerts.js +3 -9
- package/dist/kernel/chatOverrides.js +46 -0
- package/dist/kernel/chatOverrides.test.js +59 -0
- package/dist/kernel/chatSession.js +65 -0
- package/dist/kernel/chatSession.test.js +46 -0
- package/dist/kernel/commandAccess.js +66 -0
- package/dist/kernel/commandAccess.test.js +74 -0
- package/dist/kernel/commandDeprecation.js +170 -0
- package/dist/kernel/commandDeprecation.test.js +114 -0
- package/dist/kernel/commandMenu.js +357 -0
- package/dist/kernel/commandMenu.test.js +363 -0
- package/dist/kernel/commandPermissions.js +171 -0
- package/dist/kernel/commandPermissions.test.js +227 -0
- package/dist/kernel/commandRegistry.js +583 -0
- package/dist/kernel/commandRegistry.test.js +158 -0
- package/dist/kernel/commandsConfig.js +949 -0
- package/dist/kernel/commandsConfig.test.js +482 -0
- package/dist/kernel/contactAutoSave.js +6 -6
- package/dist/kernel/contactAutoSave.test.js +87 -0
- package/dist/kernel/coreCommands.js +62 -0
- package/dist/kernel/driverManager.js +10 -6
- package/dist/kernel/driverManager.test.js +90 -0
- package/dist/kernel/integrationMode.js +88 -0
- package/dist/kernel/integrationMode.test.js +95 -0
- package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
- package/dist/kernel/pluginApi.test.js +600 -0
- package/dist/kernel/pluginGuard.js +18 -13
- package/dist/kernel/pluginGuard.test.js +39 -0
- package/dist/kernel/pluginLoader.js +169 -11
- package/dist/kernel/pluginLoader.test.js +190 -0
- package/dist/kernel/runCommand.js +284 -0
- package/dist/kernel/runCommand.test.js +497 -0
- package/dist/kernel/sendFallbackGuard.js +19 -48
- package/dist/kernel/sendFallbackGuard.test.js +80 -0
- package/dist/kernel/sendGuard.js +38 -42
- package/dist/kernel/sendGuard.test.js +102 -0
- package/dist/kernel/settingsDb.js +19 -5
- package/dist/kernel/statusServer.js +9 -2
- package/dist/kernel/statusServer.test.js +70 -0
- package/dist/kernel/testConfig.js +192 -0
- package/dist/kernel/testConfig.test.js +181 -0
- package/dist/kernel/updateCheck.js +33 -10
- package/dist/locales/en.json +77 -13
- package/dist/locales/es.json +77 -13
- package/dist/locales/pt.json +77 -13
- package/dist/logger/logger.js +23 -3
- package/dist/logger/logger.test.js +45 -0
- package/dist/main.js +5 -76
- package/dist/plugins/__manybot_integration__/index.js +184 -0
- package/dist/plugins/__manybot_integration__/index.test.js +218 -0
- package/dist/utils/phoneNumber.js +83 -0
- package/dist/utils/phoneNumber.test.js +53 -0
- package/package.json +76 -18
- package/dist/drivers/whatsmeow/client.js +0 -252
- package/dist/drivers/whatsmeow/index.js +0 -79
- package/dist/drivers/whatsmeow/installer.js +0 -86
- package/dist/drivers/whatsmeow/supervisor.js +0 -328
- package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
|
@@ -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,18 +16,17 @@
|
|
|
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";
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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: "
|
|
268
|
-
message:
|
|
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
|
-
// ──
|
|
417
|
+
// ── history ──────────────────────────────────────────────────────────
|
|
394
418
|
// Delegates to the adapter, which reads from the in-memory Baileys
|
|
395
|
-
// store (store.messages). The adapter
|
|
396
|
-
//
|
|
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
|
-
|
|
463
|
-
|
|
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) ||
|
|
491
|
+
if (clack.isCancel(picked) || !Array.isArray(picked) || picked.length === 0) {
|
|
467
492
|
teardownSock(sock);
|
|
468
493
|
process.exit(0);
|
|
469
494
|
}
|
|
470
|
-
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
await
|
|
474
|
-
|
|
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
|
-
|
|
477
|
-
logger.
|
|
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:
|
|
584
|
-
fromPn:
|
|
585
|
-
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
|
-
|
|
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
|
}
|