@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
@@ -0,0 +1,328 @@
1
+ /**
2
+ * supervisor.ts
3
+ *
4
+ * Lifecycle manager for the Go whatsmeow subprocess. Two responsibilities:
5
+ *
6
+ * 1. Spawn the binary, watch for crashes, restart with exponential
7
+ * backoff (same circuit-breaker pattern the Baileys driver uses in
8
+ * drivers/baileys/index.ts:98-106). After MAX_RECONNECT_ATTEMPTS
9
+ * consecutive failures, halt permanently and fire
10
+ * `whatsmeow_subprocess_halted` — ManyBot keeps running on Baileys
11
+ * alone.
12
+ * 2. Block `whenReady()` until the gRPC server has answered
13
+ * `HealthCheck{ready:true}` at least once. Without this, callers
14
+ * race ahead of the subprocess and try to `connect()` against an
15
+ * empty SQLite / pre-auth state.
16
+ *
17
+ * Resolution of the binary path: `CONFIG.drivers.whatsmeow.binaryPath`
18
+ * wins; if empty, falls back to the env var `WM_BINARY_PATH`, then
19
+ * `<cwd>/whatsmeow-service/bin/whatsmeow-service` (dev), then the
20
+ * npm-global install layout. Falls open into a logged fatal if nothing
21
+ * matches — `enabled=true` without a binary is a setup bug, not a
22
+ * silent degradation.
23
+ *
24
+ * `config.drivers.whatsmeow.enabled = false` (TOML:
25
+ * `driver_whatsmeow_enabled = false`) ⇒ no supervisor at all
26
+ * (`null` return from `startWhatsmeowSupervisor`); the rest of the
27
+ * codebase is unaware anything is missing — it just never sees a
28
+ * ready whatsmeow.
29
+ *
30
+ * See the lifecycle contract this implements.
31
+ */
32
+ import { spawn } from "node:child_process";
33
+ import { existsSync, mkdirSync } from "node:fs";
34
+ import path from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import { CONFIG, CONFIG_DIR, CLIENT_ID } from "#config";
37
+ import { logger } from "#logger";
38
+ import { fireAlert } from "#kernel/alerts.js";
39
+ import { getDriverManager } from "#kernel/driverManager.js";
40
+ // ── Tunables (mirror drivers/baileys/index.ts:98-106) ──────────────────────
41
+ const RECONNECT_BASE_MS = 1000;
42
+ const RECONNECT_MAX_MS = 60_000;
43
+ const MAX_RECONNECT_ATTEMPTS = 6;
44
+ const HEALTHCHECK_INTERVAL_MS = 500;
45
+ const HEALTHCHECK_TIMEOUT_MS = 5_000;
46
+ const SHUTDOWN_GRACE_MS = 5_000;
47
+ // ── Resolution helpers ─────────────────────────────────────────────────────
48
+ /**
49
+ * Walks the conventional locations looking for the binary. Returns the
50
+ * first match that exists and is a regular file. Order:
51
+ * 1. CONFIG.drivers.whatsmeow.binaryPath (explicit user choice)
52
+ * 2. env WM_BINARY_PATH (escape hatch for exotic installs)
53
+ * 3. stable config dir (~/.manybot/whatsmeow-service/bin/whatsmeow-service)
54
+ * 4. dev layout (<cwd>/whatsmeow-service/bin/whatsmeow-service)
55
+ * 4. npm-global layout (sibling of the node binary, /usr/local style)
56
+ */
57
+ function resolveBinaryPath() {
58
+ const candidates = [];
59
+ const fromConfig = CONFIG.drivers.whatsmeow.binaryPath;
60
+ if (fromConfig)
61
+ candidates.push(path.resolve(fromConfig));
62
+ const fromEnv = process.env.WM_BINARY_PATH;
63
+ if (fromEnv)
64
+ candidates.push(path.resolve(fromEnv));
65
+ // Stable config dir: ~/.manybot/whatsmeow-service/bin/whatsmeow-service
66
+ candidates.push(path.resolve(CONFIG_DIR, "whatsmeow-service", "bin", "whatsmeow-service"));
67
+ // Dev: `<repo>/whatsmeow-service/bin/whatsmeow-service`
68
+ candidates.push(path.resolve(process.cwd(), "whatsmeow-service", "bin", "whatsmeow-service"));
69
+ // Global npm install: `<prefix>/bin/../share/manybot/bin/whatsmeow-service`
70
+ // `<prefix>` is the parent of the running node binary on most setups.
71
+ const prefix = path.dirname(path.dirname(process.execPath));
72
+ candidates.push(path.join(prefix, "share", "manybot", "bin", "whatsmeow-service"));
73
+ // Belt-and-suspenders: macOS/Linux homebrew-style shared dir.
74
+ candidates.push(path.join(prefix, "lib", "node_modules", "manybot", "whatsmeow-service", "bin", "whatsmeow-service"));
75
+ for (const c of candidates) {
76
+ if (existsSync(c))
77
+ return c;
78
+ }
79
+ return null;
80
+ }
81
+ /**
82
+ * Single HealthCheck RPC against `addr`. Implemented with the low-level
83
+ * @grpc/grpc-js client API so the supervisor doesn't need the rest of
84
+ * the whatsmeow driver to be importable (the supervisor can outlive a
85
+ * broken contract, e.g. when the proto is being regenerated).
86
+ *
87
+ * Uses an in-memory `.proto` with the bare minimum types needed for
88
+ * HealthCheck so we don't depend on the driver module's loader cache.
89
+ */
90
+ async function probeHealth(addr) {
91
+ // Lazy imports keep the fast path (enabled=false) cheap.
92
+ const grpc = await import("@grpc/grpc-js");
93
+ const protoLoader = await import("@grpc/proto-loader");
94
+ // Resolve the .proto relative to this compiled module — works under
95
+ // `node dist/main.js` (proto is copied into dist/drivers/whatsmeow/
96
+ // by the build script), under a global npm install (proto ships in
97
+ // the package's `dist/`), and in dev (`tsx src/main.ts`, where the
98
+ // proto sits next to this source file). ESM has no `__dirname`, so
99
+ // use `import.meta.url`.
100
+ const protoPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "whatsmeow.proto");
101
+ if (!existsSync(protoPath)) {
102
+ return { ready: false, error: `proto not found at ${protoPath}` };
103
+ }
104
+ const def = protoLoader.loadSync(protoPath, {
105
+ keepCase: true,
106
+ longs: String,
107
+ enums: String,
108
+ defaults: true,
109
+ oneofs: true,
110
+ });
111
+ const Service = grpc.loadPackageDefinition(def).whatsmeow.WhatsmeowService;
112
+ const client = new Service(addr, grpc.credentials.createInsecure());
113
+ return new Promise((resolve) => {
114
+ const timer = setTimeout(() => {
115
+ try {
116
+ client.close();
117
+ }
118
+ catch { }
119
+ resolve({ ready: false, error: "healthcheck timeout" });
120
+ }, HEALTHCHECK_TIMEOUT_MS);
121
+ client.HealthCheck({}, (err, resp) => {
122
+ clearTimeout(timer);
123
+ try {
124
+ client.close();
125
+ }
126
+ catch { }
127
+ if (err)
128
+ return resolve({ ready: false, error: err.message });
129
+ resolve({ ready: !!resp?.ready, error: resp?.last_error || undefined });
130
+ });
131
+ });
132
+ }
133
+ async function waitMs(ms) {
134
+ return new Promise((r) => setTimeout(r, ms));
135
+ }
136
+ /**
137
+ * Spawn the subprocess, register lifecycle handlers, and kick the
138
+ * healthcheck loop. Returns the supervisor handle, or `null` if
139
+ * `enabled = false`. Throws only on fatal startup conditions (binary
140
+ * not found, spawn() failed synchronously); transient RPC failures are
141
+ * logged + backed off, never thrown.
142
+ */
143
+ export async function startWhatsmeowSupervisor() {
144
+ if (!CONFIG.drivers.whatsmeow.enabled) {
145
+ logger.info("[supervisor] disabled by config (driver_whatsmeow_enabled = false)");
146
+ return null;
147
+ }
148
+ const binary = resolveBinaryPath();
149
+ if (!binary) {
150
+ logger.error("[supervisor] whatsmeow-service binary not found. Set `driver_whatsmeow_binary_path` in manybot.toml, " +
151
+ "set env WM_BINARY_PATH, or place the binary at " +
152
+ "whatsmeow-service/bin/whatsmeow-service relative to cwd. Bot will run on Baileys only.");
153
+ return null;
154
+ }
155
+ logger.info(`[supervisor] using binary: ${binary}`);
156
+ const grpcAddress = CONFIG.drivers.whatsmeow.grpcAddress || "localhost:50051";
157
+ const sessionDir = path.resolve(CONFIG_DIR, "sessions", CLIENT_ID, "whatsmeow", "session.db");
158
+ // Ensure the parent directory exists before the Go subprocess tries to
159
+ // create/open the SQLite file.
160
+ mkdirSync(path.dirname(sessionDir), { recursive: true });
161
+ const state = {
162
+ proc: null,
163
+ pid: null,
164
+ addr: grpcAddress,
165
+ binary,
166
+ sessionDir,
167
+ reconnectTries: 0,
168
+ halted: false,
169
+ ready: false,
170
+ readyDeferred: null,
171
+ shuttingDown: false,
172
+ restartTimer: null,
173
+ };
174
+ // First ready promise — created lazily on the first `whenReady()` so
175
+ // a never-called instance doesn't carry dangling deferreds.
176
+ function ensureReadyPromise() {
177
+ if (state.ready)
178
+ return Promise.resolve();
179
+ if (state.halted)
180
+ return Promise.reject(new Error("whatsmeow supervisor halted"));
181
+ if (!state.readyDeferred) {
182
+ let resolve;
183
+ let reject;
184
+ const p = new Promise((res, rej) => { resolve = res; reject = rej; });
185
+ state.readyDeferred = { resolve, reject };
186
+ }
187
+ return new Promise((resolve, reject) => {
188
+ // Re-attach so callers can `await` multiple times.
189
+ const d = state.readyDeferred;
190
+ const origResolve = d.resolve;
191
+ const origReject = d.reject;
192
+ d.resolve = () => { origResolve(); resolve(); };
193
+ d.reject = (e) => { origReject(e); reject(e); };
194
+ });
195
+ }
196
+ function markReady() {
197
+ if (state.ready)
198
+ return;
199
+ state.ready = true;
200
+ state.reconnectTries = 0; // healthy again → reset circuit breaker
201
+ state.readyDeferred?.resolve();
202
+ }
203
+ function markHalted(reason) {
204
+ state.halted = true;
205
+ state.ready = false;
206
+ state.readyDeferred?.reject(new Error(reason));
207
+ getDriverManager().markDegraded("whatsmeow", 600_000);
208
+ fireAlert("whatsmeow_subprocess_halted", { reason });
209
+ }
210
+ function scheduleRestart() {
211
+ if (state.shuttingDown || state.halted)
212
+ return;
213
+ if (state.reconnectTries >= MAX_RECONNECT_ATTEMPTS) {
214
+ logger.error(`[supervisor] exhausted ${MAX_RECONNECT_ATTEMPTS} reconnect attempts — halting`);
215
+ markHalted(`exhausted ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`);
216
+ return;
217
+ }
218
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** state.reconnectTries, RECONNECT_MAX_MS);
219
+ state.reconnectTries += 1;
220
+ logger.warn(`[supervisor] scheduling restart in ${delay}ms (attempt ${state.reconnectTries}/${MAX_RECONNECT_ATTEMPTS})`);
221
+ state.restartTimer = setTimeout(() => {
222
+ state.restartTimer = null;
223
+ void spawnAndWatch();
224
+ }, delay);
225
+ }
226
+ async function spawnAndWatch() {
227
+ if (state.shuttingDown || state.halted)
228
+ return;
229
+ let proc;
230
+ try {
231
+ proc = spawn(state.binary, ["--grpc-addr", state.addr, "--session-dir", state.sessionDir], {
232
+ stdio: ["ignore", "pipe", "pipe"],
233
+ });
234
+ // Forward Go service stdout/stderr to the bot log so we can see
235
+ // crashes, missing dependencies, port-in-use, etc. Without this
236
+ // `stdio: "ignore"` would discard everything and a crashed
237
+ // subprocess would only surface as an exit code.
238
+ proc.stdout?.on("data", (chunk) => {
239
+ process.stdout.write(`[whatsmeow-stdout] ${chunk}`);
240
+ });
241
+ proc.stderr?.on("data", (chunk) => {
242
+ process.stderr.write(`[whatsmeow-stderr] ${chunk}`);
243
+ });
244
+ }
245
+ catch (e) {
246
+ logger.error(`[supervisor] spawn failed: ${e.message}`);
247
+ scheduleRestart();
248
+ return;
249
+ }
250
+ state.proc = proc;
251
+ state.pid = proc.pid ?? null;
252
+ proc.on("error", (err) => {
253
+ logger.warn(`[supervisor] subprocess error: ${err.message}`);
254
+ // Don't restart here — the `exit` handler will fire on ENOENT etc.
255
+ });
256
+ proc.on("exit", (code, signal) => {
257
+ logger.warn(`[supervisor] subprocess exited code=${code} signal=${signal} ready=${state.ready}`);
258
+ state.proc = null;
259
+ state.pid = null;
260
+ state.ready = false;
261
+ if (!state.shuttingDown && !state.halted)
262
+ scheduleRestart();
263
+ });
264
+ logger.info(`[supervisor] subprocess started (pid=${state.pid}, binary=${state.binary})`);
265
+ // Don't await — healthcheck loop runs in its own cycle.
266
+ void runHealthLoop();
267
+ }
268
+ async function runHealthLoop() {
269
+ while (!state.shuttingDown && !state.halted) {
270
+ await waitMs(HEALTHCHECK_INTERVAL_MS);
271
+ if (!state.proc)
272
+ break; // subprocess died; `exit` handler owns restart
273
+ const result = await probeHealth(state.addr);
274
+ if (result.ready) {
275
+ if (!state.ready)
276
+ logger.info("[supervisor] healthcheck ready");
277
+ markReady();
278
+ // Once ready, keep polling at the same cadence to detect
279
+ // when the subprocess silently dies. A slow poll would mean
280
+ // we miss the reconnect window.
281
+ }
282
+ else if (result.error) {
283
+ logger.debug(`[supervisor] healthcheck: not ready (${result.error})`);
284
+ }
285
+ }
286
+ }
287
+ const supervisor = {
288
+ whenReady: ensureReadyPromise,
289
+ isReady: () => state.ready && !!state.proc,
290
+ isAlive: () => !!state.proc,
291
+ pid: () => state.pid,
292
+ async shutdown() {
293
+ if (state.shuttingDown)
294
+ return;
295
+ state.shuttingDown = true;
296
+ if (state.restartTimer) {
297
+ clearTimeout(state.restartTimer);
298
+ state.restartTimer = null;
299
+ }
300
+ state.readyDeferred?.reject(new Error("supervisor shutting down"));
301
+ const proc = state.proc;
302
+ if (!proc)
303
+ return;
304
+ try {
305
+ proc.kill("SIGTERM");
306
+ }
307
+ catch { }
308
+ const exited = await new Promise((resolve) => {
309
+ const t = setTimeout(() => {
310
+ try {
311
+ proc.kill("SIGKILL");
312
+ }
313
+ catch { }
314
+ resolve(true);
315
+ }, SHUTDOWN_GRACE_MS);
316
+ proc.once("exit", () => { clearTimeout(t); resolve(true); });
317
+ });
318
+ state.proc = null;
319
+ state.pid = null;
320
+ void exited;
321
+ },
322
+ };
323
+ // Kick off — intentionally not awaited. The supervisor handle is
324
+ // returned synchronously so callers can stash it / attach shutdown
325
+ // hooks; the spawn + healthcheck loop runs in the background.
326
+ void spawnAndWatch();
327
+ return supervisor;
328
+ }
@@ -0,0 +1,64 @@
1
+ syntax = "proto3";
2
+ package whatsmeow;
3
+
4
+ service WhatsmeowService {
5
+ rpc Connect(ConnectRequest) returns (ConnectResponse);
6
+ rpc Disconnect(Empty) returns (Empty);
7
+ rpc SendText(SendTextRequest) returns (SentMessageRef);
8
+ rpc SendMedia(SendMediaRequest) returns (SentMessageRef);
9
+ rpc React(ReactRequest) returns (Empty);
10
+ rpc GetHistory(GetHistoryRequest) returns (GetHistoryResponse);
11
+ rpc SubscribeEvents(Empty) returns (stream WaEvent);
12
+ rpc HealthCheck(Empty) returns (HealthStatus);
13
+ }
14
+
15
+ message Empty {}
16
+
17
+ message ConnectRequest { string sessionDir = 1; }
18
+ message ConnectResponse { bool ok = 1; string qrCode = 2; }
19
+
20
+ message SendTextRequest {
21
+ string jid = 1;
22
+ string text = 2;
23
+ string quotedId = 3;
24
+ repeated string mentions = 4;
25
+ }
26
+
27
+ message SendMediaRequest {
28
+ string jid = 1;
29
+ string kind = 2; // image|video|audio|sticker|document
30
+ bytes buffer = 3;
31
+ string caption = 4;
32
+ string quotedId = 5;
33
+ }
34
+
35
+ message SentMessageRef {
36
+ string id = 1;
37
+ string chatId = 2;
38
+ int64 timestamp = 3;
39
+ }
40
+
41
+ message ReactRequest { string jid = 1; string messageId = 2; string emoji = 3; }
42
+
43
+ message GetHistoryRequest { string jid = 1; int32 limit = 2; }
44
+ message GetHistoryResponse { repeated BotMessage messages = 1; }
45
+
46
+ message BotMessage {
47
+ string id = 1;
48
+ string chatId = 2;
49
+ bool fromMe = 3;
50
+ string type = 4;
51
+ string contentHash = 5;
52
+ int64 timestamp = 6;
53
+ }
54
+
55
+ message WaEvent {
56
+ oneof payload {
57
+ BotMessage message = 1;
58
+ ConnStateUpdate connState = 2;
59
+ }
60
+ }
61
+
62
+ message ConnStateUpdate { string state = 1; }
63
+
64
+ message HealthStatus { bool ready = 1; }
@@ -123,12 +123,6 @@ function interpolate(str, context = {}) {
123
123
  return context[key] !== undefined ? String(context[key]) : match;
124
124
  });
