baileys-antiban 3.1.0 → 3.3.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 CHANGED
@@ -5,6 +5,84 @@ 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.3.0] — 2026-04-26
9
+
10
+ ### Added
11
+ - **`JidCanonicalizer.canonicalKey(jid)`** — Returns stable thread key for DB storage/indexing
12
+ - Solves the split-thread bug from Baileys v7 LID migration ([#1832](https://github.com/WhiskeySockets/Baileys/issues/1832))
13
+ - Always returns same key regardless of whether message arrives as `@lid` or `@s.whatsapp.net`
14
+ - Format: `thread:<digits>` for known contacts, `thread:lid:<digits>` for unknown, `thread:group:<id>` for groups
15
+ - Uses learned LID↔PN mappings when available, falls back to LID form when not
16
+ - Handles edge cases: groups, broadcasts, newsletters, empty/null inputs
17
+ - Tracks stats: `canonicalKeyHits` (PN known) vs `canonicalKeyMisses` (LID only)
18
+ - **`docs/lid-migration.md`** — Comprehensive guide for surviving Baileys v7's LID migration
19
+ - Explains the three major bugs LID causes (#1832 split-thread, #1718 phone lookup, #2030 call routing)
20
+ - Full integration examples: learning from events, canonicalizing sends, stable DB keys
21
+ - Production setup with persistence, stats logging, cleanup
22
+ - Limitations and best practices
23
+
24
+ ### Why v3.3
25
+ Baileys v7 made `@lid` the default JID format, but many apps still use `remoteJid` as their database thread key. This causes the same conversation to appear as two separate threads when messages arrive under different forms. `canonicalKey()` provides a stable, form-independent identifier that prevents this split-thread bug. The LID migration doc owns the narrative for the v7 transition.
26
+
27
+ ## [3.2.0] — 2026-04-26
28
+
29
+ ### New Features
30
+ - **deviceFingerprint** — Randomizes appVersion, osVersion, and deviceModel to prevent Meta's clientPayload fingerprinting (the #1 gap in anti-ban coverage per GapHunter analysis)
31
+ - Randomizes appVersion patch number within safe range (e.g. 2.24.5.18 → 2.24.5.[15-22])
32
+ - Randomizes osVersion (Android versions 10-14)
33
+ - Randomizes deviceModel from pool of 12 real-world devices (Pixel, Galaxy, Xiaomi, OnePlus, etc.)
34
+ - Deterministic PRNG seeded from sessionId for stable fingerprints per session
35
+ - `generateFingerprint()` creates unique fingerprint per session
36
+ - `applyFingerprint()` applies to Baileys SocketConfig before makeWASocket()
37
+ - User-configurable pools for custom device/OS combinations
38
+ - Master switch: `enabled: false` to disable all randomization
39
+ - **credsSnapshot** — Atomic credentials backup to prevent code-500 corruption loop
40
+ - `take()` creates atomic snapshot of creds.json before risky operations
41
+ - `restoreLatest()` recovers from most recent snapshot
42
+ - Automatic rotation keeps only N newest snapshots (default: 3)
43
+ - Atomic file operations (write to .tmp, rename) prevent partial writes
44
+ - Graceful handling of missing creds file (no crashes)
45
+ - **readReceiptVariance** — Randomizes read receipt timing to avoid instant-read bot signals
46
+ - Gaussian-jittered delay before sending read receipts (mean: 1500ms, stdDev: 800ms)
47
+ - Configurable min/max clamps (default: 200-8000ms)
48
+ - Skips variance for backlog messages (older than 60s by default)
49
+ - `wrap()` proxies sock.readMessages with transparent delay injection
50
+ - `delayMs()` for manual delay computation in custom receipt logic
51
+ - Box-Muller transform for realistic human timing variance
52
+ - `stop()` cancels all pending timers on disconnect
53
+
54
+ ### Why v3.2
55
+ 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).
56
+
57
+ ### Usage
58
+ ```ts
59
+ import { generateFingerprint, applyFingerprint, credsSnapshot, readReceiptVariance } from 'baileys-antiban';
60
+
61
+ // 1. Device fingerprint randomization
62
+ const fp = generateFingerprint({ seed: 'my-session-123' });
63
+ const sock = makeWASocket(applyFingerprint(socketConfig, fp));
64
+
65
+ // 2. Atomic creds snapshot
66
+ const snapshot = credsSnapshot({ credsPath: './auth/creds.json', keep: 5 });
67
+ await snapshot.take(); // Before risky reconnect
68
+ // ... on code-500 corruption:
69
+ await snapshot.restoreLatest();
70
+
71
+ // 3. Read receipt variance
72
+ const variance = readReceiptVariance({ meanMs: 2000, stdDevMs: 1000 });
73
+ const wrappedSock = variance.wrap(sock);
74
+ // Now all readMessages() calls have human-like delays
75
+ ```
76
+
77
+ ### Technical Details
78
+ - Zero runtime dependencies (Box-Muller in pure JS, fs from Node stdlib)
79
+ - TypeScript strict mode compliant
80
+ - Deterministic PRNG (mulberry32) for reproducible testing
81
+ - Atomic file operations prevent corruption on crash
82
+ - All modules are standalone and can be used independently
83
+
84
+ ---
85
+
8
86
  ## [3.1.0] — 2026-04-25
9
87
 
10
88
  ### New Features
package/README.md CHANGED
@@ -9,6 +9,8 @@
9
9
 
10
10
  > Rate limiting with Gaussian jitter, 7-day warmup, session health monitoring, LID resolver, disconnect classification, contact graph enforcement — all in one `npm install`. Works with [Baileys](https://github.com/WhiskeySockets/Baileys) and [@oxidezap/baileyrs](https://github.com/oxidezap/baileyrs) (Rust/WASM).
11
11
 
12
+ > **New in v3.3:** [LID Migration Guide](./docs/lid-migration.md) — survive Baileys v7's @lid default with stable thread keys.
13
+
12
14
  ## v2.0 New Features — Session Stability Module
13
15
 
14
16
  ### What's New in v2.0
@@ -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
@@ -32,3 +32,6 @@ export { resolveConfig, PRESETS, type AntiBanInput, type ResolvedConfig, type Pr
32
32
  export { StateManager, type PersistedState } from './persist.js';
33
33
  export { isGroup, isNewsletter, isBroadcast, shouldUseGroupProfile, applyGroupMultiplier, type RateLimits } from './profiles.js';
34
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
@@ -43,3 +43,7 @@ export { StateManager } from './persist.js';
43
43
  export { isGroup, isNewsletter, isBroadcast, shouldUseGroupProfile, applyGroupMultiplier } from './profiles.js';
44
44
  // v3.1 new modules
45
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';
@@ -39,6 +39,8 @@ export interface JidCanonicalizerStats {
39
39
  outboundCanonicalized: number;
40
40
  outboundPassthrough: number;
41
41
  inboundLearned: number;
42
+ canonicalKeyHits: number;
43
+ canonicalKeyMisses: number;
42
44
  }
43
45
  export declare class JidCanonicalizer {
44
46
  private config;
@@ -54,6 +56,23 @@ export declare class JidCanonicalizer {
54
56
  * Called by wrapper on every outbound send. Returns canonical JID.
55
57
  */
56
58
  canonicalizeTarget(jid: string): string;
59
+ /**
60
+ * Returns a stable, canonical thread key for storage / DB indexing.
61
+ *
62
+ * Different from `canonicalizeTarget()` (which picks the right send target):
63
+ * - canonicalizeTarget('1234@lid') → '+27...@s.whatsapp.net' (best send target)
64
+ * - canonicalKey('1234@lid') → 'thread:27...' (stable thread identifier)
65
+ *
66
+ * If LID has known PN mapping → use phone-number form
67
+ * If only LID known → use LID stripped of suffix
68
+ * Always lowercase, no @-suffix, prefixed with `thread:`
69
+ *
70
+ * Apps using this as their DB key won't double-thread on LID/PN drift.
71
+ *
72
+ * @param jid - WhatsApp JID (can be PN, LID, group, or broadcast)
73
+ * @returns Stable thread key for DB indexing
74
+ */
75
+ canonicalKey(jid: string): string;
57
76
  /**
58
77
  * Called by wrapper on messages.upsert event. Learns mappings.
59
78
  */
@@ -35,6 +35,8 @@ export class JidCanonicalizer {
35
35
  outboundCanonicalized: 0,
36
36
  outboundPassthrough: 0,
37
37
  inboundLearned: 0,
38
+ canonicalKeyHits: 0,
39
+ canonicalKeyMisses: 0,
38
40
  };
39
41
  constructor(config = {}) {
40
42
  this.config = { ...DEFAULT_CONFIG, ...config };
@@ -70,6 +72,72 @@ export class JidCanonicalizer {
70
72
  }
71
73
  return canonical;
72
74
  }
75
+ /**
76
+ * Returns a stable, canonical thread key for storage / DB indexing.
77
+ *
78
+ * Different from `canonicalizeTarget()` (which picks the right send target):
79
+ * - canonicalizeTarget('1234@lid') → '+27...@s.whatsapp.net' (best send target)
80
+ * - canonicalKey('1234@lid') → 'thread:27...' (stable thread identifier)
81
+ *
82
+ * If LID has known PN mapping → use phone-number form
83
+ * If only LID known → use LID stripped of suffix
84
+ * Always lowercase, no @-suffix, prefixed with `thread:`
85
+ *
86
+ * Apps using this as their DB key won't double-thread on LID/PN drift.
87
+ *
88
+ * @param jid - WhatsApp JID (can be PN, LID, group, or broadcast)
89
+ * @returns Stable thread key for DB indexing
90
+ */
91
+ canonicalKey(jid) {
92
+ // Defensive: handle null/undefined/empty
93
+ if (!jid || typeof jid !== 'string' || jid.trim() === '') {
94
+ return 'thread:invalid';
95
+ }
96
+ const normalized = jid.trim().toLowerCase();
97
+ // Extract parts: user@domain
98
+ const atIndex = normalized.indexOf('@');
99
+ if (atIndex === -1) {
100
+ return 'thread:invalid';
101
+ }
102
+ const user = normalized.substring(0, atIndex);
103
+ const domain = normalized.substring(atIndex + 1);
104
+ // Handle special domains
105
+ if (domain === 'g.us') {
106
+ // Group chat
107
+ return `thread:group:${user}`;
108
+ }
109
+ if (domain === 'broadcast') {
110
+ // Broadcast list
111
+ return `thread:broadcast:${user}`;
112
+ }
113
+ if (domain === 'newsletter') {
114
+ // Newsletter (WA Channels)
115
+ return `thread:newsletter:${user}`;
116
+ }
117
+ // Handle @s.whatsapp.net (PN form)
118
+ if (domain === 's.whatsapp.net') {
119
+ this.stats.canonicalKeyHits++;
120
+ return `thread:${user}`;
121
+ }
122
+ // Handle @lid form
123
+ if (domain === 'lid') {
124
+ // Try to resolve to PN via learned mappings
125
+ const mapping = this.lidResolver.getMapping(normalized);
126
+ if (mapping?.pn) {
127
+ // We have a PN mapping — use it
128
+ const pnUser = mapping.pn.split('@')[0];
129
+ this.stats.canonicalKeyHits++;
130
+ return `thread:${pnUser}`;
131
+ }
132
+ else {
133
+ // No PN known yet — use LID form
134
+ this.stats.canonicalKeyMisses++;
135
+ return `thread:lid:${user}`;
136
+ }
137
+ }
138
+ // Unknown domain — return generic form
139
+ return `thread:${domain}:${user}`;
140
+ }
73
141
  /**
74
142
  * Called by wrapper on messages.upsert event. Learns mappings.
75
143
  */
@@ -102,6 +170,8 @@ export class JidCanonicalizer {
102
170
  outboundCanonicalized: this.stats.outboundCanonicalized,
103
171
  outboundPassthrough: this.stats.outboundPassthrough,
104
172
  inboundLearned: this.stats.inboundLearned,
173
+ canonicalKeyHits: this.stats.canonicalKeyHits,
174
+ canonicalKeyMisses: this.stats.canonicalKeyMisses,
105
175
  };
106
176
  }
107
177
  destroy() {
@@ -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.1.0",
3
+ "version": "3.3.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",