@manybot/manybot 5.5.4 → 5.6.1

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 (40) hide show
  1. package/README.md +15 -1
  2. package/dist/client/cache.js +1 -1
  3. package/dist/client/store.js +9 -0
  4. package/dist/config.js +230 -12
  5. package/dist/drivers/baileys/adapter.js +629 -0
  6. package/dist/drivers/{whatsapp → baileys}/api/index.js +553 -393
  7. package/dist/drivers/baileys/index.js +594 -0
  8. package/dist/drivers/{whatsapp → baileys}/loginPrompt.js +2 -0
  9. package/dist/drivers/{whatsapp → baileys}/messageHandler.js +46 -16
  10. package/dist/drivers/{whatsapp → baileys}/sdk/baileysSock.js +1 -30
  11. package/dist/drivers/jid.js +31 -0
  12. package/dist/drivers/types.js +14 -0
  13. package/dist/drivers/whatsmeow/client.js +252 -0
  14. package/dist/drivers/whatsmeow/index.js +79 -0
  15. package/dist/drivers/whatsmeow/installer.js +86 -0
  16. package/dist/drivers/whatsmeow/supervisor.js +328 -0
  17. package/dist/drivers/whatsmeow/whatsmeow.proto +64 -0
  18. package/dist/i18n/index.js +8 -12
  19. package/dist/kernel/alerts.js +190 -0
  20. package/dist/kernel/contactAutoSave.js +200 -0
  21. package/dist/kernel/driverManager.js +117 -0
  22. package/dist/kernel/pluginApi.js +25 -7
  23. package/dist/kernel/pluginLoader.js +12 -12
  24. package/dist/kernel/sendFallbackGuard.js +183 -0
  25. package/dist/kernel/sendGuard.js +143 -33
  26. package/dist/kernel/statusServer.js +39 -0
  27. package/dist/kernel/updateCheck.js +88 -0
  28. package/dist/kernel/waContract.js +16 -0
  29. package/dist/locales/en.json +16 -1
  30. package/dist/locales/es.json +16 -1
  31. package/dist/locales/pt.json +16 -1
  32. package/dist/main.js +100 -5
  33. package/dist/types.js +18 -11
  34. package/package.json +6 -8
  35. package/dist/core/adapter.js +0 -12
  36. package/dist/core/capabilities.js +0 -16
  37. package/dist/core/types.js +0 -6
  38. package/dist/drivers/index.js +0 -14
  39. package/dist/drivers/whatsapp/adapter.js +0 -7
  40. package/dist/drivers/whatsapp/index.js +0 -382
@@ -3,41 +3,82 @@
3
3
  *
4
4
  * Anti-detection throttle layer for all outbound sends.
5
5
  *
6
- * Three protections applied before every message:
7
- * 1. Global token bucket — hard cap on messages/second across all chats
8
- * 2. Per-chat cooldown — minimum gap between sends to the same chat
9
- * 3. Human jitter — random delay to break robotic timing patterns
6
+ * Protections applied before every message:
7
+ * 1. Global token bucket — hard cap on messages/second across all chats
8
+ * 2. Per-chat cooldown — minimum gap between sends to the same chat
9
+ * 3. Human jitter — random delay to break robotic timing patterns
10
+ * 4. Chat-concurrency gate — caps how many different chats the bot can be
11
+ * actively answering at the same time
12
+ * 5. Edit throttle — jittered minimum gap + cap on edits per
13
+ * message, so things like loading animations
14
+ * don't edit on a fixed, bot-like cadence
15
+ *
16
+ * All of the above scale with SECURITY_LEVEL ("low" | "medium" | "high").
17
+ * Higher levels are slower and more conservative — lower risk of WhatsApp's
18
+ * automation detection, at the cost of response speed.
10
19
  *
11
20
  * Text sends simulate the typing/recording presence indicator before
12
21
  * the message arrives, so the chat shows "typing..." realistically.
13
22
  */
23
+ import { CONFIG } from "#config";
14
24
  import { logger } from "#logger";