125
125
  }
126
- /**
127
- * Main translation function
128
- * @param {string} key - translation key (e.g., "system.connected")
129
- * @param {object} context - values to interpolate {{key}}
130
- * @returns {string}
131
- */
132
126
  export function t(key, context = {}) {
133
127
  ensureLoaded();
134
128
  // Try current language first
@@ -141,6 +135,11 @@ export function t(key, context = {}) {
141
135
  if (value === undefined) {
142
136
  return key;
143
137
  }
138
+ // Caller explicitly wants the raw nested object (e.g. a map like
139
+ // messages.mobs), skip stringification/interpolation entirely.
140
+ if (context.returnObjects && typeof value === "object" && value !== null) {
141
+ return value;
142
+ }
144
143
  // If not string, convert
145
144
  if (typeof value !== "string") {
146
145
  return String(value);
@@ -191,12 +190,6 @@ export function createPluginT(pluginMetaUrl) {
191
190
  catch (err) {
192
191
  // Silent fail - plugin may not have translations
193
192
  }
194
- /**
195
- * Plugin-specific translation function
196
- * @param {string} key
197
- * @param {object} context
198
- * @returns {string}
199
- */
200
193
  function pluginT(key, context = {}) {
201
194
  // Try plugin's target language first
202
195
  let value = getNestedValue(pluginTranslations, key);
@@ -208,6 +201,9 @@ export function createPluginT(pluginMetaUrl) {
208
201
  if (value === undefined) {
209
202
  return key;
210
203
  }
204
+ if (context.returnObjects && typeof value === "object" && value !== null) {
205
+ return value;
206
+ }
211
207
  if (typeof value !== "string") {
212
208
  return String(value);
213
209
  }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * alerts.ts
3
+ *
4
+ * Local-first critical alerting. The whole point is to still reach the
5
+ * dev when the thing most likely to fail — the bot's own WhatsApp
6
+ * connection — is exactly what's down. So WhatsApp is one sink among
7
+ * several, never the only one:
8
+ *
9
+ * 1. Log file (~/.manybot/alerts.log) — always written, no dependency
10
+ * on anything external. The one sink guaranteed to work.
11
+ * 2. OS notification (notify-send / osascript) — best-effort, only
12
+ * if the process is still alive to fire it.
13
+ * 3. WhatsApp (ADMIN_JID) — best-effort, only if a
14
+ * socket has been registered and the bot is actually connected.
15
+ * 4. Email (SMTP) — best-effort, only if
16
+ * SMTP_HOST is configured.
17
+ *
18
+ * Sinks never block each other — a failure in one (e.g. SMTP down)
19
+ * must not stop the log write or the other sinks.
20
+ *
21
+ * Kernel code must stay driver-agnostic: this module never imports the
22
+ * WhatsApp driver directly. Instead the driver calls
23
+ * registerAlertSockProvider() once at startup so this module can reach
24
+ * it indirectly, keeping the dependency pointing driver → kernel.
25
+ */
26
+ import fs from "fs/promises";
27
+ import path from "path";
28
+ import { spawn } from "child_process";
29
+ import nodemailer from "nodemailer";
30
+ import { CONFIG_DIR, ADMIN_JID, SMTP_HOST, SMTP_PORT, SMTP_SEC, SMTP_USER, SMTP_PASS, SMTP_FROM, SMTP_TO, SMTP_INSECURE, } from "#config";
31
+ import { logger } from "#logger";
32
+ const ALERTS_LOG_FILE = path.join(CONFIG_DIR, "alerts.log");
33
+ let sockProvider = null;
34
+ /**
35
+ * Called once by a driver (e.g. the WhatsApp driver) at startup so alerts
36
+ * can reach ADMIN_JID when the bot is connected. Safe to call multiple
37
+ * times — the latest provider wins.
38
+ * @param {() => SockLike | null} provider
39
+ */
40
+ export function registerAlertSockProvider(provider) {
41
+ sockProvider = provider;
42
+ }
43
+ // ── Sinks ─────────────────────────────────────────────────────────────────
44
+ async function logToFile(event) {
45
+ try {
46
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
47
+ const line = `[${new Date().toISOString()}] [${event.level.toUpperCase()}] ${event.title} — ${event.message}\n`;
48
+ await fs.appendFile(ALERTS_LOG_FILE, line, "utf8");
49
+ }
50
+ catch (e) {
51
+ // Last-resort fallback — if even the log write fails, at least surface
52
+ // it on stderr so it's visible in whatever is supervising the process.
53
+ logger.error(`[alerts] failed to write ${ALERTS_LOG_FILE}: ${e.message}`);
54
+ }
55
+ }
56
+ function notifyOS(event) {
57
+ return new Promise((resolve) => {
58
+ const platform = process.platform;
59
+ let cmd;
60
+ let args;
61
+ if (platform === "linux") {
62
+ cmd = "notify-send";
63
+ args = [event.title, event.message];
64
+ }
65
+ else if (platform === "darwin") {
66
+ cmd = "osascript";
67
+ args = ["-e", `display notification ${JSON.stringify(event.message)} with title ${JSON.stringify(event.title)}`];
68
+ }
69
+ else {
70
+ // Windows toast notifications need extra tooling (BurntToast) that
71
+ // isn't available out of the box — skip rather than half-implement.
72
+ logger.debug(`[alerts] OS notification not supported on ${platform}, skipping`);
73
+ resolve();
74
+ return;
75
+ }
76
+ const proc = spawn(cmd, args, { stdio: "ignore" });
77
+ proc.on("error", (e) => {
78
+ logger.debug(`[alerts] OS notification failed (non-fatal): ${e.message}`);
79
+ resolve();
80
+ });
81
+ proc.on("exit", () => resolve());
82
+ });
83
+ }
84
+ /**
85
+ * Accepts either a full JID ("5511999999999@s.whatsapp.net") or a bare
86
+ * phone number ("+55 11 99999-9999", "5511999999999") in ADMIN_JID —
87
+ * strips formatting and appends the WhatsApp suffix when missing.
88
+ * @param {string} raw
89
+ */
90
+ function normalizeAdminJid(raw) {
91
+ if (raw.includes("@"))
92
+ return raw;
93
+ const digits = raw.replace(/\D/g, "");
94
+ return `${digits}@s.whatsapp.net`;
95
+ }
96
+ async function notifyWhatsApp(event) {
97
+ if (!ADMIN_JID)
98
+ return;
99
+ const sock = sockProvider?.();
100
+ if (!sock)
101
+ return; // bot not connected — expected during the exact outages this exists for
102
+ try {
103
+ await sock.sendMessage(normalizeAdminJid(ADMIN_JID), { text: `*[${event.level.toUpperCase()}] ${event.title}*\n\n${event.message}` });
104
+ }
105
+ catch (e) {
106
+ logger.debug(`[alerts] WhatsApp sink failed (non-fatal): ${e.message}`);
107
+ }
108
+ }
109
+ let mailer = null;
110
+ function getMailer() {
111
+ if (!SMTP_HOST || !SMTP_TO)
112
+ return null;
113
+ if (mailer)
114
+ return mailer;
115
+ mailer = nodemailer.createTransport({
116
+ host: SMTP_HOST,
117
+ port: SMTP_PORT,
118
+ secure: SMTP_SEC === "ssl",
119
+ requireTLS: SMTP_SEC === "starttls",
120
+ auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
121
+ // Local SMTP proxies (Proton Mail Bridge, Mailhog, Mailpit...) present
122
+ // a self-signed cert — only skip validation when explicitly opted in.
123
+ tls: SMTP_INSECURE ? { rejectUnauthorized: false } : undefined,
124
+ });
125
+ return mailer;
126
+ }
127
+ async function notifyEmail(event) {
128
+ const transport = getMailer();
129
+ if (!transport)
130
+ return;
131
+ try {
132
+ await transport.sendMail({
133
+ from: SMTP_FROM || SMTP_USER,
134
+ to: SMTP_TO,
135
+ subject: `[manybot] [${event.level.toUpperCase()}] ${event.title}`,
136
+ text: event.message,
137
+ });
138
+ }
139
+ catch (e) {
140
+ logger.debug(`[alerts] email sink failed (non-fatal): ${e.message}`);
141
+ }
142
+ }
143
+ // ── Public API ────────────────────────────────────────────────────────────
144
+ /**
145
+ * Fires an alert through every configured sink. The log write always
146
+ * happens; OS notification, WhatsApp, and email are best-effort and run
147
+ * independently — one failing never blocks the others.
148
+ * @param {AlertEvent} event
149
+ */
150
+ export async function sendAlert(event) {
151
+ await logToFile(event);
152
+ await Promise.allSettled([
153
+ notifyOS(event),
154
+ notifyWhatsApp(event),
155
+ notifyEmail(event),
156
+ ]);
157
+ }
158
+ export function fireAlert(kind, details = {}) {
159
+ let event;
160
+ if (kind === "send_failed_no_fallback") {
161
+ event = {
162
+ level: "critical",
163
+ title: "manybot: sem driver de fallback",
164
+ message: `jid=${details.jid} primary=${details.primary}`,
165
+ };
166
+ }
167
+ else if (kind === "send_failed_both_drivers") {
168
+ event = {
169
+ level: "critical",
170
+ title: "manybot: envio falhou nos dois drivers",
171
+ message: `jid=${details.jid} ${details.primary}->${details.secondary}` +
172
+ (details.error ? ` error=${String(details.error)}` : ""),
173
+ };
174
+ }
175
+ else if (kind === "whatsmeow_subprocess_halted") {
176
+ event = {
177
+ level: "critical",
178
+ title: "manybot: subprocesso whatsmeow halted",
179
+ message: `fallback indisponível: ${details.reason ?? "unknown"} — bot segue só com Baileys`,
180
+ };
181
+ }
182
+ else {
183
+ event = {
184
+ level: "warning",
185
+ title: kind,
186
+ message: JSON.stringify(details),
187
+ };
188
+ }
189
+ void sendAlert(event);
190
+ }