baileys-antiban 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ 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.6.0] - 2026-04-18
9
+
10
+ ### Added
11
+ - **LID/PN Race Condition Mitigation** — New modules to address the #1 reported Baileys bug: "Bad MAC / No Session / Invalid PreKey" errors caused by WhatsApp's Linked Identity (LID) migration
12
+ - `LidResolver` — Standalone utility for maintaining bidirectional LID↔PN mappings learned from message events
13
+ - `JidCanonicalizer` — Opt-in middleware that auto-learns from incoming events and canonicalizes outbound send targets to a single form (phone number by default)
14
+ - Both modules default to **disabled** — backward compatible, zero behavior change for existing users
15
+ - Middleware-layer mitigation only — root fix still requires [PR #2372](https://github.com/WhiskeySockets/Baileys/pull/2372) merged upstream
16
+ - Comprehensive test coverage: 56 new tests (29 LidResolver + 18 JidCanonicalizer + 9 integration)
17
+
18
+ ### Changed
19
+ - `AntiBan` class now exposes `lidResolver` and `jidCanonicalizer` getters for direct access
20
+ - `AntiBanConfig` extended with `lidResolver` and `jidCanonicalizer` config options
21
+ - `AntiBanStats` includes `lidResolver` and `jidCanonicalizer` stats when enabled
22
+ - Wrapper's `sendMessage` now canonicalizes JID before all rate-limit/timelock/graph checks
23
+ - `messages.upsert` and `messages.update` handlers now auto-learn LID mappings when canonicalizer enabled
24
+
25
+ ### Technical Details
26
+ - LRU eviction at configurable `maxEntries` (default 10,000)
27
+ - Optional persistence hooks for cross-restart state survival
28
+ - Device suffix stripping (`:N` in JIDs) for robust matching
29
+ - Supports both `canonical: 'pn'` (phone number) and `canonical: 'lid'` modes
30
+ - Shared resolver mode allows multiple canonicalizers to reference same mapping state
31
+
8
32
  ## [1.5.0] - 2026-04-18
9
33
 
10
34
  ### Added
package/README.md CHANGED
@@ -61,6 +61,58 @@ console.log(stats.throttledSendCount); // Sends gated since reconnect
61
61
 
62
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
63
 
64
+ ## LID / Phone Number Canonicalization
65
+
66
+ WhatsApp migrated to **Linked Identity (LID)** in 2024. A contact now has two JID forms:
67
+ - Phone number: `27825651069@s.whatsapp.net`
68
+ - LID: `123456789@lid`
69
+
70
+ Messages can arrive under either form. If an encryption session was established under one form and a message arrives under the other, decryption fails → **"Bad MAC / No Session / Invalid PreKey"** errors (the #1 reported Baileys bug).
71
+
72
+ baileys-antiban v1.6+ provides **middleware-layer mitigation** via two new modules:
73
+
74
+ ```typescript
75
+ import { wrapSocket } from 'baileys-antiban';
76
+
77
+ const sock = makeWASocket({ ... });
78
+ const safeSock = wrapSocket(sock, {
79
+ jidCanonicalizer: {
80
+ enabled: true, // Enable LID/PN canonicalization
81
+ canonical: 'pn', // Normalize to phone-number form (default)
82
+ },
83
+ });
84
+
85
+ // That's it! Incoming events auto-learn LID↔PN mappings.
86
+ // Outbound sends are auto-canonicalized to phone-number form.
87
+ ```
88
+
89
+ **Advanced: Standalone Resolver**
90
+
91
+ ```typescript
92
+ import { LidResolver } from 'baileys-antiban';
93
+
94
+ const resolver = new LidResolver({
95
+ canonical: 'pn',
96
+ maxEntries: 10_000, // LRU cache size
97
+ persistence: {
98
+ load: async () => JSON.parse(await fs.readFile('lid-map.json', 'utf8')),
99
+ save: async (map) => fs.writeFile('lid-map.json', JSON.stringify(map)),
100
+ },
101
+ });
102
+
103
+ // Learn from message events
104
+ resolver.learn({
105
+ lid: '123456789@lid',
106
+ pn: '27825651069@s.whatsapp.net',
107
+ });
108
+
109
+ // Resolve canonical form
110
+ const canonical = resolver.resolveCanonical('123456789@lid');
111
+ // → '27825651069@s.whatsapp.net'
112
+ ```
113
+
114
+ **Note:** This is a middleware-layer workaround. The root fix lives inside Baileys' crypto pipeline ([PR #2372](https://github.com/WhiskeySockets/Baileys/pull/2372)).
115
+
64
116
  ## v1.3 Features
65
117
 
66
118
  ### ReplyRatioGuard
package/dist/antiban.d.ts CHANGED
@@ -22,6 +22,8 @@ import { ContactGraphWarmer, type ContactGraphConfig, type ContactGraphStats } f
22
22
  import { PresenceChoreographer, type PresenceChoreographerConfig, type PresenceChoreographerStats } from './presenceChoreographer.js';
23
23
  import { RetryReasonTracker, type RetryTrackerConfig, type RetryStats } from './retryTracker.js';
24
24
  import { PostReconnectThrottle, type ReconnectThrottleConfig, type ReconnectThrottleStats } from './reconnectThrottle.js';
25
+ import { LidResolver, type LidResolverConfig, type LidResolverStats } from './lidResolver.js';
26
+ import { JidCanonicalizer, type JidCanonicalizerConfig, type JidCanonicalizerStats } from './jidCanonicalizer.js';
25
27
  export interface AntiBanConfig {
26
28
  rateLimiter?: Partial<RateLimiterConfig>;
27
29
  warmUp?: Partial<WarmUpConfig>;
@@ -32,6 +34,8 @@ export interface AntiBanConfig {
32
34
  presence?: Partial<PresenceChoreographerConfig>;
33
35
  retryTracker?: Partial<RetryTrackerConfig>;
34
36
  reconnectThrottle?: Partial<ReconnectThrottleConfig>;
37
+ lidResolver?: LidResolverConfig;
38
+ jidCanonicalizer?: JidCanonicalizerConfig;
35
39
  /** Log warnings and blocks to console (default: true) */
36
40
  logging?: boolean;
37
41
  }
@@ -54,6 +58,8 @@ export interface AntiBanStats {
54
58
  presence?: PresenceChoreographerStats;
55
59
  retryTracker?: RetryStats | null;
56
60
  reconnectThrottle?: ReconnectThrottleStats | null;
61
+ lidResolver?: LidResolverStats | null;
62
+ jidCanonicalizer?: JidCanonicalizerStats | null;
57
63
  }
58
64
  export declare class AntiBan {
59
65
  private rateLimiter;
@@ -65,6 +71,8 @@ export declare class AntiBan {
65
71
  private presenceChoreographer;
66
72
  private retryTrackerModule;
67
73
  private reconnectThrottleModule;
74
+ private lidResolverModule;
75
+ private jidCanonicalizerModule;
68
76
  private logging;
69
77
  private stats;
70
78
  constructor(config?: AntiBanConfig, warmUpState?: WarmUpState);
@@ -114,6 +122,10 @@ export declare class AntiBan {
114
122
  get retryTracker(): RetryReasonTracker;
115
123
  /** Get the reconnect throttle for direct access */
116
124
  get reconnectThrottle(): PostReconnectThrottle;
125
+ /** Get the LID resolver for direct access */
126
+ get lidResolver(): LidResolver | null;
127
+ /** Get the JID canonicalizer for direct access */
128
+ get jidCanonicalizer(): JidCanonicalizer | null;
117
129
  /**
118
130
  * Export warm-up state for persistence between restarts
119
131
  */
package/dist/antiban.js CHANGED
@@ -22,6 +22,8 @@ import { ContactGraphWarmer } from './contactGraph.js';
22
22
  import { PresenceChoreographer } from './presenceChoreographer.js';
23
23
  import { RetryReasonTracker } from './retryTracker.js';
24
24
  import { PostReconnectThrottle } from './reconnectThrottle.js';
25
+ import { LidResolver } from './lidResolver.js';
26
+ import { JidCanonicalizer } from './jidCanonicalizer.js';
25
27
  export class AntiBan {
26
28
  rateLimiter;
27
29
  warmUp;
@@ -32,6 +34,8 @@ export class AntiBan {
32
34
  presenceChoreographer;
33
35
  retryTrackerModule;
34
36
  reconnectThrottleModule;
37
+ lidResolverModule = null;
38
+ jidCanonicalizerModule = null;
35
39
  logging;
36
40
  stats = {
37
41
  messagesAllowed: 0,
@@ -86,6 +90,30 @@ export class AntiBan {
86
90
  ...config.reconnectThrottle,
87
91
  baselineRatePerMinute: () => this.rateLimiter.getStats().limits.perMinute,
88
92
  });
93
+ // Initialize LID resolver and canonicalizer if configured
94
+ // If jidCanonicalizer is enabled but no resolver provided, create standalone resolver
95
+ if (config.jidCanonicalizer?.enabled) {
96
+ // Create or use provided resolver
97
+ if (config.jidCanonicalizer.resolver) {
98
+ // User provided their own resolver
99
+ this.jidCanonicalizerModule = new JidCanonicalizer(config.jidCanonicalizer);
100
+ this.lidResolverModule = config.jidCanonicalizer.resolver;
101
+ }
102
+ else {
103
+ // Create new resolver using lidResolver config if provided
104
+ const resolverConfig = config.lidResolver || config.jidCanonicalizer.resolverConfig;
105
+ const resolver = new LidResolver(resolverConfig);
106
+ this.lidResolverModule = resolver;
107
+ this.jidCanonicalizerModule = new JidCanonicalizer({
108
+ ...config.jidCanonicalizer,
109
+ resolver,
110
+ });
111
+ }
112
+ }
113
+ else if (config.lidResolver) {
114
+ // Standalone resolver without canonicalizer
115
+ this.lidResolverModule = new LidResolver(config.lidResolver);
116
+ }
89
117
  }
90
118
  /**
91
119
  * Check if a message can be sent and get required delay.
@@ -286,6 +314,12 @@ export class AntiBan {
286
314
  if (this.reconnectThrottleModule['config']?.enabled) {
287
315
  stats.reconnectThrottle = this.reconnectThrottleModule.getStats();
288
316
  }
317
+ if (this.lidResolverModule) {
318
+ stats.lidResolver = this.lidResolverModule.getStats();
319
+ }
320
+ if (this.jidCanonicalizerModule) {
321
+ stats.jidCanonicalizer = this.jidCanonicalizerModule.getStats();
322
+ }
289
323
  return stats;
290
324
  }
291
325
  /** Get the timelock guard for direct access */
@@ -312,6 +346,14 @@ export class AntiBan {
312
346
  get reconnectThrottle() {
313
347
  return this.reconnectThrottleModule;
314
348
  }
349
+ /** Get the LID resolver for direct access */
350
+ get lidResolver() {
351
+ return this.lidResolverModule;
352
+ }
353
+ /** Get the JID canonicalizer for direct access */
354
+ get jidCanonicalizer() {
355
+ return this.jidCanonicalizerModule;
356
+ }
315
357
  /**
316
358
  * Export warm-up state for persistence between restarts
317
359
  */
@@ -364,6 +406,8 @@ export class AntiBan {
364
406
  this.presenceChoreographer.reset();
365
407
  this.retryTrackerModule.destroy();
366
408
  this.reconnectThrottleModule.destroy();
409
+ this.jidCanonicalizerModule?.destroy();
410
+ this.lidResolverModule?.destroy();
367
411
  if (this.logging) {
368
412
  console.log('[baileys-antiban] 🧹 Destroyed — all timers cleared');
369
413
  }
package/dist/index.d.ts CHANGED
@@ -17,6 +17,8 @@ export { ContactGraphWarmer, type ContactGraphConfig, type ContactGraphStats, ty
17
17
  export { PresenceChoreographer, type PresenceChoreographerConfig, type PresenceChoreographerStats } from './presenceChoreographer.js';
18
18
  export { RetryReasonTracker, type RetryTrackerConfig, type RetryStats, type RetryReason } from './retryTracker.js';
19
19
  export { PostReconnectThrottle, type ReconnectThrottleConfig, type ReconnectThrottleStats } from './reconnectThrottle.js';
20
+ export { LidResolver, type LidResolverConfig, type LidResolverStats, type LidMapping } from './lidResolver.js';
21
+ export { JidCanonicalizer, type JidCanonicalizerConfig, type JidCanonicalizerStats } from './jidCanonicalizer.js';
20
22
  export { wrapSocket, type WrappedSocket, type WrapSocketOptions } from './wrapper.js';
21
23
  export { MessageQueue, type QueuedMessage, type MessageQueueConfig } from './messageQueue.js';
22
24
  export { ContentVariator, type VariatorConfig } from './contentVariator.js';
package/dist/index.js CHANGED
@@ -20,6 +20,9 @@ export { PresenceChoreographer } from './presenceChoreographer.js';
20
20
  // v1.5 new modules
21
21
  export { RetryReasonTracker } from './retryTracker.js';
22
22
  export { PostReconnectThrottle } from './reconnectThrottle.js';
23
+ // v1.6 new modules
24
+ export { LidResolver } from './lidResolver.js';
25
+ export { JidCanonicalizer } from './jidCanonicalizer.js';
23
26
  // Socket wrapper
24
27
  export { wrapSocket } from './wrapper.js';
25
28
  // Optional features
@@ -0,0 +1,78 @@
1
+ /**
2
+ * JID Canonicalizer — Opt-in middleware for LID/PN normalization
3
+ *
4
+ * Wraps LidResolver to provide automatic:
5
+ * 1. Learning from incoming message events
6
+ * 2. Canonicalization of outbound send targets
7
+ *
8
+ * This mitigates the LID/PN race condition that causes "Bad MAC / No Session /
9
+ * Invalid PreKey" errors (Baileys issue #1769, our PR #2372).
10
+ *
11
+ * Usage:
12
+ * const canonicalizer = new JidCanonicalizer({ enabled: true });
13
+ *
14
+ * // On incoming event
15
+ * canonicalizer.onIncomingEvent({ messages: [...] });
16
+ *
17
+ * // On outbound send
18
+ * const canonicalJid = canonicalizer.canonicalizeTarget(jid);
19
+ * await sock.sendMessage(canonicalJid, content);
20
+ *
21
+ * Note: This is a middleware-layer mitigation. The root fix requires merging
22
+ * PR #2372 into Baileys' crypto pipeline.
23
+ */
24
+ import { LidResolver, type LidResolverConfig, type LidResolverStats } from './lidResolver.js';
25
+ export interface JidCanonicalizerConfig {
26
+ /** Enable canonicalization (default: false — opt-in) */
27
+ enabled?: boolean;
28
+ /** Provide your own resolver to share across modules. Otherwise one is created. */
29
+ resolver?: LidResolver;
30
+ /** Config for creating a new resolver (ignored if resolver provided) */
31
+ resolverConfig?: LidResolverConfig;
32
+ /** Canonicalize outbound sendMessage targets. Default true. */
33
+ canonicalizeOutbound?: boolean;
34
+ /** Learn from inbound events. Default true. */
35
+ learnFromEvents?: boolean;
36
+ }
37
+ export interface JidCanonicalizerStats {
38
+ resolver: LidResolverStats;
39
+ outboundCanonicalized: number;
40
+ outboundPassthrough: number;
41
+ inboundLearned: number;
42
+ }
43
+ export declare class JidCanonicalizer {
44
+ private config;
45
+ private lidResolver;
46
+ private ownsResolver;
47
+ private stats;
48
+ constructor(config?: JidCanonicalizerConfig);
49
+ /**
50
+ * Access the underlying resolver (for cross-module sharing)
51
+ */
52
+ get resolver(): LidResolver;
53
+ /**
54
+ * Called by wrapper on every outbound send. Returns canonical JID.
55
+ */
56
+ canonicalizeTarget(jid: string): string;
57
+ /**
58
+ * Called by wrapper on messages.upsert event. Learns mappings.
59
+ */
60
+ onIncomingEvent(upsert: {
61
+ messages: Array<any>;
62
+ type?: string;
63
+ }): void;
64
+ /**
65
+ * Called by wrapper on messages.update event. Learns from sent-message refs.
66
+ */
67
+ onMessageUpdate(updates: Array<any>): void;
68
+ getStats(): JidCanonicalizerStats;
69
+ destroy(): void;
70
+ /**
71
+ * Extract LID↔PN mappings from a message object
72
+ */
73
+ private learnFromMessage;
74
+ /**
75
+ * Extract mappings from message.key
76
+ */
77
+ private learnFromMessageKey;
78
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * JID Canonicalizer — Opt-in middleware for LID/PN normalization
3
+ *
4
+ * Wraps LidResolver to provide automatic:
5
+ * 1. Learning from incoming message events
6
+ * 2. Canonicalization of outbound send targets
7
+ *
8
+ * This mitigates the LID/PN race condition that causes "Bad MAC / No Session /
9
+ * Invalid PreKey" errors (Baileys issue #1769, our PR #2372).
10
+ *
11
+ * Usage:
12
+ * const canonicalizer = new JidCanonicalizer({ enabled: true });
13
+ *
14
+ * // On incoming event
15
+ * canonicalizer.onIncomingEvent({ messages: [...] });
16
+ *
17
+ * // On outbound send
18
+ * const canonicalJid = canonicalizer.canonicalizeTarget(jid);
19
+ * await sock.sendMessage(canonicalJid, content);
20
+ *
21
+ * Note: This is a middleware-layer mitigation. The root fix requires merging
22
+ * PR #2372 into Baileys' crypto pipeline.
23
+ */
24
+ import { LidResolver } from './lidResolver.js';
25
+ const DEFAULT_CONFIG = {
26
+ enabled: false,
27
+ canonicalizeOutbound: true,
28
+ learnFromEvents: true,
29
+ };
30
+ export class JidCanonicalizer {
31
+ config;
32
+ lidResolver;
33
+ ownsResolver; // Track if we created the resolver (for destroy)
34
+ stats = {
35
+ outboundCanonicalized: 0,
36
+ outboundPassthrough: 0,
37
+ inboundLearned: 0,
38
+ };
39
+ constructor(config = {}) {
40
+ this.config = { ...DEFAULT_CONFIG, ...config };
41
+ // Use provided resolver or create new one
42
+ if (config.resolver) {
43
+ this.lidResolver = config.resolver;
44
+ this.ownsResolver = false;
45
+ }
46
+ else {
47
+ this.lidResolver = new LidResolver(config.resolverConfig);
48
+ this.ownsResolver = true;
49
+ }
50
+ }
51
+ /**
52
+ * Access the underlying resolver (for cross-module sharing)
53
+ */
54
+ get resolver() {
55
+ return this.lidResolver;
56
+ }
57
+ /**
58
+ * Called by wrapper on every outbound send. Returns canonical JID.
59
+ */
60
+ canonicalizeTarget(jid) {
61
+ if (!this.config.enabled || !this.config.canonicalizeOutbound) {
62
+ return jid;
63
+ }
64
+ const canonical = this.lidResolver.resolveCanonical(jid);
65
+ if (canonical !== jid) {
66
+ this.stats.outboundCanonicalized++;
67
+ }
68
+ else {
69
+ this.stats.outboundPassthrough++;
70
+ }
71
+ return canonical;
72
+ }
73
+ /**
74
+ * Called by wrapper on messages.upsert event. Learns mappings.
75
+ */
76
+ onIncomingEvent(upsert) {
77
+ if (!this.config.enabled || !this.config.learnFromEvents) {
78
+ return;
79
+ }
80
+ for (const msg of upsert.messages || []) {
81
+ this.learnFromMessage(msg);
82
+ }
83
+ }
84
+ /**
85
+ * Called by wrapper on messages.update event. Learns from sent-message refs.
86
+ */
87
+ onMessageUpdate(updates) {
88
+ if (!this.config.enabled || !this.config.learnFromEvents) {
89
+ return;
90
+ }
91
+ for (const update of updates) {
92
+ // messages.update doesn't typically carry LID info — mostly for retry tracking
93
+ // But handle edge case where update.key has participant/participantPn
94
+ if (update.key) {
95
+ this.learnFromMessageKey(update.key);
96
+ }
97
+ }
98
+ }
99
+ getStats() {
100
+ return {
101
+ resolver: this.lidResolver.getStats(),
102
+ outboundCanonicalized: this.stats.outboundCanonicalized,
103
+ outboundPassthrough: this.stats.outboundPassthrough,
104
+ inboundLearned: this.stats.inboundLearned,
105
+ };
106
+ }
107
+ destroy() {
108
+ // Only destroy resolver if we created it
109
+ if (this.ownsResolver) {
110
+ this.lidResolver.destroy();
111
+ }
112
+ }
113
+ // Private helpers
114
+ /**
115
+ * Extract LID↔PN mappings from a message object
116
+ */
117
+ learnFromMessage(msg) {
118
+ if (!msg.key)
119
+ return;
120
+ // Extract from message key
121
+ this.learnFromMessageKey(msg.key);
122
+ // Additional extraction from message envelope (if present)
123
+ // Some Baileys forks/versions expose participantPn at the message level
124
+ if (msg.participantPn && msg.key.participant) {
125
+ this.lidResolver.learn({
126
+ lid: msg.key.participant.endsWith('@lid') ? msg.key.participant : undefined,
127
+ pn: msg.participantPn,
128
+ });
129
+ this.stats.inboundLearned++;
130
+ }
131
+ }
132
+ /**
133
+ * Extract mappings from message.key
134
+ */
135
+ learnFromMessageKey(key) {
136
+ if (!key)
137
+ return;
138
+ // Case 1: participant (LID) + participantPn (PN)
139
+ // This appears in group messages where sender uses LID
140
+ if (key.participant && key.participantPn) {
141
+ if (key.participant.endsWith('@lid')) {
142
+ this.lidResolver.learn({
143
+ lid: key.participant,
144
+ pn: key.participantPn,
145
+ });
146
+ this.stats.inboundLearned++;
147
+ }
148
+ }
149
+ // Case 2: remoteJid (LID) + senderPn (PN)
150
+ // This appears in 1:1 messages from LID senders
151
+ if (key.remoteJid && key.senderPn) {
152
+ if (key.remoteJid.endsWith('@lid')) {
153
+ this.lidResolver.learn({
154
+ lid: key.remoteJid,
155
+ pn: key.senderPn,
156
+ });
157
+ this.stats.inboundLearned++;
158
+ }
159
+ }
160
+ // Case 3: participant (PN) exists but we have remoteJid (LID)
161
+ // Inverse case where participant is PN form
162
+ if (key.participant && key.remoteJid) {
163
+ if (key.participant.endsWith('@s.whatsapp.net') && key.remoteJid.endsWith('@lid')) {
164
+ this.lidResolver.learn({
165
+ lid: key.remoteJid,
166
+ pn: key.participant,
167
+ });
168
+ this.stats.inboundLearned++;
169
+ }
170
+ }
171
+ }
172
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * LID Resolver — Maintains bidirectional LID↔PN mapping for contacts
3
+ *
4
+ * WhatsApp migrated to LID (Linked Identity) in 2024. A contact now has two JIDs:
5
+ * - Phone number form: "27825651069@s.whatsapp.net"
6
+ * - LID form: "123456789@lid"
7
+ *
8
+ * Messages can arrive under either form. If an encryption session was established
9
+ * under one form and a message arrives under the other, decryption fails → "Bad MAC".
10
+ *
11
+ * This utility:
12
+ * - Learns LID↔PN mappings from message events
13
+ * - Normalizes JIDs to a canonical form (phone number by default)
14
+ * - Provides lookup for cross-form resolution
15
+ * - Optionally persists state across restarts
16
+ *
17
+ * This is a standalone utility — can be used independently or via JidCanonicalizer.
18
+ */
19
+ export interface LidResolverConfig {
20
+ /** Canonical form to normalize to. Default: 'pn' (phone-number) */
21
+ canonical?: 'pn' | 'lid';
22
+ /** Optional persistence hooks — if provided, map survives restarts */
23
+ persistence?: {
24
+ load?: () => Promise<Record<string, any>> | Record<string, any>;
25
+ save?: (map: Record<string, any>) => Promise<void> | void;
26
+ };
27
+ /** Max entries to hold in memory (LRU). Default 10_000 */
28
+ maxEntries?: number;
29
+ }
30
+ export interface LidMapping {
31
+ lid: string;
32
+ pn: string;
33
+ phone?: string;
34
+ learnedAt: number;
35
+ seenCount: number;
36
+ }
37
+ export interface LidResolverStats {
38
+ totalMappings: number;
39
+ learnedFromEvents: number;
40
+ lookupsServed: number;
41
+ lookupMisses: number;
42
+ canonicalForm: 'pn' | 'lid';
43
+ }
44
+ export declare class LidResolver {
45
+ private config;
46
+ private persistence?;
47
+ private lidToPn;
48
+ private pnToLid;
49
+ private stats;
50
+ constructor(config?: LidResolverConfig);
51
+ /**
52
+ * Learn from a message event. Idempotent.
53
+ * Accepts partial mappings — will use whatever fields are available.
54
+ */
55
+ learn(mapping: {
56
+ lid?: string;
57
+ pn?: string;
58
+ phone?: string;
59
+ }): void;
60
+ /**
61
+ * Given any form (LID or PN), return the canonical form.
62
+ * Falls back to input if unknown (no throw).
63
+ */
64
+ resolveCanonical(jid: string): string;
65
+ /**
66
+ * Lookup partner form. Returns null if unknown.
67
+ */
68
+ getLid(pn: string): string | null;
69
+ getPn(lid: string): string | null;
70
+ /**
71
+ * Full mapping for inspection
72
+ */
73
+ getMapping(jid: string): LidMapping | null;
74
+ /**
75
+ * Seed from persistence (called automatically in constructor if persistence provided)
76
+ */
77
+ hydrate(): Promise<void>;
78
+ /**
79
+ * Flush current map to persistence
80
+ */
81
+ flush(): Promise<void>;
82
+ getStats(): LidResolverStats;
83
+ /**
84
+ * Clear everything
85
+ */
86
+ reset(): void;
87
+ destroy(): void;
88
+ /**
89
+ * Normalize JID: strip device suffix `:N`
90
+ */
91
+ private normalizeJid;
92
+ /**
93
+ * Evict least recently accessed mapping (LRU)
94
+ */
95
+ private evictLRU;
96
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * LID Resolver — Maintains bidirectional LID↔PN mapping for contacts
3
+ *
4
+ * WhatsApp migrated to LID (Linked Identity) in 2024. A contact now has two JIDs:
5
+ * - Phone number form: "27825651069@s.whatsapp.net"
6
+ * - LID form: "123456789@lid"
7
+ *
8
+ * Messages can arrive under either form. If an encryption session was established
9
+ * under one form and a message arrives under the other, decryption fails → "Bad MAC".
10
+ *
11
+ * This utility:
12
+ * - Learns LID↔PN mappings from message events
13
+ * - Normalizes JIDs to a canonical form (phone number by default)
14
+ * - Provides lookup for cross-form resolution
15
+ * - Optionally persists state across restarts
16
+ *
17
+ * This is a standalone utility — can be used independently or via JidCanonicalizer.
18
+ */
19
+ const DEFAULT_CONFIG = {
20
+ canonical: 'pn',
21
+ maxEntries: 10_000,
22
+ };
23
+ export class LidResolver {
24
+ config;
25
+ persistence;
26
+ // Bidirectional maps: lid→pn and pn→lid
27
+ lidToPn = new Map();
28
+ pnToLid = new Map(); // pn → lid (for quick reverse lookup)
29
+ stats = {
30
+ learnedFromEvents: 0,
31
+ lookupsServed: 0,
32
+ lookupMisses: 0,
33
+ };
34
+ constructor(config = {}) {
35
+ this.config = { ...DEFAULT_CONFIG, ...config };
36
+ this.persistence = config.persistence;
37
+ // Auto-hydrate if persistence provided
38
+ if (this.persistence?.load) {
39
+ void this.hydrate();
40
+ }
41
+ }
42
+ /**
43
+ * Learn from a message event. Idempotent.
44
+ * Accepts partial mappings — will use whatever fields are available.
45
+ */
46
+ learn(mapping) {
47
+ let lid = mapping.lid ? this.normalizeJid(mapping.lid) : undefined;
48
+ let pn = mapping.pn ? this.normalizeJid(mapping.pn) : undefined;
49
+ const phone = mapping.phone;
50
+ // Validate we have both lid and pn (or can derive pn from phone)
51
+ if (!lid || (!pn && !phone)) {
52
+ return; // Insufficient data
53
+ }
54
+ // Derive pn from phone if not provided
55
+ if (!pn && phone) {
56
+ pn = `${phone}@s.whatsapp.net`;
57
+ }
58
+ // Validate forms
59
+ if (!lid || !pn)
60
+ return;
61
+ if (!lid.endsWith('@lid'))
62
+ return;
63
+ if (!pn.endsWith('@s.whatsapp.net'))
64
+ return;
65
+ // Check if we already know this mapping
66
+ const existing = this.lidToPn.get(lid);
67
+ if (existing) {
68
+ // Already learned — just increment seen count
69
+ existing.seenCount++;
70
+ existing.learnedAt = Date.now(); // Update access time for LRU
71
+ return;
72
+ }
73
+ // Learn new mapping
74
+ const extractedPhone = phone || pn.split('@')[0];
75
+ const newMapping = {
76
+ lid,
77
+ pn,
78
+ phone: extractedPhone,
79
+ learnedAt: Date.now(),
80
+ seenCount: 1,
81
+ };
82
+ // Check if we need to evict (LRU)
83
+ if (this.lidToPn.size >= this.config.maxEntries) {
84
+ this.evictLRU();
85
+ }
86
+ this.lidToPn.set(lid, newMapping);
87
+ this.pnToLid.set(pn, lid);
88
+ this.stats.learnedFromEvents++;
89
+ // Async flush to persistence (don't await)
90
+ if (this.persistence?.save) {
91
+ void this.flush();
92
+ }
93
+ }
94
+ /**
95
+ * Given any form (LID or PN), return the canonical form.
96
+ * Falls back to input if unknown (no throw).
97
+ */
98
+ resolveCanonical(jid) {
99
+ const normalized = this.normalizeJid(jid);
100
+ if (this.config.canonical === 'pn') {
101
+ // Want PN form
102
+ if (normalized.endsWith('@lid')) {
103
+ const mapping = this.lidToPn.get(normalized);
104
+ if (mapping) {
105
+ this.stats.lookupsServed++;
106
+ mapping.learnedAt = Date.now(); // Update LRU access time
107
+ return mapping.pn;
108
+ }
109
+ this.stats.lookupMisses++;
110
+ return jid; // Fallback to original input
111
+ }
112
+ // Already PN form
113
+ this.stats.lookupsServed++;
114
+ return normalized;
115
+ }
116
+ else {
117
+ // Want LID form
118
+ if (normalized.endsWith('@s.whatsapp.net')) {
119
+ const lid = this.pnToLid.get(normalized);
120
+ if (lid) {
121
+ this.stats.lookupsServed++;
122
+ const mapping = this.lidToPn.get(lid);
123
+ if (mapping) {
124
+ mapping.learnedAt = Date.now(); // Update LRU access time
125
+ }
126
+ return lid;
127
+ }
128
+ this.stats.lookupMisses++;
129
+ return jid; // Fallback to original input
130
+ }
131
+ // Already LID form
132
+ this.stats.lookupsServed++;
133
+ return normalized;
134
+ }
135
+ }
136
+ /**
137
+ * Lookup partner form. Returns null if unknown.
138
+ */
139
+ getLid(pn) {
140
+ const normalized = this.normalizeJid(pn);
141
+ const lid = this.pnToLid.get(normalized);
142
+ if (lid) {
143
+ const mapping = this.lidToPn.get(lid);
144
+ if (mapping) {
145
+ mapping.learnedAt = Date.now(); // Update LRU access time
146
+ }
147
+ }
148
+ return lid || null;
149
+ }
150
+ getPn(lid) {
151
+ const normalized = this.normalizeJid(lid);
152
+ const mapping = this.lidToPn.get(normalized);
153
+ if (mapping) {
154
+ mapping.learnedAt = Date.now(); // Update LRU access time
155
+ return mapping.pn;
156
+ }
157
+ return null;
158
+ }
159
+ /**
160
+ * Full mapping for inspection
161
+ */
162
+ getMapping(jid) {
163
+ const normalized = this.normalizeJid(jid);
164
+ // Try as LID first
165
+ const byLid = this.lidToPn.get(normalized);
166
+ if (byLid) {
167
+ byLid.learnedAt = Date.now(); // Update LRU access time
168
+ return byLid;
169
+ }
170
+ // Try as PN
171
+ const lid = this.pnToLid.get(normalized);
172
+ if (lid) {
173
+ const mapping = this.lidToPn.get(lid);
174
+ if (mapping) {
175
+ mapping.learnedAt = Date.now(); // Update LRU access time
176
+ return mapping;
177
+ }
178
+ }
179
+ return null;
180
+ }
181
+ /**
182
+ * Seed from persistence (called automatically in constructor if persistence provided)
183
+ */
184
+ async hydrate() {
185
+ if (!this.persistence?.load)
186
+ return;
187
+ try {
188
+ const stored = await this.persistence.load();
189
+ if (!stored || typeof stored !== 'object')
190
+ return;
191
+ // Restore mappings
192
+ for (const [lid, serialized] of Object.entries(stored)) {
193
+ if (typeof serialized === 'string') {
194
+ // Old format: lid → pn string
195
+ const pn = serialized;
196
+ const phone = pn.split('@')[0];
197
+ const mapping = {
198
+ lid,
199
+ pn,
200
+ phone,
201
+ learnedAt: Date.now(),
202
+ seenCount: 1,
203
+ };
204
+ this.lidToPn.set(lid, mapping);
205
+ this.pnToLid.set(pn, lid);
206
+ }
207
+ else if (typeof serialized === 'object' && serialized !== null) {
208
+ // New format: lid → LidMapping object
209
+ const mapping = serialized;
210
+ this.lidToPn.set(lid, mapping);
211
+ this.pnToLid.set(mapping.pn, lid);
212
+ }
213
+ }
214
+ }
215
+ catch (error) {
216
+ // Silently fail hydration — don't crash on corrupt persistence
217
+ }
218
+ }
219
+ /**
220
+ * Flush current map to persistence
221
+ */
222
+ async flush() {
223
+ if (!this.persistence?.save)
224
+ return;
225
+ try {
226
+ const toStore = {};
227
+ for (const [lid, mapping] of this.lidToPn.entries()) {
228
+ toStore[lid] = mapping;
229
+ }
230
+ await this.persistence.save(toStore);
231
+ }
232
+ catch (error) {
233
+ // Silently fail flush — don't crash
234
+ }
235
+ }
236
+ getStats() {
237
+ return {
238
+ totalMappings: this.lidToPn.size,
239
+ learnedFromEvents: this.stats.learnedFromEvents,
240
+ lookupsServed: this.stats.lookupsServed,
241
+ lookupMisses: this.stats.lookupMisses,
242
+ canonicalForm: this.config.canonical,
243
+ };
244
+ }
245
+ /**
246
+ * Clear everything
247
+ */
248
+ reset() {
249
+ this.lidToPn.clear();
250
+ this.pnToLid.clear();
251
+ this.stats = {
252
+ learnedFromEvents: 0,
253
+ lookupsServed: 0,
254
+ lookupMisses: 0,
255
+ };
256
+ }
257
+ destroy() {
258
+ this.reset();
259
+ // Flush one final time
260
+ if (this.persistence?.save) {
261
+ void this.flush();
262
+ }
263
+ }
264
+ // Private helpers
265
+ /**
266
+ * Normalize JID: strip device suffix `:N`
267
+ */
268
+ normalizeJid(jid) {
269
+ // Strip device suffix e.g. "123:45@s.whatsapp.net" → "123@s.whatsapp.net"
270
+ return jid.replace(/:\d+@/, '@');
271
+ }
272
+ /**
273
+ * Evict least recently accessed mapping (LRU)
274
+ */
275
+ evictLRU() {
276
+ let oldestLid = null;
277
+ let oldestTime = Infinity;
278
+ for (const [lid, mapping] of this.lidToPn.entries()) {
279
+ if (mapping.learnedAt < oldestTime) {
280
+ oldestTime = mapping.learnedAt;
281
+ oldestLid = lid;
282
+ }
283
+ }
284
+ if (oldestLid) {
285
+ const mapping = this.lidToPn.get(oldestLid);
286
+ if (mapping) {
287
+ this.pnToLid.delete(mapping.pn);
288
+ }
289
+ this.lidToPn.delete(oldestLid);
290
+ }
291
+ }
292
+ }
package/dist/wrapper.js CHANGED
@@ -63,7 +63,7 @@ export function wrapSocket(sock, config, warmUpState, wrapOptions) {
63
63
  });
64
64
  }
65
65
  }
66
- // Catch 463 errors from message updates + track retries
66
+ // Catch 463 errors from message updates + track retries + learn LID mappings
67
67
  if (events['messages.update']) {
68
68
  const updates = events['messages.update'];
69
69
  for (const update of updates) {
@@ -77,10 +77,14 @@ export function wrapSocket(sock, config, warmUpState, wrapOptions) {
77
77
  // Retry tracking
78
78
  antiban.retryTracker.onMessageUpdate(update);
79
79
  }
80
+ // LID canonicalizer learning
81
+ antiban.jidCanonicalizer?.onMessageUpdate(updates);
80
82
  }
81
- // Register known chats from incoming messages + handle reply suggestions
83
+ // Register known chats from incoming messages + handle reply suggestions + learn LID mappings
82
84
  if (events['messages.upsert']) {
83
85
  const { messages } = events['messages.upsert'];
86
+ // Learn LID mappings FIRST (before any other processing)
87
+ antiban.jidCanonicalizer?.onIncomingEvent(events['messages.upsert']);
84
88
  for (const msg of messages || []) {
85
89
  const jid = msg.key?.remoteJid;
86
90
  if (!jid)
@@ -136,7 +140,7 @@ export function wrapSocket(sock, config, warmUpState, wrapOptions) {
136
140
  });
137
141
  }
138
142
  });
139
- // Catch 463 errors from message updates + track retries
143
+ // Catch 463 errors from message updates + track retries + learn LID mappings
140
144
  sock.ev.on('messages.update', (updates) => {
141
145
  for (const update of updates) {
142
146
  // 463 error detection
@@ -149,9 +153,14 @@ export function wrapSocket(sock, config, warmUpState, wrapOptions) {
149
153
  // Retry tracking
150
154
  antiban.retryTracker.onMessageUpdate(update);
151
155
  }
156
+ // LID canonicalizer learning
157
+ antiban.jidCanonicalizer?.onMessageUpdate(updates);
152
158
  });
153
- // Register known chats from incoming messages + handle reply suggestions
154
- sock.ev.on('messages.upsert', ({ messages }) => {
159
+ // Register known chats from incoming messages + handle reply suggestions + learn LID mappings
160
+ sock.ev.on('messages.upsert', (upsert) => {
161
+ const { messages } = upsert;
162
+ // Learn LID mappings FIRST (before any other processing)
163
+ antiban.jidCanonicalizer?.onIncomingEvent(upsert);
155
164
  for (const msg of messages || []) {
156
165
  const jid = msg.key?.remoteJid;
157
166
  if (!jid)
@@ -189,9 +198,20 @@ export function wrapSocket(sock, config, warmUpState, wrapOptions) {
189
198
  // Create proxy that intercepts sendMessage
190
199
  const originalSendMessage = sock.sendMessage.bind(sock);
191
200
  const wrappedSendMessage = async (jid, content, options) => {
201
+ /**
202
+ * LID/PN Canonicalization — Normalize JID to canonical form
203
+ *
204
+ * This mitigates the LID/PN race that causes "Bad MAC / No Session / Invalid PreKey"
205
+ * errors (Baileys #1769, our PR #2372). When a message event arrives under one form
206
+ * (e.g. LID) but the crypto session was established under another (e.g. PN), decryption
207
+ * fails. By normalizing all outbound targets to a single form, we reduce this race.
208
+ *
209
+ * This is middleware-layer mitigation only. Root fix requires PR #2372 merged upstream.
210
+ */
211
+ const canonicalJid = antiban.jidCanonicalizer?.canonicalizeTarget(jid) || jid;
192
212
  // Extract text content for rate limiter analysis
193
213
  const text = content?.text || content?.caption || content?.image?.caption || '';
194
- const decision = await antiban.beforeSend(jid, text);
214
+ const decision = await antiban.beforeSend(canonicalJid, text);
195
215
  if (!decision.allowed) {
196
216
  throw new Error(`[baileys-antiban] Message blocked: ${decision.reason}`);
197
217
  }
@@ -199,11 +219,11 @@ export function wrapSocket(sock, config, warmUpState, wrapOptions) {
199
219
  if (decision.delayMs > 0) {
200
220
  await new Promise(resolve => setTimeout(resolve, decision.delayMs));
201
221
  }
202
- // Send message
222
+ // Send message (using canonical JID)
203
223
  try {
204
- const result = await originalSendMessage(jid, content, options);
205
- antiban.afterSend(jid, text);
206
- antiban.timelock.registerKnownChat(jid);
224
+ const result = await originalSendMessage(canonicalJid, content, options);
225
+ antiban.afterSend(canonicalJid, text);
226
+ antiban.timelock.registerKnownChat(canonicalJid);
207
227
  // Clear retry tracking on successful send
208
228
  if (result?.key?.id) {
209
229
  antiban.retryTracker.clear(result.key.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baileys-antiban",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
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",