15
- // ── Tunables ──────────────────────────────────────────────────────────────────
16
- const GLOBAL_MSG_PER_SEC = 5;
17
- const CHAT_COOLDOWN_MS = 150;
18
- const JITTER_MS = { min: 50, max: 200 };
25
+ const PROFILES = {
26
+ low: {
27
+ globalMsgPerSec: 8,
28
+ chatCooldownMs: 100,
29
+ jitterMs: { min: 30, max: 120 },
30
+ concurrency: Infinity,
31
+ editIntervalMs: { min: 800, max: 2000 },
32
+ maxEditsPerMessage: 20,
33
+ typingMaxMs: 2000,
34
+ },
35
+ medium: {
36
+ globalMsgPerSec: 5,
37
+ chatCooldownMs: 150,
38
+ jitterMs: { min: 50, max: 200 },
39
+ concurrency: 2,
40
+ editIntervalMs: { min: 1200, max: 3000 },
41
+ maxEditsPerMessage: 12,
42
+ typingMaxMs: 4000,
43
+ },
44
+ high: {
45
+ globalMsgPerSec: 2,
46
+ chatCooldownMs: 400,
47
+ jitterMs: { min: 150, max: 500 },
48
+ concurrency: 1,
49
+ editIntervalMs: { min: 2000, max: 5000 },
50
+ maxEditsPerMessage: 6,
51
+ typingMaxMs: 8000,
52
+ },
53
+ };
54
+ function currentProfile() {
55
+ const level = CONFIG.SECURITY_LEVEL;
56
+ return PROFILES[level] ?? PROFILES.medium;
57
+ }
19
58
  const TYPING_CPS = 90;
20
- const TYPING_MAX_MS = 2000;
21
59
  const MEDIA_INDICATOR_MS = { min: 400, max: 1000 };
22
60
  // ── Global token bucket ───────────────────────────────────────────────────────
23
- const MS_PER_TOKEN = 1000 / GLOBAL_MSG_PER_SEC;
24
- let tokens = GLOBAL_MSG_PER_SEC;
61
+ // Refill rate follows the active profile, re-read on every call so a
62
+ // SECURITY_LEVEL change via reloadConfig() takes effect immediately.
63
+ let tokens = PROFILES.medium.globalMsgPerSec;
25
64
  let lastRefill = Date.now();
26
65
  function consumeGlobalToken() {
66
+ const rate = currentProfile().globalMsgPerSec;
67
+ const msPerToken = 1000 / rate;
27
68
  const now = Date.now();
28
69
  const elapsed = now - lastRefill;
29
- tokens = Math.min(GLOBAL_MSG_PER_SEC, tokens + elapsed / MS_PER_TOKEN);
70
+ tokens = Math.min(rate, tokens + elapsed / msPerToken);
30
71
  lastRefill = now;
31
72
  if (tokens >= 1) {
32
73
  tokens -= 1;
33
74
  return 0;
34
75
  }
35
- return Math.ceil((1 - tokens) * MS_PER_TOKEN);
76
+ return Math.ceil((1 - tokens) * msPerToken);
36
77
  }
37
78
  // ── Per-chat cooldown ─────────────────────────────────────────────────────────
38
79
  const lastSentAt = new Map();
39
80
  function chatCooldownMs(jid) {
40
- const wait = (lastSentAt.get(jid) ?? 0) + CHAT_COOLDOWN_MS - Date.now();
81
+ const wait = (lastSentAt.get(jid) ?? 0) + currentProfile().chatCooldownMs - Date.now();
41
82
  return wait > 0 ? wait : 0;
42
83
  }
