@manybot/manybot 5.6.0 → 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.
@@ -25,12 +25,111 @@
25
25
  import { normalizeMessageContent, downloadMediaMessage, jidNormalizedUser, } from "@whiskeysockets/baileys";
26
26
  import { createHash } from "node:crypto";
27
27
  import { logger } from "#logger";
28
+ /**
29
+ * Classify a Baileys message-content payload (`WAMessageContent`) into
30
+ * the neutral `(type, body, mimetype)` triple used by `BotMessage`. Pure
31
+ * function — no side effects, no closures — so it's safe to call for
32
+ * both real incoming messages and the embedded `quotedMessage` content
33
+ * carried in `contextInfo` (which can be an `ephemeralMessage`/
34
+ * `viewOnceMessage` wrapper, hence the `normalizeMessageContent` call).
35
+ *
36
+ * Exported so `api/index.ts` can decode the quoted payload when
37
+ * synthesizing the `BotMessage` returned by `getReply()`.
38
+ */
39
+ export function decodeContent(content) {
40
+ const m = normalizeMessageContent(content) ?? undefined;
41
+ let type = "other";
42
+ let body = "";
43
+ let mimetype;
44
+ if (m?.conversation) {
45
+ type = "text";
46
+ body = m.conversation;
47
+ }
48
+ else if (m?.extendedTextMessage?.text) {
49
+ type = "text";
50
+ body = m.extendedTextMessage.text ?? "";
51
+ }
52
+ else if (m?.imageMessage) {
53
+ type = "image";
54
+ body = m.imageMessage.caption ?? "";
55
+ mimetype = m.imageMessage.mimetype ?? undefined;
56
+ }
57
+ else if (m?.videoMessage) {
58
+ type = "video";
59
+ body = m.videoMessage.caption ?? "";
60
+ mimetype = m.videoMessage.mimetype ?? undefined;
61
+ }
62
+ else if (m?.audioMessage) {
63
+ type = "audio";
64
+ mimetype = m.audioMessage.mimetype ?? undefined;
65
+ }
66
+ else if (m?.documentMessage) {
67
+ type = "document";
68
+ body = m.documentMessage.caption ?? "";
69
+ mimetype = m.documentMessage.mimetype ?? undefined;
70
+ }
71
+ else if (m?.stickerMessage) {
72
+ type = "sticker";
73
+ mimetype = m.stickerMessage.mimetype ?? undefined;
74
+ }
75
+ return { type, body, mimetype };
76
+ }
28
77
  export function createBaileysAdapter(initial) {
29
78
  // mutable so rebind() can swap it; closure-scoped so the contract below
30
79
  // always sees the latest sock.
31
80
  let sock = initial.sock;
32
81
  const store = initial.store;
33
82
  // ── Adapter-local helpers ────────────────────────────────────────────────
83
+ /**
84
+ * Build the `quoted` option for `sock.sendMessage(jid, content, opts)`.
85
+ *
86
+ * Baileys expects the `quoted` field to be a full message-shaped object —
87
+ * `{ key: {...}, message: {...} }` — so it can extract both the attribution
88
+ * (stanzaId/participant/fromMe from `key`) and the preview content (from
89
+ * `message`) when generating contextInfo. Passing only `key` makes
90
+ * `generateWAMessageFromContent` call `normalizeMessageContent(undefined)`
91
+ * → `getContentType(undefined)` → `undefined`, then index it with `[]`
92
+ * and throw `Cannot read properties of undefined (reading 'undefined')`.
93
+ *
94
+ * We resolve the full envelope from the in-memory store, indexed by the
95
+ * (remoteJid, id) pair carried in the neutral `BotQuotedRef`. If the
96
+ * envelope is no longer present (evicted past MAX_MSGS_PER_CHAT, lost on
97
+ * restart, etc.) we return `undefined` rather than emit a half-formed
98
+ * `quoted` — degrading to a plain unquoted reply is safer than crashing
99
+ * the calling plugin.
100
+ */
101
+ function buildQuotedOpts(quoted) {
102
+ if (!quoted?.id || !quoted?.remoteJid)
103
+ return undefined;
104
+ const raw = store.messages.get(quoted.remoteJid)?.get(quoted.id);
105
+ const inner = raw?.message;
106
+ if (!inner)
107
+ return undefined; // safe fallback: no quoted at all
108
+ // `quoted` (used by sendMessage for reply-citation) is a
109
+ // message-shaped object: `{ key: {...}, message: {...} }`. Baileys
110
+ // reads `quoted.key.*` to populate contextInfo (stanzaId, participant,
111
+ // fromMe) — passing the fields flat used to misattribute the reply to
112
+ // the bot itself. `quoted.message` is also required: Baileys' internal
113
+ // `generateWAMessageFromContent` calls `normalizeMessageContent(
114
+ // quoted.message)` and indexes the result with `getContentType(...)`,
115
+ // which throws if `.message` is missing.
116
+ return { quoted: { key: toFlatKey(quoted), message: inner } };
117
+ }
118
+ /**
119
+ * Translate a neutral `BotQuotedRef` into the FLAT key shape Baileys
120
+ * expects for `react`/`delete`/`edit`/`readMessages` (proto.IMessageKey
121
+ * — `{ id, remoteJid, fromMe, participant }` directly, NOT nested under
122
+ * a `.key` field). The nested `{key, message}` form is reserved for the
123
+ * `quoted` field on sendMessage — see `buildQuotedOpts` above.
124
+ */
125
+ function toFlatKey(ref) {
126
+ return {
127
+ id: ref.id ?? null,
128
+ remoteJid: ref.remoteJid ?? "",
129
+ fromMe: !!ref.fromMe,
130
+ participant: ref.participant ?? undefined,
131
+ };
132
+ }
34
133
  /** Compute sha1-hex of a normalized buffer or string. */
35
134
  function sha1(input) {
36
135
  const hash = createHash("sha1");
@@ -45,40 +144,7 @@ export function createBaileysAdapter(initial) {
45
144
  /** Translate a Baileys WAMessage into the neutral BotMessage envelope. */
46
145
  function toBotMessage(msg) {
47
146
  const m = normalizeMessageContent(msg.message) ?? undefined;
48
- let type = "other";
49
- let body = "";
50
- let mimetype;
51
- if (m?.conversation) {
52
- type = "text";
53
- body = m.conversation;
54
- }
55
- else if (m?.extendedTextMessage?.text) {
56
- type = "text";
57
- body = m.extendedTextMessage.text ?? "";
58
- }
59
- else if (m?.imageMessage) {
60
- type = "image";
61
- body = m.imageMessage.caption ?? "";
62
- mimetype = m.imageMessage.mimetype ?? undefined;
63
- }
64
- else if (m?.videoMessage) {
65
- type = "video";
66
- body = m.videoMessage.caption ?? "";
67
- mimetype = m.videoMessage.mimetype ?? undefined;
68
- }
69
- else if (m?.audioMessage) {
70
- type = "audio";
71
- mimetype = m.audioMessage.mimetype ?? undefined;
72
- }
73
- else if (m?.documentMessage) {
74
- type = "document";
75
- body = m.documentMessage.caption ?? "";
76
- mimetype = m.documentMessage.mimetype ?? undefined;
77
- }
78
- else if (m?.stickerMessage) {
79
- type = "sticker";
80
- mimetype = m.stickerMessage.mimetype ?? undefined;
81
- }
147
+ const { type, body, mimetype } = decodeContent(msg.message);
82
148
  const key = msg.key;
83
149
  const contextInfo = m?.extendedTextMessage?.contextInfo ??
84
150
  m?.imageMessage?.contextInfo ??
@@ -86,6 +152,7 @@ export function createBaileysAdapter(initial) {
86
152
  m?.audioMessage?.contextInfo ??
87
153
  m?.documentMessage?.contextInfo ??
88
154
  undefined;
155
+ const ciTyped = contextInfo;
89
156
  return {
90
157
  id: msg.key.id ?? "",
91
158
  chatId: msg.key.remoteJid ?? "",
@@ -96,20 +163,27 @@ export function createBaileysAdapter(initial) {
96
163
  body,
97
164
  mimetype: mimetype ?? undefined,
98
165
  pushName: msg.pushName,
99
- mentionedJid: contextInfo?.mentionedJid ?? undefined,
100
- quotedKey: contextInfo?.stanzaId ? {
101
- id: contextInfo.stanzaId,
166
+ mentionedJid: ciTyped?.mentionedJid ?? undefined,
167
+ quotedKey: ciTyped?.stanzaId ? {
168
+ id: ciTyped.stanzaId,
102
169
  remoteJid: msg.key.remoteJid ?? undefined,
103
170
  fromMe: false,
104
- participant: contextInfo.participant ?? undefined,
171
+ participant: ciTyped.participant ?? undefined,
105
172
  } : undefined,
106
173
  fromLid: key.participantAlt,
107
174
  fromPn: key.participant,
108
175
  participantAlt: key.participantAlt,
109
176
  remoteJidAlt: key.remoteJidAlt,
110
- // Poll-decryption key lives here for poll vote decryption downstream.
177
+ // Driver-specific escape hatches:
178
+ // - pollEncKeyRaw: poll-decryption key for vote decryption
179
+ // - contextInfo: full IContextInfo (incl. embedded quotedMessage)
180
+ // so quoted-message consumers (api/index.ts
181
+ // buildMessageContext / downloadMedia fallback)
182
+ // can decode the quoted payload without going
183
+ // back through the store.
111
184
  _raw: {
112
185
  pollEncKeyRaw: m?.messageContextInfo?.messageSecret ?? undefined,
186
+ contextInfo: ciTyped ?? undefined,
113
187
  },
114
188
  };
115
189
  }
@@ -237,17 +311,17 @@ export function createBaileysAdapter(initial) {
237
311
  return toSentRef(ref, jid);
238
312
  },
239
313
  async react(jid, target, emoji) {
240
- const key = toBaileysKey(target);
314
+ const key = toFlatKey(target);
241
315
  await sock.sendMessage(jid, { react: { text: emoji, key } });
242
316
  },
243
317
  async deleteMessage(jid, target, forEveryone) {
244
318
  if (!forEveryone)
245
319
  return;
246
- const key = toBaileysKey(target);
320
+ const key = toFlatKey(target);
247
321
  await sock.sendMessage(jid, { delete: key });
248
322
  },
249
323
  async editMessage(jid, target, text) {
250
- const key = toBaileysKey(target);
324
+ const key = toFlatKey(target);
251
325
  await sock.sendMessage(jid, { text, edit: key });
252
326
  },
253
327
  // ── presence + read ───────────────────────────────────────────────────
@@ -258,7 +332,7 @@ export function createBaileysAdapter(initial) {
258
332
  await sock.sendPresenceUpdate(baileyState, jid);
259
333
  },
260
334
  async readMessages(keys) {
261
- const baileyKeys = keys.map(toBaileysKey);
335
+ const baileyKeys = keys.map((k) => toFlatKey(k));
262
336
  await sock.readMessages(baileyKeys);
263
337
  },
264
338
  // ── contacts ──────────────────────────────────────────────────────────
@@ -385,10 +459,30 @@ export function createBaileysAdapter(initial) {
385
459
  },
386
460
  // ── media (download) ───────────────────────────────────────────────────
387
461
  async downloadMedia(msg, opts) {
388
- // The poll-decryption raw envelope left only what's needed for that,
389
- // so to downloadMediaMessage we still need the original WAMessage.
390
- // Get it back from the store by id.
391
- const raw = store.messages.get(msg.chatId)?.get(msg.id);
462
+ // Resolve the Baileys message envelope needed by `downloadMediaMessage`.
463
+ // Preferred path: if the caller already carries the embedded message
464
+ // payload (synthetic `BotMessage` for a quoted message, whose
465
+ // `_raw.contextInfo.quotedMessage` is the full WAMessageContent),
466
+ // build the envelope directly from that. This avoids the silent
467
+ // failure mode where the quoted message's original envelope has
468
+ // aged out of the store's per-chat ring buffer.
469
+ //
470
+ // Fallback: the regular case (downloading media for the incoming
471
+ // message itself, or any other BotMessage that has a real envelope
472
+ // in the store) — look it up by (chatId, id).
473
+ const embedded = msg._raw;
474
+ const embeddedContent = embedded?.contextInfo?.quotedMessage;
475
+ const raw = embeddedContent
476
+ ? {
477
+ key: {
478
+ id: embedded.contextInfo?.stanzaId ?? msg.id,
479
+ remoteJid: msg.chatId,
480
+ fromMe: false,
481
+ participant: embedded.contextInfo?.participant ?? undefined,
482
+ },
483
+ message: embeddedContent,
484
+ }
485
+ : store.messages.get(msg.chatId)?.get(msg.id);
392
486
  if (!raw)
393
487
  return null;
394
488
  try {
@@ -508,31 +602,10 @@ export function createBaileysAdapter(initial) {
508
602
  };
509
603
  }
510
604
  // ── Helpers used inside the adapter above ────────────────────────────────────
511
- function buildQuotedOpts(quoted) {
512
- if (!quoted)
513
- return undefined;
514
- return { quoted: toBaileysKey(quoted) };
515
- }
516
- function toBaileysKey(ref) {
517
- // Baileys expects `quoted` to be a message-shaped object with these
518
- // fields nested under `.key` (quoted.key.id / .remoteJid / .fromMe /
519
- // .participant) — it reads quoted.key.* to build contextInfo
520
- // (stanzaId, participant, fromMe). Passing them flat (as this used to)
521
- // means quoted.key is undefined inside Baileys, so participant/fromMe
522
- // resolve to undefined and the quoted reply gets misattributed to the
523
- // bot itself instead of the original sender. The quoted text still
524
- // rendered before this fix because WhatsApp's client resolves the
525
- // preview content locally via stanzaId — only the author attribution
526
- // was broken.
527
- return {
528
- key: {
529
- id: ref.id ?? null,
530
- remoteJid: ref.remoteJid ?? "",
531
- fromMe: !!ref.fromMe,
532
- participant: ref.participant ?? undefined,
533
- },
534
- };
535
- }
605
+ //
606
+ // `buildQuotedOpts` and `toFlatKey` are defined inside the
607
+ // `createBaileysAdapter` closure (so they can see the `store`) — only
608
+ // `toSentRef` and `silentBaileysLogger` remain module-scoped helpers.
536
609
  function toSentRef(raw, fallbackChatId) {
537
610
  const r = raw;
538
611
  return {
@@ -9,6 +9,7 @@
9
9
  * The ctx surface area is preserved so existing plugins stay compatible.
10
10
  */
11
11
  import { toBotMessage } from "#drivers/baileys/index.js";
12
+ import { decodeContent } from "#drivers/baileys/adapter.js";
12
13
  import { logger } from "#logger";
13
14
  import { t, createPluginT, reloadTranslations, getCurrentLang } from "#i18n";
14
15
  import { CONFIG, CONFIG_DIR } from "#config";
@@ -731,22 +732,53 @@ export function buildMessageContext(msg, contract, store, guardOptions = {}) {
731
732
  const jitter = guardOptions.jitter ?? true;
732
733
  const contextInfo = getContextInfo(msg);
733
734
  // Build a synthetic quoted BotMessage when the original envelope carries
734
- // a quotedMessage (the adapter pre-decoded it into msg.quotedKey).
735
- const quotedRaw = msg.quotedKey
736
- ? {
737
- id: msg.quotedKey.id ?? "",
738
- chatId: msg.chatId,
739
- fromMe: false,
740
- type: "other",
741
- contentHash: "",
742
- timestamp: 0,
743
- _raw: msg._raw ? {
744
- quotedMessage: msg._raw.quotedMessage,
745
- stanzaId: msg._raw.stanzaId,
746
- participant: msg._raw.participant,
747
- } : undefined,
748
- }
749
- : null;
735
+ // a quotedMessage (the adapter pre-decodes the full IContextInfo into
736
+ // msg._raw.contextInfo). The synthetic uses the same decodeContent
737
+ // helper as toBotMessage so type/body/mimetype reflect what's actually
738
+ // in the quoted payload — without this, msgHasMedia()/downloadMedia()
739
+ // on the result of getReply() always reported type=other / no media,
740
+ // even when the quoted message was an image/video/document/etc.
741
+ //
742
+ // We carry the same _raw.contextInfo on the synthetic so a recursive
743
+ // getReply().getReply() keeps working (the inner call re-reads
744
+ // getContextInfo off _raw.contextInfo).
745
+ const quotedRaw = contextInfo?.quotedMessage
746
+ ? (() => {
747
+ const decoded = decodeContent(contextInfo.quotedMessage);
748
+ return {
749
+ id: contextInfo.stanzaId ?? "",
750
+ chatId: msg.chatId,
751
+ fromMe: false,
752
+ type: decoded.type,
753
+ contentHash: "",
754
+ timestamp: 0,
755
+ body: decoded.body,
756
+ mimetype: decoded.mimetype,
757
+ _raw: {
758
+ contextInfo: {
759
+ stanzaId: contextInfo.stanzaId,
760
+ participant: contextInfo.participant,
761
+ mentionedJid: contextInfo.mentionedJid,
762
+ quotedMessage: contextInfo.quotedMessage,
763
+ },
764
+ },
765
+ };
766
+ })()
767
+ : msg.quotedKey
768
+ // No embedded quotedMessage (older envelopes, evicted from store, or
769
+ // the quoted message pre-dates contextInfo-quoting). Fall back to a
770
+ // key-only synthetic so hasReply()/getReply() still work, but
771
+ // hasMedia/downloadMedia on the result will degrade gracefully
772
+ // (type=other, mimetype=undefined).
773
+ ? {
774
+ id: msg.quotedKey.id ?? "",
775
+ chatId: msg.chatId,
776
+ fromMe: false,
777
+ type: "other",
778
+ contentHash: "",
779
+ timestamp: 0,
780
+ }
781
+ : null;
750
782
  return {
751
783
  id: msg.id,
752
784
  timestamp: msg.timestamp || 0,
@@ -1831,25 +1863,6 @@ export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginNam
1831
1863
  const cooldown = (guardOptions.cooldown ?? true);
1832
1864
  const jitter = (guardOptions.jitter ?? true);
1833
1865
  bindGroupMetaInvalidation(contract);
1834
- // Sender for quoted messages — synthesize a neutral BotQuotedRef off
1835
- // the adapter's pre-extracted contextInfo fields. No raw Baileys
1836
- // envelope access needed here.
1837
- const contextInfo = getContextInfo(msg);
1838
- const quotedRaw = contextInfo?.quotedMessage
1839
- ? {
1840
- id: contextInfo.stanzaId ?? "",
1841
- chatId: msg.chatId,
1842
- fromMe: false,
1843
- type: "other",
1844
- contentHash: "",
1845
- timestamp: 0,
1846
- _raw: {
1847
- quotedMessage: contextInfo.quotedMessage,
1848
- stanzaId: contextInfo.stanzaId,
1849
- participant: contextInfo.participant,
1850
- },
1851
- }
1852
- : null;
1853
1866
  // Group participant JIDs come back in whatever addressing mode the group
1854
1867
  // uses (@lid or @s.whatsapp.net/@c.us) — same issue as poll vote decryption.
1855
1868
  // "sender" and the bot's own JID are usually PN-normalized, so a straight
@@ -27,6 +27,7 @@ import { normalizeJid } from "#drivers/jid.js";
27
27
  import { loadPlugins, setupPlugins } from "#kernel/pluginLoader.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";
30
31
  import { startUpdateCheckSchedule, stopUpdateCheckSchedule } from "#kernel/updateCheck.js";
31
32
  import { setStatus } from "#kernel/statusServer.js";
32
33
  import { logger } from "#logger";
@@ -93,6 +94,12 @@ const RECONNECT_MAX_MS = 60000;
93
94
  // silently retrying past this point can make a restriction last longer.
94
95
  const MAX_RECONNECT_ATTEMPTS = 6;
95
96
  const CACHE_SAVE_INTERVAL_MS = 5 * 60 * 1000; // 5min
97
+ // Track consecutive restartRequired (515) — same counter space as
98
+ // reconnectAttempts but with a lower threshold for degradation since
99
+ // repeated 515 signals a protocol drift the current session can't
100
+ // recover from on its own.
101
+ const MAX_RESTART_REQUIRED = 3;
102
+ let restartRequiredCount = 0;
96
103
  /**
97
104
  * Loads the on-disk cache and merges it into `store` (union, never
98
105
  * overwrite — see client/cache.ts). Runs once per process: the shared
@@ -193,6 +200,7 @@ async function startBot() {
193
200
  if (connection === "open") {
194
201
  state = "READY_INIT";
195
202
  reconnectAttempts = 0;
203
+ restartRequiredCount = 0;
196
204
  setStatus(true);
197
205
  logger.success(t("system.connected"));
198
206
  logger.info(t("system.clientId", { id: CLIENT_ID }));
@@ -211,11 +219,19 @@ async function startBot() {
211
219
  if (connection === "close") {
212
220
  const code = lastDisconnect?.error?.output?.statusCode;
213
221
  const loggedOut = code === DisconnectReason.loggedOut;
222
+ const badSession = code === DisconnectReason.badSession;
223
+ const restartReq = code === DisconnectReason.restartRequired;
214
224
  state = "BOOT";
215
225
  setStatus(false, String(code));
216
226
  logger.warn(t("system.disconnected", { reason: String(code) }));
217
- if (loggedOut) {
218
- logger.warn(t("system.sessionExpired"));
227
+ if (loggedOut || badSession) {
228
+ if (badSession) {
229
+ logger.warn("Session data corrupted (badSession=500). Clearing session dir.");
230
+ getDriverManager().markDegraded("baileys", 300_000);
231
+ }
232
+ else {
233
+ logger.warn(t("system.sessionExpired"));
234
+ }
219
235
  try {
220
236
  await fs.rm(AUTH_DIR, { recursive: true, force: true });
221
237
  }
@@ -224,9 +240,27 @@ async function startBot() {
224
240
  }
225
241
  scheduleReconnect(1000);
226
242
  }
243
+ else if (restartReq) {
244
+ restartRequiredCount++;
245
+ if (restartRequiredCount >= MAX_RESTART_REQUIRED) {
246
+ halted = true;
247
+ logger.error(`restartRequired (515) recurring — protocol drift suspected. Halting.`);
248
+ getDriverManager().markDegraded("baileys", 600_000);
249
+ sendAlert({
250
+ level: "critical",
251
+ title: "manybot — restartRequired recurring",
252
+ message: `Protocol drift suspected after ${restartRequiredCount}x restartRequired. Bot halted on Baileys. Run connect() manually.`,
253
+ }).catch(() => { });
254
+ return;
255
+ }
256
+ const delay = Math.min(500, RECONNECT_BASE_MS);
257
+ logger.info(t("system.reconnecting", { secs: Math.round(delay / 1000) }));
258
+ scheduleReconnect(delay);
259
+ }
227
260
  else if (!shuttingDown) {
228
261
  if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
229
262
  halted = true;
263
+ getDriverManager().markDegraded("baileys", 600_000);
230
264
  logger.error(t("system.reconnectHalted", { attempts: reconnectAttempts }));
231
265
  sendAlert({
232
266
  level: "critical",
@@ -19,7 +19,7 @@ import pino from "pino";
19
19
  // setMaxListeners (see the comment in createSocket() below) — raised
20
20
  // globally instead, before makeWASocket() ever constructs one.
21
21
  EventEmitter.defaultMaxListeners = 50;
22
- export const AUTH_DIR = path.join(CONFIG_DIR, "sessions", CLIENT_ID);
22
+ export const AUTH_DIR = path.join(CONFIG_DIR, "sessions", CLIENT_ID, "baileys");
23
23
  // ── Shared store (survives socket reconnects) ─────────────────────────────────
24
24
  export const store = createStore();
25
25
  /**
@@ -1,9 +1,11 @@
1
1
  import { logger } from "#logger";
2
2
  import { CONFIG } from "#config";
3
+ import { t } from "#i18n";
3
4
  import * as grpc from "@grpc/grpc-js";
4
5
  import * as protoLoader from "@grpc/proto-loader";
5
6
  import path from "path";
6
7
  import { fileURLToPath } from "node:url";
8
+ import qrcode from "qrcode-terminal";
7
9
  /**
8
10
  * Whatsmeow gRPC client implementing the WaContract interface.
9
11
  *
@@ -50,7 +52,7 @@ class WhatsmeowClient {
50
52
  const address = CONFIG.drivers.whatsmeow.grpcAddress ?? "localhost:50051";
51
53
  const Service = this.loadProto();
52
54
  this.client = new Service(address, grpc.credentials.createInsecure());
53
- // Perform a health check to confirm service is ready
55
+ // 1. Health check confirm the gRPC server is up
54
56
  await new Promise((resolve, reject) => {
55
57
  this.client.HealthCheck({}, (err, resp) => {
56
58
  if (err)
@@ -62,12 +64,43 @@ class WhatsmeowClient {
62
64
  reject(new Error("Whatsmeow service not ready"));
63
65
  });
64
66
  });
65
- logger.info("[whatsmeow] connected via gRPC");
66
- // Open the server-streaming event subscription. Each WaEvent that
67
- // arrives is fanned out to the local on() subscribers in the
68
- // neutral envelope shape.
67
+ logger.info("[whatsmeow] gRPC service ready");
68
+ // 2. Call Connect RPC initiates WhatsApp auth (QR or reuse existing session)
69
+ const connectResp = await new Promise((resolve, reject) => {
70
+ this.client.Connect({}, (err, resp) => {
71
+ if (err)
72
+ return reject(err);
73
+ resolve(resp);
74
+ });
75
+ });
76
+ const needsAuth = !connectResp.ok;
77
+ if (needsAuth && connectResp.qrCode) {
78
+ logger.info(t("system.qrScan"));
79
+ qrcode.generate(connectResp.qrCode, { small: true });
80
+ }
81
+ // 3. Set up auth deferred BEFORE SubscribeEvents to avoid race
82
+ let authDeferred = null;
83
+ let authDone = false;
84
+ const authPromise = needsAuth
85
+ ? new Promise((resolve, reject) => {
86
+ authDeferred = { resolve, reject };
87
+ setTimeout(() => {
88
+ if (!authDone) {
89
+ authDone = true;
90
+ reject(new Error("Whatsmeow auth timeout (2 min)"));
91
+ }
92
+ }, 120_000);
93
+ })
94
+ : Promise.resolve();
95
+ // 4. Open the server-streaming event subscription
69
96
  const stream = this.client.SubscribeEvents({});
70
97
  stream.on("data", (raw) => {
98
+ // Resolve auth promise when connection opens (QR scanned / session reused)
99
+ if (!authDone && authDeferred && raw.connState?.state === "open") {
100
+ authDone = true;
101
+ authDeferred.resolve();
102
+ authDeferred = null;
103
+ }
71
104
  try {
72
105
  if (raw.connState) {
73
106
  const state = raw.connState.state ?? "connecting";
@@ -87,13 +120,29 @@ class WhatsmeowClient {
87
120
  }
88
121
  });
89
122
  stream.on("error", (err) => {
123
+ if (!authDone) {
124
+ authDone = true;
125
+ authDeferred?.reject(err);
126
+ authDeferred = null;
127
+ }
90
128
  logger.warn(`[whatsmeow] event stream error: ${err.message}`);
91
129
  this.ready = false;
92
130
  });
93
131
  stream.on("end", () => {
132
+ if (!authDone) {
133
+ authDone = true;
134
+ authDeferred?.reject(new Error("Event stream ended before auth completed"));
135
+ authDeferred = null;
136
+ }
94
137
  logger.warn(`[whatsmeow] event stream ended`);
95
138
  this.ready = false;
96
139
  });
140
+ // 5. If not authenticated, wait for connState === "open" from the event stream
141
+ await authPromise;
142
+ if (needsAuth) {
143
+ logger.info("[whatsmeow] authenticated");
144
+ }
145
+ this.ready = true;
97
146
  }
98
147
  async disconnect() {
99
148
  if (this.client) {
@@ -1,7 +1,7 @@
1
- import { mkdirSync, writeFileSync, chmodSync } from "node:fs";
1
+ import { mkdirSync, writeFileSync, chmodSync, existsSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import * as clack from "@clack/prompts";
4
- import { persistConfigValue } from "#config";
4
+ import { persistConfigValue, CONFIG_DIR } from "#config";
5
5
  import { t } from "#i18n";
6
6
  const SUPPORTED = [
7
7
  { os: "linux", arch: "x64", name: "whatsmeow-service-linux-x64" },
@@ -22,7 +22,7 @@ async function fetchLatestTag() {
22
22
  return data.tag_name ?? "v5.6.1";
23
23
  }
24
24
  function binaryDir() {
25
- return path.resolve(process.cwd(), "whatsmeow-service", "bin");
25
+ return path.resolve(CONFIG_DIR, "whatsmeow-service", "bin");
26
26
  }
27
27
  export async function promptWhatsmeowInstall() {
28
28
  const target = detectTarget();
@@ -30,6 +30,16 @@ export async function promptWhatsmeowInstall() {
30
30
  clack.log.warn(str(t("whatsmeow.unsupportedArch", { os: process.platform, arch: process.arch })));
31
31
  return;
32
32
  }
33
+ const outPath = path.join(binaryDir(), "whatsmeow-service");
34
+ // Binary already on disk → skip the prompt and the download, but
35
+ // make sure the config flag is set so the supervisor boots on the
36
+ // next run. The earlier "no, declined install" flow only writes the
37
+ // TOML on success, so users who later build the binary by hand also
38
+ // hit this branch on their next first-login.
39
+ if (existsSync(outPath)) {
40
+ await persistConfigValue("driver_whatsmeow_enabled", "true");
41
+ return;
42
+ }
33
43
  const choice = await clack.confirm({
34
44
  message: str(t("whatsmeow.installPrompt")),
35
45
  initialValue: false,
@@ -61,10 +71,16 @@ export async function promptWhatsmeowInstall() {
61
71
  const buffer = Buffer.from(await res.arrayBuffer());
62
72
  const dir = binaryDir();
63
73
  mkdirSync(dir, { recursive: true });
64
- const outPath = path.join(dir, "whatsmeow-service");
65
74
  writeFileSync(outPath, buffer);
66
75
  chmodSync(outPath, 0o755);
67
76
  await persistConfigValue("driver_whatsmeow_enabled", "true");
68
77
  spin.stop(str(t("whatsmeow.installed", { path: outPath })));
69
78
  clack.note(str(t("whatsmeow.restartNotice")), str(t("whatsmeow.installTitle")));
79
+ // The bot is still in its very first run (no Baileys session yet, no
80
+ // supervisor spawned) — the config has just been updated on disk but
81
+ // the in-memory `CONFIG` object and the supervisor lifecycle were
82
+ // initialized at startup with `whatsmeow.enabled = false`. Restarting
83
+ // is required for the new value to take effect. Exit cleanly so the
84
+ // user just re-runs the bot.
85
+ setTimeout(() => process.exit(0), 100);
70
86
  }
@@ -30,12 +30,13 @@
30
30
  * See the lifecycle contract this implements.
31
31
  */
32
32
  import { spawn } from "node:child_process";
33
- import { existsSync } from "node:fs";
33
+ import { existsSync, mkdirSync } from "node:fs";
34
34
  import path from "node:path";
35
35
  import { fileURLToPath } from "node:url";
36
- import { CONFIG } from "#config";
36
+ import { CONFIG, CONFIG_DIR, CLIENT_ID } from "#config";
37
37
  import { logger } from "#logger";
38
38
  import { fireAlert } from "#kernel/alerts.js";
39
+ import { getDriverManager } from "#kernel/driverManager.js";
39
40
  // ── Tunables (mirror drivers/baileys/index.ts:98-106) ──────────────────────
40
41
  const RECONNECT_BASE_MS = 1000;
41
42
  const RECONNECT_MAX_MS = 60_000;
@@ -49,7 +50,8 @@ const SHUTDOWN_GRACE_MS = 5_000;
49
50
  * first match that exists and is a regular file. Order:
50
51
  * 1. CONFIG.drivers.whatsmeow.binaryPath (explicit user choice)
51
52
  * 2. env WM_BINARY_PATH (escape hatch for exotic installs)
52
- * 3. dev layout (<cwd>/whatsmeow-service/bin/whatsmeow-service)
53
+ * 3. stable config dir (~/.manybot/whatsmeow-service/bin/whatsmeow-service)
54
+ * 4. dev layout (<cwd>/whatsmeow-service/bin/whatsmeow-service)
53
55
  * 4. npm-global layout (sibling of the node binary, /usr/local style)
54
56
  */
55
57
  function resolveBinaryPath() {
@@ -60,6 +62,8 @@ function resolveBinaryPath() {
60
62
  const fromEnv = process.env.WM_BINARY_PATH;
61
63
  if (fromEnv)
62
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"));
63
67
  // Dev: `<repo>/whatsmeow-service/bin/whatsmeow-service`
64
68
  candidates.push(path.resolve(process.cwd(), "whatsmeow-service", "bin", "whatsmeow-service"));
65
69
  // Global npm install: `<prefix>/bin/../share/manybot/bin/whatsmeow-service`
@@ -148,8 +152,12 @@ export async function startWhatsmeowSupervisor() {
148
152
  "whatsmeow-service/bin/whatsmeow-service relative to cwd. Bot will run on Baileys only.");
149
153
  return null;
150
154
  }
155
+ logger.info(`[supervisor] using binary: ${binary}`);
151
156
  const grpcAddress = CONFIG.drivers.whatsmeow.grpcAddress || "localhost:50051";
152
- const sessionDir = path.resolve(process.cwd(), "whatsmeow-session.db");
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 });
153
161
  const state = {
154
162
  proc: null,
155
163
  pid: null,
@@ -196,6 +204,7 @@ export async function startWhatsmeowSupervisor() {
196
204
  state.halted = true;
197
205
  state.ready = false;
198
206
  state.readyDeferred?.reject(new Error(reason));
207
+ getDriverManager().markDegraded("whatsmeow", 600_000);
199
208
  fireAlert("whatsmeow_subprocess_halted", { reason });
200
209
  }
201
210
  function scheduleRestart() {
@@ -220,7 +229,17 @@ export async function startWhatsmeowSupervisor() {
220
229
  let proc;
221
230
  try {
222
231
  proc = spawn(state.binary, ["--grpc-addr", state.addr, "--session-dir", state.sessionDir], {
223
- stdio: "ignore",
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}`);
224
243
  });
225
244
  }
226
245
  catch (e) {
@@ -81,11 +81,21 @@ export async function sendWithFallback(jid, text, opts = {}) {
81
81
  // waitForSendSlot is the same throttle the rest of the senders use
82
82
  // (fallback must respect rate-limit too).
83
83
  await waitForSendSlot(jid, { cooldown: true, jitter: true });
84
- const ref = await primary.sendText(jid, text, opts);
85
- if (await verifyDelivery(primary, jid, ref, drivers.verifyWindowMs)) {
86
- return ref;
84
+ let primaryRef = null;
85
+ let primarySendFailed = false;
86
+ try {
87
+ primaryRef = await primary.sendText(jid, text, opts);
88
+ }
89
+ catch (err) {
90
+ primarySendFailed = true;
91
+ logger.warn({ driver: primaryKey, jid, error: String(err) }, "send threw on primary");
92
+ }
93
+ if (!primarySendFailed) {
94
+ if (await verifyDelivery(primary, jid, primaryRef, drivers.verifyWindowMs)) {
95
+ return primaryRef;
96
+ }
97
+ logger.warn({ driver: primaryKey, jid, messageId: primaryRef.id }, "send not confirmed by primary");
87
98
  }
88
- logger.warn({ driver: primaryKey, jid, messageId: ref.id }, "send not confirmed by primary");
89
99
  dm.markDegraded(primaryKey, drivers.fallbackCooldownMs);
90
100
  const secondary = pickSecondary(dm, primaryKey);
91
101
  if (!secondary || !secondary.isReady()) {
@@ -94,7 +104,7 @@ export async function sendWithFallback(jid, text, opts = {}) {
94
104
  }
95
105
  try {
96
106
  const fallbackRef = await sendVia(secondary, jid, text, opts, drivers.verifyWindowMs, /*skipGuard=*/ true);
97
- logger.info({ driver: secondary.name, jid, messageId: fallbackRef.id, reason: "primary verification failed" }, "message sent via fallback");
107
+ logger.info({ driver: secondary.name, jid, messageId: fallbackRef.id, reason: primarySendFailed ? "send threw" : "primary verification failed" }, "message sent via fallback");
98
108
  return fallbackRef;
99
109
  }
100
110
  catch (err) {
@@ -71,7 +71,7 @@
71
71
  "connectGaveUp": "Couldn't connect after several attempts. Check your network and try again."
72
72
  },
73
73
  "whatsmeow": {
74
- "installPrompt": "Install whatsmeow driver for better fallback support?",
74
+ "installPrompt": "Install whatsmeow driver for fallback support? (EXPERIMENTAL — only text send & history work; other methods throw)",
75
75
  "unsupportedArch": "whatsmeow driver not available for {{os}}-{{arch}}. The bot will use Baileys only.",
76
76
  "fetchingTag": "Fetching latest whatsmeow release...",
77
77
  "fetchFailed": "Could not reach Codeberg. Skipping whatsmeow install.",
@@ -79,6 +79,7 @@
79
79
  "downloadFailed": "Download failed: {{reason}}",
80
80
  "installTitle": "whatsmeow driver",
81
81
  "installed": "whatsmeow driver installed at {{path}}",
82
- "restartNotice": "Restart the bot for the whatsmeow driver to take effect."
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."
83
84
  }
84
85
  }
@@ -71,7 +71,7 @@
71
71
  "connectGaveUp": "No se pudo conectar tras varios intentos. Revisa tu conexión e intenta de nuevo."
72
72
  },
73
73
  "whatsmeow": {
74
- "installPrompt": "¿Instalar el driver whatsmeow para mejor soporte de fallback?",
74
+ "installPrompt": "¿Instalar el driver whatsmeow para soporte de fallback? (EXPERIMENTAL — solo sendText e historial funcionan; otros métodos lanzan error)",
75
75
  "unsupportedArch": "Driver whatsmeow no disponible para {{os}}-{{arch}}. El bot usará solo Baileys.",
76
76
  "fetchingTag": "Obteniendo última versión de whatsmeow...",
77
77
  "fetchFailed": "No se pudo contactar a Codeberg. Instalación de whatsmeow omitida.",
@@ -79,6 +79,7 @@
79
79
  "downloadFailed": "Descarga fallida: {{reason}}",
80
80
  "installTitle": "Driver whatsmeow",
81
81
  "installed": "Driver whatsmeow instalado en {{path}}",
82
- "restartNotice": "Reinicia el bot para que el driver whatsmeow surta efecto."
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."
83
84
  }
84
85
  }
@@ -71,7 +71,7 @@
71
71
  "connectGaveUp": "Não foi possível conectar após várias tentativas. Verifique sua conexão e tente de novo."
72
72
  },
73
73
  "whatsmeow": {
74
- "installPrompt": "Instalar driver whatsmeow para melhor suporte a fallback?",
74
+ "installPrompt": "Instalar driver whatsmeow para suporte a fallback? (EXPERIMENTAL — apenas sendText e histórico funcionam; outros métodos lançam erro)",
75
75
  "unsupportedArch": "Driver whatsmeow não disponível para {{os}}-{{arch}}. O bot usará apenas Baileys.",
76
76
  "fetchingTag": "Buscando última versão do whatsmeow...",
77
77
  "fetchFailed": "Não foi possível acessar o Codeberg. Instalação do whatsmeow ignorada.",
@@ -79,6 +79,7 @@
79
79
  "downloadFailed": "Download falhou: {{reason}}",
80
80
  "installTitle": "Driver whatsmeow",
81
81
  "installed": "Driver whatsmeow instalado em {{path}}",
82
- "restartNotice": "Reinicie o bot para que o driver whatsmeow entre em efeito."
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."
83
84
  }
84
85
  }
package/dist/main.js CHANGED
@@ -11,6 +11,7 @@ process.env.NODE_PATH = path.resolve(process.cwd(), "node_modules");
11
11
  Module._initPaths();
12
12
  import { baileysContract } from "#drivers/baileys/index.js";
13
13
  import { whatsmeowContract, startWhatsmeowSupervisor, wrapWithSupervisor } from "#drivers/whatsmeow/index.js";
14
+ import { promptWhatsmeowInstall } from "#drivers/whatsmeow/installer.js";
14
15
  import { cleanupPlugins } from "#kernel/pluginLoader.js";
15
16
  import { stopAll as stopScheduler } from "#kernel/scheduler.js";
16
17
  import { sendAlert } from "#kernel/alerts.js";
@@ -35,12 +36,20 @@ driverManager.register(baileysContract, { isPrimary: CONFIG.drivers.primary ===
35
36
  // running on Baileys alone, no fallback.
36
37
  let supervisor = null;
37
38
  if (CONFIG.drivers.whatsmeow.enabled) {
39
+ logger.info("[driverManager] whatsmeow enabled — spawning supervisor");
38
40
  supervisor = await startWhatsmeowSupervisor();
39
41
  if (supervisor) {
40
42
  const wrapped = wrapWithSupervisor(whatsmeowContract, supervisor);
41
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");
42
48
  }
43
49
  }
50
+ else {
51
+ logger.info("[driverManager] whatsmeow disabled by config — no fallback");
52
+ }
44
53
  const activeDriver = driverManager.active();
45
54
  const secondaryName = (activeDriver.name === "baileys" ? "whatsmeow" : "baileys");
46
55
  const secondaryDriver = driverManager.get(secondaryName);
@@ -125,6 +134,16 @@ if (process.argv.includes("--getid")) {
125
134
  process.exit(1);
126
135
  });
127
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
+ }
128
147
  else {
129
148
  // Start bot
130
149
  logger.info(t("bot.initialized"));
@@ -143,10 +162,14 @@ else {
143
162
  // here is non-fatal: the primary keeps running, fallback just stays
144
163
  // unavailable (sendFallbackGuard's `isReady()` check covers that).
145
164
  if (secondaryDriver) {
165
+ logger.info(`[driverManager] connecting secondary "${secondaryName}" in background…`);
146
166
  secondaryDriver.connect()
147
167
  .then(() => logger.info(`[driverManager] secondary "${secondaryName}" connected — fallback available`))
148
168
  .catch((err) => logger.warn(`[driverManager] secondary "${secondaryName}" connect failed: ${err.message} — fallback unavailable`));
149
169
  }
170
+ else {
171
+ logger.info(`[driverManager] no secondary driver registered — running on ${activeDriver.name} only`);
172
+ }
150
173
  })
151
174
  .catch((err) => {
152
175
  shutdown(`Failed to connect driver: ${err.message}`, true);
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "name": "SyntaxError!",
6
6
  "email": "me@stxerr.dev"
7
7
  },
8
- "version": "5.6.0",
8
+ "version": "5.6.1",
9
9
  "license": "GPL-3.0-only",
10
10
  "private": false,
11
11
  "engines": {