@manybot/manybot 5.5.4 → 5.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -1
- package/dist/client/cache.js +1 -1
- package/dist/client/store.js +9 -0
- package/dist/config.js +230 -12
- package/dist/drivers/baileys/adapter.js +629 -0
- package/dist/drivers/{whatsapp → baileys}/api/index.js +553 -393
- package/dist/drivers/baileys/index.js +594 -0
- package/dist/drivers/{whatsapp → baileys}/loginPrompt.js +2 -0
- package/dist/drivers/{whatsapp → baileys}/messageHandler.js +46 -16
- package/dist/drivers/{whatsapp → baileys}/sdk/baileysSock.js +1 -30
- package/dist/drivers/jid.js +31 -0
- package/dist/drivers/types.js +14 -0
- package/dist/drivers/whatsmeow/client.js +252 -0
- package/dist/drivers/whatsmeow/index.js +79 -0
- package/dist/drivers/whatsmeow/installer.js +86 -0
- package/dist/drivers/whatsmeow/supervisor.js +328 -0
- package/dist/drivers/whatsmeow/whatsmeow.proto +64 -0
- package/dist/i18n/index.js +8 -12
- package/dist/kernel/alerts.js +190 -0
- package/dist/kernel/contactAutoSave.js +200 -0
- package/dist/kernel/driverManager.js +117 -0
- package/dist/kernel/pluginApi.js +25 -7
- package/dist/kernel/pluginLoader.js +12 -12
- package/dist/kernel/sendFallbackGuard.js +183 -0
- package/dist/kernel/sendGuard.js +143 -33
- package/dist/kernel/statusServer.js +39 -0
- package/dist/kernel/updateCheck.js +88 -0
- package/dist/kernel/waContract.js +16 -0
- package/dist/locales/en.json +16 -1
- package/dist/locales/es.json +16 -1
- package/dist/locales/pt.json +16 -1
- package/dist/main.js +100 -5
- package/dist/types.js +18 -11
- package/package.json +6 -8
- package/dist/core/adapter.js +0 -12
- package/dist/core/capabilities.js +0 -16
- package/dist/core/types.js +0 -6
- package/dist/drivers/index.js +0 -14
- package/dist/drivers/whatsapp/adapter.js +0 -7
- 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
|
+
}
|
package/dist/kernel/pluginApi.js
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* kernel/pluginApi.ts
|
|
2
|
+
* kernel/pluginApi.ts — explicit `PluginContext` contract.
|
|
3
3
|
*
|
|
4
|
-
* This file
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
|
11
|
-
|
|
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
|
|
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(
|
|
81
|
-
|
|
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(
|
|
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 (
|
|
201
|
-
cleanupPluginEvents(name,
|
|
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 &&
|
|
205
|
+
if (updatedPlugin && updatedPlugin.status === "active" && updatedPlugin.setup && globalContract && globalStore) {
|
|
206
206
|
try {
|
|
207
|
-
const api = buildSetupApi(
|
|
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 (
|
|
244
|
-
cleanupPluginEvents(name,
|
|
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 &&
|
|
257
|
+
if (plugin && plugin.status === "active" && plugin.setup && globalContract && globalStore) {
|
|
258
258
|
try {
|
|
259
|
-
const api = buildSetupApi(
|
|
259
|
+
const api = buildSetupApi(globalContract, globalStore, pluginRegistry, name);
|
|
260
260
|
await plugin.setup(api);
|
|
261
261
|
}
|
|
262
262
|
catch (e) {
|
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
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");
|
|
98
|
+
}
|
|
99
|
+
dm.markDegraded(primaryKey, drivers.fallbackCooldownMs);
|
|
100
|
+
const secondary = pickSecondary(dm, primaryKey);
|
|
101
|
+
if (!secondary || !secondary.isReady()) {
|
|
102
|
+
fireAlert("send_failed_no_fallback", { jid, primary: primaryKey });
|
|
103
|
+
throw new SendFailedError(jid, primaryKey, "no_fallback");
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const fallbackRef = await sendVia(secondary, jid, text, opts, drivers.verifyWindowMs, /*skipGuard=*/ true);
|
|
107
|
+
logger.info({ driver: secondary.name, jid, messageId: fallbackRef.id, reason: primarySendFailed ? "send threw" : "primary verification failed" }, "message sent via fallback");
|
|
108
|
+
return fallbackRef;
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
fireAlert("send_failed_both_drivers", { jid, primary: primaryKey, secondary: secondary.name, error: String(err) });
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Send through a specific driver and verify the result. Throws if the
|
|
117
|
+
* driver itself rejects (network error, rate-limit, etc.) or if no
|
|
118
|
+
* verification window matched. `skipGuard=true` skips waitForSendSlot —
|
|
119
|
+
* used by the secondary path, where the primary already consumed the
|
|
120
|
+
* slot and the secondary is best-effort.
|
|
121
|
+
*/
|
|
122
|
+
async function sendVia(driver, jid, text, opts, windows, skipGuard) {
|
|
123
|
+
if (!skipGuard)
|
|
124
|
+
await waitForSendSlot(jid, { cooldown: true, jitter: true });
|
|
125
|
+
const ref = await driver.sendText(jid, text, opts);
|
|
126
|
+
if (await verifyDelivery(driver, jid, ref, windows))
|
|
127
|
+
return ref;
|
|
128
|
+
throw new SendFailedError(jid, driver.name, "both_failed");
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Pull recent history from the driver and look for `ref.id` among the
|
|
132
|
+
* fromMe messages. Returns true on the first hit; false if every check
|
|
133
|
+
* came up empty (including the final window, which acts as the overall
|
|
134
|
+
* timeout).
|
|
135
|
+
*
|
|
136
|
+
* The first check happens IMMEDIATELY (no sleep). For Baileys this is
|
|
137
|
+
* the common case: `sock.sendMessage` resolves after the message is
|
|
138
|
+
* already in the in-memory store (the `messages.upsert` listener runs
|
|
139
|
+
* synchronously off the same ack), so a 0ms lookup hits and the send
|
|
140
|
+
* completes with no extra latency. Only if that immediate lookup
|
|
141
|
+
* misses (driver lag, history sync delay, ...) do we start the
|
|
142
|
+
* time-windowed rechecks — `windows` are interpreted as delays
|
|
143
|
+
* BETWEEN successive checks, last one being the final timeout.
|
|
144
|
+
*
|
|
145
|
+
* id is the primary signal. We match against ALL
|
|
146
|
+
* fromMe messages in the slice, not just the newest — the secondary
|
|
147
|
+
* path skips `waitForSendSlot` (skipGuard=true) so two concurrent
|
|
148
|
+
* sends to the same jid can both be in flight at once, and "newest
|
|
149
|
+
* only" would let the second one mask the first.
|
|
150
|
+
*
|
|
151
|
+
* If the driver doesn't implement `getHistory?` (pure fire-and-forget
|
|
152
|
+
* transports), verification can't happen — return false and let the
|
|
153
|
+
* caller fall through. Both real drivers in scope today (Baileys,
|
|
154
|
+
* whatsmeow) implement it, so this is just defensive.
|
|
155
|
+
*/
|
|
156
|
+
async function verifyDelivery(driver, jid, ref, windows) {
|
|
157
|
+
if (!driver.getHistory)
|
|
158
|
+
return false;
|
|
159
|
+
// Check at t=0 first; only enter the window loop if that misses.
|
|
160
|
+
if (await historyContains(driver, jid, ref))
|
|
161
|
+
return true;
|
|
162
|
+
for (const delayMs of windows) {
|
|
163
|
+
await sleep(delayMs);
|
|
164
|
+
if (await historyContains(driver, jid, ref))
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
async function historyContains(driver, jid, ref) {
|
|
170
|
+
let history;
|
|
171
|
+
try {
|
|
172
|
+
history = await driver.getHistory(jid, { limit: 5 });
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
logger.debug({ driver: driver.name, jid, err: String(e) }, "getHistory failed during verify (non-fatal)");
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return history.some(m => m.fromMe && m.id === ref.id);
|
|
179
|
+
}
|
|
180
|
+
function pickSecondary(dm, primaryKey) {
|
|
181
|
+
const other = primaryKey === "baileys" ? "whatsmeow" : "baileys";
|
|
182
|
+
return dm.get(other);
|
|
183
|
+
}
|