@manybot/manybot 5.6.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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";
@@ -27,7 +28,7 @@ import { waitForSendSlot, simulateState, typingDuration, mediaDuration, waitForE
27
28
  import { sendWithFallback } from "#kernel/sendFallbackGuard.js";
28
29
  import { buildSettingsApi } from "#settingsdb";
29
30
  import WebP from "node-webpmux";
30
- import { getAggregateVotesInPollMessage, decryptPollVote, jidNormalizedUser, } from "@whiskeysockets/baileys";
31
+ import { jidNormalizedUser, } from "@whiskeysockets/baileys";
31
32
  // ── Raw-Baileys escape hatch ─────────────────────────────────────────────────
32
33
  //
33
34
  // This whole file is the Baileys driver's plugin-context builder, so the
@@ -222,11 +223,11 @@ function bindGroupMetaInvalidation(contract) {
222
223
  if (groupMetaInvalidationBound)
223
224
  return;
224
225
  groupMetaInvalidationBound = true;
225
- const sock = rawSocketOf(contract);
226
- const ev = sock.ev;
227
- ev.on("group-participants.update", (u) => groupMetaCache.delete(u.id));
228
- ev.on("groups.update", (updates) => {
229
- for (const u of updates)
226
+ contract.on("group-participants.update", (u) => {
227
+ groupMetaCache.delete(u.id);
228
+ });
229
+ contract.on("groups.update", (p) => {
230
+ for (const u of p.updates)
230
231
  if (u.id)
231
232
  groupMetaCache.delete(u.id);
232
233
  });
@@ -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,
@@ -1289,20 +1321,58 @@ function buildSetupSendApi(contract, store) {
1289
1321
  },
1290
1322
  };
1291
1323
  }
1292
- // ── Events API ────────────────────────────────────────────────────────────────
1324
+ const WA_EVENT_NAMES = new Set([
1325
+ "messages.upsert",
1326
+ "messages.update",
1327
+ "messages.delete",
1328
+ "messaging-history.set",
1329
+ "chats.upsert",
1330
+ "chats.update",
1331
+ "chats.delete",
1332
+ "contacts.upsert",
1333
+ "contacts.update",
1334
+ "group-participants.update",
1335
+ "groups.upsert",
1336
+ "groups.update",
1337
+ "group.join-request",
1338
+ "blocklist.set",
1339
+ "blocklist.update",
1340
+ "connection.update",
1341
+ ]);
1342
+ function assertSupportedEvent(event) {
1343
+ if (!WA_EVENT_NAMES.has(event)) {
1344
+ throw new Error(`[events] unsupported event "${event}". Supported: ${[...WA_EVENT_NAMES].join(", ")}. ` +
1345
+ `If you need this event, file an issue — adding events to WaEventName is a contract change.`);
1346
+ }
1347
+ }
1293
1348
  const listenerRegistry = new Map();
1294
- export function cleanupPluginEvents(pluginName, contract) {
1295
- const sock = rawSocketOf(contract);
1349
+ export function cleanupPluginEvents(pluginName, _contract) {
1296
1350
  const list = listenerRegistry.get(pluginName);
1297
1351
  if (list) {
1298
- for (const { event, handler } of list) {
1352
+ for (const ref of list) {
1299
1353
  try {
1300
- sock.ev.off(event, handler);
1354
+ ref.detach();
1301
1355
  }
1302
1356
  catch { }
1303
1357
  }
1304
1358
  listenerRegistry.delete(pluginName);
1305
1359
  }
1360
+ // The poll-vote subscription is created in `buildPollApi` by calling
1361
+ // `contract.on(...)` directly, so it lives outside `listenerRegistry`.
1362
+ // Detach it here so the listener doesn't outlive the plugin (and a
1363
+ // reloaded plugin can re-subscribe — `buildPollApi` gates on the
1364
+ // `boundPollPlugins` map).
1365
+ const pollDetach = boundPollPlugins.get(pluginName);
1366
+ if (pollDetach) {
1367
+ try {
1368
+ pollDetach();
1369
+ }
1370
+ catch { }
1371
+ boundPollPlugins.delete(pluginName);
1372
+ }
1373
+ // Drop the per-plugin poll registry so reloading a plugin doesn't see
1374
+ // stale PollHandles from a prior instance.
1375
+ pollRegistry.delete(pluginName);
1306
1376
  cancelPlugin(pluginName);
1307
1377
  }
1308
1378
  /**
@@ -1310,21 +1380,26 @@ export function cleanupPluginEvents(pluginName, contract) {
1310
1380
  * @param {string} pluginName
1311
1381
  */
1312
1382
  function buildEventsApi(contract, pluginName) {
1313
- const sock = rawSocketOf(contract);
1314
1383
  return {
1315
1384
  on(event, handler) {
1316
- sock.ev.on(event, handler);
1385
+ assertSupportedEvent(event);
1386
+ const wrapped = (payload) => handler(payload);
1387
+ const detach = contract.on(event, wrapped);
1317
1388
  if (!listenerRegistry.has(pluginName))
1318
1389
  listenerRegistry.set(pluginName, new Set());
1319
- const ref = { event, handler };
1390
+ const ref = { event, handler: wrapped, detach };
1320
1391
  listenerRegistry.get(pluginName).add(ref);
1321
1392
  return () => {
1322
- sock.ev.off(event, handler);
1393
+ try {
1394
+ detach();
1395
+ }
1396
+ catch { }
1323
1397
  listenerRegistry.get(pluginName)?.delete(ref);
1324
1398
  };
1325
1399
  },
1326
1400
  once(event) {
1327
- return new Promise(resolve => {
1401
+ assertSupportedEvent(event);
1402
+ return new Promise((resolve) => {
1328
1403
  const off = this.on(event, (data) => { off(); resolve(data); });
1329
1404
  });
1330
1405
  },
@@ -1332,8 +1407,12 @@ function buildEventsApi(contract, pluginName) {
1332
1407
  const list = listenerRegistry.get(pluginName);
1333
1408
  if (!list)
1334
1409
  return;
1335
- for (const { event, handler } of list)
1336
- sock.ev.off(event, handler);
1410
+ for (const ref of list) {
1411
+ try {
1412
+ ref.detach();
1413
+ }
1414
+ catch { }
1415
+ }
1337
1416
  listenerRegistry.delete(pluginName);
1338
1417
  },
1339
1418
  };
@@ -1531,11 +1610,15 @@ function buildMeApi(contract) {
1531
1610
  }
1532
1611
  // ── Poll API ──────────────────────────────────────────────────────────────────
1533
1612
  const pollRegistry = new Map();
1534
- // Rebinding must happen per socket instance a plugin name alone doesn't
1535
- // tell us whether the listener is bound to the CURRENT (post-reconnect)
1536
- // sock.ev or a dead one from before. WeakMap keyed by sock lets old
1537
- // entries fall off automatically once that socket is garbage collected.
1538
- const pollListenersBySocket = new WeakMap();
1613
+ // Per-process map of plugin names whose poll-vote subscription is bound,
1614
+ // to its unsubscribe handle. The contract handles rebinding to a fresh
1615
+ // socket on reconnect (its `on()` returns an unsubscribe that drops
1616
+ // cleanly on the dead socket's fan-out once it's torn down), so a simple
1617
+ // per-process map is enough — no per-sock WeakMap needed anymore.
1618
+ // `cleanupPluginEvents` MUST call the stored detach handle on unload so
1619
+ // the listener doesn't outlive the plugin and so a reloaded plugin can
1620
+ // re-subscribe.
1621
+ const boundPollPlugins = new Map();
1539
1622
  /**
1540
1623
  * Tracks votes for an active poll.
1541
1624
  * Obtained via ctx.poll.create().
@@ -1606,59 +1689,29 @@ function buildPollApi(contract, store, rawJid, guardOptions, pluginName) {
1606
1689
  pollRegistry.set(pluginName, new Map());
1607
1690
  const registry = pollRegistry.get(pluginName);
1608
1691
  // Keyed by creationId -> (voterKey -> latest vote entry). WhatsApp resends
1609
- // the *entire current selection* on every tap (not a diff), and Baileys'
1610
- // getAggregateVotesInPollMessage() replays whatever pollUpdates you give it
1611
- // with no dedup — so we must keep only the latest entry per voter ourselves,
1612
- // or retracted/changed votes keep counting alongside the new one.
1692
+ // the *entire current selection* on every tap (not a diff), and the
1693
+ // aggregatePollVotes() contract method replays whatever pollUpdates you
1694
+ // give it with no dedup — so we must keep only the latest entry per
1695
+ // voter ourselves, or retracted/changed votes keep counting alongside
1696
+ // the new one.
1613
1697
  const pollVotesByCreationId = new Map();
1614
- // Poll decryption needs the raw Baileys socket (sock.ev for
1615
- // messages.upsert, sock.user for self-JID shape), so we lift it off the
1616
- // contract here. The WeakMap key is the socket itself so old listeners
1617
- // fall off automatically when the socket is replaced (reconnect).
1618
- const sock = rawSocketOf(contract);
1619
- let boundPlugins = pollListenersBySocket.get(sock);
1620
- if (!boundPlugins) {
1621
- boundPlugins = new Set();
1622
- pollListenersBySocket.set(sock, boundPlugins);
1623
- }
1624
- if (!boundPlugins.has(pluginName)) {
1625
- boundPlugins.add(pluginName);
1626
- const meId = sock.user?.id ? jidNormalizedUser(sock.user.id) : "me";
1627
- // WhatsApp doesn't consistently use the same JID shape (LID vs PN) for
1628
- // pollCreatorJid/voterJid when deriving the poll-vote decryption key —
1629
- // it depends on addressingMode, 1:1 vs group, and which side sent last.
1630
- // Trying to compute "the" correct JID up front (as the old resolveAuthor
1631
- // did, always preferring participantPn) causes AES-GCM auth failures
1632
- // whenever WhatsApp actually used the LID for that message. Instead,
1633
- // gather every plausible JID for each side and brute-force combinations
1634
- // until one decrypts successfully — see
1635
- // https://github.com/WhiskeySockets/Baileys/issues/2342 and #1678.
1636
- function jidCandidates(key) {
1637
- const cands = [];
1638
- if (key.fromMe) {
1639
- const selfLid = sock.user?.lid;
1640
- if (selfLid)
1641
- cands.push(jidNormalizedUser(selfLid));
1642
- if (sock.user?.id)
1643
- cands.push(jidNormalizedUser(sock.user.id));
1644
- }
1645
- else {
1646
- const rawParticipant = key.participant ?? key.remoteJid;
1647
- if (rawParticipant)
1648
- cands.push(jidNormalizedUser(rawParticipant));
1649
- if (key.participantPn)
1650
- cands.push(jidNormalizedUser(key.participantPn));
1651
- if (rawParticipant?.endsWith("@lid")) {
1652
- const resolved = store.resolveJid(rawParticipant);
1653
- if (resolved && resolved !== rawParticipant)
1654
- cands.push(jidNormalizedUser(resolved));
1655
- }
1656
- }
1657
- return Array.from(new Set(cands.filter(Boolean)));
1658
- }
1659
- sock.ev.on("messages.upsert", async ({ messages: msgs }) => {
1698
+ // Poll decryption is Baileys-specific. Both the subscription
1699
+ // (`messages.upsert`) and the decryption/aggregation go through the
1700
+ // contract `buildPollApi` doesn't touch the raw socket anymore. The
1701
+ // contract's optional methods are only implemented by the Baileys
1702
+ // adapter; drivers that don't have poll decryption leave them off the
1703
+ // contract and we silently no-op here (a bot on a non-Baileys driver
1704
+ // simply can't track poll votes).
1705
+ if (!boundPollPlugins.has(pluginName) &&
1706
+ typeof contract.decryptPollVote === "function" &&
1707
+ typeof contract.aggregatePollVotes === "function") {
1708
+ const detach = contract.on("messages.upsert", async ({ messages: msgs }) => {
1660
1709
  for (const msg of msgs) {
1661
- const pum = msg.message?.pollUpdateMessage;
1710
+ // Filter poll-update messages by reading the raw envelope from
1711
+ // the store (the neutral `BotMessage` doesn't carry the
1712
+ // `pollUpdateMessage` field — it's a Baileys-proto detail).
1713
+ const raw = store.messages.get(msg.chatId)?.get(msg.id);
1714
+ const pum = raw?.message?.pollUpdateMessage;
1662
1715
  if (!pum)
1663
1716
  continue;
1664
1717
  const creationKey = pum.pollCreationMessageKey;
@@ -1671,44 +1724,44 @@ function buildPollApi(contract, store, rawJid, guardOptions, pluginName) {
1671
1724
  if (!storeMsg || !pollEncKeyRaw || !pum.vote)
1672
1725
  continue;
1673
1726
  try {
1674
- const pollEncKey = Buffer.isBuffer(pollEncKeyRaw)
1675
- ? pollEncKeyRaw
1676
- : Buffer.from(pollEncKeyRaw, "base64");
1677
- const creatorCandidates = jidCandidates((creationKey ?? {}));
1678
- const voterCandidates = jidCandidates(msg.key);
1679
- let decryptedVote;
1680
- for (const pollCreatorJid of creatorCandidates) {
1681
- for (const voterJid of voterCandidates) {
1682
- try {
1683
- decryptedVote = decryptPollVote(pum.vote, {
1684
- pollEncKey,
1685
- pollCreatorJid,
1686
- pollMsgId: creationId,
1687
- voterJid,
1688
- });
1689
- break;
1690
- }
1691
- catch {
1692
- // try next JID combination
1693
- }
1694
- }
1695
- if (decryptedVote)
1696
- break;
1697
- }
1698
- if (!decryptedVote) {
1699
- throw new Error(`all JID combinations failed (creator=${JSON.stringify(creatorCandidates)}, voter=${JSON.stringify(voterCandidates)})`);
1727
+ const decrypted = await contract.decryptPollVote({
1728
+ voteKey: {
1729
+ id: msg.id,
1730
+ remoteJid: msg.chatId,
1731
+ fromMe: msg.fromMe,
1732
+ participant: msg.participantAlt ?? msg.fromLid ?? msg.fromPn ?? null,
1733
+ },
1734
+ pollKey: {
1735
+ id: creationId,
1736
+ remoteJid: creationKey?.remoteJid ?? null,
1737
+ fromMe: null,
1738
+ participant: creationKey?.participant ?? null,
1739
+ },
1740
+ pollEncKey: pollEncKeyRaw,
1741
+ });
1742
+ if (!decrypted) {
1743
+ throw new Error("decryptPollVote returned null — JID candidate exhaustion or stale enc key");
1700
1744
  }
1701
- const voterKey = msg.key.fromMe
1702
- ? meId
1703
- : jidNormalizedUser(msg.key.participant ?? msg.key.remoteJid ?? "");
1745
+ const voterKey = msg.fromMe
1746
+ ? (contract.me().id ?? "me")
1747
+ : jidNormalizedUser(msg.participantAlt ?? msg.fromLid ?? msg.chatId);
1704
1748
  const votesByVoter = pollVotesByCreationId.get(creationId) ?? new Map();
1705
1749
  votesByVoter.set(voterKey, {
1706
- pollUpdateMessageKey: msg.key,
1707
- vote: decryptedVote,
1750
+ pollUpdateMessageKey: { id: msg.id, remoteJid: msg.chatId, fromMe: msg.fromMe },
1751
+ vote: decrypted,
1708
1752
  senderTimestampMs: pum.senderTimestampMs,
1709
1753
  });
1710
1754
  pollVotesByCreationId.set(creationId, votesByVoter);
1711
- const aggregated = getAggregateVotesInPollMessage({ message: storeMsg.message, pollUpdates: Array.from(votesByVoter.values()) }, meId);
1755
+ const pollAggregateOpts = contract.aggregatePollVotes;
1756
+ const aggregated = pollAggregateOpts({
1757
+ pollKey: {
1758
+ id: creationId,
1759
+ remoteJid: creationKey?.remoteJid ?? null,
1760
+ fromMe: null,
1761
+ },
1762
+ votes: Array.from(votesByVoter.values()).map((v) => v.vote),
1763
+ selfJid: contract.me().id ?? undefined,
1764
+ });
1712
1765
  handle._updateFromAggregated(aggregated);
1713
1766
  }
1714
1767
  catch (err) {
@@ -1716,6 +1769,7 @@ function buildPollApi(contract, store, rawJid, guardOptions, pluginName) {
1716
1769
  }
1717
1770
  }
1718
1771
  });
1772
+ boundPollPlugins.set(pluginName, detach);
1719
1773
  }
1720
1774
  const cooldown = (guardOptions.cooldown ?? true);
1721
1775
  const jitter = (guardOptions.jitter ?? true);
@@ -1831,25 +1885,6 @@ export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginNam
1831
1885
  const cooldown = (guardOptions.cooldown ?? true);
1832
1886
  const jitter = (guardOptions.jitter ?? true);
1833
1887
  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
1888
  // Group participant JIDs come back in whatever addressing mode the group
1854
1889
  // uses (@lid or @s.whatsapp.net/@c.us) — same issue as poll vote decryption.
1855
1890
  // "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) {