43
84
  function recordSend(jid) {
@@ -45,26 +86,100 @@ function recordSend(jid) {
45
86
  }
46
87
  // ── Helpers ───────────────────────────────────────────────────────────────────
47
88
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
48
- function randomJitter() {
49
- return JITTER_MS.min + Math.random() * (JITTER_MS.max - JITTER_MS.min);
89
+ function randomBetween(range) {
90
+ return range.min + Math.random() * (range.max - range.min);
50
91
  }
51
92
  /**
52
93
  * How long the typing indicator should appear before sending text.
94
+ * Capped by the active profile's `typingMaxMs` — higher SECURITY_LEVELs
95
+ * tolerate a longer "typing..." for long messages instead of flatlining.
53
96
  * @param {string} text
54
97
  * @returns {number} ms
55
98
  */
56
99
  export function typingDuration(text) {
57
100
  if (typeof text !== "string" || text.length === 0)
58
101
  return 0;
59
- return Math.min((text.length / TYPING_CPS) * 1000, TYPING_MAX_MS);
102
+ return Math.min((text.length / TYPING_CPS) * 1000, currentProfile().typingMaxMs);
60
103
  }
61
104
  /**
62
- * A human-feeling duration for media "processing" indicator.
105
+ * A human-feeling duration for media "processing" indicator. If a caption
106
+ * is given, adds its own typing time on top (same per-profile cap as
107
+ * typingDuration) so a media message with a long caption doesn't look
108
+ * instant.
109
+ * @param {string} [caption]
63
110
  * @returns {number} ms
64
111
  */
65
- export function mediaDuration() {
66
- return MEDIA_INDICATOR_MS.min
67
- + Math.random() * (MEDIA_INDICATOR_MS.max - MEDIA_INDICATOR_MS.min);
112
+ export function mediaDuration(caption) {
113
+ const base = randomBetween(MEDIA_INDICATOR_MS);
114
+ return caption ? base + typingDuration(caption) : base;
115
+ }
116
+ // ── Chat-concurrency gate ─────────────────────────────────────────────────────
117
+ // Caps how many DIFFERENT chats can be actively answered at once, globally
118
+ // (not per-chat — messageHandler.ts already serializes a single chat's own
119
+ // messages). high=1 effectively locks the bot to one chat at a time,
120
+ // medium=2, low=unlimited. FIFO queue for anything past the cap.
121
+ let activeSlots = 0;
122
+ const slotWaiters = [];
123
+ function releaseChatSlot() {
124
+ activeSlots--;
125
+ const next = slotWaiters.shift();
126
+ if (next)
127
+ next();
128
+ }
129
+ /**
130
+ * Acquire a global chat-concurrency slot before processing a message for
131
+ * `jid`. Resolves once a slot is free. Always call the returned release
132
+ * function (e.g. in a `finally`) or the pool leaks.
133
+ * @param {string} jid — kept for logging/debugging, not used for scoping
134
+ * @returns {Promise<() => void>} release function
135
+ */
136
+ export async function acquireChatSlot(jid) {
137
+ const max = currentProfile().concurrency;
138
+ if (activeSlots < max) {
139
+ activeSlots++;
140
+ return releaseChatSlot;
141
+ }
142
+ logger.debug(`[sendGuard] chat-concurrency gate full — queuing ${jid}`);
143
+ return new Promise(resolve => {
144
+ slotWaiters.push(() => {
145
+ activeSlots++;
146
+ resolve(releaseChatSlot);
147
+ });
148
+ });
149
+ }
150
+ const editState = new Map();
151
+ const EDIT_STATE_STALE_MS = 10 * 60 * 1000;
152
+ function cleanupEditState(now) {
153
+ for (const [id, s] of editState) {
154
+ if (now - s.lastEditAt > EDIT_STATE_STALE_MS)
155
+ editState.delete(id);
156
+ }
157
+ }
158
+ /**
159
+ * Waits for a safe edit slot for `messageId`, applying a jittered minimum
160
+ * gap since its last edit. Returns false once the message has hit its
161
+ * per-level edit cap — callers should skip the edit silently in that case.
162
+ * @param {string} messageId
163
+ * @returns {Promise<boolean>} true if the edit may proceed
164
+ */
165
+ export async function waitForEditSlot(messageId) {
166
+ const now = Date.now();
167
+ cleanupEditState(now);
168
+ const profile = currentProfile();
169
+ const s = editState.get(messageId) ?? { lastEditAt: 0, count: 0 };
170
+ if (s.count >= profile.maxEditsPerMessage) {
171
+ editState.set(messageId, s);
172
+ logger.debug(`[sendGuard] edit cap reached for ${messageId}`);
173
+ return false;
174
+ }
175
+ const minGap = randomBetween(profile.editIntervalMs);
176
+ const wait = s.lastEditAt + minGap - Date.now();
177
+ if (wait > 0)
178
+ await sleep(wait);
179
+ s.lastEditAt = Date.now();
180
+ s.count += 1;
181
+ editState.set(messageId, s);
182
+ return true;
68
183
  }
69
184
  // ── Public API ────────────────────────────────────────────────────────────────
70
185
  /**
@@ -90,30 +205,25 @@ export async function waitForSendSlot(jid, { cooldown = true, jitter = true } =
90
205
  }
91
206
  }
92
207
  if (jitter)
93
- await sleep(randomJitter());
208
+ await sleep(randomBetween(currentProfile().jitterMs));
94
209
  recordSend(jid);
95
210
  }
96
211
  /**
97
212
  * Show a presence indicator for `ms` milliseconds, then clear it.
98
- * No-op on drivers without the "presence" capability. Best-effort —
99
- * errors are swallowed.
213
+ * Best-effort errors are swallowed.
100
214
  *
101
- * @param {PresenceCapable|null} adapter
215
+ * @param {WaContract|null} contract
102
216
  * @param {string|null} chatId
103
217
  * @param {number} ms
104
218
  * @param {"typing"|"recording"} [state="typing"]
105
219
  */
106
- export async function simulateState(adapter, chatId, ms, state = "typing") {
107
- if (!adapter || !chatId || ms <= 0)
108
- return;
109
- if (!adapter.capabilities.has("presence") || !adapter.setPresence)
220
+ export async function simulateState(contract, chatId, ms, state = "typing") {
221
+ if (!contract || !chatId || ms <= 0)
110
222
  return;
111
223
  try {
112
- // Adapter contract only knows "composing" recording is a WhatsApp nuance
113
- // collapsed here until a driver needs to distinguish it.
114
- await adapter.setPresence(chatId, "composing");
224
+ await contract.sendPresenceUpdate(state === "recording" ? "recording" : "composing", chatId);
115
225
  await sleep(ms);
116
- await adapter.setPresence(chatId, "paused");
226
+ await contract.sendPresenceUpdate("paused", chatId);
117
227
  }
118
228
  catch (e) {
119
229
  logger.debug(`[sendGuard] presence simulation failed (non-fatal): ${e.message}`);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * kernel/statusServer.ts
3
+ *
4
+ * Minimal HTTP endpoint exposing the bot's connection state as JSON,
5
+ * for an external status page (or any other consumer) to poll.
6
+ */
7
+ import http from "http";
8
+ import { logger } from "#logger";
9
+ let status = {
10
+ online: false,
11
+ since: new Date().toISOString(),
12
+ };
13
+ export function setStatus(online, lastError) {
14
+ if (status.online === online)
15
+ return;
16
+ status = {
17
+ online,
18
+ since: new Date().toISOString(),
19
+ ...(lastError ? { lastError } : {}),
20
+ };
21
+ }
22
+ export function getStatus() {
23
+ return status;
24
+ }
25
+ export function startStatusServer(port) {
26
+ const server = http.createServer((req, res) => {
27
+ res.writeHead(200, {
28
+ "Content-Type": "application/json",
29
+ "Access-Control-Allow-Origin": "*",
30
+ });
31
+ res.end(JSON.stringify(getStatus()));
32
+ });
33
+ server.on("error", (err) => {
34
+ logger.error(`[status] Failed to start status server: ${err.message}`);
35
+ });
36
+ server.listen(port, () => {
37
+ logger.info(`[status] JSON endpoint em http://localhost:${port}`);
38
+ });
39
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * updateCheck.ts
3
+ *
4
+ * Compares the locally installed manybot version against the latest
5
+ * published on npm, and fires an "info" alert (via alerts.ts) when a
6
+ * newer version is available. Runs once on startup and then on a
7
+ * schedule — both configurable (UPDATE_CHECK_ENABLED,
8
+ * UPDATE_CHECK_INTERVAL_HOURS).
9
+ *
10
+ * Never throws — a failed check (offline, npm down) is logged at debug
11
+ * level and silently skipped; it'll just try again next cycle.
12
+ */
13
+ import { readFileSync } from "fs";
14
+ import { fileURLToPath } from "url";
15
+ import path from "path";
16
+ import { UPDATE_CHECK_ENABLED, UPDATE_CHECK_INTERVAL_HOURS } from "#config";
17
+ import { sendAlert } from "#kernel/alerts.js";
18
+ import { logger } from "#logger";
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = path.dirname(__filename);
21
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
22
+ const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/latest`;
23
+ /** Naive semver compare — good enough for x.y.z, no pre-release handling. */
24
+ function isNewer(latest, current) {
25
+ const a = latest.split(".").map(Number);
26
+ const b = current.split(".").map(Number);
27
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
28
+ const x = a[i] ?? 0;
29
+ const y = b[i] ?? 0;
30
+ if (x > y)
31
+ return true;
32
+ if (x < y)
33
+ return false;
34
+ }
35
+ return false;
36
+ }
37
+ let alreadyNotifiedFor = null;
38
+ /**
39
+ * Runs a single check. Safe to call anytime (startup, interval, manual
40
+ * trigger) — never throws.
41
+ */
42
+ export async function checkForUpdate() {
43
+ if (!UPDATE_CHECK_ENABLED)
44
+ return;
45
+ try {
46
+ const res = await fetch(REGISTRY_URL);
47
+ if (!res.ok) {
48
+ logger.debug(`[updateCheck] npm registry responded ${res.status}`);
49
+ return;
50
+ }
51
+ const data = await res.json();
52
+ const latest = data.version;
53
+ if (!latest || !isNewer(latest, pkg.version))
54
+ return;
55
+ // Don't re-alert every cycle for the same version once already notified.
56
+ if (alreadyNotifiedFor === latest)
57
+ return;
58
+ alreadyNotifiedFor = latest;
59
+ await sendAlert({
60
+ level: "info",
61
+ title: "Nova versão do manybot disponível",
62
+ message: `Instalada: ${pkg.version} → disponível: ${latest}. Rode "npm install -g ${pkg.name}@${latest}" (ou equivalente) para atualizar.`,
63
+ });
64
+ }
65
+ catch (e) {
66
+ logger.debug(`[updateCheck] check failed (non-fatal): ${e.message}`);
67
+ }
68
+ }
69
+ let intervalTimer = null;
70
+ /**
71
+ * Runs one check immediately, then schedules recurring checks every
72
+ * UPDATE_CHECK_INTERVAL_HOURS. Safe to call multiple times — a second
73
+ * call is a no-op while a schedule is already running.
74
+ */
75
+ export function startUpdateCheckSchedule() {
76
+ if (!UPDATE_CHECK_ENABLED || intervalTimer)
77
+ return;
78
+ checkForUpdate().catch(() => { });
79
+ intervalTimer = setInterval(() => {
80
+ checkForUpdate().catch(() => { });
81
+ }, UPDATE_CHECK_INTERVAL_HOURS * 60 * 60 * 1000);
82
+ }
83
+ export function stopUpdateCheckSchedule() {
84
+ if (!intervalTimer)
85
+ return;
86
+ clearInterval(intervalTimer);
87
+ intervalTimer = null;
88
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * kernel/waContract.ts
3
+ *
4
+ * Driver-neutral contract that the core (kernel + pluginApi + sendGuard +
5
+ * contactAutoSave + pluginLoader + messageHandler) consumes. Every WhatsApp
6
+ * driver — Baileys today, whatsmeow in a later phase — implements this
7
+ * interface. The core never imports a driver package directly.
8
+ *
9
+ * Event payloads are all driver-neutral: adapters translate incoming
10
+ * driver-specific event shapes (e.g. Baileys WAMessage) into the records
11
+ * declared here before letting the rest of the kernel see them.
12
+ *
13
+ * This file MUST NOT import from `@whiskeysockets/baileys` or any
14
+ * other driver package.
15
+ */
16
+ export {};
@@ -31,9 +31,12 @@
31
31
  "schedulerRegistered": "Schedule registered — plugin \"{{name}}\" → \"{{expression}}\"",
32
32
  "downloadJobFailed": "Download job failed — {{message}}",
33
33
  "reconnecting": "Reconnecting in {{secs}}s...",
34
+ "reconnectHalted": "Gave up reconnecting after {{attempts}} attempts — call connect() manually to retry. Repeated reconnect loops can make a WhatsApp restriction last longer.",
34
35
  "sessionExpired": "Session expired or logged out — removing local session and restarting...",
35
36
  "cacheLoaded": "{{count}} chat(s) loaded from cache.",
36
- "cacheLoadedStale": "{{count}} chat(s) loaded from cache (stale)."
37
+ "cacheLoadedStale": "{{count}} chat(s) loaded from cache (stale).",
38
+ "sendFailedNoFallback": "manybot: no fallback driver available (jid={{jid}}, primary={{driver}})",
39
+ "sendFailedBothDrivers": "manybot: send failed on both drivers (jid={{jid}}, tried={{primary}} then {{secondary}})"
37
40
  },
38
41
  "errors": {
39
42
  "stack": "Stack",
@@ -66,5 +69,17 @@
66
69
  "retrying": "Retrying ({{attempt}}/{{max}})...",
67
70
  "sessionWiped": "Session discarded, pairing again ({{round}}/{{max}})...",
68
71
  "connectGaveUp": "Couldn't connect after several attempts. Check your network and try again."
72
+ },
73
+ "whatsmeow": {
74
+ "installPrompt": "Install whatsmeow driver for fallback support? (EXPERIMENTAL — only text send & history work; other methods throw)",
75
+ "unsupportedArch": "whatsmeow driver not available for {{os}}-{{arch}}. The bot will use Baileys only.",
76
+ "fetchingTag": "Fetching latest whatsmeow release...",
77
+ "fetchFailed": "Could not reach Codeberg. Skipping whatsmeow install.",
78
+ "downloading": "Downloading {{url}}...",
79
+ "downloadFailed": "Download failed: {{reason}}",
80
+ "installTitle": "whatsmeow driver",
81
+ "installed": "whatsmeow driver installed at {{path}}",
82
+ "restartNotice": "Restart the bot for the whatsmeow driver to take effect.",
83
+ "experimentalNotice": "whatsmeow is EXPERIMENTAL: only sendText and getHistory are implemented. sendImage, sendPoll, groupMetadata, and other methods throw."
69
84
  }
70
85
  }
@@ -31,9 +31,12 @@
31
31
  "schedulerRegistered": "Programación registrada — plugin \"{{name}}\" → \"{{expression}}\"",
32
32
  "downloadJobFailed": "Error en el trabajo de descarga — {{message}}",
33
33
  "reconnecting": "Reconectando en {{secs}}s...",
34
+ "reconnectHalted": "Se dejó de reconectar tras {{attempts}} intentos — llama a connect() manualmente para reintentar. Los bucles de reconexión repetidos pueden prolongar una restricción de WhatsApp.",
34
35
  "sessionExpired": "Sesión expirada o cerrada — eliminando sesión local y reiniciando...",
35
36
  "cacheLoaded": "{{count}} chat(s) cargado(s) desde la caché.",
36
- "cacheLoadedStale": "{{count}} chat(s) cargado(s) desde la caché (desactualizada)."
37
+ "cacheLoadedStale": "{{count}} chat(s) cargado(s) desde la caché (desactualizada).",
38
+ "sendFailedNoFallback": "manybot: no hay driver de respaldo disponible (jid={{jid}}, primario={{driver}})",
39
+ "sendFailedBothDrivers": "manybot: envío falló en ambos drivers (jid={{jid}}, se probó {{primary}} y luego {{secondary}})"
37
40
  },
38
41
  "errors": {
39
42
  "stack": "Stack",
@@ -66,5 +69,17 @@
66
69
  "retrying": "Reintentando ({{attempt}}/{{max}})...",
67
70
  "sessionWiped": "Sesión descartada, emparejando de nuevo ({{round}}/{{max}})...",
68
71
  "connectGaveUp": "No se pudo conectar tras varios intentos. Revisa tu conexión e intenta de nuevo."
72
+ },
73
+ "whatsmeow": {
74
+ "installPrompt": "¿Instalar el driver whatsmeow para soporte de fallback? (EXPERIMENTAL — solo sendText e historial funcionan; otros métodos lanzan error)",
75
+ "unsupportedArch": "Driver whatsmeow no disponible para {{os}}-{{arch}}. El bot usará solo Baileys.",
76
+ "fetchingTag": "Obteniendo última versión de whatsmeow...",
77
+ "fetchFailed": "No se pudo contactar a Codeberg. Instalación de whatsmeow omitida.",
78
+ "downloading": "Descargando {{url}}...",
79
+ "downloadFailed": "Descarga fallida: {{reason}}",
80
+ "installTitle": "Driver whatsmeow",
81
+ "installed": "Driver whatsmeow instalado en {{path}}",
82
+ "restartNotice": "Reinicia el bot para que el driver whatsmeow surta efecto.",
83
+ "experimentalNotice": "whatsmeow es EXPERIMENTAL: solo sendText y getHistory están implementados. sendImage, sendPoll, groupMetadata y otros métodos lanzan error."
69
84
  }
70
85
  }
@@ -31,9 +31,12 @@
31
31
  "schedulerRegistered": "Agendamento registrado — plugin \"{{name}}\" → \"{{expression}}\"",
32
32
  "downloadJobFailed": "Falha no job de download — {{message}}",
33
33
  "reconnecting": "Reconectando em {{secs}}s...",
34
+ "reconnectHalted": "Desisti de reconectar após {{attempts}} tentativas — chame connect() manualmente para tentar de novo. Loops de reconexão repetidos podem prolongar uma restrição do WhatsApp.",
34
35
  "sessionExpired": "Sessão expirada ou desconectada — removendo sessão local e reiniciando...",
35
36
  "cacheLoaded": "{{count}} conversa(s) carregada(s) do cache.",
36
- "cacheLoadedStale": "{{count}} conversa(s) carregada(s) do cache (desatualizado)."
37
+ "cacheLoadedStale": "{{count}} conversa(s) carregada(s) do cache (desatualizado).",
38
+ "sendFailedNoFallback": "manybot: nenhum driver de fallback disponível (jid={{jid}}, primário={{driver}})",
39
+ "sendFailedBothDrivers": "manybot: envio falhou nos dois drivers (jid={{jid}}, tentou {{primary}} depois {{secondary}})"
37
40
  },
38
41
  "errors": {
39
42
  "stack": "Stack",
@@ -66,5 +69,17 @@
66
69
  "retrying": "Tentando novamente ({{attempt}}/{{max}})...",
67
70
  "sessionWiped": "Sessão descartada, tentando parear novamente ({{round}}/{{max}})...",
68
71
  "connectGaveUp": "Não foi possível conectar após várias tentativas. Verifique sua conexão e tente de novo."
72
+ },
73
+ "whatsmeow": {
74
+ "installPrompt": "Instalar driver whatsmeow para suporte a fallback? (EXPERIMENTAL — apenas sendText e histórico funcionam; outros métodos lançam erro)",
75
+ "unsupportedArch": "Driver whatsmeow não disponível para {{os}}-{{arch}}. O bot usará apenas Baileys.",
76
+ "fetchingTag": "Buscando última versão do whatsmeow...",
77
+ "fetchFailed": "Não foi possível acessar o Codeberg. Instalação do whatsmeow ignorada.",
78
+ "downloading": "Baixando {{url}}...",
79
+ "downloadFailed": "Download falhou: {{reason}}",
80
+ "installTitle": "Driver whatsmeow",
81
+ "installed": "Driver whatsmeow instalado em {{path}}",
82
+ "restartNotice": "Reinicie o bot para que o driver whatsmeow entre em efeito.",
83
+ "experimentalNotice": "whatsmeow é EXPERIMENTAL: apenas sendText e getHistory estão implementados. sendImage, sendPoll, groupMetadata e outros métodos lançam erro."
69
84
  }
70
85
  }
package/dist/main.js CHANGED
@@ -9,19 +9,67 @@ import Module from "module";
9
9
  import path from "path";
10
10
  process.env.NODE_PATH = path.resolve(process.cwd(), "node_modules");
11
11
  Module._initPaths();
12
- import { initializeSelectedDriver } from "#drivers/index.js";
12
+ import { baileysContract } from "#drivers/baileys/index.js";
13
+ import { whatsmeowContract, startWhatsmeowSupervisor, wrapWithSupervisor } from "#drivers/whatsmeow/index.js";
14
+ import { promptWhatsmeowInstall } from "#drivers/whatsmeow/installer.js";
13
15
  import { cleanupPlugins } from "#kernel/pluginLoader.js";
14
16
  import { stopAll as stopScheduler } from "#kernel/scheduler.js";
17
+ import { sendAlert } from "#kernel/alerts.js";
18
+ import { startStatusServer } from "#kernel/statusServer.js";
19
+ import { getDriverManager } from "#kernel/driverManager.js";
20
+ import { CONFIG, STATUS_ENABLED, STATUS_PORT } from "#config";
15
21
  import { logger } from "#logger";
16
22
  import { t } from "#i18n";
17
23
  let shuttingDown = false;
18
- const activeDriver = initializeSelectedDriver();
24
+ // DriverManager registration: only register drivers that are enabled in
25
+ // the config. whatsmeow.enabled = false means no gRPC
26
+ // subprocess, no Go binary lookup, no extra work at boot — the manager
27
+ // simply doesn't know about it and sendFallbackGuard sees a missing
28
+ // secondary and fires send_failed_no_fallback if needed.
29
+ const driverManager = getDriverManager();
30
+ driverManager.register(baileysContract, { isPrimary: CONFIG.drivers.primary === "baileys" });
31
+ // Whatsmeow supervisor: spawns the Go subprocess when enabled=true,
32
+ // owns the restart/backoff/circuit-breaker logic, and gates the
33
+ // driver's connect()/isReady() until HealthCheck{ready:true}. When
34
+ // enabled=false or the binary can't be located, `supervisor` is null
35
+ // and the whatsmeow driver is simply not registered — ManyBot keeps
36
+ // running on Baileys alone, no fallback.
37
+ let supervisor = null;
38
+ if (CONFIG.drivers.whatsmeow.enabled) {
39
+ logger.info("[driverManager] whatsmeow enabled — spawning supervisor");
40
+ supervisor = await startWhatsmeowSupervisor();
41
+ if (supervisor) {
42
+ const wrapped = wrapWithSupervisor(whatsmeowContract, supervisor);
43
+ driverManager.register(wrapped, { isPrimary: CONFIG.drivers.primary === "whatsmeow" });
44
+ logger.info(`[driverManager] whatsmeow registered (primary=${CONFIG.drivers.primary === "whatsmeow"})`);
45
+ }
46
+ else {
47
+ logger.warn("[driverManager] whatsmeow supervisor failed to start — fallback disabled");
48
+ }
49
+ }
50
+ else {
51
+ logger.info("[driverManager] whatsmeow disabled by config — no fallback");
52
+ }
53
+ const activeDriver = driverManager.active();
54
+ const secondaryName = (activeDriver.name === "baileys" ? "whatsmeow" : "baileys");
55
+ const secondaryDriver = driverManager.get(secondaryName);
19
56
  async function shutdown(reason, isError = false) {
20
57
  if (shuttingDown)
21
58
  return;
22
59
  shuttingDown = true;
23
60
  if (isError) {
24
61
  logger.error(`${t("bot.error.uncaught")}: ${reason}`);
62
+ try {
63
+ await sendAlert({
64
+ level: "critical",
65
+ title: "manybot crashed",
66
+ message: reason,
67
+ });
68
+ }
69
+ catch {
70
+ // sendAlert already swallows sink failures internally; this is only
71
+ // a final safety net so a crash alert never blocks shutdown itself.
72
+ }
25
73
  }
26
74
  else {
27
75
  logger.warn(t("bot.signal.sigterm", { signal: reason }));
@@ -34,11 +82,21 @@ async function shutdown(reason, isError = false) {
34
82
  }
35
83
  stopScheduler();
36
84
  try {
37
- await activeDriver.disconnect();
85
+ await driverManager.shutdown();
38
86
  }
39
87
  catch (err) {
40
88
  logger.error(`Error disconnecting driver: ${err.message}`);
41
89
  }
90
+ // Belt-and-suspenders: driverManager.shutdown() should already have
91
+ // disconnected the wrapped contract, which in turn calls
92
+ // supervisor.shutdown(). This catches the case where the supervisor
93
+ // was started but the driver wasn't registered (binary missing).
94
+ if (supervisor) {
95
+ try {
96
+ await supervisor.shutdown();
97
+ }
98
+ catch { }
99
+ }
42
100
  process.exit(isError ? 1 : 0);
43
101
  }
44
102
  // Global error listeners
@@ -58,23 +116,60 @@ process.on("SIGINT", () => shutdown("SIGINT"));
58
116
  // the JID to the console, to paste into CHATS in manybot.toml.
59
117
  // Does not enter the normal bot flow (plugins are not loaded).
60
118
  if (process.argv.includes("--getid")) {
61
- if (!activeDriver.getId) {
119
+ // getId? is a Baileys-only diagnostic method. Look it
120
+ // up on the registered Baileys driver regardless of which one is
121
+ // active — --getid always uses Baileys, even in a whatsmeow-primary
122
+ // configuration, because it needs the diagnostic session, not the
123
+ // bot's normal one.
124
+ const baileys = driverManager.get("baileys");
125
+ const getIdFn = baileys?.getId;
126
+ if (!getIdFn) {
62
127
  logger.error(`Current driver does not support --getid.`);
63
128
  process.exit(1);
64
129
  }
65
- activeDriver.getId()
130
+ getIdFn()
66
131
  .then(() => process.exit(0))
67
132
  .catch((err) => {
68
133
  logger.error(`--getid mode failed: ${err.message}`);
69
134
  process.exit(1);
70
135
  });
71
136
  }
137
+ else if (process.argv.includes("--install-whatsmeow")) {
138
+ // Re-run the whatsmeow installer outside the normal setup flow.
139
+ // Useful when the initial install failed or the binary was moved.
140
+ promptWhatsmeowInstall()
141
+ .then(() => process.exit(0))
142
+ .catch((err) => {
143
+ logger.error(`--install-whatsmeow failed: ${err.message}`);
144
+ process.exit(1);
145
+ });
146
+ }
72
147
  else {
73
148
  // Start bot
74
149
  logger.info(t("bot.initialized"));
150
+ if (STATUS_ENABLED) {
151
+ startStatusServer(STATUS_PORT);
152
+ }
75
153
  activeDriver.connect()
76
154
  .then(() => {
77
155
  logger.success(t("bot.ready"));
156
+ // The secondary driver is connected and paired in the
157
+ // background so sendFallbackGuard can reach for it without first
158
+ // having to wait through a connect() round-trip when the primary
159
+ // fails. The secondary does NOT register `messages.upsert` handlers
160
+ // (no kernel code subscribes on it — only the primary path does),
161
+ // so connecting it does not duplicate inbound processing. A failure
162
+ // here is non-fatal: the primary keeps running, fallback just stays
163
+ // unavailable (sendFallbackGuard's `isReady()` check covers that).
164
+ if (secondaryDriver) {
165
+ logger.info(`[driverManager] connecting secondary "${secondaryName}" in background…`);
166
+ secondaryDriver.connect()
167
+ .then(() => logger.info(`[driverManager] secondary "${secondaryName}" connected — fallback available`))
168
+ .catch((err) => logger.warn(`[driverManager] secondary "${secondaryName}" connect failed: ${err.message} — fallback unavailable`));
169
+ }
170
+ else {
171
+ logger.info(`[driverManager] no secondary driver registered — running on ${activeDriver.name} only`);
172
+ }
78
173
  })
79
174
  .catch((err) => {
80
175
  shutdown(`Failed to connect driver: ${err.message}`, true);