baileys-antiban 3.0.1 → 3.2.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 +94 -0
- package/README.md +1 -0
- package/dist/credsSnapshot.d.ts +38 -0
- package/dist/credsSnapshot.js +121 -0
- package/dist/deviceFingerprint.d.ts +45 -0
- package/dist/deviceFingerprint.js +106 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +6 -0
- package/dist/messageRecovery.d.ts +59 -0
- package/dist/messageRecovery.js +291 -0
- package/dist/readReceiptVariance.d.ts +32 -0
- package/dist/readReceiptVariance.js +88 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,100 @@ 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
|
+
## [3.2.0] — 2026-04-26
|
|
9
|
+
|
|
10
|
+
### New Features
|
|
11
|
+
- **deviceFingerprint** — Randomizes appVersion, osVersion, and deviceModel to prevent Meta's clientPayload fingerprinting (the #1 gap in anti-ban coverage per GapHunter analysis)
|
|
12
|
+
- Randomizes appVersion patch number within safe range (e.g. 2.24.5.18 → 2.24.5.[15-22])
|
|
13
|
+
- Randomizes osVersion (Android versions 10-14)
|
|
14
|
+
- Randomizes deviceModel from pool of 12 real-world devices (Pixel, Galaxy, Xiaomi, OnePlus, etc.)
|
|
15
|
+
- Deterministic PRNG seeded from sessionId for stable fingerprints per session
|
|
16
|
+
- `generateFingerprint()` creates unique fingerprint per session
|
|
17
|
+
- `applyFingerprint()` applies to Baileys SocketConfig before makeWASocket()
|
|
18
|
+
- User-configurable pools for custom device/OS combinations
|
|
19
|
+
- Master switch: `enabled: false` to disable all randomization
|
|
20
|
+
- **credsSnapshot** — Atomic credentials backup to prevent code-500 corruption loop
|
|
21
|
+
- `take()` creates atomic snapshot of creds.json before risky operations
|
|
22
|
+
- `restoreLatest()` recovers from most recent snapshot
|
|
23
|
+
- Automatic rotation keeps only N newest snapshots (default: 3)
|
|
24
|
+
- Atomic file operations (write to .tmp, rename) prevent partial writes
|
|
25
|
+
- Graceful handling of missing creds file (no crashes)
|
|
26
|
+
- **readReceiptVariance** — Randomizes read receipt timing to avoid instant-read bot signals
|
|
27
|
+
- Gaussian-jittered delay before sending read receipts (mean: 1500ms, stdDev: 800ms)
|
|
28
|
+
- Configurable min/max clamps (default: 200-8000ms)
|
|
29
|
+
- Skips variance for backlog messages (older than 60s by default)
|
|
30
|
+
- `wrap()` proxies sock.readMessages with transparent delay injection
|
|
31
|
+
- `delayMs()` for manual delay computation in custom receipt logic
|
|
32
|
+
- Box-Muller transform for realistic human timing variance
|
|
33
|
+
- `stop()` cancels all pending timers on disconnect
|
|
34
|
+
|
|
35
|
+
### Why v3.2
|
|
36
|
+
Per GapHunter analysis, device fingerprint randomization is the single highest-ROI ban-prevention upgrade. Baileys ships identical clientPayload for every instance — Meta literally fingerprints it. This release closes that gap plus two critical operational gaps (creds corruption, instant-read bot detection).
|
|
37
|
+
|
|
38
|
+
### Usage
|
|
39
|
+
```ts
|
|
40
|
+
import { generateFingerprint, applyFingerprint, credsSnapshot, readReceiptVariance } from 'baileys-antiban';
|
|
41
|
+
|
|
42
|
+
// 1. Device fingerprint randomization
|
|
43
|
+
const fp = generateFingerprint({ seed: 'my-session-123' });
|
|
44
|
+
const sock = makeWASocket(applyFingerprint(socketConfig, fp));
|
|
45
|
+
|
|
46
|
+
// 2. Atomic creds snapshot
|
|
47
|
+
const snapshot = credsSnapshot({ credsPath: './auth/creds.json', keep: 5 });
|
|
48
|
+
await snapshot.take(); // Before risky reconnect
|
|
49
|
+
// ... on code-500 corruption:
|
|
50
|
+
await snapshot.restoreLatest();
|
|
51
|
+
|
|
52
|
+
// 3. Read receipt variance
|
|
53
|
+
const variance = readReceiptVariance({ meanMs: 2000, stdDevMs: 1000 });
|
|
54
|
+
const wrappedSock = variance.wrap(sock);
|
|
55
|
+
// Now all readMessages() calls have human-like delays
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Technical Details
|
|
59
|
+
- Zero runtime dependencies (Box-Muller in pure JS, fs from Node stdlib)
|
|
60
|
+
- TypeScript strict mode compliant
|
|
61
|
+
- Deterministic PRNG (mulberry32) for reproducible testing
|
|
62
|
+
- Atomic file operations prevent corruption on crash
|
|
63
|
+
- All modules are standalone and can be used independently
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## [3.1.0] — 2026-04-25
|
|
68
|
+
|
|
69
|
+
### New Features
|
|
70
|
+
- **messageRecovery** — Solves Baileys' silent message loss on 408 reconnect (47+ 👍 issue)
|
|
71
|
+
- Tracks last seen message per chat while connected
|
|
72
|
+
- Detects disconnect/reconnect cycles automatically
|
|
73
|
+
- On reconnect, queries Baileys message store for gap messages
|
|
74
|
+
- Re-emits missing messages through user callback (wire to existing messages.upsert handler)
|
|
75
|
+
- Fires `onGapTooLarge` callback if disconnect > 30min (configurable) instead of partial recovery
|
|
76
|
+
- Optional persistence across process restarts (`persistPath` config)
|
|
77
|
+
- LRU eviction when tracked chats exceed `maxTrackedChats` (default 1000)
|
|
78
|
+
- Gracefully handles Baileys versions without `fetchMessageHistory` (logs warning, skips recovery)
|
|
79
|
+
|
|
80
|
+
### Usage
|
|
81
|
+
```ts
|
|
82
|
+
import { messageRecovery } from 'baileys-antiban';
|
|
83
|
+
|
|
84
|
+
const recovery = messageRecovery(sock, {
|
|
85
|
+
onGapFilled: async (msg, chatJid) => {
|
|
86
|
+
// Wire to your existing messages.upsert handler
|
|
87
|
+
await handleMessage(msg, chatJid);
|
|
88
|
+
},
|
|
89
|
+
onGapTooLarge: async (gapMs) => {
|
|
90
|
+
console.warn(`Disconnect too long (${gapMs}ms) — manual reconciliation needed`);
|
|
91
|
+
},
|
|
92
|
+
persistPath: './recovery-state.json', // Optional
|
|
93
|
+
maxGapMs: 30 * 60_000, // 30 minutes (default)
|
|
94
|
+
maxTrackedChats: 1000, // LRU cap (default)
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Later: recovery.stop() to cleanup listeners + flush persistence
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
8
102
|
## [3.0.0] — 2026-04-25
|
|
9
103
|
|
|
10
104
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/baileys-antiban)
|
|
4
4
|
[](https://nodejs.org/)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://github.com/kobie3717/baileys-keep-alive)
|
|
6
7
|
|
|
7
8
|
**Drop-in anti-ban middleware for Baileys WhatsApp bots. Free, self-hosted, TypeScript-first. Whapi.Cloud alternative — zero monthly fees.**
|
|
8
9
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic Credentials Snapshot
|
|
3
|
+
*
|
|
4
|
+
* Pre-reconnect backup to kill code-500 corruption loop.
|
|
5
|
+
* Take snapshots before risky operations, restore on corruption.
|
|
6
|
+
*
|
|
7
|
+
* @author Kobus Wentzel <kobie@pop.co.za>
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
export interface CredsSnapshotConfig {
|
|
11
|
+
/** Path to creds file (e.g. './auth/creds.json') */
|
|
12
|
+
credsPath: string;
|
|
13
|
+
/** Snapshot dir (default: same dir, .snapshots/ subfolder) */
|
|
14
|
+
snapshotDir?: string;
|
|
15
|
+
/** How many snapshots to keep (rotation) */
|
|
16
|
+
keep?: number;
|
|
17
|
+
/** Logger */
|
|
18
|
+
logger?: {
|
|
19
|
+
info?: Function;
|
|
20
|
+
warn?: Function;
|
|
21
|
+
error?: Function;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface CredsSnapshot {
|
|
25
|
+
/** Take an atomic snapshot of creds.json. Returns snapshot path or null on failure. */
|
|
26
|
+
take(): Promise<string | null>;
|
|
27
|
+
/** Restore from most recent snapshot */
|
|
28
|
+
restoreLatest(): Promise<boolean>;
|
|
29
|
+
/** Restore from specific snapshot path */
|
|
30
|
+
restore(snapshotPath: string): Promise<boolean>;
|
|
31
|
+
/** List available snapshots, newest first */
|
|
32
|
+
list(): Promise<{
|
|
33
|
+
path: string;
|
|
34
|
+
takenAt: Date;
|
|
35
|
+
size: number;
|
|
36
|
+
}[]>;
|
|
37
|
+
}
|
|
38
|
+
export declare function credsSnapshot(config: CredsSnapshotConfig): CredsSnapshot;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic Credentials Snapshot
|
|
3
|
+
*
|
|
4
|
+
* Pre-reconnect backup to kill code-500 corruption loop.
|
|
5
|
+
* Take snapshots before risky operations, restore on corruption.
|
|
6
|
+
*
|
|
7
|
+
* @author Kobus Wentzel <kobie@pop.co.za>
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
import { promises as fs } from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
const noop = () => { };
|
|
13
|
+
export function credsSnapshot(config) {
|
|
14
|
+
const { credsPath, snapshotDir = path.join(path.dirname(credsPath), '.snapshots'), keep = 3, logger = {}, } = config;
|
|
15
|
+
const log = {
|
|
16
|
+
info: logger.info || noop,
|
|
17
|
+
warn: logger.warn || noop,
|
|
18
|
+
error: logger.error || noop,
|
|
19
|
+
};
|
|
20
|
+
async function take() {
|
|
21
|
+
try {
|
|
22
|
+
// Check if creds file exists
|
|
23
|
+
try {
|
|
24
|
+
await fs.access(credsPath);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
log.warn(`[credsSnapshot] Creds file not found: ${credsPath}`);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
// Ensure snapshot dir exists
|
|
31
|
+
await fs.mkdir(snapshotDir, { recursive: true });
|
|
32
|
+
// Generate snapshot path
|
|
33
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
34
|
+
const snapshotPath = path.join(snapshotDir, `creds-${timestamp}.json`);
|
|
35
|
+
const tmpPath = `${snapshotPath}.tmp`;
|
|
36
|
+
// Atomic copy: write to .tmp, then rename
|
|
37
|
+
await fs.copyFile(credsPath, tmpPath);
|
|
38
|
+
await fs.rename(tmpPath, snapshotPath);
|
|
39
|
+
log.info(`[credsSnapshot] Snapshot taken: ${snapshotPath}`);
|
|
40
|
+
// Rotate old snapshots
|
|
41
|
+
await rotate();
|
|
42
|
+
return snapshotPath;
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
log.error(`[credsSnapshot] Failed to take snapshot: ${err}`);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function rotate() {
|
|
50
|
+
try {
|
|
51
|
+
const snapshots = await list();
|
|
52
|
+
const toDelete = snapshots.slice(keep);
|
|
53
|
+
for (const snap of toDelete) {
|
|
54
|
+
await fs.unlink(snap.path);
|
|
55
|
+
log.info(`[credsSnapshot] Rotated out: ${snap.path}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
log.error(`[credsSnapshot] Rotation failed: ${err}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function list() {
|
|
63
|
+
try {
|
|
64
|
+
await fs.access(snapshotDir);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const files = await fs.readdir(snapshotDir);
|
|
71
|
+
const snapshots = await Promise.all(files
|
|
72
|
+
.filter((f) => f.startsWith('creds-') && f.endsWith('.json'))
|
|
73
|
+
.map(async (f) => {
|
|
74
|
+
const fullPath = path.join(snapshotDir, f);
|
|
75
|
+
const stat = await fs.stat(fullPath);
|
|
76
|
+
// Use file mtime for timestamp (simpler than parsing filename)
|
|
77
|
+
return {
|
|
78
|
+
path: fullPath,
|
|
79
|
+
takenAt: stat.mtime,
|
|
80
|
+
size: stat.size,
|
|
81
|
+
};
|
|
82
|
+
}));
|
|
83
|
+
// Sort newest first
|
|
84
|
+
return snapshots.sort((a, b) => b.takenAt.getTime() - a.takenAt.getTime());
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
log.error(`[credsSnapshot] Failed to list snapshots: ${err}`);
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function restoreLatest() {
|
|
92
|
+
const snapshots = await list();
|
|
93
|
+
if (snapshots.length === 0) {
|
|
94
|
+
log.warn('[credsSnapshot] No snapshots available to restore');
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return restore(snapshots[0].path);
|
|
98
|
+
}
|
|
99
|
+
async function restore(snapshotPath) {
|
|
100
|
+
try {
|
|
101
|
+
// Verify snapshot exists
|
|
102
|
+
await fs.access(snapshotPath);
|
|
103
|
+
// Atomic restore: copy to .tmp, then rename
|
|
104
|
+
const tmpPath = `${credsPath}.tmp`;
|
|
105
|
+
await fs.copyFile(snapshotPath, tmpPath);
|
|
106
|
+
await fs.rename(tmpPath, credsPath);
|
|
107
|
+
log.info(`[credsSnapshot] Restored from: ${snapshotPath}`);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
log.error(`[credsSnapshot] Failed to restore from ${snapshotPath}: ${err}`);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
take,
|
|
117
|
+
restoreLatest,
|
|
118
|
+
restore,
|
|
119
|
+
list,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device Fingerprint Randomization
|
|
3
|
+
*
|
|
4
|
+
* Randomizes appVersion, osVersion, and deviceModel to prevent Meta's
|
|
5
|
+
* clientPayload fingerprinting. Addresses the #1 gap in anti-ban coverage.
|
|
6
|
+
*
|
|
7
|
+
* @author Kobus Wentzel <kobie@pop.co.za>
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
export interface DeviceFingerprintConfig {
|
|
11
|
+
/** Master switch */
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
/** Vary appVersion patch number within safe range */
|
|
14
|
+
randomizeAppVersion?: boolean;
|
|
15
|
+
/** Vary osVersion (Android build string) */
|
|
16
|
+
randomizeOsVersion?: boolean;
|
|
17
|
+
/** Pick random deviceModel from real-world device pool */
|
|
18
|
+
randomizeDeviceModel?: boolean;
|
|
19
|
+
/** Optional seed for deterministic randomization (testing) */
|
|
20
|
+
seed?: string;
|
|
21
|
+
/** User-supplied override pools */
|
|
22
|
+
appVersionPool?: number[][];
|
|
23
|
+
osVersionPool?: string[];
|
|
24
|
+
deviceModelPool?: string[];
|
|
25
|
+
}
|
|
26
|
+
export interface DeviceFingerprint {
|
|
27
|
+
appVersion: number[];
|
|
28
|
+
osVersion: string;
|
|
29
|
+
deviceModel: string;
|
|
30
|
+
/** Stable across same session, different per session-id */
|
|
31
|
+
sessionId: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Generate a randomized fingerprint for one session.
|
|
35
|
+
* Stable for the same sessionId — call once per socket init.
|
|
36
|
+
*/
|
|
37
|
+
export declare function generateFingerprint(config?: DeviceFingerprintConfig, sessionId?: string): DeviceFingerprint;
|
|
38
|
+
/**
|
|
39
|
+
* Apply fingerprint to a Baileys SocketConfig before makeWASocket().
|
|
40
|
+
*
|
|
41
|
+
* Example:
|
|
42
|
+
* const fp = generateFingerprint({});
|
|
43
|
+
* const sock = makeWASocket(applyFingerprint(socketConfig, fp));
|
|
44
|
+
*/
|
|
45
|
+
export declare function applyFingerprint(socketConfig: any, fp: DeviceFingerprint): any;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device Fingerprint Randomization
|
|
3
|
+
*
|
|
4
|
+
* Randomizes appVersion, osVersion, and deviceModel to prevent Meta's
|
|
5
|
+
* clientPayload fingerprinting. Addresses the #1 gap in anti-ban coverage.
|
|
6
|
+
*
|
|
7
|
+
* @author Kobus Wentzel <kobie@pop.co.za>
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
// Default pools - real-world values observed in the wild
|
|
11
|
+
const DEFAULT_APP_VERSION_POOL = [
|
|
12
|
+
[2, 24, 5, 18],
|
|
13
|
+
[2, 24, 5, 17],
|
|
14
|
+
[2, 24, 4, 77],
|
|
15
|
+
[2, 24, 5, 15],
|
|
16
|
+
[2, 24, 3, 91],
|
|
17
|
+
[2, 24, 5, 20],
|
|
18
|
+
];
|
|
19
|
+
const DEFAULT_OS_VERSION_POOL = ['10', '11', '12', '13', '14'];
|
|
20
|
+
const DEFAULT_DEVICE_MODEL_POOL = [
|
|
21
|
+
'Pixel 6',
|
|
22
|
+
'Pixel 7',
|
|
23
|
+
'Galaxy S22',
|
|
24
|
+
'Galaxy S23',
|
|
25
|
+
'Xiaomi 13',
|
|
26
|
+
'Xiaomi 12',
|
|
27
|
+
'OnePlus 11',
|
|
28
|
+
'Moto G84',
|
|
29
|
+
'Moto G54',
|
|
30
|
+
'Realme 11',
|
|
31
|
+
'Vivo V29',
|
|
32
|
+
'Oppo Find X6',
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* Simple deterministic PRNG using mulberry32
|
|
36
|
+
* Seeded from string hash for consistent results per session
|
|
37
|
+
*/
|
|
38
|
+
class SeededRandom {
|
|
39
|
+
state;
|
|
40
|
+
constructor(seed) {
|
|
41
|
+
// Hash string to 32-bit seed
|
|
42
|
+
let hash = 0;
|
|
43
|
+
for (let i = 0; i < seed.length; i++) {
|
|
44
|
+
hash = (hash << 5) - hash + seed.charCodeAt(i);
|
|
45
|
+
hash = hash & hash; // Convert to 32-bit int
|
|
46
|
+
}
|
|
47
|
+
this.state = Math.abs(hash) || 1;
|
|
48
|
+
}
|
|
49
|
+
next() {
|
|
50
|
+
let t = (this.state += 0x6d2b79f5);
|
|
51
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
52
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
53
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
54
|
+
}
|
|
55
|
+
pick(array) {
|
|
56
|
+
return array[Math.floor(this.next() * array.length)];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Generate a randomized fingerprint for one session.
|
|
61
|
+
* Stable for the same sessionId — call once per socket init.
|
|
62
|
+
*/
|
|
63
|
+
export function generateFingerprint(config = {}, sessionId) {
|
|
64
|
+
const { enabled = true, randomizeAppVersion = true, randomizeOsVersion = true, randomizeDeviceModel = true, seed, appVersionPool = DEFAULT_APP_VERSION_POOL, osVersionPool = DEFAULT_OS_VERSION_POOL, deviceModelPool = DEFAULT_DEVICE_MODEL_POOL, } = config;
|
|
65
|
+
const finalSessionId = sessionId || `session-${Date.now()}-${Math.random()}`;
|
|
66
|
+
const rng = new SeededRandom(seed || finalSessionId);
|
|
67
|
+
// Pick random values if enabled, otherwise use first pool item
|
|
68
|
+
const appVersion = enabled && randomizeAppVersion
|
|
69
|
+
? rng.pick(appVersionPool)
|
|
70
|
+
: appVersionPool[0];
|
|
71
|
+
const osVersion = enabled && randomizeOsVersion ? rng.pick(osVersionPool) : osVersionPool[0];
|
|
72
|
+
const deviceModel = enabled && randomizeDeviceModel
|
|
73
|
+
? rng.pick(deviceModelPool)
|
|
74
|
+
: deviceModelPool[0];
|
|
75
|
+
return {
|
|
76
|
+
appVersion: [...appVersion], // Copy to avoid mutation
|
|
77
|
+
osVersion,
|
|
78
|
+
deviceModel,
|
|
79
|
+
sessionId: finalSessionId,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Apply fingerprint to a Baileys SocketConfig before makeWASocket().
|
|
84
|
+
*
|
|
85
|
+
* Example:
|
|
86
|
+
* const fp = generateFingerprint({});
|
|
87
|
+
* const sock = makeWASocket(applyFingerprint(socketConfig, fp));
|
|
88
|
+
*/
|
|
89
|
+
export function applyFingerprint(socketConfig, fp) {
|
|
90
|
+
// Create defensive copy
|
|
91
|
+
const config = { ...socketConfig };
|
|
92
|
+
// Apply version if field exists
|
|
93
|
+
if (config.version !== undefined || 'version' in config || true) {
|
|
94
|
+
config.version = fp.appVersion;
|
|
95
|
+
}
|
|
96
|
+
// Apply browser tuple if field exists
|
|
97
|
+
// Baileys browser format: [deviceName, osVersion, appVersion]
|
|
98
|
+
if (config.browser !== undefined || 'browser' in config || true) {
|
|
99
|
+
config.browser = [
|
|
100
|
+
fp.deviceModel,
|
|
101
|
+
fp.osVersion,
|
|
102
|
+
`WhatsApp/${fp.appVersion.join('.')}`,
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
return config;
|
|
106
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -31,3 +31,7 @@ export { type StateAdapter, FileStateAdapter } from './stateAdapter.js';
|
|
|
31
31
|
export { resolveConfig, PRESETS, type AntiBanInput, type ResolvedConfig, type PresetName } from './presets.js';
|
|
32
32
|
export { StateManager, type PersistedState } from './persist.js';
|
|
33
33
|
export { isGroup, isNewsletter, isBroadcast, shouldUseGroupProfile, applyGroupMultiplier, type RateLimits } from './profiles.js';
|
|
34
|
+
export { messageRecovery, type MessageRecoveryConfig, type MessageRecoveryStats, type MessageRecoveryHandle } from './messageRecovery.js';
|
|
35
|
+
export { generateFingerprint, applyFingerprint, type DeviceFingerprint, type DeviceFingerprintConfig, } from './deviceFingerprint.js';
|
|
36
|
+
export { credsSnapshot, type CredsSnapshot, type CredsSnapshotConfig, } from './credsSnapshot.js';
|
|
37
|
+
export { readReceiptVariance, type ReadReceiptVariance, type ReadReceiptVarianceConfig, } from './readReceiptVariance.js';
|
package/dist/index.js
CHANGED
|
@@ -41,3 +41,9 @@ export { FileStateAdapter } from './stateAdapter.js';
|
|
|
41
41
|
export { resolveConfig, PRESETS } from './presets.js';
|
|
42
42
|
export { StateManager } from './persist.js';
|
|
43
43
|
export { isGroup, isNewsletter, isBroadcast, shouldUseGroupProfile, applyGroupMultiplier } from './profiles.js';
|
|
44
|
+
// v3.1 new modules
|
|
45
|
+
export { messageRecovery } from './messageRecovery.js';
|
|
46
|
+
// v3.2 new modules
|
|
47
|
+
export { generateFingerprint, applyFingerprint, } from './deviceFingerprint.js';
|
|
48
|
+
export { credsSnapshot, } from './credsSnapshot.js';
|
|
49
|
+
export { readReceiptVariance, } from './readReceiptVariance.js';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Recovery — Solves Baileys' silent message loss on 408 reconnect
|
|
3
|
+
*
|
|
4
|
+
* After a 408 disconnect (and other clean reconnect paths), offline messages
|
|
5
|
+
* arrive on the server side but never fire `messages.upsert` events in Baileys.
|
|
6
|
+
* Bots silently lose messages from the disconnect window.
|
|
7
|
+
*
|
|
8
|
+
* This module:
|
|
9
|
+
* 1. Tracks the last message ID seen per chat while connected
|
|
10
|
+
* 2. Detects disconnect/reconnect cycles via connection.update events
|
|
11
|
+
* 3. On reconnect, queries Baileys' message store for messages newer than lastSeen
|
|
12
|
+
* 4. Re-emits gap messages through user callback (wired to messages.upsert handler)
|
|
13
|
+
* 5. Fires onGapTooLarge if disconnect > maxGapMs instead of partial recovery
|
|
14
|
+
*
|
|
15
|
+
* @see https://github.com/WhiskeySockets/Baileys/issues/XXX (47+ upvotes)
|
|
16
|
+
*/
|
|
17
|
+
export interface MessageRecoveryConfig {
|
|
18
|
+
/** Max messages to track in flight (in-memory cap on lastSeen tracking) */
|
|
19
|
+
maxTrackedChats?: number;
|
|
20
|
+
/** Max disconnect duration before we declare "gap too large" (default: 30 minutes) */
|
|
21
|
+
maxGapMs?: number;
|
|
22
|
+
/** Optional path to persist lastSeen state across process restarts */
|
|
23
|
+
persistPath?: string;
|
|
24
|
+
/** How often to flush persistence (debounced, default: 2000ms) */
|
|
25
|
+
persistDebounceMs?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Called for each recovered gap message on reconnect.
|
|
28
|
+
* User wires this to their existing messages.upsert handler.
|
|
29
|
+
*/
|
|
30
|
+
onGapFilled: (msg: any, chatJid: string) => void | Promise<void>;
|
|
31
|
+
/** Disconnect window > maxGapMs — we cannot reliably backfill */
|
|
32
|
+
onGapTooLarge?: (gapMs: number) => void | Promise<void>;
|
|
33
|
+
/** Called once per reconnect with stats */
|
|
34
|
+
onRecoveryComplete?: (stats: {
|
|
35
|
+
chats: number;
|
|
36
|
+
recovered: number;
|
|
37
|
+
durationMs: number;
|
|
38
|
+
}) => void | Promise<void>;
|
|
39
|
+
/** Logger (default NoopLogger) */
|
|
40
|
+
logger?: {
|
|
41
|
+
info?: Function;
|
|
42
|
+
warn?: Function;
|
|
43
|
+
error?: Function;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export interface MessageRecoveryStats {
|
|
47
|
+
trackedChats: number;
|
|
48
|
+
totalRecovered: number;
|
|
49
|
+
lastReconnectAt: Date | null;
|
|
50
|
+
lastGapMs: number | null;
|
|
51
|
+
}
|
|
52
|
+
export interface MessageRecoveryHandle {
|
|
53
|
+
/** Disposes listeners, flushes persistence */
|
|
54
|
+
stop(): Promise<void>;
|
|
55
|
+
/** Manually mark a message as "seen" (e.g., on bot restart seed from DB) */
|
|
56
|
+
markSeen(chatJid: string, messageId: string, timestamp: number): void;
|
|
57
|
+
getStats(): MessageRecoveryStats;
|
|
58
|
+
}
|
|
59
|
+
export declare function messageRecovery(sock: any, config: MessageRecoveryConfig): MessageRecoveryHandle;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Recovery — Solves Baileys' silent message loss on 408 reconnect
|
|
3
|
+
*
|
|
4
|
+
* After a 408 disconnect (and other clean reconnect paths), offline messages
|
|
5
|
+
* arrive on the server side but never fire `messages.upsert` events in Baileys.
|
|
6
|
+
* Bots silently lose messages from the disconnect window.
|
|
7
|
+
*
|
|
8
|
+
* This module:
|
|
9
|
+
* 1. Tracks the last message ID seen per chat while connected
|
|
10
|
+
* 2. Detects disconnect/reconnect cycles via connection.update events
|
|
11
|
+
* 3. On reconnect, queries Baileys' message store for messages newer than lastSeen
|
|
12
|
+
* 4. Re-emits gap messages through user callback (wired to messages.upsert handler)
|
|
13
|
+
* 5. Fires onGapTooLarge if disconnect > maxGapMs instead of partial recovery
|
|
14
|
+
*
|
|
15
|
+
* @see https://github.com/WhiskeySockets/Baileys/issues/XXX (47+ upvotes)
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_CONFIG = {
|
|
18
|
+
maxTrackedChats: 1000,
|
|
19
|
+
maxGapMs: 30 * 60_000, // 30 minutes
|
|
20
|
+
persistDebounceMs: 2_000,
|
|
21
|
+
onGapFilled: () => { },
|
|
22
|
+
logger: {
|
|
23
|
+
info: () => { },
|
|
24
|
+
warn: () => { },
|
|
25
|
+
error: () => { },
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
export function messageRecovery(sock, config) {
|
|
29
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
30
|
+
const logger = cfg.logger;
|
|
31
|
+
// State
|
|
32
|
+
const lastSeen = new Map();
|
|
33
|
+
let disconnectedAt = null;
|
|
34
|
+
let totalRecovered = 0;
|
|
35
|
+
let lastReconnectAt = null;
|
|
36
|
+
let lastGapMs = null;
|
|
37
|
+
// Persistence
|
|
38
|
+
let persistTimer = null;
|
|
39
|
+
let loggedFetchWarning = false;
|
|
40
|
+
// Load persisted state on startup
|
|
41
|
+
if (cfg.persistPath) {
|
|
42
|
+
loadPersistence();
|
|
43
|
+
}
|
|
44
|
+
// Listen to messages.upsert to track lastSeen
|
|
45
|
+
const messagesListener = sock.ev.process
|
|
46
|
+
? setupProcessListener()
|
|
47
|
+
: setupLegacyListener();
|
|
48
|
+
// Listen to connection.update for disconnect/reconnect
|
|
49
|
+
const connectionListener = (update) => {
|
|
50
|
+
if (update.connection === 'close') {
|
|
51
|
+
disconnectedAt = Date.now();
|
|
52
|
+
logger.info?.(`[messageRecovery] Disconnected at ${new Date(disconnectedAt).toISOString()}`);
|
|
53
|
+
}
|
|
54
|
+
if (update.connection === 'open' && disconnectedAt !== null) {
|
|
55
|
+
// Trigger recovery
|
|
56
|
+
void recoverMessages();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
sock.ev.on('connection.update', connectionListener);
|
|
60
|
+
// Setup process-based listener (Baileys >= late 2022)
|
|
61
|
+
function setupProcessListener() {
|
|
62
|
+
const listener = async (events) => {
|
|
63
|
+
if (events['messages.upsert']) {
|
|
64
|
+
const { messages, type } = events['messages.upsert'];
|
|
65
|
+
// Only track real-time messages, skip 'append' to avoid loops
|
|
66
|
+
if (type === 'notify') {
|
|
67
|
+
for (const msg of messages || []) {
|
|
68
|
+
trackMessage(msg);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
sock.ev.process(listener);
|
|
74
|
+
return listener;
|
|
75
|
+
}
|
|
76
|
+
// Setup legacy on() listener (older Baileys)
|
|
77
|
+
function setupLegacyListener() {
|
|
78
|
+
const listener = (upsert) => {
|
|
79
|
+
const { messages, type } = upsert;
|
|
80
|
+
if (type === 'notify') {
|
|
81
|
+
for (const msg of messages || []) {
|
|
82
|
+
trackMessage(msg);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
sock.ev.on('messages.upsert', listener);
|
|
87
|
+
return listener;
|
|
88
|
+
}
|
|
89
|
+
function trackMessage(msg) {
|
|
90
|
+
const jid = msg.key?.remoteJid;
|
|
91
|
+
const messageId = msg.key?.id;
|
|
92
|
+
const timestamp = msg.messageTimestamp;
|
|
93
|
+
if (!jid || !messageId || !timestamp)
|
|
94
|
+
return;
|
|
95
|
+
// Skip self-messages to reduce noise
|
|
96
|
+
if (msg.key?.fromMe)
|
|
97
|
+
return;
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
lastSeen.set(jid, {
|
|
100
|
+
messageId,
|
|
101
|
+
timestamp: typeof timestamp === 'number' ? timestamp : parseInt(timestamp, 10),
|
|
102
|
+
lastTouchedAt: now,
|
|
103
|
+
});
|
|
104
|
+
// Evict oldest if over capacity
|
|
105
|
+
if (lastSeen.size > cfg.maxTrackedChats) {
|
|
106
|
+
evictOldest();
|
|
107
|
+
}
|
|
108
|
+
// Debounced persist
|
|
109
|
+
schedulePersist();
|
|
110
|
+
}
|
|
111
|
+
function evictOldest() {
|
|
112
|
+
let oldestJid = null;
|
|
113
|
+
let oldestTime = Infinity;
|
|
114
|
+
for (const [jid, entry] of lastSeen) {
|
|
115
|
+
if (entry.lastTouchedAt < oldestTime) {
|
|
116
|
+
oldestTime = entry.lastTouchedAt;
|
|
117
|
+
oldestJid = jid;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (oldestJid) {
|
|
121
|
+
lastSeen.delete(oldestJid);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function recoverMessages() {
|
|
125
|
+
const recoveryStartMs = Date.now();
|
|
126
|
+
const gapMs = recoveryStartMs - disconnectedAt;
|
|
127
|
+
logger.info?.(`[messageRecovery] Reconnected after ${(gapMs / 1000).toFixed(1)}s`);
|
|
128
|
+
if (gapMs > cfg.maxGapMs) {
|
|
129
|
+
logger.warn?.(`[messageRecovery] Gap too large (${(gapMs / 1000).toFixed(0)}s > ${(cfg.maxGapMs / 1000).toFixed(0)}s) — skipping recovery`);
|
|
130
|
+
disconnectedAt = null;
|
|
131
|
+
lastGapMs = gapMs;
|
|
132
|
+
await cfg.onGapTooLarge?.(gapMs);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
let recovered = 0;
|
|
136
|
+
const chatsToRecover = Array.from(lastSeen.entries());
|
|
137
|
+
// Check if fetchMessageHistory exists
|
|
138
|
+
if (typeof sock.fetchMessageHistory !== 'function') {
|
|
139
|
+
if (!loggedFetchWarning) {
|
|
140
|
+
logger.warn?.(`[messageRecovery] sock.fetchMessageHistory not available — recovery disabled. Baileys version may not support history fetch. User must implement manual reconciliation.`);
|
|
141
|
+
loggedFetchWarning = true;
|
|
142
|
+
}
|
|
143
|
+
disconnectedAt = null;
|
|
144
|
+
lastReconnectAt = new Date();
|
|
145
|
+
lastGapMs = gapMs;
|
|
146
|
+
await cfg.onRecoveryComplete?.({
|
|
147
|
+
chats: 0,
|
|
148
|
+
recovered: 0,
|
|
149
|
+
durationMs: Date.now() - recoveryStartMs,
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
for (const [jid, lastSeenEntry] of chatsToRecover) {
|
|
154
|
+
try {
|
|
155
|
+
// Fetch messages newer than lastSeen timestamp
|
|
156
|
+
// fetchMessageHistory typically: (jid, count, cursor) => Promise<messages[]>
|
|
157
|
+
// We'll fetch up to 50 messages and filter by timestamp
|
|
158
|
+
const messages = await sock.fetchMessageHistory(jid, 50, {
|
|
159
|
+
before: undefined, // Get latest
|
|
160
|
+
});
|
|
161
|
+
if (!messages || !Array.isArray(messages))
|
|
162
|
+
continue;
|
|
163
|
+
// Filter to messages newer than lastSeen
|
|
164
|
+
const gapMessages = messages.filter((msg) => {
|
|
165
|
+
const ts = msg.messageTimestamp;
|
|
166
|
+
if (!ts)
|
|
167
|
+
return false;
|
|
168
|
+
const msgTs = typeof ts === 'number' ? ts : parseInt(ts, 10);
|
|
169
|
+
return msgTs > lastSeenEntry.timestamp;
|
|
170
|
+
});
|
|
171
|
+
// Sort chronologically (oldest first for replay)
|
|
172
|
+
gapMessages.sort((a, b) => {
|
|
173
|
+
const aTs = typeof a.messageTimestamp === 'number' ? a.messageTimestamp : parseInt(a.messageTimestamp, 10);
|
|
174
|
+
const bTs = typeof b.messageTimestamp === 'number' ? b.messageTimestamp : parseInt(b.messageTimestamp, 10);
|
|
175
|
+
return aTs - bTs;
|
|
176
|
+
});
|
|
177
|
+
// Re-emit gap messages
|
|
178
|
+
for (const msg of gapMessages) {
|
|
179
|
+
await cfg.onGapFilled(msg, jid);
|
|
180
|
+
recovered++;
|
|
181
|
+
// Update lastSeen to newest delivered
|
|
182
|
+
const msgTs = typeof msg.messageTimestamp === 'number' ? msg.messageTimestamp : parseInt(msg.messageTimestamp, 10);
|
|
183
|
+
if (msgTs > lastSeenEntry.timestamp) {
|
|
184
|
+
lastSeenEntry.timestamp = msgTs;
|
|
185
|
+
lastSeenEntry.messageId = msg.key?.id || lastSeenEntry.messageId;
|
|
186
|
+
lastSeenEntry.lastTouchedAt = Date.now();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (gapMessages.length > 0) {
|
|
190
|
+
logger.info?.(`[messageRecovery] Recovered ${gapMessages.length} messages from ${jid}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
logger.error?.(`[messageRecovery] Failed to recover from ${jid}: ${err.message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
totalRecovered += recovered;
|
|
198
|
+
lastReconnectAt = new Date();
|
|
199
|
+
lastGapMs = gapMs;
|
|
200
|
+
disconnectedAt = null;
|
|
201
|
+
logger.info?.(`[messageRecovery] Recovery complete: ${recovered} messages across ${chatsToRecover.length} chats in ${Date.now() - recoveryStartMs}ms`);
|
|
202
|
+
await cfg.onRecoveryComplete?.({
|
|
203
|
+
chats: chatsToRecover.length,
|
|
204
|
+
recovered,
|
|
205
|
+
durationMs: Date.now() - recoveryStartMs,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
function schedulePersist() {
|
|
209
|
+
if (!cfg.persistPath)
|
|
210
|
+
return;
|
|
211
|
+
if (persistTimer) {
|
|
212
|
+
clearTimeout(persistTimer);
|
|
213
|
+
}
|
|
214
|
+
persistTimer = setTimeout(() => {
|
|
215
|
+
void flushPersistence();
|
|
216
|
+
}, cfg.persistDebounceMs);
|
|
217
|
+
}
|
|
218
|
+
async function flushPersistence() {
|
|
219
|
+
if (!cfg.persistPath)
|
|
220
|
+
return;
|
|
221
|
+
try {
|
|
222
|
+
const fs = await import('fs/promises');
|
|
223
|
+
const data = {};
|
|
224
|
+
for (const [jid, entry] of lastSeen) {
|
|
225
|
+
data[jid] = {
|
|
226
|
+
id: entry.messageId,
|
|
227
|
+
timestamp: entry.timestamp,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
await fs.writeFile(cfg.persistPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
logger.error?.(`[messageRecovery] Failed to persist state: ${err.message}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function loadPersistence() {
|
|
237
|
+
if (!cfg.persistPath)
|
|
238
|
+
return;
|
|
239
|
+
try {
|
|
240
|
+
const fs = require('fs');
|
|
241
|
+
if (!fs.existsSync(cfg.persistPath))
|
|
242
|
+
return;
|
|
243
|
+
const raw = fs.readFileSync(cfg.persistPath, 'utf-8');
|
|
244
|
+
const data = JSON.parse(raw);
|
|
245
|
+
for (const [jid, entry] of Object.entries(data)) {
|
|
246
|
+
lastSeen.set(jid, {
|
|
247
|
+
messageId: entry.id,
|
|
248
|
+
timestamp: entry.timestamp,
|
|
249
|
+
lastTouchedAt: Date.now(),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
logger.info?.(`[messageRecovery] Loaded ${lastSeen.size} entries from ${cfg.persistPath}`);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
logger.warn?.(`[messageRecovery] Failed to load persisted state: ${err.message}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Public API
|
|
259
|
+
return {
|
|
260
|
+
async stop() {
|
|
261
|
+
// Remove listeners
|
|
262
|
+
sock.ev.off('connection.update', connectionListener);
|
|
263
|
+
if (!sock.ev.process) {
|
|
264
|
+
sock.ev.off('messages.upsert', messagesListener);
|
|
265
|
+
}
|
|
266
|
+
// Flush persistence
|
|
267
|
+
if (persistTimer) {
|
|
268
|
+
clearTimeout(persistTimer);
|
|
269
|
+
persistTimer = null;
|
|
270
|
+
}
|
|
271
|
+
await flushPersistence();
|
|
272
|
+
logger.info?.(`[messageRecovery] Stopped — total recovered: ${totalRecovered}`);
|
|
273
|
+
},
|
|
274
|
+
markSeen(chatJid, messageId, timestamp) {
|
|
275
|
+
lastSeen.set(chatJid, {
|
|
276
|
+
messageId,
|
|
277
|
+
timestamp,
|
|
278
|
+
lastTouchedAt: Date.now(),
|
|
279
|
+
});
|
|
280
|
+
schedulePersist();
|
|
281
|
+
},
|
|
282
|
+
getStats() {
|
|
283
|
+
return {
|
|
284
|
+
trackedChats: lastSeen.size,
|
|
285
|
+
totalRecovered,
|
|
286
|
+
lastReconnectAt,
|
|
287
|
+
lastGapMs,
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read Receipt Timing Variance
|
|
3
|
+
*
|
|
4
|
+
* Extends presence choreography to randomize read-receipt delay.
|
|
5
|
+
* Instant reads = bot signal. Gaussian jitter makes reads feel human.
|
|
6
|
+
*
|
|
7
|
+
* @author Kobus Wentzel <kobie@pop.co.za>
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
export interface ReadReceiptVarianceConfig {
|
|
11
|
+
/** Mean delay before sending read receipt, ms */
|
|
12
|
+
meanMs?: number;
|
|
13
|
+
/** Standard deviation, ms */
|
|
14
|
+
stdDevMs?: number;
|
|
15
|
+
/** Min clamp, ms */
|
|
16
|
+
minMs?: number;
|
|
17
|
+
/** Max clamp, ms */
|
|
18
|
+
maxMs?: number;
|
|
19
|
+
/** Skip variance for messages older than this (already-read backlog) */
|
|
20
|
+
skipIfOlderThanMs?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface ReadReceiptVariance {
|
|
23
|
+
/** Wrap a sock — call sock.readMessages internally with jittered delay */
|
|
24
|
+
wrap<T extends {
|
|
25
|
+
readMessages: Function;
|
|
26
|
+
}>(sock: T): T;
|
|
27
|
+
/** Manually compute jittered delay (for users wiring their own receipt logic) */
|
|
28
|
+
delayMs(): number;
|
|
29
|
+
/** Stop pending timers */
|
|
30
|
+
stop(): void;
|
|
31
|
+
}
|
|
32
|
+
export declare function readReceiptVariance(config?: ReadReceiptVarianceConfig): ReadReceiptVariance;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read Receipt Timing Variance
|
|
3
|
+
*
|
|
4
|
+
* Extends presence choreography to randomize read-receipt delay.
|
|
5
|
+
* Instant reads = bot signal. Gaussian jitter makes reads feel human.
|
|
6
|
+
*
|
|
7
|
+
* @author Kobus Wentzel <kobie@pop.co.za>
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Box-Muller transform for Gaussian random samples
|
|
12
|
+
* Returns a value from normal distribution (mean=0, stdDev=1)
|
|
13
|
+
*/
|
|
14
|
+
function gaussianRandom() {
|
|
15
|
+
let u = 0;
|
|
16
|
+
let v = 0;
|
|
17
|
+
while (u === 0)
|
|
18
|
+
u = Math.random(); // Avoid log(0)
|
|
19
|
+
while (v === 0)
|
|
20
|
+
v = Math.random();
|
|
21
|
+
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
|
|
22
|
+
}
|
|
23
|
+
export function readReceiptVariance(config = {}) {
|
|
24
|
+
const { meanMs = 1500, stdDevMs = 800, minMs = 200, maxMs = 8000, skipIfOlderThanMs = 60_000, } = config;
|
|
25
|
+
const pendingTimers = new Set();
|
|
26
|
+
function delayMs() {
|
|
27
|
+
// Generate Gaussian sample and scale to configured mean/stdDev
|
|
28
|
+
const gaussian = gaussianRandom();
|
|
29
|
+
const value = meanMs + gaussian * stdDevMs;
|
|
30
|
+
// Clamp to min/max
|
|
31
|
+
return Math.max(minMs, Math.min(maxMs, value));
|
|
32
|
+
}
|
|
33
|
+
function wrap(sock) {
|
|
34
|
+
const originalReadMessages = sock.readMessages.bind(sock);
|
|
35
|
+
// Proxy the readMessages method
|
|
36
|
+
const wrappedReadMessages = async (keys) => {
|
|
37
|
+
// Check if messages are too old (backlog)
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const oldMessages = keys.every((key) => {
|
|
40
|
+
if (!key.messageTimestamp)
|
|
41
|
+
return false;
|
|
42
|
+
const msgTime = typeof key.messageTimestamp === 'number'
|
|
43
|
+
? key.messageTimestamp * 1000 // Baileys uses seconds
|
|
44
|
+
: parseInt(key.messageTimestamp, 10) * 1000;
|
|
45
|
+
return now - msgTime > skipIfOlderThanMs;
|
|
46
|
+
});
|
|
47
|
+
if (oldMessages) {
|
|
48
|
+
// Skip delay for backlog messages
|
|
49
|
+
return originalReadMessages(keys);
|
|
50
|
+
}
|
|
51
|
+
// Apply jittered delay
|
|
52
|
+
const delay = delayMs();
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const timer = setTimeout(async () => {
|
|
55
|
+
pendingTimers.delete(timer);
|
|
56
|
+
try {
|
|
57
|
+
const result = await originalReadMessages(keys);
|
|
58
|
+
resolve(result);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
reject(err);
|
|
62
|
+
}
|
|
63
|
+
}, delay);
|
|
64
|
+
pendingTimers.add(timer);
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
// Return proxy with wrapped readMessages
|
|
68
|
+
return new Proxy(sock, {
|
|
69
|
+
get(target, prop) {
|
|
70
|
+
if (prop === 'readMessages') {
|
|
71
|
+
return wrappedReadMessages;
|
|
72
|
+
}
|
|
73
|
+
return target[prop];
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function stop() {
|
|
78
|
+
for (const timer of pendingTimers) {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
pendingTimers.clear();
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
wrap,
|
|
85
|
+
delayMs,
|
|
86
|
+
stop,
|
|
87
|
+
};
|
|
88
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "baileys-antiban",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Anti-ban middleware for Baileys WhatsApp bots. Rate limiting, warmup, health monitor, LID resolver, disconnect classifier. Free Whapi.Cloud alternative.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"jest": "^29.7.0",
|
|
85
85
|
"ts-jest": "^29.4.9",
|
|
86
86
|
"tsx": "^4.21.0",
|
|
87
|
-
"typescript": "^5.0.0"
|
|
87
|
+
"typescript": "^5.0.0",
|
|
88
|
+
"vitest": "^4.1.5"
|
|
88
89
|
}
|
|
89
90
|
}
|