baileys-antiban 1.3.1 → 1.5.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.
- package/CHANGELOG.md +40 -0
- package/README.md +95 -4
- package/dist/antiban.d.ts +12 -0
- package/dist/antiban.js +51 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/reconnectThrottle.d.ts +89 -0
- package/dist/reconnectThrottle.js +180 -0
- package/dist/retryTracker.d.ts +87 -0
- package/dist/retryTracker.js +171 -0
- package/dist/wrapper.d.ts +14 -1
- package/dist/wrapper.js +26 -3
- package/package.json +15 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.5.0] - 2026-04-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **RetryReasonTracker** module: Track message retry reasons and detect retry spirals
|
|
12
|
+
- Classifies 10 retry reason types (no_session, invalid_key, bad_mac, decryption_failure, server_error_463, server_error_429, timeout, no_route, node_malformed, unknown)
|
|
13
|
+
- Detects retry spirals when same message retries exceed threshold (default: 3)
|
|
14
|
+
- Provides stats on total retries, retries by reason, spirals detected, and active retries
|
|
15
|
+
- Auto-integrates with messages.update events in wrapper
|
|
16
|
+
- Inspired by whatsapp-rust's protocol/retry.rs module
|
|
17
|
+
- **PostReconnectThrottle** module: Throttle outbound messages after reconnection
|
|
18
|
+
- Prevents burst-floods on reconnect that trigger WhatsApp rate limits
|
|
19
|
+
- Configurable ramp-up from initial rate multiplier (default: 10%) to full rate over ramp duration (default: 60s)
|
|
20
|
+
- Linear ramp with configurable steps (default: 6 steps)
|
|
21
|
+
- Auto-integrates with connection.update events
|
|
22
|
+
- Inspired by whatsapp-rust's client/sessions.rs semaphore swap pattern
|
|
23
|
+
- Both modules are opt-in (enabled: false by default) for backward compatibility
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- `AntiBan.beforeSend()` now also consults reconnect throttle
|
|
27
|
+
- `AntiBan.onReconnect()` triggers reconnect throttle window
|
|
28
|
+
- `AntiBan.getStats()` includes retry tracker and reconnect throttle stats when enabled
|
|
29
|
+
- Wrapper now tracks message updates for retry classification and clears on successful send
|
|
30
|
+
|
|
31
|
+
## [1.4.0] - 2026-04-18
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- **Transport-agnostic support** — works with both `baileys` and `@oxidezap/baileyrs` (Rust/WASM WhatsApp library)
|
|
35
|
+
- Both transports now listed as optional peer dependencies
|
|
36
|
+
- GitHub Actions CI workflow with dual-transport matrix testing (Node 18.x + 20.x × baileys + baileyrs)
|
|
37
|
+
- New test suite: `tests/transport-agnostic.test.ts` for duck-typed socket validation
|
|
38
|
+
- Updated JSDoc examples showing usage with both transports
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
- `peerDependencies` now includes both `baileys` and `@oxidezap/baileyrs` as optional
|
|
42
|
+
- Package description updated to mention transport-agnostic support
|
|
43
|
+
- Wrapper comments clarify baileyrs timelock behavior (no `reachoutTimeLock` events in v0.0.8 — operates in detection-only mode)
|
|
44
|
+
|
|
45
|
+
### Why
|
|
46
|
+
Positions baileys-antiban as "Switzerland" of WhatsApp anti-ban — works with any Baileys-compatible transport layer. No breaking changes for existing baileys users.
|
|
47
|
+
|
|
8
48
|
## [1.3.1] - 2026-04-16
|
|
9
49
|
|
|
10
50
|
### Changed
|
package/README.md
CHANGED
|
@@ -4,9 +4,64 @@
|
|
|
4
4
|
[](https://nodejs.org/)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**Transport-agnostic** anti-ban middleware — protect your WhatsApp number with human-like messaging patterns. Works with both [Baileys](https://github.com/WhiskeySockets/Baileys) and [@oxidezap/baileyrs](https://github.com/oxidezap/baileyrs) (Rust/WASM).
|
|
8
8
|
|
|
9
|
-
## v1.
|
|
9
|
+
## v1.5 New Features
|
|
10
|
+
|
|
11
|
+
### RetryReasonTracker
|
|
12
|
+
Tracks message retry reasons and detects retry spirals (when the same message keeps failing). Inspired by whatsapp-rust's protocol/retry.rs module.
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
import { AntiBan } from 'baileys-antiban';
|
|
16
|
+
|
|
17
|
+
const antiban = new AntiBan({
|
|
18
|
+
retryTracker: {
|
|
19
|
+
enabled: true,
|
|
20
|
+
maxRetries: 5, // Max retries before considering a message failed
|
|
21
|
+
spiralThreshold: 3, // Retries before warning about retry spiral
|
|
22
|
+
onSpiral: (msgId, reason) => {
|
|
23
|
+
console.warn(`Message ${msgId} stuck in retry spiral: ${reason}`);
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Stats show retry patterns
|
|
29
|
+
const stats = antiban.getStats().retryTracker;
|
|
30
|
+
console.log(stats.totalRetries); // Total retries across all messages
|
|
31
|
+
console.log(stats.byReason.timeout); // Retries due to timeout
|
|
32
|
+
console.log(stats.spiralsDetected); // Messages stuck in retry loops
|
|
33
|
+
console.log(stats.activeRetries); // Messages currently retrying
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Retry reasons tracked**: no_session, invalid_key, bad_mac, decryption_failure, server_error_463, server_error_429, timeout, no_route, node_malformed, unknown
|
|
37
|
+
|
|
38
|
+
### PostReconnectThrottle
|
|
39
|
+
Throttles outbound messages after reconnection to prevent burst-floods that trigger rate limits. Inspired by whatsapp-rust's client/sessions.rs semaphore swap pattern.
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
const antiban = new AntiBan({
|
|
43
|
+
reconnectThrottle: {
|
|
44
|
+
enabled: true,
|
|
45
|
+
rampDurationMs: 60_000, // 60s ramp-up to full rate
|
|
46
|
+
initialRateMultiplier: 0.1, // Start at 10% of normal rate
|
|
47
|
+
rampSteps: 6, // 10% → 25% → 50% → 75% → 90% → 100%
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// After reconnect, sends are automatically throttled for 60 seconds
|
|
52
|
+
// Ramps from 10% rate to 100% rate linearly over 6 steps
|
|
53
|
+
|
|
54
|
+
// Stats show throttle state
|
|
55
|
+
const stats = antiban.getStats().reconnectThrottle;
|
|
56
|
+
console.log(stats.isThrottled); // Currently throttled?
|
|
57
|
+
console.log(stats.currentMultiplier); // 0.1 to 1.0
|
|
58
|
+
console.log(stats.remainingMs); // Time until full rate
|
|
59
|
+
console.log(stats.throttledSendCount); // Sends gated since reconnect
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Why?** When WhatsApp reconnects after a disconnection, sending messages at full rate immediately can trigger rate limit alarms. The reconnect throttle gradually ramps up sending rate over 60 seconds, mimicking how a human would resume messaging after their internet came back.
|
|
63
|
+
|
|
64
|
+
## v1.3 Features
|
|
10
65
|
|
|
11
66
|
### ReplyRatioGuard
|
|
12
67
|
Tracks outbound:inbound message ratio per contact. Blocks sends to non-responsive contacts to avoid "spray-and-pray" ban patterns. Optionally suggests auto-replies to maintain healthy engagement.
|
|
@@ -92,21 +147,46 @@ WhatsApp bans numbers that behave like bots. This library makes your Baileys bot
|
|
|
92
147
|
- **Reply ratio tracking** (v1.3) — blocks sends to non-responsive contacts
|
|
93
148
|
- **Contact graph enforcement** (v1.3) — requires handshakes before bulk/group sends
|
|
94
149
|
- **Circadian rhythm** (v1.3) — realistic time-of-day activity patterns
|
|
150
|
+
- **Retry tracking** (v1.5) — detect retry spirals and classify retry reasons
|
|
151
|
+
- **Reconnect throttle** (v1.5) — prevent burst-floods after reconnection
|
|
152
|
+
|
|
153
|
+
## Supported Transports
|
|
154
|
+
|
|
155
|
+
**v1.4+** is transport-agnostic and works with any Baileys-compatible WhatsApp library:
|
|
156
|
+
|
|
157
|
+
- **[Baileys](https://github.com/WhiskeySockets/Baileys)** (Node.js, JavaScript/TypeScript)
|
|
158
|
+
- **[@oxidezap/baileyrs](https://github.com/oxidezap/baileyrs)** (Rust/WASM, Baileys-compatible API)
|
|
159
|
+
|
|
160
|
+
Both use the same `wrapSocket()` integration. Zero code changes needed.
|
|
95
161
|
|
|
96
162
|
## Installation
|
|
97
163
|
|
|
164
|
+
### With Baileys (Node.js)
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
npm install baileys baileys-antiban
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### With baileyrs (Rust/WASM)
|
|
171
|
+
|
|
98
172
|
```bash
|
|
99
|
-
npm install baileys-antiban
|
|
173
|
+
npm install @oxidezap/baileyrs baileys-antiban
|
|
100
174
|
```
|
|
101
175
|
|
|
102
|
-
Requires Node.js ≥16
|
|
176
|
+
Requires Node.js ≥16.
|
|
103
177
|
|
|
104
178
|
## Quick Start
|
|
105
179
|
|
|
106
180
|
### Option 1: Wrap Your Socket (Easiest)
|
|
107
181
|
|
|
182
|
+
Works with both baileys and baileyrs — same code:
|
|
183
|
+
|
|
108
184
|
```typescript
|
|
185
|
+
// With baileys:
|
|
109
186
|
import makeWASocket from 'baileys';
|
|
187
|
+
// OR with baileyrs:
|
|
188
|
+
// import { makeWASocket } from '@oxidezap/baileyrs';
|
|
189
|
+
|
|
110
190
|
import { wrapSocket } from 'baileys-antiban';
|
|
111
191
|
|
|
112
192
|
const sock = makeWASocket({ /* your config */ });
|
|
@@ -200,6 +280,17 @@ const antiban = new AntiBan({
|
|
|
200
280
|
console.log('Timelock lifted — resuming normal operation');
|
|
201
281
|
},
|
|
202
282
|
},
|
|
283
|
+
retryTracker: {
|
|
284
|
+
enabled: false, // Opt-in (default: false)
|
|
285
|
+
maxRetries: 5,
|
|
286
|
+
spiralThreshold: 3,
|
|
287
|
+
},
|
|
288
|
+
reconnectThrottle: {
|
|
289
|
+
enabled: false, // Opt-in (default: false)
|
|
290
|
+
rampDurationMs: 60_000,
|
|
291
|
+
initialRateMultiplier: 0.1,
|
|
292
|
+
rampSteps: 6,
|
|
293
|
+
},
|
|
203
294
|
logging: true, // Console logging (default: true)
|
|
204
295
|
});
|
|
205
296
|
```
|
package/dist/antiban.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ import { TimelockGuard, type TimelockGuardConfig } from './timelockGuard.js';
|
|
|
20
20
|
import { ReplyRatioGuard, type ReplyRatioConfig, type ReplyRatioStats } from './replyRatio.js';
|
|
21
21
|
import { ContactGraphWarmer, type ContactGraphConfig, type ContactGraphStats } from './contactGraph.js';
|
|
22
22
|
import { PresenceChoreographer, type PresenceChoreographerConfig, type PresenceChoreographerStats } from './presenceChoreographer.js';
|
|
23
|
+
import { RetryReasonTracker, type RetryTrackerConfig, type RetryStats } from './retryTracker.js';
|
|
24
|
+
import { PostReconnectThrottle, type ReconnectThrottleConfig, type ReconnectThrottleStats } from './reconnectThrottle.js';
|
|
23
25
|
export interface AntiBanConfig {
|
|
24
26
|
rateLimiter?: Partial<RateLimiterConfig>;
|
|
25
27
|
warmUp?: Partial<WarmUpConfig>;
|
|
@@ -28,6 +30,8 @@ export interface AntiBanConfig {
|
|
|
28
30
|
replyRatio?: Partial<ReplyRatioConfig>;
|
|
29
31
|
contactGraph?: Partial<ContactGraphConfig>;
|
|
30
32
|
presence?: Partial<PresenceChoreographerConfig>;
|
|
33
|
+
retryTracker?: Partial<RetryTrackerConfig>;
|
|
34
|
+
reconnectThrottle?: Partial<ReconnectThrottleConfig>;
|
|
31
35
|
/** Log warnings and blocks to console (default: true) */
|
|
32
36
|
logging?: boolean;
|
|
33
37
|
}
|
|
@@ -48,6 +52,8 @@ export interface AntiBanStats {
|
|
|
48
52
|
replyRatio?: ReplyRatioStats;
|
|
49
53
|
contactGraph?: ContactGraphStats;
|
|
50
54
|
presence?: PresenceChoreographerStats;
|
|
55
|
+
retryTracker?: RetryStats | null;
|
|
56
|
+
reconnectThrottle?: ReconnectThrottleStats | null;
|
|
51
57
|
}
|
|
52
58
|
export declare class AntiBan {
|
|
53
59
|
private rateLimiter;
|
|
@@ -57,6 +63,8 @@ export declare class AntiBan {
|
|
|
57
63
|
private replyRatioGuard;
|
|
58
64
|
private contactGraphWarmer;
|
|
59
65
|
private presenceChoreographer;
|
|
66
|
+
private retryTrackerModule;
|
|
67
|
+
private reconnectThrottleModule;
|
|
60
68
|
private logging;
|
|
61
69
|
private stats;
|
|
62
70
|
constructor(config?: AntiBanConfig, warmUpState?: WarmUpState);
|
|
@@ -102,6 +110,10 @@ export declare class AntiBan {
|
|
|
102
110
|
get contactGraph(): ContactGraphWarmer;
|
|
103
111
|
/** Get the presence choreographer for direct access */
|
|
104
112
|
get presence(): PresenceChoreographer;
|
|
113
|
+
/** Get the retry tracker for direct access */
|
|
114
|
+
get retryTracker(): RetryReasonTracker;
|
|
115
|
+
/** Get the reconnect throttle for direct access */
|
|
116
|
+
get reconnectThrottle(): PostReconnectThrottle;
|
|
105
117
|
/**
|
|
106
118
|
* Export warm-up state for persistence between restarts
|
|
107
119
|
*/
|
package/dist/antiban.js
CHANGED
|
@@ -20,6 +20,8 @@ import { TimelockGuard } from './timelockGuard.js';
|
|
|
20
20
|
import { ReplyRatioGuard } from './replyRatio.js';
|
|
21
21
|
import { ContactGraphWarmer } from './contactGraph.js';
|
|
22
22
|
import { PresenceChoreographer } from './presenceChoreographer.js';
|
|
23
|
+
import { RetryReasonTracker } from './retryTracker.js';
|
|
24
|
+
import { PostReconnectThrottle } from './reconnectThrottle.js';
|
|
23
25
|
export class AntiBan {
|
|
24
26
|
rateLimiter;
|
|
25
27
|
warmUp;
|
|
@@ -28,6 +30,8 @@ export class AntiBan {
|
|
|
28
30
|
replyRatioGuard;
|
|
29
31
|
contactGraphWarmer;
|
|
30
32
|
presenceChoreographer;
|
|
33
|
+
retryTrackerModule;
|
|
34
|
+
reconnectThrottleModule;
|
|
31
35
|
logging;
|
|
32
36
|
stats = {
|
|
33
37
|
messagesAllowed: 0,
|
|
@@ -69,6 +73,19 @@ export class AntiBan {
|
|
|
69
73
|
this.replyRatioGuard = new ReplyRatioGuard(config.replyRatio);
|
|
70
74
|
this.contactGraphWarmer = new ContactGraphWarmer(config.contactGraph);
|
|
71
75
|
this.presenceChoreographer = new PresenceChoreographer(config.presence);
|
|
76
|
+
this.retryTrackerModule = new RetryReasonTracker({
|
|
77
|
+
...config.retryTracker,
|
|
78
|
+
onSpiral: (msgId, reason) => {
|
|
79
|
+
if (this.logging) {
|
|
80
|
+
console.log(`[baileys-antiban] ⚠️ Message ${msgId} stuck in retry spiral (${reason})`);
|
|
81
|
+
}
|
|
82
|
+
config.retryTracker?.onSpiral?.(msgId, reason);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
this.reconnectThrottleModule = new PostReconnectThrottle({
|
|
86
|
+
...config.reconnectThrottle,
|
|
87
|
+
baselineRatePerMinute: () => this.rateLimiter.getStats().limits.perMinute,
|
|
88
|
+
});
|
|
72
89
|
}
|
|
73
90
|
/**
|
|
74
91
|
* Check if a message can be sent and get required delay.
|
|
@@ -146,6 +163,20 @@ export class AntiBan {
|
|
|
146
163
|
health: healthStatus,
|
|
147
164
|
};
|
|
148
165
|
}
|
|
166
|
+
// Reconnect throttle check
|
|
167
|
+
const reconnectThrottleDecision = this.reconnectThrottleModule.beforeSend();
|
|
168
|
+
if (!reconnectThrottleDecision.allowed) {
|
|
169
|
+
this.stats.messagesBlocked++;
|
|
170
|
+
if (this.logging) {
|
|
171
|
+
console.log(`[baileys-antiban] 🔄 BLOCKED — reconnect throttle: ${reconnectThrottleDecision.reason}`);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
allowed: false,
|
|
175
|
+
delayMs: reconnectThrottleDecision.retryAfterMs || 0,
|
|
176
|
+
reason: reconnectThrottleDecision.reason || 'Post-reconnect throttle',
|
|
177
|
+
health: healthStatus,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
149
180
|
// Rate limiter delay
|
|
150
181
|
let delay = await this.rateLimiter.getDelay(recipient, content);
|
|
151
182
|
if (delay === -1) {
|
|
@@ -211,12 +242,14 @@ export class AntiBan {
|
|
|
211
242
|
*/
|
|
212
243
|
onDisconnect(reason) {
|
|
213
244
|
this.health.recordDisconnect(reason);
|
|
245
|
+
this.reconnectThrottleModule.onDisconnect();
|
|
214
246
|
}
|
|
215
247
|
/**
|
|
216
248
|
* Record a successful reconnection
|
|
217
249
|
*/
|
|
218
250
|
onReconnect() {
|
|
219
251
|
this.health.recordReconnect();
|
|
252
|
+
this.reconnectThrottleModule.onReconnect();
|
|
220
253
|
}
|
|
221
254
|
/**
|
|
222
255
|
* Handle incoming message — record in reply ratio + contact graph.
|
|
@@ -247,6 +280,12 @@ export class AntiBan {
|
|
|
247
280
|
if (this.presenceChoreographer['config']?.enabled) {
|
|
248
281
|
stats.presence = this.presenceChoreographer.getStats();
|
|
249
282
|
}
|
|
283
|
+
if (this.retryTrackerModule['config']?.enabled) {
|
|
284
|
+
stats.retryTracker = this.retryTrackerModule.getStats();
|
|
285
|
+
}
|
|
286
|
+
if (this.reconnectThrottleModule['config']?.enabled) {
|
|
287
|
+
stats.reconnectThrottle = this.reconnectThrottleModule.getStats();
|
|
288
|
+
}
|
|
250
289
|
return stats;
|
|
251
290
|
}
|
|
252
291
|
/** Get the timelock guard for direct access */
|
|
@@ -265,6 +304,14 @@ export class AntiBan {
|
|
|
265
304
|
get presence() {
|
|
266
305
|
return this.presenceChoreographer;
|
|
267
306
|
}
|
|
307
|
+
/** Get the retry tracker for direct access */
|
|
308
|
+
get retryTracker() {
|
|
309
|
+
return this.retryTrackerModule;
|
|
310
|
+
}
|
|
311
|
+
/** Get the reconnect throttle for direct access */
|
|
312
|
+
get reconnectThrottle() {
|
|
313
|
+
return this.reconnectThrottleModule;
|
|
314
|
+
}
|
|
268
315
|
/**
|
|
269
316
|
* Export warm-up state for persistence between restarts
|
|
270
317
|
*/
|
|
@@ -299,6 +346,8 @@ export class AntiBan {
|
|
|
299
346
|
this.replyRatioGuard.reset();
|
|
300
347
|
this.contactGraphWarmer.reset();
|
|
301
348
|
this.presenceChoreographer.reset();
|
|
349
|
+
this.retryTrackerModule.destroy();
|
|
350
|
+
this.reconnectThrottleModule.destroy();
|
|
302
351
|
this.stats = { messagesAllowed: 0, messagesBlocked: 0, totalDelayMs: 0 };
|
|
303
352
|
if (this.logging) {
|
|
304
353
|
console.log('[baileys-antiban] 🔄 Reset — starting fresh warm-up');
|
|
@@ -313,6 +362,8 @@ export class AntiBan {
|
|
|
313
362
|
this.replyRatioGuard.reset();
|
|
314
363
|
this.contactGraphWarmer.reset();
|
|
315
364
|
this.presenceChoreographer.reset();
|
|
365
|
+
this.retryTrackerModule.destroy();
|
|
366
|
+
this.reconnectThrottleModule.destroy();
|
|
316
367
|
if (this.logging) {
|
|
317
368
|
console.log('[baileys-antiban] 🧹 Destroyed — all timers cleared');
|
|
318
369
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export { TimelockGuard, type TimelockGuardConfig, type TimelockState } from './t
|
|
|
15
15
|
export { ReplyRatioGuard, type ReplyRatioConfig, type ReplyRatioStats } from './replyRatio.js';
|
|
16
16
|
export { ContactGraphWarmer, type ContactGraphConfig, type ContactGraphStats, type ContactState } from './contactGraph.js';
|
|
17
17
|
export { PresenceChoreographer, type PresenceChoreographerConfig, type PresenceChoreographerStats } from './presenceChoreographer.js';
|
|
18
|
+
export { RetryReasonTracker, type RetryTrackerConfig, type RetryStats, type RetryReason } from './retryTracker.js';
|
|
19
|
+
export { PostReconnectThrottle, type ReconnectThrottleConfig, type ReconnectThrottleStats } from './reconnectThrottle.js';
|
|
18
20
|
export { wrapSocket, type WrappedSocket, type WrapSocketOptions } from './wrapper.js';
|
|
19
21
|
export { MessageQueue, type QueuedMessage, type MessageQueueConfig } from './messageQueue.js';
|
|
20
22
|
export { ContentVariator, type VariatorConfig } from './contentVariator.js';
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,9 @@ export { TimelockGuard } from './timelockGuard.js';
|
|
|
17
17
|
export { ReplyRatioGuard } from './replyRatio.js';
|
|
18
18
|
export { ContactGraphWarmer } from './contactGraph.js';
|
|
19
19
|
export { PresenceChoreographer } from './presenceChoreographer.js';
|
|
20
|
+
// v1.5 new modules
|
|
21
|
+
export { RetryReasonTracker } from './retryTracker.js';
|
|
22
|
+
export { PostReconnectThrottle } from './reconnectThrottle.js';
|
|
20
23
|
// Socket wrapper
|
|
21
24
|
export { wrapSocket } from './wrapper.js';
|
|
22
25
|
// Optional features
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostReconnectThrottle — Throttle outbound messages after reconnection
|
|
3
|
+
*
|
|
4
|
+
* Inspired by whatsapp-rust's client/sessions.rs which uses semaphore=1 during
|
|
5
|
+
* offline sync (serializes all processing), then swaps to semaphore=64 when sync
|
|
6
|
+
* completes. This prevents burst-floods on reconnect that trigger WA rate limits.
|
|
7
|
+
*
|
|
8
|
+
* In middleware layer: on reconnect, enter a throttled window where beforeSend()
|
|
9
|
+
* gates outbound messages to an artificially low rate, then ramps back to normal
|
|
10
|
+
* over a configurable period.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* const throttle = new PostReconnectThrottle({
|
|
14
|
+
* enabled: true,
|
|
15
|
+
* rampDurationMs: 60_000,
|
|
16
|
+
* initialRateMultiplier: 0.1,
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* // On connection.update with connection === 'open':
|
|
20
|
+
* throttle.onReconnect();
|
|
21
|
+
*
|
|
22
|
+
* // Before sending:
|
|
23
|
+
* const decision = throttle.beforeSend();
|
|
24
|
+
* if (!decision.allowed) {
|
|
25
|
+
* // Wait decision.retryAfterMs
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* // Get current throttle multiplier (1.0 = no throttle):
|
|
29
|
+
* const multiplier = throttle.getCurrentMultiplier();
|
|
30
|
+
*/
|
|
31
|
+
export interface ReconnectThrottleConfig {
|
|
32
|
+
enabled?: boolean;
|
|
33
|
+
rampDurationMs?: number;
|
|
34
|
+
initialRateMultiplier?: number;
|
|
35
|
+
rampSteps?: number;
|
|
36
|
+
baselineRatePerMinute?: () => number;
|
|
37
|
+
}
|
|
38
|
+
export interface ReconnectThrottleStats {
|
|
39
|
+
isThrottled: boolean;
|
|
40
|
+
currentMultiplier: number;
|
|
41
|
+
throttledSinceMs: number | null;
|
|
42
|
+
remainingMs: number;
|
|
43
|
+
throttledSendCount: number;
|
|
44
|
+
lifetimeReconnects: number;
|
|
45
|
+
}
|
|
46
|
+
export declare class PostReconnectThrottle {
|
|
47
|
+
private config;
|
|
48
|
+
private throttledSince;
|
|
49
|
+
private throttledSendCount;
|
|
50
|
+
private lifetimeReconnects;
|
|
51
|
+
private rampTimer;
|
|
52
|
+
private currentStep;
|
|
53
|
+
private sendsInCurrentWindow;
|
|
54
|
+
private currentWindowStart;
|
|
55
|
+
private readonly WINDOW_DURATION_MS;
|
|
56
|
+
constructor(config?: ReconnectThrottleConfig);
|
|
57
|
+
/**
|
|
58
|
+
* Call when connection is re-established. Starts throttle window.
|
|
59
|
+
*/
|
|
60
|
+
onReconnect(): void;
|
|
61
|
+
/**
|
|
62
|
+
* Call when connection drops (optional — reset state).
|
|
63
|
+
*/
|
|
64
|
+
onDisconnect(): void;
|
|
65
|
+
/**
|
|
66
|
+
* Schedule the next ramp step
|
|
67
|
+
*/
|
|
68
|
+
private scheduleNextRampStep;
|
|
69
|
+
/**
|
|
70
|
+
* Returns current rate multiplier (1.0 = no throttle)
|
|
71
|
+
*/
|
|
72
|
+
getCurrentMultiplier(): number;
|
|
73
|
+
/**
|
|
74
|
+
* Checks if a send should be gated. Returns {allowed, reason, retryAfterMs?}
|
|
75
|
+
*/
|
|
76
|
+
beforeSend(): {
|
|
77
|
+
allowed: boolean;
|
|
78
|
+
reason?: string;
|
|
79
|
+
retryAfterMs?: number;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Get current stats
|
|
83
|
+
*/
|
|
84
|
+
getStats(): ReconnectThrottleStats;
|
|
85
|
+
/**
|
|
86
|
+
* Destroy and clean up timers
|
|
87
|
+
*/
|
|
88
|
+
destroy(): void;
|
|
89
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostReconnectThrottle — Throttle outbound messages after reconnection
|
|
3
|
+
*
|
|
4
|
+
* Inspired by whatsapp-rust's client/sessions.rs which uses semaphore=1 during
|
|
5
|
+
* offline sync (serializes all processing), then swaps to semaphore=64 when sync
|
|
6
|
+
* completes. This prevents burst-floods on reconnect that trigger WA rate limits.
|
|
7
|
+
*
|
|
8
|
+
* In middleware layer: on reconnect, enter a throttled window where beforeSend()
|
|
9
|
+
* gates outbound messages to an artificially low rate, then ramps back to normal
|
|
10
|
+
* over a configurable period.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* const throttle = new PostReconnectThrottle({
|
|
14
|
+
* enabled: true,
|
|
15
|
+
* rampDurationMs: 60_000,
|
|
16
|
+
* initialRateMultiplier: 0.1,
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* // On connection.update with connection === 'open':
|
|
20
|
+
* throttle.onReconnect();
|
|
21
|
+
*
|
|
22
|
+
* // Before sending:
|
|
23
|
+
* const decision = throttle.beforeSend();
|
|
24
|
+
* if (!decision.allowed) {
|
|
25
|
+
* // Wait decision.retryAfterMs
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* // Get current throttle multiplier (1.0 = no throttle):
|
|
29
|
+
* const multiplier = throttle.getCurrentMultiplier();
|
|
30
|
+
*/
|
|
31
|
+
const DEFAULT_CONFIG = {
|
|
32
|
+
enabled: false,
|
|
33
|
+
rampDurationMs: 60_000,
|
|
34
|
+
initialRateMultiplier: 0.1,
|
|
35
|
+
rampSteps: 6,
|
|
36
|
+
baselineRatePerMinute: null,
|
|
37
|
+
};
|
|
38
|
+
export class PostReconnectThrottle {
|
|
39
|
+
config;
|
|
40
|
+
throttledSince = null;
|
|
41
|
+
throttledSendCount = 0;
|
|
42
|
+
lifetimeReconnects = 0;
|
|
43
|
+
rampTimer = null;
|
|
44
|
+
currentStep = 0;
|
|
45
|
+
// Tracking sends in current window
|
|
46
|
+
sendsInCurrentWindow = 0;
|
|
47
|
+
currentWindowStart = 0;
|
|
48
|
+
WINDOW_DURATION_MS = 60_000; // 1 minute window
|
|
49
|
+
constructor(config) {
|
|
50
|
+
this.config = {
|
|
51
|
+
...DEFAULT_CONFIG,
|
|
52
|
+
...config,
|
|
53
|
+
baselineRatePerMinute: config?.baselineRatePerMinute || null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Call when connection is re-established. Starts throttle window.
|
|
58
|
+
*/
|
|
59
|
+
onReconnect() {
|
|
60
|
+
if (!this.config.enabled)
|
|
61
|
+
return;
|
|
62
|
+
this.throttledSince = Date.now();
|
|
63
|
+
this.currentStep = 0;
|
|
64
|
+
this.throttledSendCount = 0;
|
|
65
|
+
this.lifetimeReconnects++;
|
|
66
|
+
this.sendsInCurrentWindow = 0;
|
|
67
|
+
this.currentWindowStart = Date.now();
|
|
68
|
+
// Clear any existing ramp timer
|
|
69
|
+
if (this.rampTimer) {
|
|
70
|
+
clearTimeout(this.rampTimer);
|
|
71
|
+
}
|
|
72
|
+
// Set up ramp schedule
|
|
73
|
+
this.scheduleNextRampStep();
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Call when connection drops (optional — reset state).
|
|
77
|
+
*/
|
|
78
|
+
onDisconnect() {
|
|
79
|
+
// Keep throttle state for now — it will expire naturally
|
|
80
|
+
// This prevents rapid reconnect/disconnect cycles from resetting throttle too early
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Schedule the next ramp step
|
|
84
|
+
*/
|
|
85
|
+
scheduleNextRampStep() {
|
|
86
|
+
if (this.currentStep >= this.config.rampSteps) {
|
|
87
|
+
// Ramp complete — no longer throttled
|
|
88
|
+
this.throttledSince = null;
|
|
89
|
+
this.rampTimer = null;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const stepDuration = this.config.rampDurationMs / this.config.rampSteps;
|
|
93
|
+
this.rampTimer = setTimeout(() => {
|
|
94
|
+
this.currentStep++;
|
|
95
|
+
this.scheduleNextRampStep();
|
|
96
|
+
}, stepDuration);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Returns current rate multiplier (1.0 = no throttle)
|
|
100
|
+
*/
|
|
101
|
+
getCurrentMultiplier() {
|
|
102
|
+
if (!this.config.enabled || !this.throttledSince) {
|
|
103
|
+
return 1.0;
|
|
104
|
+
}
|
|
105
|
+
const elapsed = Date.now() - this.throttledSince;
|
|
106
|
+
if (elapsed >= this.config.rampDurationMs) {
|
|
107
|
+
// Ramp complete
|
|
108
|
+
return 1.0;
|
|
109
|
+
}
|
|
110
|
+
// Linear ramp from initialRateMultiplier to 1.0 across rampSteps
|
|
111
|
+
const progress = this.currentStep / this.config.rampSteps;
|
|
112
|
+
const multiplier = this.config.initialRateMultiplier +
|
|
113
|
+
(1.0 - this.config.initialRateMultiplier) * progress;
|
|
114
|
+
return Math.min(1.0, multiplier);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Checks if a send should be gated. Returns {allowed, reason, retryAfterMs?}
|
|
118
|
+
*/
|
|
119
|
+
beforeSend() {
|
|
120
|
+
if (!this.config.enabled || !this.throttledSince) {
|
|
121
|
+
return { allowed: true };
|
|
122
|
+
}
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
const multiplier = this.getCurrentMultiplier();
|
|
125
|
+
// If fully ramped up, allow all sends
|
|
126
|
+
if (multiplier >= 1.0) {
|
|
127
|
+
this.throttledSince = null;
|
|
128
|
+
return { allowed: true };
|
|
129
|
+
}
|
|
130
|
+
// Reset window if needed
|
|
131
|
+
if (now - this.currentWindowStart >= this.WINDOW_DURATION_MS) {
|
|
132
|
+
this.sendsInCurrentWindow = 0;
|
|
133
|
+
this.currentWindowStart = now;
|
|
134
|
+
}
|
|
135
|
+
// Calculate budget for current window
|
|
136
|
+
const baselineRate = this.config.baselineRatePerMinute ? this.config.baselineRatePerMinute() : 8;
|
|
137
|
+
const allowedInWindow = Math.floor(baselineRate * multiplier);
|
|
138
|
+
// Check if we're over budget
|
|
139
|
+
if (this.sendsInCurrentWindow >= allowedInWindow) {
|
|
140
|
+
const windowRemaining = this.WINDOW_DURATION_MS - (now - this.currentWindowStart);
|
|
141
|
+
return {
|
|
142
|
+
allowed: false,
|
|
143
|
+
reason: `Post-reconnect throttle: ${Math.floor(multiplier * 100)}% rate (${this.sendsInCurrentWindow}/${allowedInWindow} sends in window)`,
|
|
144
|
+
retryAfterMs: windowRemaining,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Allow send and increment counter
|
|
148
|
+
this.sendsInCurrentWindow++;
|
|
149
|
+
this.throttledSendCount++;
|
|
150
|
+
return { allowed: true };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Get current stats
|
|
154
|
+
*/
|
|
155
|
+
getStats() {
|
|
156
|
+
const multiplier = this.getCurrentMultiplier();
|
|
157
|
+
const isThrottled = this.throttledSince !== null && multiplier < 1.0;
|
|
158
|
+
const remainingMs = isThrottled && this.throttledSince
|
|
159
|
+
? Math.max(0, this.config.rampDurationMs - (Date.now() - this.throttledSince))
|
|
160
|
+
: 0;
|
|
161
|
+
return {
|
|
162
|
+
isThrottled,
|
|
163
|
+
currentMultiplier: multiplier,
|
|
164
|
+
throttledSinceMs: this.throttledSince,
|
|
165
|
+
remainingMs,
|
|
166
|
+
throttledSendCount: this.throttledSendCount,
|
|
167
|
+
lifetimeReconnects: this.lifetimeReconnects,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Destroy and clean up timers
|
|
172
|
+
*/
|
|
173
|
+
destroy() {
|
|
174
|
+
if (this.rampTimer) {
|
|
175
|
+
clearTimeout(this.rampTimer);
|
|
176
|
+
this.rampTimer = null;
|
|
177
|
+
}
|
|
178
|
+
this.throttledSince = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RetryReasonTracker — Track message retry reasons and detect retry spirals
|
|
3
|
+
*
|
|
4
|
+
* Inspired by whatsapp-rust's protocol/retry.rs module which defines 13 typed
|
|
5
|
+
* RetryReason codes with MAX_RETRY=5 and optimized key-include behavior.
|
|
6
|
+
*
|
|
7
|
+
* In the middleware layer, we can't control key inclusion (that's transport-level),
|
|
8
|
+
* but we CAN observe retry patterns from messages.update events and classify them.
|
|
9
|
+
* High retry rates per reason = ban signal precursor.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* const tracker = new RetryReasonTracker({ enabled: true, maxRetries: 5 });
|
|
13
|
+
*
|
|
14
|
+
* // In messages.update handler:
|
|
15
|
+
* tracker.onMessageUpdate(update);
|
|
16
|
+
*
|
|
17
|
+
* // Check for spirals:
|
|
18
|
+
* if (tracker.isSpiraling(msgId)) {
|
|
19
|
+
* console.warn('Message stuck in retry spiral, dropping');
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* // On successful send:
|
|
23
|
+
* tracker.clear(msgId);
|
|
24
|
+
*
|
|
25
|
+
* // Get stats:
|
|
26
|
+
* const stats = tracker.getStats();
|
|
27
|
+
*/
|
|
28
|
+
export type RetryReason = 'no_session' | 'invalid_key' | 'bad_mac' | 'decryption_failure' | 'server_error_463' | 'server_error_429' | 'timeout' | 'no_route' | 'node_malformed' | 'unknown';
|
|
29
|
+
export interface RetryTrackerConfig {
|
|
30
|
+
enabled?: boolean;
|
|
31
|
+
maxRetries?: number;
|
|
32
|
+
spiralThreshold?: number;
|
|
33
|
+
onSpiral?: (msgId: string, reason: RetryReason) => void;
|
|
34
|
+
}
|
|
35
|
+
export interface RetryStats {
|
|
36
|
+
totalRetries: number;
|
|
37
|
+
byReason: Record<RetryReason, number>;
|
|
38
|
+
spiralsDetected: number;
|
|
39
|
+
activeRetries: number;
|
|
40
|
+
}
|
|
41
|
+
export declare class RetryReasonTracker {
|
|
42
|
+
private config;
|
|
43
|
+
private retries;
|
|
44
|
+
private totalRetries;
|
|
45
|
+
private reasonCounts;
|
|
46
|
+
private spiralsDetected;
|
|
47
|
+
constructor(config?: RetryTrackerConfig);
|
|
48
|
+
/**
|
|
49
|
+
* Call when a messages.update event arrives with a status/error.
|
|
50
|
+
* Classifies and records the retry.
|
|
51
|
+
*/
|
|
52
|
+
onMessageUpdate(update: {
|
|
53
|
+
key: {
|
|
54
|
+
id?: string;
|
|
55
|
+
};
|
|
56
|
+
status?: number;
|
|
57
|
+
error?: any;
|
|
58
|
+
}): void;
|
|
59
|
+
/**
|
|
60
|
+
* Classify an arbitrary error object into a RetryReason
|
|
61
|
+
*/
|
|
62
|
+
classify(err: any): RetryReason;
|
|
63
|
+
/**
|
|
64
|
+
* Record a retry for a message
|
|
65
|
+
*/
|
|
66
|
+
private recordRetry;
|
|
67
|
+
/**
|
|
68
|
+
* Should we warn the user this message is spiraling?
|
|
69
|
+
*/
|
|
70
|
+
isSpiraling(msgId: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Reset counters for a specific message (call on successful delivery)
|
|
73
|
+
*/
|
|
74
|
+
clear(msgId: string): void;
|
|
75
|
+
/**
|
|
76
|
+
* Get current stats
|
|
77
|
+
*/
|
|
78
|
+
getStats(): RetryStats;
|
|
79
|
+
/**
|
|
80
|
+
* Clean up old retry records (>5 minutes old)
|
|
81
|
+
*/
|
|
82
|
+
private cleanup;
|
|
83
|
+
/**
|
|
84
|
+
* Destroy and clean up
|
|
85
|
+
*/
|
|
86
|
+
destroy(): void;
|
|
87
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RetryReasonTracker — Track message retry reasons and detect retry spirals
|
|
3
|
+
*
|
|
4
|
+
* Inspired by whatsapp-rust's protocol/retry.rs module which defines 13 typed
|
|
5
|
+
* RetryReason codes with MAX_RETRY=5 and optimized key-include behavior.
|
|
6
|
+
*
|
|
7
|
+
* In the middleware layer, we can't control key inclusion (that's transport-level),
|
|
8
|
+
* but we CAN observe retry patterns from messages.update events and classify them.
|
|
9
|
+
* High retry rates per reason = ban signal precursor.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* const tracker = new RetryReasonTracker({ enabled: true, maxRetries: 5 });
|
|
13
|
+
*
|
|
14
|
+
* // In messages.update handler:
|
|
15
|
+
* tracker.onMessageUpdate(update);
|
|
16
|
+
*
|
|
17
|
+
* // Check for spirals:
|
|
18
|
+
* if (tracker.isSpiraling(msgId)) {
|
|
19
|
+
* console.warn('Message stuck in retry spiral, dropping');
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* // On successful send:
|
|
23
|
+
* tracker.clear(msgId);
|
|
24
|
+
*
|
|
25
|
+
* // Get stats:
|
|
26
|
+
* const stats = tracker.getStats();
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_CONFIG = {
|
|
29
|
+
enabled: false,
|
|
30
|
+
maxRetries: 5,
|
|
31
|
+
spiralThreshold: 3,
|
|
32
|
+
onSpiral: () => { },
|
|
33
|
+
};
|
|
34
|
+
export class RetryReasonTracker {
|
|
35
|
+
config;
|
|
36
|
+
retries = new Map();
|
|
37
|
+
totalRetries = 0;
|
|
38
|
+
reasonCounts = {
|
|
39
|
+
no_session: 0,
|
|
40
|
+
invalid_key: 0,
|
|
41
|
+
bad_mac: 0,
|
|
42
|
+
decryption_failure: 0,
|
|
43
|
+
server_error_463: 0,
|
|
44
|
+
server_error_429: 0,
|
|
45
|
+
timeout: 0,
|
|
46
|
+
no_route: 0,
|
|
47
|
+
node_malformed: 0,
|
|
48
|
+
unknown: 0,
|
|
49
|
+
};
|
|
50
|
+
spiralsDetected = 0;
|
|
51
|
+
constructor(config) {
|
|
52
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Call when a messages.update event arrives with a status/error.
|
|
56
|
+
* Classifies and records the retry.
|
|
57
|
+
*/
|
|
58
|
+
onMessageUpdate(update) {
|
|
59
|
+
if (!this.config.enabled)
|
|
60
|
+
return;
|
|
61
|
+
const msgId = update.key?.id;
|
|
62
|
+
if (!msgId)
|
|
63
|
+
return;
|
|
64
|
+
// Only track error statuses
|
|
65
|
+
if (update.status !== 0 && !update.error)
|
|
66
|
+
return;
|
|
67
|
+
const reason = this.classify(update.error || update);
|
|
68
|
+
this.recordRetry(msgId, reason);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Classify an arbitrary error object into a RetryReason
|
|
72
|
+
*/
|
|
73
|
+
classify(err) {
|
|
74
|
+
if (!err)
|
|
75
|
+
return 'unknown';
|
|
76
|
+
// Check for HTTP status codes
|
|
77
|
+
const statusCode = err.output?.statusCode || err.statusCode || err.status;
|
|
78
|
+
if (statusCode === 463)
|
|
79
|
+
return 'server_error_463';
|
|
80
|
+
if (statusCode === 429)
|
|
81
|
+
return 'server_error_429';
|
|
82
|
+
// Extract error message/text for pattern matching
|
|
83
|
+
const errorMsg = (err.message || err.text || String(err)).toLowerCase();
|
|
84
|
+
// Pattern matching for known errors
|
|
85
|
+
if (errorMsg.includes('bad mac'))
|
|
86
|
+
return 'bad_mac';
|
|
87
|
+
if (errorMsg.includes('no session') || errorMsg.includes('session not found'))
|
|
88
|
+
return 'no_session';
|
|
89
|
+
if (errorMsg.includes('invalid key') || errorMsg.includes('key error'))
|
|
90
|
+
return 'invalid_key';
|
|
91
|
+
if (errorMsg.includes('decryption') || errorMsg.includes('decrypt'))
|
|
92
|
+
return 'decryption_failure';
|
|
93
|
+
if (errorMsg.includes('timeout') || errorMsg.includes('timed out'))
|
|
94
|
+
return 'timeout';
|
|
95
|
+
if (errorMsg.includes('no route') || errorMsg.includes('unreachable') || errorMsg.includes('offline'))
|
|
96
|
+
return 'no_route';
|
|
97
|
+
if (errorMsg.includes('malformed') || errorMsg.includes('invalid node'))
|
|
98
|
+
return 'node_malformed';
|
|
99
|
+
return 'unknown';
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Record a retry for a message
|
|
103
|
+
*/
|
|
104
|
+
recordRetry(msgId, reason) {
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
let record = this.retries.get(msgId);
|
|
107
|
+
if (!record) {
|
|
108
|
+
record = {
|
|
109
|
+
msgId,
|
|
110
|
+
count: 0,
|
|
111
|
+
reasons: [],
|
|
112
|
+
firstRetry: now,
|
|
113
|
+
lastRetry: now,
|
|
114
|
+
};
|
|
115
|
+
this.retries.set(msgId, record);
|
|
116
|
+
}
|
|
117
|
+
record.count++;
|
|
118
|
+
record.reasons.push(reason);
|
|
119
|
+
record.lastRetry = now;
|
|
120
|
+
this.totalRetries++;
|
|
121
|
+
this.reasonCounts[reason]++;
|
|
122
|
+
// Check for spiral
|
|
123
|
+
if (record.count >= this.config.spiralThreshold) {
|
|
124
|
+
this.spiralsDetected++;
|
|
125
|
+
this.config.onSpiral(msgId, reason);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Should we warn the user this message is spiraling?
|
|
130
|
+
*/
|
|
131
|
+
isSpiraling(msgId) {
|
|
132
|
+
const record = this.retries.get(msgId);
|
|
133
|
+
return record ? record.count >= this.config.spiralThreshold : false;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Reset counters for a specific message (call on successful delivery)
|
|
137
|
+
*/
|
|
138
|
+
clear(msgId) {
|
|
139
|
+
this.retries.delete(msgId);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Get current stats
|
|
143
|
+
*/
|
|
144
|
+
getStats() {
|
|
145
|
+
return {
|
|
146
|
+
totalRetries: this.totalRetries,
|
|
147
|
+
byReason: { ...this.reasonCounts },
|
|
148
|
+
spiralsDetected: this.spiralsDetected,
|
|
149
|
+
activeRetries: this.retries.size,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Clean up old retry records (>5 minutes old)
|
|
154
|
+
*/
|
|
155
|
+
cleanup() {
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
const maxAge = 5 * 60 * 1000; // 5 minutes
|
|
158
|
+
for (const [msgId, record] of this.retries.entries()) {
|
|
159
|
+
if (now - record.lastRetry > maxAge) {
|
|
160
|
+
this.retries.delete(msgId);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Destroy and clean up
|
|
166
|
+
*/
|
|
167
|
+
destroy() {
|
|
168
|
+
this.retries.clear();
|
|
169
|
+
this.cleanup();
|
|
170
|
+
}
|
|
171
|
+
}
|
package/dist/wrapper.d.ts
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Socket Wrapper — Drop-in replacement that wraps sendMessage with anti-ban protection
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Works with both baileys and @oxidezap/baileyrs transports.
|
|
5
|
+
*
|
|
6
|
+
* Usage with baileys:
|
|
5
7
|
* import makeWASocket from 'baileys';
|
|
6
8
|
* import { wrapSocket } from 'baileys-antiban';
|
|
7
9
|
*
|
|
8
10
|
* const sock = makeWASocket({ ... });
|
|
9
11
|
* const safeSock = wrapSocket(sock);
|
|
10
12
|
*
|
|
13
|
+
* Usage with baileyrs:
|
|
14
|
+
* import { makeWASocket } from '@oxidezap/baileyrs';
|
|
15
|
+
* import { wrapSocket } from 'baileys-antiban';
|
|
16
|
+
*
|
|
17
|
+
* const sock = makeWASocket({ ... });
|
|
18
|
+
* const safeSock = wrapSocket(sock);
|
|
19
|
+
*
|
|
11
20
|
* // Use safeSock.sendMessage() — automatically rate-limited and monitored
|
|
12
21
|
* await safeSock.sendMessage(jid, { text: 'Hello!' });
|
|
13
22
|
*
|
|
14
23
|
* // Check health anytime
|
|
15
24
|
* console.log(safeSock.antiban.getStats());
|
|
25
|
+
*
|
|
26
|
+
* Note: reachoutTimeLock timelock module silently noops on baileyrs until upstream
|
|
27
|
+
* emits reachoutTimeLock events — confirmed NOT present in baileyrs v0.0.8.
|
|
28
|
+
* Timelock guard will operate in detection-only mode (relies on 463 errors only).
|
|
16
29
|
*/
|
|
17
30
|
import { AntiBan, type AntiBanConfig } from './antiban.js';
|
|
18
31
|
import type { WarmUpState } from './warmup.js';
|
package/dist/wrapper.js
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Socket Wrapper — Drop-in replacement that wraps sendMessage with anti-ban protection
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Works with both baileys and @oxidezap/baileyrs transports.
|
|
5
|
+
*
|
|
6
|
+
* Usage with baileys:
|
|
5
7
|
* import makeWASocket from 'baileys';
|
|
6
8
|
* import { wrapSocket } from 'baileys-antiban';
|
|
7
9
|
*
|
|
8
10
|
* const sock = makeWASocket({ ... });
|
|
9
11
|
* const safeSock = wrapSocket(sock);
|
|
10
12
|
*
|
|
13
|
+
* Usage with baileyrs:
|
|
14
|
+
* import { makeWASocket } from '@oxidezap/baileyrs';
|
|
15
|
+
* import { wrapSocket } from 'baileys-antiban';
|
|
16
|
+
*
|
|
17
|
+
* const sock = makeWASocket({ ... });
|
|
18
|
+
* const safeSock = wrapSocket(sock);
|
|
19
|
+
*
|
|
11
20
|
* // Use safeSock.sendMessage() — automatically rate-limited and monitored
|
|
12
21
|
* await safeSock.sendMessage(jid, { text: 'Hello!' });
|
|
13
22
|
*
|
|
14
23
|
* // Check health anytime
|
|
15
24
|
* console.log(safeSock.antiban.getStats());
|
|
25
|
+
*
|
|
26
|
+
* Note: reachoutTimeLock timelock module silently noops on baileyrs until upstream
|
|
27
|
+
* emits reachoutTimeLock events — confirmed NOT present in baileyrs v0.0.8.
|
|
28
|
+
* Timelock guard will operate in detection-only mode (relies on 463 errors only).
|
|
16
29
|
*/
|
|
17
30
|
import { AntiBan } from './antiban.js';
|
|
18
31
|
/**
|
|
@@ -50,16 +63,19 @@ export function wrapSocket(sock, config, warmUpState, wrapOptions) {
|
|
|
50
63
|
});
|
|
51
64
|
}
|
|
52
65
|
}
|
|
53
|
-
// Catch 463 errors from message updates
|
|
66
|
+
// Catch 463 errors from message updates + track retries
|
|
54
67
|
if (events['messages.update']) {
|
|
55
68
|
const updates = events['messages.update'];
|
|
56
69
|
for (const update of updates) {
|
|
70
|
+
// 463 error detection
|
|
57
71
|
if (update?.update?.messageStubParameters) {
|
|
58
72
|
const params = update.update.messageStubParameters;
|
|
59
73
|
if (params.includes(463) || params.includes('463')) {
|
|
60
74
|
antiban.timelock.record463Error();
|
|
61
75
|
}
|
|
62
76
|
}
|
|
77
|
+
// Retry tracking
|
|
78
|
+
antiban.retryTracker.onMessageUpdate(update);
|
|
63
79
|
}
|
|
64
80
|
}
|
|
65
81
|
// Register known chats from incoming messages + handle reply suggestions
|
|
@@ -120,15 +136,18 @@ export function wrapSocket(sock, config, warmUpState, wrapOptions) {
|
|
|
120
136
|
});
|
|
121
137
|
}
|
|
122
138
|
});
|
|
123
|
-
// Catch 463 errors from message updates
|
|
139
|
+
// Catch 463 errors from message updates + track retries
|
|
124
140
|
sock.ev.on('messages.update', (updates) => {
|
|
125
141
|
for (const update of updates) {
|
|
142
|
+
// 463 error detection
|
|
126
143
|
if (update?.update?.messageStubParameters) {
|
|
127
144
|
const params = update.update.messageStubParameters;
|
|
128
145
|
if (params.includes(463) || params.includes('463')) {
|
|
129
146
|
antiban.timelock.record463Error();
|
|
130
147
|
}
|
|
131
148
|
}
|
|
149
|
+
// Retry tracking
|
|
150
|
+
antiban.retryTracker.onMessageUpdate(update);
|
|
132
151
|
}
|
|
133
152
|
});
|
|
134
153
|
// Register known chats from incoming messages + handle reply suggestions
|
|
@@ -185,6 +204,10 @@ export function wrapSocket(sock, config, warmUpState, wrapOptions) {
|
|
|
185
204
|
const result = await originalSendMessage(jid, content, options);
|
|
186
205
|
antiban.afterSend(jid, text);
|
|
187
206
|
antiban.timelock.registerKnownChat(jid);
|
|
207
|
+
// Clear retry tracking on successful send
|
|
208
|
+
if (result?.key?.id) {
|
|
209
|
+
antiban.retryTracker.clear(result.key.id);
|
|
210
|
+
}
|
|
188
211
|
return result;
|
|
189
212
|
}
|
|
190
213
|
catch (error) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "baileys-antiban",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "Transport-agnostic anti-ban middleware for Baileys and baileyrs — human-like messaging patterns to protect your WhatsApp number",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"type": "module",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"keywords": [
|
|
31
31
|
"baileys",
|
|
32
|
+
"baileyrs",
|
|
32
33
|
"whatsapp",
|
|
33
34
|
"anti-ban",
|
|
34
35
|
"rate-limit",
|
|
@@ -36,7 +37,8 @@
|
|
|
36
37
|
"whatsapp-bot",
|
|
37
38
|
"bot-protection",
|
|
38
39
|
"whatsapp-api",
|
|
39
|
-
"nodejs"
|
|
40
|
+
"nodejs",
|
|
41
|
+
"transport-agnostic"
|
|
40
42
|
],
|
|
41
43
|
"author": "Kobus Wentzel <kobie@pop.co.za>",
|
|
42
44
|
"license": "MIT",
|
|
@@ -49,13 +51,22 @@
|
|
|
49
51
|
},
|
|
50
52
|
"homepage": "https://github.com/kobie3717/baileys-antiban#readme",
|
|
51
53
|
"peerDependencies": {
|
|
54
|
+
"@oxidezap/baileyrs": ">=0.0.8",
|
|
52
55
|
"baileys": ">=6.0.0"
|
|
53
56
|
},
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"baileys": {
|
|
59
|
+
"optional": true
|
|
60
|
+
},
|
|
61
|
+
"@oxidezap/baileyrs": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
64
|
+
},
|
|
54
65
|
"devDependencies": {
|
|
55
66
|
"@types/jest": "^29.5.14",
|
|
56
67
|
"@types/node": "^20.0.0",
|
|
57
68
|
"jest": "^29.7.0",
|
|
58
|
-
"ts-jest": "^29.4.
|
|
69
|
+
"ts-jest": "^29.4.9",
|
|
59
70
|
"tsx": "^4.21.0",
|
|
60
71
|
"typescript": "^5.0.0"
|
|
61
72
|
}
|