@manybot/manybot 5.5.3 → 5.6.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.
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 +11 -0
  4. package/dist/config.js +230 -12
  5. package/dist/drivers/baileys/adapter.js +556 -0
  6. package/dist/drivers/{whatsapp → baileys}/api/index.js +533 -386
  7. package/dist/drivers/baileys/index.js +560 -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 +11 -31
  11. package/dist/drivers/jid.js +31 -0
  12. package/dist/drivers/types.js +14 -0
  13. package/dist/drivers/whatsmeow/client.js +203 -0
  14. package/dist/drivers/whatsmeow/index.js +79 -0
  15. package/dist/drivers/whatsmeow/installer.js +70 -0
  16. package/dist/drivers/whatsmeow/supervisor.js +309 -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 +173 -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 +15 -1
  30. package/dist/locales/es.json +15 -1
  31. package/dist/locales/pt.json +15 -1
  32. package/dist/main.js +77 -5
  33. package/dist/types.js +18 -11
  34. package/package.json +7 -9
  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,200 @@
1
+ /**
2
+ * contactAutoSave.ts
3
+ *
4
+ * Gradually saves incoming senders as real contacts (using their pushName)
5
+ * once they've shown they're likely to keep talking, and periodically
6
+ * refreshes stale pushNames. Purely additive to sendGuard's
7
+ * anti-detection posture — a saved contact scores as "known" rather than
8
+ * "stranger" on WhatsApp's contact-graph-distance signal, and mobile
9
+ * clients resolve @mentions to a name instead of raw digits.
10
+ *
11
+ * Rules:
12
+ * - DMs: every message from the sender counts toward the DM threshold.
13
+ * - Groups: only messages that actually invoke the bot (hasPrefix) count
14
+ * toward the group threshold, so silent group members are never
15
+ * auto-added.
16
+ * - DM and group progress are tracked as two SEPARATE counters per
17
+ * sender, each with its own randomized target — a person who first
18
+ * messages in a group and later DMs the bot doesn't get to skip the
19
+ * (deliberately higher) group threshold via their DM count, or vice
20
+ * versa. Saving happens as soon as EITHER counter reaches its target.
21
+ * - The thresholds are randomized per sender (not fixed), so the save
22
+ * timing doesn't look scripted.
23
+ * - Saves happen one at a time, paced naturally by how often people
24
+ * actually talk to the bot — never a bulk import.
25
+ * - If the actual contact-save call fails (e.g. transient error), the
26
+ * sender is NOT marked as saved — the next qualifying message retries
27
+ * automatically, rather than waiting for the 30-day refresh cycle.
28
+ * - Refresh: occasionally, a small random sample of long-saved contacts
29
+ * get removed and are transparently re-added on their next message,
30
+ * picking up pushName changes instead of keeping a stale name forever.
31
+ *
32
+ * Storage: each sender is its own key (not one shared blob) so that a
33
+ * slow contact-save call for one sender can never cause a concurrent
34
+ * update for a different sender to be silently lost — see setSenderState.
35
+ */
36
+ import { buildSettingsApi } from "#kernel/settingsDb.js";
37
+ import { toWireJid } from "#drivers/jid.js";
38
+ import { logger } from "#logger";
39
+ const DM_THRESHOLD_RANGE = { min: 3, max: 6 };
40
+ const GROUP_THRESHOLD_RANGE = { min: 5, max: 9 }; // groups need extra calm
41
+ const REFRESH_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000; // re-check after 30 days
42
+ const REFRESH_SWEEP_SAMPLE = 2; // stale contacts touched per sweep, not all at once
43
+ // Reserved plugin namespace — not chat-scoped, since a saved contact is a
44
+ // bot-wide fact, not a per-chat one. Each sender gets its own key
45
+ // ("sender:<jid>") rather than one shared JSON blob, so concurrent
46
+ // updates for different senders (normal — different chats run
47
+ // concurrently) never clobber each other.
48
+ const store = buildSettingsApi("__contactAutoSave__", "_global").global;
49
+ const SENDER_KEY_PREFIX = "sender:";
50
+ function senderKey(jid) {
51
+ return `${SENDER_KEY_PREFIX}${jid}`;
52
+ }
53
+ function getSenderState(jid) {
54
+ return store.get(senderKey(jid));
55
+ }
56
+ function setSenderState(jid, s) {
57
+ store.set(senderKey(jid), s);
58
+ }
59
+ function getAllSenderStates() {
60
+ const all = store.getAll();
61
+ const result = {};
62
+ for (const [key, value] of Object.entries(all)) {
63
+ if (key.startsWith(SENDER_KEY_PREFIX)) {
64
+ result[key.slice(SENDER_KEY_PREFIX.length)] = value;
65
+ }
66
+ }
67
+ return result;
68
+ }
69
+ function randomInt(min, max) {
70
+ return Math.floor(min + Math.random() * (max - min + 1));
71
+ }
72
+ /**
73
+ * @returns whether the contact was actually saved. Callers must not mark
74
+ * a sender as "saved" unless this returns true — otherwise a transient
75
+ * failure would be mistaken for a real save and never retried.
76
+ */
77
+ async function addContact(contract, jid, name) {
78
+ // `jid` here is this framework's internal "@c.us" form (see
79
+ // getMsgSender()/normalizeJid()) — Baileys needs the real wire JID or
80
+ // it silently no-ops instead of throwing, which is exactly how this
81
+ // went unnoticed before.
82
+ const wireJid = toWireJid(jid);
83
+ try {
84
+ await contract.addOrEditContact(wireJid, {
85
+ fullName: name,
86
+ firstName: name,
87
+ saveOnPrimaryAddressbook: true,
88
+ });
89
+ logger.debug(`[contactAutoSave] saved contact ${wireJid} as "${name}"`);
90
+ return true;
91
+ }
92
+ catch (e) {
93
+ logger.debug(`[contactAutoSave] failed to save contact ${wireJid}: ${e.message}`);
94
+ return false;
95
+ }
96
+ }
97
+ /**
98
+ * Call on every incoming message that has a resolvable sender. Handles
99
+ * both the gradual first-save flow and completing a pending refresh.
100
+ * Best-effort — never throws.
101
+ *
102
+ * @param {WaContract} contract
103
+ * @param {BotMessage} msg — driver-neutral incoming message envelope
104
+ * @param {string} senderJid — normalized sender JID (never a group JID)
105
+ * @param {boolean} isGroup — whether this message came from a group chat
106
+ * @param {boolean} triggeredBot — true if this message invoked the bot
107
+ * (command prefix). Ignored outside groups.
108
+ */
109
+ export async function trackIncomingForContactSave(contract, msg, senderJid, isGroup, triggeredBot) {
110
+ try {
111
+ const pushName = msg.pushName?.trim();
112
+ if (!pushName || senderJid.endsWith("@g.us"))
113
+ return;
114
+ let s = getSenderState(senderJid);
115
+ // Completing a refresh takes priority over new-save logic, but in
116
+ // groups it still only fires on messages that invoke the bot — same
117
+ // rule as the initial save, so a refresh never re-adds someone based
118
+ // on a silent group message.
119
+ if (s?.pendingRefresh && (!isGroup || triggeredBot)) {
120
+ const ok = await addContact(contract, senderJid, pushName);
121
+ if (ok) {
122
+ // Re-read rather than reuse `s` — another concurrent message from
123
+ // this same sender may have updated the record while we awaited.
124
+ const latest = getSenderState(senderJid) ?? s;
125
+ latest.pendingRefresh = false;
126
+ latest.savedAt = Date.now();
127
+ setSenderState(senderJid, latest);
128
+ }
129
+ return;
130
+ }
131
+ if (s?.saved)
132
+ return; // already saved, not due for refresh
133
+ if (isGroup && !triggeredBot)
134
+ return; // groups: only count messages that invoke the bot
135
+ if (!s) {
136
+ s = {
137
+ dmTarget: randomInt(DM_THRESHOLD_RANGE.min, DM_THRESHOLD_RANGE.max),
138
+ groupTarget: randomInt(GROUP_THRESHOLD_RANGE.min, GROUP_THRESHOLD_RANGE.max),
139
+ dmCount: 0,
140
+ groupCount: 0,
141
+ saved: false,
142
+ savedAt: 0,
143
+ pendingRefresh: false,
144
+ };
145
+ }
146
+ if (isGroup)
147
+ s.groupCount += 1;
148
+ else
149
+ s.dmCount += 1;
150
+ // Persist the increment right away, with no await in between — so a
151
+ // slow addContact() call below never leaves this sender's counter
152
+ // based on stale data for longer than necessary.
153
+ setSenderState(senderJid, s);
154
+ const reachedTarget = s.dmCount >= s.dmTarget || s.groupCount >= s.groupTarget;
155
+ if (reachedTarget) {
156
+ const ok = await addContact(contract, senderJid, pushName);
157
+ if (ok) {
158
+ const latest = getSenderState(senderJid) ?? s;
159
+ latest.saved = true;
160
+ latest.savedAt = Date.now();
161
+ setSenderState(senderJid, latest);
162
+ }
163
+ // If it failed, `saved` stays false — the next qualifying message
164
+ // will see the target already reached and retry automatically.
165
+ }
166
+ }
167
+ catch (e) {
168
+ logger.debug(`[contactAutoSave] tracking failed (non-fatal): ${e.message}`);
169
+ }
170
+ }
171
+ /**
172
+ * Periodic maintenance: picks a small random sample of contacts saved
173
+ * more than REFRESH_MIN_AGE_MS ago and starts their refresh cycle
174
+ * (remove now, transparently re-added on their next message via
175
+ * trackIncomingForContactSave) so a changed pushName doesn't linger.
176
+ *
177
+ * Call this occasionally (e.g. every few hours) from the driver.
178
+ * @param {WaContract} contract
179
+ */
180
+ export async function runContactRefreshSweep(contract) {
181
+ const now = Date.now();
182
+ const all = getAllSenderStates();
183
+ const due = Object.entries(all)
184
+ .filter(([, s]) => s.saved && !s.pendingRefresh && now - s.savedAt > REFRESH_MIN_AGE_MS)
185
+ .sort(() => Math.random() - 0.5)
186
+ .slice(0, REFRESH_SWEEP_SAMPLE);
187
+ if (due.length === 0)
188
+ return;
189
+ for (const [jid, s] of due) {
190
+ try {
191
+ await contract.removeContact(toWireJid(jid));
192
+ s.pendingRefresh = true;
193
+ setSenderState(jid, s);
194
+ logger.debug(`[contactAutoSave] queued refresh for ${jid}`);
195
+ }
196
+ catch (e) {
197
+ logger.debug(`[contactAutoSave] failed to remove contact ${jid} for refresh: ${e.message}`);
198
+ }
199
+ }
200
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * driverManager.ts
3
+ *
4
+ * Registry for the active WhatsApp driver and any fallback drivers
5
+ * registered alongside it. Centralizes the answer to "which driver do
6
+ * I send through right now?" so sendFallbackGuard can pick the primary,
7
+ * notice when it's degraded, and reach for the secondary without
8
+ * scattering that decision across the codebase.
9
+ *
10
+ * Singleton access via getDriverManager() — same pattern as the
11
+ * globalSock in pluginLoader.ts. Only main.ts is expected to call
12
+ * register(); everywhere else reads through active() / get() / isDegraded.
13
+ *
14
+ * Shutdown order in shutdown() is reverse-registration, so a driver
15
+ * that was added later (e.g. whatsmeow) is disconnected before the
16
+ * primary one (typically Baileys). Re-registering the same name
17
+ * overwrites the previous instance — the old driver is NOT disconnected
18
+ * automatically, callers must disconnect it first if they want it torn
19
+ * down.
20
+ *
21
+ * See the interface and cooldown semantics.
22
+ */
23
+ import { logger } from "#logger";
24
+ class DriverManager {
25
+ drivers = new Map();
26
+ activeName = "";
27
+ /** Insertion order — used by shutdown() to disconnect in reverse. */
28
+ order = [];
29
+ degradedUntil = new Map();
30
+ /**
31
+ * Register a driver. The first call with isPrimary=true (or the first
32
+ * call overall if none sets it) becomes the active driver. Subsequent
33
+ * calls with isPrimary=false are stored as fallbacks.
34
+ */
35
+ register(driver, opts = {}) {
36
+ const name = driver.name;
37
+ if (this.drivers.has(name)) {
38
+ logger.warn(`[driverManager] re-registering driver "${name}" — old instance NOT disconnected`);
39
+ }
40
+ else {
41
+ this.order.push(name);
42
+ }
43
+ this.drivers.set(name, driver);
44
+ if (opts.isPrimary || !this.activeName) {
45
+ this.activeName = name;
46
+ }
47
+ }
48
+ active() {
49
+ const d = this.drivers.get(this.activeName);
50
+ if (!d) {
51
+ throw new Error(`[driverManager] no active driver registered (activeName="${this.activeName}")`);
52
+ }
53
+ return d;
54
+ }
55
+ get(name) {
56
+ return this.drivers.get(name);
57
+ }
58
+ activeName_() {
59
+ return this.activeName;
60
+ }
61
+ /** True if `name` is registered AND its connect() has resolved with state="open". */
62
+ isReady(name) {
63
+ return this.drivers.get(name)?.isReady() ?? false;
64
+ }
65
+ isDegraded(name) {
66
+ const until = this.degradedUntil.get(name);
67
+ return !!until && Date.now() < until;
68
+ }
69
+ markDegraded(name, durationMs) {
70
+ this.degradedUntil.set(name, Date.now() + durationMs);
71
+ }
72
+ /**
73
+ * Promote a different driver to active. Used by tests / hot-swap;
74
+ * the production sendFallbackGuard never calls this — fallback uses
75
+ * the secondary by direct call, leaving activeName untouched so the
76
+ * primary gets retried after the cooldown.
77
+ */
78
+ switchTo(name) {
79
+ if (!this.drivers.has(name)) {
80
+ throw new Error(`[driverManager] cannot switch to unregistered driver "${name}"`);
81
+ }
82
+ this.activeName = name;
83
+ }
84
+ /**
85
+ * Disconnect every registered driver in reverse-registration order.
86
+ * Errors are logged, not thrown, so a stubborn secondary can't block
87
+ * the primary's shutdown (or vice versa).
88
+ */
89
+ async shutdown() {
90
+ for (let i = this.order.length - 1; i >= 0; i--) {
91
+ const name = this.order[i];
92
+ const d = this.drivers.get(name);
93
+ if (!d)
94
+ continue;
95
+ try {
96
+ await d.disconnect();
97
+ }
98
+ catch (e) {
99
+ logger.warn(`[driverManager] error disconnecting "${name}": ${e.message}`);
100
+ }
101
+ }
102
+ this.drivers.clear();
103
+ this.order.length = 0;
104
+ this.degradedUntil.clear();
105
+ this.activeName = "";
106
+ }
107
+ }
108
+ let instance = null;
109
+ export function getDriverManager() {
110
+ if (!instance)
111
+ instance = new DriverManager();
112
+ return instance;
113
+ }
114
+ /** Test-only — reset the singleton so unit tests start clean. */
115
+ export function _resetDriverManagerForTests() {
116
+ instance = null;
117
+ }
@@ -1,11 +1,29 @@
1
1
  /**
2
- * kernel/pluginApi.ts (DEPRECATED)
2
+ * kernel/pluginApi.ts — explicit `PluginContext` contract.
3
3
  *
4
- * This file is now a re-export from the WhatsApp driver.
5
- * Plugins should continue to work without any changes.
4
+ * This file declares the typed surface that the
5
+ * plugin runtime passes to `plugin.default(ctx)` and `plugin.setup(ctx)`.
6
+ * Plugins depend ONLY on `PluginContext` (and the driver-neutral types
7
+ * it references — `WaContract`, `BotMessage`, …). The
8
+ * implementation lives in `drivers/baileys/api/index.ts`; this file is
9
+ * the source of truth that the implementation must satisfy via
10
+ * `tsc --noEmit`.
6
11
  *
7
- * All logic is now in: drivers/whatsapp/api/
8
- * This file exists for backward compatibility.
12
+ * History:
13
+ * - Originally this file was a re-export shim from the WhatsApp
14
+ * driver; now that the driver has been split into `drivers/baileys/`,
15
+ * the contract is promoted here and the implementation annotates
16
+ * `(): PluginContext`.
17
+ * - WAMessageContext / WAMessageSender / WAHistoryArray / PollHandle
18
+ * stay in `drivers/baileys/api/index.ts` because their internal field
19
+ * shapes still mirror Baileys-proto (e.g. `msg.key.id`). whatsapp-
20
+ * platform-neutrality is a per-property evolution; the contract
21
+ * exposes them at the boundary (`ctx.msg`) so future swaps only touch
22
+ * the implementation, never plugins.
9
23
  */
10
- // Re-export everything from the WhatsApp driver's plugin API
11
- export * from "#drivers/whatsapp/api/index.js";
24
+ // Re-export the implementation module so existing callers that
25
+ // `import { buildSetupApi, cleanupPluginEvents } from "#manyapi"`
26
+ // keep working. The contract types below are the new source of truth;
27
+ // the implementation in drivers/baileys/api/index.ts is annotated
28
+ // to satisfy them.
29
+ export { buildApi, buildSetupApi, buildMessageContext, buildChatFromMsg, buildStorageApi, cleanupPluginEvents, } from "#drivers/baileys/api/index.js";
@@ -17,7 +17,7 @@ import { PATHS } from "#config";
17
17
  import { buildSetupApi, cleanupPluginEvents } from "#manyapi";
18
18
  const PLUGINS_DIR = path.join(PATHS.HOME, "plugins");
19
19
  export const pluginRegistry = new Map();
20
- let globalSock = null;
20
+ let globalContract = null;
21
21
  let globalStore = null;
22
22
  const pluginWatchers = new Map();
23
23
  // fs.watch's `recursive: true` emulates recursion on Linux by opening one
@@ -77,14 +77,14 @@ export async function loadPlugins(activePlugins) {
77
77
  * Call setup(api) on all plugins that export it.
78
78
  * Executed once after bot connects.
79
79
  */
80
- export async function setupPlugins(sock, store) {
81
- globalSock = sock;
80
+ export async function setupPlugins(contract, store) {
81
+ globalContract = contract;
82
82
  globalStore = store;
83
83
  for (const plugin of pluginRegistry.values()) {
84
84
  if (plugin.status !== "active" || !plugin.setup)
85
85
  continue;
86
86
  try {
87
- const api = buildSetupApi(sock, store, pluginRegistry, plugin.name);
87
+ const api = buildSetupApi(contract, store, pluginRegistry, plugin.name);
88
88
  await plugin.setup(api);
89
89
  }
90
90
  catch (e) {
@@ -197,14 +197,14 @@ export async function reloadPlugin(name) {
197
197
  const plugin = pluginRegistry.get(name);
198
198
  if (!plugin)
199
199
  return;
200
- if (globalSock) {
201
- cleanupPluginEvents(name, globalSock);
200
+ if (globalContract) {
201
+ cleanupPluginEvents(name, globalContract);
202
202
  }
203
203
  await loadPlugin(name, true);
204
204
  const updatedPlugin = pluginRegistry.get(name);
205
- if (updatedPlugin && updatedPlugin.status === "active" && updatedPlugin.setup && globalSock && globalStore) {
205
+ if (updatedPlugin && updatedPlugin.status === "active" && updatedPlugin.setup && globalContract && globalStore) {
206
206
  try {
207
- const api = buildSetupApi(globalSock, globalStore, pluginRegistry, name);
207
+ const api = buildSetupApi(globalContract, globalStore, pluginRegistry, name);
208
208
  await updatedPlugin.setup(api);
209
209
  }
210
210
  catch (e) {
@@ -240,8 +240,8 @@ export async function syncPlugins() {
240
240
  logger.info(`[pluginLoader] Disabling plugin "${name}"`);
241
241
  const plugin = pluginRegistry.get(name);
242
242
  if (plugin) {
243
- if (globalSock) {
244
- cleanupPluginEvents(name, globalSock);
243
+ if (globalContract) {
244
+ cleanupPluginEvents(name, globalContract);
245
245
  }
246
246
  plugin.status = "disabled";
247
247
  unwatchPlugin(name);
@@ -254,9 +254,9 @@ export async function syncPlugins() {
254
254
  logger.info(`[pluginLoader] Enabling plugin "${name}"`);
255
255
  await loadPlugin(name);
256
256
  const plugin = pluginRegistry.get(name);
257
- if (plugin && plugin.status === "active" && plugin.setup && globalSock && globalStore) {
257
+ if (plugin && plugin.status === "active" && plugin.setup && globalContract && globalStore) {
258
258
  try {
259
- const api = buildSetupApi(globalSock, globalStore, pluginRegistry, name);
259
+ const api = buildSetupApi(globalContract, globalStore, pluginRegistry, name);
260
260
  await plugin.setup(api);
261
261
  }
262
262
  catch (e) {
@@ -0,0 +1,173 @@
1
+ /**
2
+ * sendFallbackGuard.ts
3
+ *
4
+ * The only place in the codebase that knows more than one WhatsApp
5
+ * driver exists. Every outbound text send from a plugin flows through
6
+ * sendWithFallback(): try the primary, verify the message actually
7
+ * appeared in the driver's history, and if it didn't, swap to the
8
+ * secondary and try again. See "flow", "verification",
9
+ * and cooldown).
10
+ *
11
+ * Why a guard instead of inlining the logic in the sender: every
12
+ * downstream caller (makeSender, buildSendApi, buildSetupSendApi) gets
13
+ * the same fallback behavior for free, and a future change in policy
14
+ * (e.g. "after N consecutive fallbacks, drop to system queue") touches
15
+ * one file.
16
+ *
17
+ * The guard is intentionally text-only in this phase. sendMedia and
18
+ * react go straight to the active driver — media fallback is
19
+ * marked as out of scope until the path is designed.
20
+ */
21
+ import { CONFIG } from "#config";
22
+ import { logger } from "#logger";
23
+ import { waitForSendSlot } from "./sendGuard.js";
24
+ import { getDriverManager } from "./driverManager.js";
25
+ import { fireAlert } from "./alerts.js";
26
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
27
+ /**
28
+ * Thrown by sendWithFallback when the message could not be delivered
29
+ * through any available driver. `reason` distinguishes between
30
+ * "primary failed and no secondary was available" vs "both failed".
31
+ */
32
+ export class SendFailedError extends Error {
33
+ jid;
34
+ driver;
35
+ reason;
36
+ constructor(jid, driver, reason) {
37
+ super(`send failed: ${reason} (jid=${jid}, lastDriver=${driver})`);
38
+ this.name = "SendFailedError";
39
+ this.jid = jid;
40
+ this.driver = driver;
41
+ this.reason = reason;
42
+ }
43
+ }
44
+ /**
45
+ * Try the active driver, then the other one if the active one failed to
46
+ * confirm the send. Honors `drivers.fallbackCooldownMs` (skip the active
47
+ * if it's currently degraded) and uses `drivers.verifyWindowMs` to decide
48
+ * how long to wait for confirmation before giving up on a given attempt.
49
+ *
50
+ * Resolves with the SentMessageRef of the driver that actually delivered
51
+ * the message. Rejects with SendFailedError if neither driver confirmed
52
+ * the send (or if the only driver that could have been tried was the
53
+ * active one and it wasn't ready).
54
+ */
55
+ export async function sendWithFallback(jid, text, opts = {}) {
56
+ const dm = getDriverManager();
57
+ const drivers = CONFIG.drivers;
58
+ const primary = dm.active();
59
+ const primaryKey = primary.name;
60
+ // if the primary is in cooldown, skip straight to the secondary
61
+ // (don't repeat the same failing call message after message). Same
62
+ // try/catch as the normal path so a failing secondary here also fires
63
+ // send_failed_both_drivers — observability stays consistent across
64
+ // the degraded and fresh-primary paths.
65
+ if (dm.isDegraded(primaryKey)) {
66
+ const secondary = pickSecondary(dm, primaryKey);
67
+ if (secondary && secondary.isReady()) {
68
+ try {
69
+ return await sendVia(secondary, jid, text, opts, drivers.verifyWindowMs, /*skipGuard=*/ false);
70
+ }
71
+ catch (err) {
72
+ fireAlert("send_failed_both_drivers", { jid, primary: primaryKey, secondary: secondary.name, error: String(err) });
73
+ throw err;
74
+ }
75
+ }
76
+ logger.warn({ jid, primary: primaryKey }, "send skipped primary (degraded) and no fallback ready");
77
+ fireAlert("send_failed_no_fallback", { jid, primary: primaryKey });
78
+ throw new SendFailedError(jid, primaryKey, "no_fallback");
79
+ }
80
+ // Normal path: try primary, verify, fall back if verification fails.
81
+ // waitForSendSlot is the same throttle the rest of the senders use
82
+ // (fallback must respect rate-limit too).
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;
87
+ }
88
+ logger.warn({ driver: primaryKey, jid, messageId: ref.id }, "send not confirmed by primary");
89
+ dm.markDegraded(primaryKey, drivers.fallbackCooldownMs);
90
+ const secondary = pickSecondary(dm, primaryKey);
91
+ if (!secondary || !secondary.isReady()) {
92
+ fireAlert("send_failed_no_fallback", { jid, primary: primaryKey });
93
+ throw new SendFailedError(jid, primaryKey, "no_fallback");
94
+ }
95
+ try {
96
+ 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");
98
+ return fallbackRef;
99
+ }
100
+ catch (err) {
101
+ fireAlert("send_failed_both_drivers", { jid, primary: primaryKey, secondary: secondary.name, error: String(err) });
102
+ throw err;
103
+ }
104
+ }
105
+ /**
106
+ * Send through a specific driver and verify the result. Throws if the
107
+ * driver itself rejects (network error, rate-limit, etc.) or if no
108
+ * verification window matched. `skipGuard=true` skips waitForSendSlot —
109
+ * used by the secondary path, where the primary already consumed the
110
+ * slot and the secondary is best-effort.
111
+ */
112
+ async function sendVia(driver, jid, text, opts, windows, skipGuard) {
113
+ if (!skipGuard)
114
+ await waitForSendSlot(jid, { cooldown: true, jitter: true });
115
+ const ref = await driver.sendText(jid, text, opts);
116
+ if (await verifyDelivery(driver, jid, ref, windows))
117
+ return ref;
118
+ throw new SendFailedError(jid, driver.name, "both_failed");
119
+ }
120
+ /**
121
+ * Pull recent history from the driver and look for `ref.id` among the
122
+ * fromMe messages. Returns true on the first hit; false if every check
123
+ * came up empty (including the final window, which acts as the overall
124
+ * timeout).
125
+ *
126
+ * The first check happens IMMEDIATELY (no sleep). For Baileys this is
127
+ * the common case: `sock.sendMessage` resolves after the message is
128
+ * already in the in-memory store (the `messages.upsert` listener runs
129
+ * synchronously off the same ack), so a 0ms lookup hits and the send
130
+ * completes with no extra latency. Only if that immediate lookup
131
+ * misses (driver lag, history sync delay, ...) do we start the
132
+ * time-windowed rechecks — `windows` are interpreted as delays
133
+ * BETWEEN successive checks, last one being the final timeout.
134
+ *
135
+ * id is the primary signal. We match against ALL
136
+ * fromMe messages in the slice, not just the newest — the secondary
137
+ * path skips `waitForSendSlot` (skipGuard=true) so two concurrent
138
+ * sends to the same jid can both be in flight at once, and "newest
139
+ * only" would let the second one mask the first.
140
+ *
141
+ * If the driver doesn't implement `getHistory?` (pure fire-and-forget
142
+ * transports), verification can't happen — return false and let the
143
+ * caller fall through. Both real drivers in scope today (Baileys,
144
+ * whatsmeow) implement it, so this is just defensive.
145
+ */
146
+ async function verifyDelivery(driver, jid, ref, windows) {
147
+ if (!driver.getHistory)
148
+ return false;
149
+ // Check at t=0 first; only enter the window loop if that misses.
150
+ if (await historyContains(driver, jid, ref))
151
+ return true;
152
+ for (const delayMs of windows) {
153
+ await sleep(delayMs);
154
+ if (await historyContains(driver, jid, ref))
155
+ return true;
156
+ }
157
+ return false;
158
+ }
159
+ async function historyContains(driver, jid, ref) {
160
+ let history;
161
+ try {
162
+ history = await driver.getHistory(jid, { limit: 5 });
163
+ }
164
+ catch (e) {
165
+ logger.debug({ driver: driver.name, jid, err: String(e) }, "getHistory failed during verify (non-fatal)");
166
+ return false;
167
+ }
168
+ return history.some(m => m.fromMe && m.id === ref.id);
169
+ }
170
+ function pickSecondary(dm, primaryKey) {
171
+ const other = primaryKey === "baileys" ? "whatsmeow" : "baileys";
172
+ return dm.get(other);
173
+ }