baileys-antiban 4.8.0 → 4.9.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/dist/antiban.d.ts CHANGED
@@ -21,6 +21,7 @@ import { ReplyRatioGuard, type ReplyRatioConfig, type ReplyRatioStats } from './
21
21
  import { ContactGraphWarmer, type ContactGraphConfig, type ContactGraphStats } from './contactGraph.js';
22
22
  import { PresenceChoreographer, type PresenceChoreographerConfig, type PresenceChoreographerStats } from './presenceChoreographer.js';
23
23
  import { RetryReasonTracker, type RetryTrackerConfig, type RetryStats } from './retryTracker.js';
24
+ import { TopologyThrottler, type TopologyThrottlerConfig } from './topologyThrottler.js';
24
25
  import { PostReconnectThrottle, type ReconnectThrottleConfig, type ReconnectThrottleStats } from './reconnectThrottle.js';
25
26
  import { LidResolver, type LidResolverConfig, type LidResolverStats } from './lidResolver.js';
26
27
  import { JidCanonicalizer, type JidCanonicalizerConfig, type JidCanonicalizerStats } from './jidCanonicalizer.js';
@@ -41,6 +42,7 @@ export interface AntiBanConfigLegacy {
41
42
  presence?: Partial<PresenceChoreographerConfig>;
42
43
  retryTracker?: Partial<RetryTrackerConfig>;
43
44
  reconnectThrottle?: Partial<ReconnectThrottleConfig>;
45
+ topologyThrottler?: Partial<TopologyThrottlerConfig>;
44
46
  lidResolver?: LidResolverConfig;
45
47
  jidCanonicalizer?: JidCanonicalizerConfig;
46
48
  sessionStability?: {
@@ -72,6 +74,16 @@ export interface AntiBanStats {
72
74
  presence?: PresenceChoreographerStats;
73
75
  retryTracker?: RetryStats | null;
74
76
  reconnectThrottle?: ReconnectThrottleStats | null;
77
+ topologyThrottler?: {
78
+ newContactsThisHour: number;
79
+ newContactsToday: number;
80
+ replyRatio: number | null;
81
+ blockedRatio: number | null;
82
+ hotspots: Array<{
83
+ sourceGroup: string;
84
+ count: number;
85
+ }>;
86
+ } | null;
75
87
  lidResolver?: LidResolverStats | null;
76
88
  jidCanonicalizer?: JidCanonicalizerStats | null;
77
89
  sessionStability?: SessionHealthStats | null;
@@ -93,6 +105,7 @@ export declare class AntiBan {
93
105
  private presenceChoreographer;
94
106
  private retryTrackerModule;
95
107
  private reconnectThrottleModule;
108
+ private topologyThrottlerModule;
96
109
  private lidResolverModule;
97
110
  private jidCanonicalizerModule;
98
111
  private sessionStabilityMonitor;
@@ -160,6 +173,10 @@ export declare class AntiBan {
160
173
  get retryTracker(): RetryReasonTracker;
161
174
  /** Get the reconnect throttle for direct access */
162
175
  get reconnectThrottle(): PostReconnectThrottle;
176
+ /** Get the topology throttler for direct access */
177
+ get topologyThrottler(): TopologyThrottler | null;
178
+ /** Get the topology throttler for direct access (alias) */
179
+ get topology(): TopologyThrottler | null;
163
180
  /** Get the LID resolver for direct access */
164
181
  get lidResolver(): LidResolver | null;
165
182
  /** Get the JID canonicalizer for direct access */
@@ -186,6 +203,7 @@ export declare class AntiBan {
186
203
  * Reset everything (use after a ban period)
187
204
  */
188
205
  reset(): void;
206
+ private isGroupJid;
189
207
  private runAdaptiveCheck;
190
208
  private persistStateDebounced;
191
209
  private persistStateImmediate;
package/dist/antiban.js CHANGED
@@ -21,6 +21,7 @@ import { ReplyRatioGuard } from './replyRatio.js';
21
21
  import { ContactGraphWarmer } from './contactGraph.js';
22
22
  import { PresenceChoreographer } from './presenceChoreographer.js';
23
23
  import { RetryReasonTracker } from './retryTracker.js';
24
+ import { TopologyThrottler } from './topologyThrottler.js';
24
25
  import { PostReconnectThrottle } from './reconnectThrottle.js';
25
26
  import { LidResolver } from './lidResolver.js';
26
27
  import { JidCanonicalizer } from './jidCanonicalizer.js';
@@ -105,6 +106,7 @@ export class AntiBan {
105
106
  presenceChoreographer;
106
107
  retryTrackerModule;
107
108
  reconnectThrottleModule;
109
+ topologyThrottlerModule = null;
108
110
  lidResolverModule = null;
109
111
  jidCanonicalizerModule = null;
110
112
  sessionStabilityMonitor = null;
@@ -246,6 +248,13 @@ export class AntiBan {
246
248
  ...(legacyPassthrough?.reconnectThrottle || {}),
247
249
  baselineRatePerMinute: () => this.rateLimiter.getStats().limits.perMinute,
248
250
  });
251
+ // Initialize topology throttler if configured
252
+ if (legacyPassthrough?.topologyThrottler) {
253
+ this.topologyThrottlerModule = new TopologyThrottler(legacyPassthrough.topologyThrottler);
254
+ if (this.logging) {
255
+ console.log(`[baileys-antiban] 🌐 Topology throttler enabled — max ${legacyPassthrough.topologyThrottler.maxNewContactsPerHour || 5}/hr, ${legacyPassthrough.topologyThrottler.maxNewContactsPerDay || 20}/day new contacts`);
256
+ }
257
+ }
249
258
  // Initialize LID resolver and canonicalizer if configured
250
259
  // If jidCanonicalizer is enabled but no resolver provided, create standalone resolver
251
260
  if (legacyPassthrough?.jidCanonicalizer?.enabled) {
@@ -381,6 +390,46 @@ export class AntiBan {
381
390
  health: healthStatus,
382
391
  };
383
392
  }
393
+ // Topology throttler check — only applies to DMs to new/unknown contacts
394
+ if (this.topologyThrottlerModule && !this.isGroupJid(recipient)) {
395
+ const knownChats = this.rateLimiter.getKnownChats();
396
+ const isNewContact = !knownChats.has(recipient);
397
+ if (isNewContact) {
398
+ // Check if we can send to new contact based on topology limits
399
+ const topologyDecision = this.topologyThrottlerModule.canSendToNewContact();
400
+ if (!topologyDecision.allowed) {
401
+ this.stats.messagesBlocked++;
402
+ if (this.logging) {
403
+ console.log(`[baileys-antiban] 🌐 BLOCKED — topology: ${topologyDecision.reason}`);
404
+ }
405
+ return {
406
+ allowed: false,
407
+ delayMs: topologyDecision.retryAfterMs || 0,
408
+ reason: `Topology: ${topologyDecision.reason}`,
409
+ health: healthStatus,
410
+ };
411
+ }
412
+ // Assess contact risk
413
+ const riskAssessment = this.topologyThrottlerModule.assessContact(recipient, {
414
+ messageType: 'dm',
415
+ hasReplied: false,
416
+ knownGroups: [],
417
+ });
418
+ if (riskAssessment.recommendation === 'abort') {
419
+ this.stats.messagesBlocked++;
420
+ if (this.logging) {
421
+ console.log(`[baileys-antiban] ⛔ BLOCKED — contact risk ${riskAssessment.risk} (score ${riskAssessment.score}): ${riskAssessment.reasons.join(', ')}`);
422
+ }
423
+ return {
424
+ allowed: false,
425
+ delayMs: 0,
426
+ reason: `Contact risk too high: ${riskAssessment.reasons.join(', ')}`,
427
+ health: healthStatus,
428
+ };
429
+ }
430
+ // If delay recommended, we'll add it to the total delay later
431
+ }
432
+ }
384
433
  // Reply ratio check
385
434
  const replyRatioDecision = this.replyRatioGuard.beforeSend(recipient);
386
435
  if (!replyRatioDecision.allowed) {
@@ -482,6 +531,24 @@ export class AntiBan {
482
531
  }
483
532
  }
484
533
  }
534
+ // Topology throttler recommended delay
535
+ if (this.topologyThrottlerModule && !this.isGroupJid(recipient)) {
536
+ const knownChats = this.rateLimiter.getKnownChats();
537
+ const isNewContact = !knownChats.has(recipient);
538
+ if (isNewContact) {
539
+ const riskAssessment = this.topologyThrottlerModule.assessContact(recipient, {
540
+ messageType: 'dm',
541
+ hasReplied: false,
542
+ knownGroups: [],
543
+ });
544
+ if (riskAssessment.recommendation === 'delay' && riskAssessment.suggestedDelayMs) {
545
+ delay += riskAssessment.suggestedDelayMs;
546
+ if (this.logging) {
547
+ console.log(`[baileys-antiban] ⚠️ Topology risk ${riskAssessment.risk} — adding ${Math.floor(riskAssessment.suggestedDelayMs / 60000)}min delay`);
548
+ }
549
+ }
550
+ }
551
+ }
485
552
  // Roll for distraction pause
486
553
  const distractionCheck = this.presenceChoreographer.shouldPauseForDistraction();
487
554
  if (distractionCheck.pause) {
@@ -513,6 +580,7 @@ export class AntiBan {
513
580
  this.rateLimiter.record(recipient, content);
514
581
  this.warmUp.record();
515
582
  this.replyRatioGuard.recordSent(recipient);
583
+ this.topologyThrottlerModule?.recordSent(recipient);
516
584
  this.stats.messagesAllowed++;
517
585
  if (msgId) {
518
586
  this.deliveryTracker.onMessageSent(msgId);
@@ -552,6 +620,7 @@ export class AntiBan {
552
620
  onIncomingMessage(jid, msgText) {
553
621
  this.replyRatioGuard.recordReceived(jid);
554
622
  this.contactGraphWarmer.onIncomingMessage(jid);
623
+ this.topologyThrottlerModule?.recordReplied(jid);
555
624
  return this.replyRatioGuard.suggestReply(jid, msgText);
556
625
  }
557
626
  /**
@@ -595,6 +664,9 @@ export class AntiBan {
595
664
  if (this.reconnectThrottleModule['config']?.enabled) {
596
665
  stats.reconnectThrottle = this.reconnectThrottleModule.getStats();
597
666
  }
667
+ if (this.topologyThrottlerModule) {
668
+ stats.topologyThrottler = this.topologyThrottlerModule.getTopologyStats();
669
+ }
598
670
  if (this.lidResolverModule) {
599
671
  stats.lidResolver = this.lidResolverModule.getStats();
600
672
  }
@@ -640,6 +712,14 @@ export class AntiBan {
640
712
  get reconnectThrottle() {
641
713
  return this.reconnectThrottleModule;
642
714
  }
715
+ /** Get the topology throttler for direct access */
716
+ get topologyThrottler() {
717
+ return this.topologyThrottlerModule;
718
+ }
719
+ /** Get the topology throttler for direct access (alias) */
720
+ get topology() {
721
+ return this.topologyThrottlerModule;
722
+ }
643
723
  /** Get the LID resolver for direct access */
644
724
  get lidResolver() {
645
725
  return this.lidResolverModule;
@@ -701,6 +781,9 @@ export class AntiBan {
701
781
  console.log('[baileys-antiban] 🔄 Reset — starting fresh warm-up');
702
782
  }
703
783
  }
784
+ isGroupJid(jid) {
785
+ return jid.endsWith('@g.us') || jid.endsWith('@newsletter');
786
+ }
704
787
  runAdaptiveCheck() {
705
788
  const delivery = this.deliveryTracker.getStats();
706
789
  // Need min sample to be meaningful
@@ -763,6 +846,7 @@ export class AntiBan {
763
846
  rateLimiter: this.rateLimiter,
764
847
  timelockGuard: this.timelockGuard,
765
848
  messageRegistry: this.messageTypeRegistry || undefined,
849
+ topologyThrottler: this.topologyThrottlerModule || undefined,
766
850
  instanceId: this.resolvedConfig.instanceId,
767
851
  });
768
852
  }
@@ -777,6 +861,7 @@ export class AntiBan {
777
861
  rateLimiter: this.rateLimiter,
778
862
  timelockGuard: this.timelockGuard,
779
863
  messageRegistry: this.messageTypeRegistry || undefined,
864
+ topologyThrottler: this.topologyThrottlerModule || undefined,
780
865
  });
781
866
  }
782
867
  /**
@@ -24,6 +24,7 @@ const replyRatio_js_1 = require("./replyRatio.js");
24
24
  const contactGraph_js_1 = require("./contactGraph.js");
25
25
  const presenceChoreographer_js_1 = require("./presenceChoreographer.js");
26
26
  const retryTracker_js_1 = require("./retryTracker.js");
27
+ const topologyThrottler_js_1 = require("./topologyThrottler.js");
27
28
  const reconnectThrottle_js_1 = require("./reconnectThrottle.js");
28
29
  const lidResolver_js_1 = require("./lidResolver.js");
29
30
  const jidCanonicalizer_js_1 = require("./jidCanonicalizer.js");
@@ -108,6 +109,7 @@ class AntiBan {
108
109
  presenceChoreographer;
109
110
  retryTrackerModule;
110
111
  reconnectThrottleModule;
112
+ topologyThrottlerModule = null;
111
113
  lidResolverModule = null;
112
114
  jidCanonicalizerModule = null;
113
115
  sessionStabilityMonitor = null;
@@ -249,6 +251,13 @@ class AntiBan {
249
251
  ...(legacyPassthrough?.reconnectThrottle || {}),
250
252
  baselineRatePerMinute: () => this.rateLimiter.getStats().limits.perMinute,
251
253
  });
254
+ // Initialize topology throttler if configured
255
+ if (legacyPassthrough?.topologyThrottler) {
256
+ this.topologyThrottlerModule = new topologyThrottler_js_1.TopologyThrottler(legacyPassthrough.topologyThrottler);
257
+ if (this.logging) {
258
+ console.log(`[baileys-antiban] 🌐 Topology throttler enabled — max ${legacyPassthrough.topologyThrottler.maxNewContactsPerHour || 5}/hr, ${legacyPassthrough.topologyThrottler.maxNewContactsPerDay || 20}/day new contacts`);
259
+ }
260
+ }
252
261
  // Initialize LID resolver and canonicalizer if configured
253
262
  // If jidCanonicalizer is enabled but no resolver provided, create standalone resolver
254
263
  if (legacyPassthrough?.jidCanonicalizer?.enabled) {
@@ -384,6 +393,46 @@ class AntiBan {
384
393
  health: healthStatus,
385
394
  };
386
395
  }
396
+ // Topology throttler check — only applies to DMs to new/unknown contacts
397
+ if (this.topologyThrottlerModule && !this.isGroupJid(recipient)) {
398
+ const knownChats = this.rateLimiter.getKnownChats();
399
+ const isNewContact = !knownChats.has(recipient);
400
+ if (isNewContact) {
401
+ // Check if we can send to new contact based on topology limits
402
+ const topologyDecision = this.topologyThrottlerModule.canSendToNewContact();
403
+ if (!topologyDecision.allowed) {
404
+ this.stats.messagesBlocked++;
405
+ if (this.logging) {
406
+ console.log(`[baileys-antiban] 🌐 BLOCKED — topology: ${topologyDecision.reason}`);
407
+ }
408
+ return {
409
+ allowed: false,
410
+ delayMs: topologyDecision.retryAfterMs || 0,
411
+ reason: `Topology: ${topologyDecision.reason}`,
412
+ health: healthStatus,
413
+ };
414
+ }
415
+ // Assess contact risk
416
+ const riskAssessment = this.topologyThrottlerModule.assessContact(recipient, {
417
+ messageType: 'dm',
418
+ hasReplied: false,
419
+ knownGroups: [],
420
+ });
421
+ if (riskAssessment.recommendation === 'abort') {
422
+ this.stats.messagesBlocked++;
423
+ if (this.logging) {
424
+ console.log(`[baileys-antiban] ⛔ BLOCKED — contact risk ${riskAssessment.risk} (score ${riskAssessment.score}): ${riskAssessment.reasons.join(', ')}`);
425
+ }
426
+ return {
427
+ allowed: false,
428
+ delayMs: 0,
429
+ reason: `Contact risk too high: ${riskAssessment.reasons.join(', ')}`,
430
+ health: healthStatus,
431
+ };
432
+ }
433
+ // If delay recommended, we'll add it to the total delay later
434
+ }
435
+ }
387
436
  // Reply ratio check
388
437
  const replyRatioDecision = this.replyRatioGuard.beforeSend(recipient);
389
438
  if (!replyRatioDecision.allowed) {
@@ -485,6 +534,24 @@ class AntiBan {
485
534
  }
486
535
  }
487
536
  }
537
+ // Topology throttler recommended delay
538
+ if (this.topologyThrottlerModule && !this.isGroupJid(recipient)) {
539
+ const knownChats = this.rateLimiter.getKnownChats();
540
+ const isNewContact = !knownChats.has(recipient);
541
+ if (isNewContact) {
542
+ const riskAssessment = this.topologyThrottlerModule.assessContact(recipient, {
543
+ messageType: 'dm',
544
+ hasReplied: false,
545
+ knownGroups: [],
546
+ });
547
+ if (riskAssessment.recommendation === 'delay' && riskAssessment.suggestedDelayMs) {
548
+ delay += riskAssessment.suggestedDelayMs;
549
+ if (this.logging) {
550
+ console.log(`[baileys-antiban] ⚠️ Topology risk ${riskAssessment.risk} — adding ${Math.floor(riskAssessment.suggestedDelayMs / 60000)}min delay`);
551
+ }
552
+ }
553
+ }
554
+ }
488
555
  // Roll for distraction pause
489
556
  const distractionCheck = this.presenceChoreographer.shouldPauseForDistraction();
490
557
  if (distractionCheck.pause) {
@@ -516,6 +583,7 @@ class AntiBan {
516
583
  this.rateLimiter.record(recipient, content);
517
584
  this.warmUp.record();
518
585
  this.replyRatioGuard.recordSent(recipient);
586
+ this.topologyThrottlerModule?.recordSent(recipient);
519
587
  this.stats.messagesAllowed++;
520
588
  if (msgId) {
521
589
  this.deliveryTracker.onMessageSent(msgId);
@@ -555,6 +623,7 @@ class AntiBan {
555
623
  onIncomingMessage(jid, msgText) {
556
624
  this.replyRatioGuard.recordReceived(jid);
557
625
  this.contactGraphWarmer.onIncomingMessage(jid);
626
+ this.topologyThrottlerModule?.recordReplied(jid);
558
627
  return this.replyRatioGuard.suggestReply(jid, msgText);
559
628
  }
560
629
  /**
@@ -598,6 +667,9 @@ class AntiBan {
598
667
  if (this.reconnectThrottleModule['config']?.enabled) {
599
668
  stats.reconnectThrottle = this.reconnectThrottleModule.getStats();
600
669
  }
670
+ if (this.topologyThrottlerModule) {
671
+ stats.topologyThrottler = this.topologyThrottlerModule.getTopologyStats();
672
+ }
601
673
  if (this.lidResolverModule) {
602
674
  stats.lidResolver = this.lidResolverModule.getStats();
603
675
  }
@@ -643,6 +715,14 @@ class AntiBan {
643
715
  get reconnectThrottle() {
644
716
  return this.reconnectThrottleModule;
645
717
  }
718
+ /** Get the topology throttler for direct access */
719
+ get topologyThrottler() {
720
+ return this.topologyThrottlerModule;
721
+ }
722
+ /** Get the topology throttler for direct access (alias) */
723
+ get topology() {
724
+ return this.topologyThrottlerModule;
725
+ }
646
726
  /** Get the LID resolver for direct access */
647
727
  get lidResolver() {
648
728
  return this.lidResolverModule;
@@ -704,6 +784,9 @@ class AntiBan {
704
784
  console.log('[baileys-antiban] 🔄 Reset — starting fresh warm-up');
705
785
  }
706
786
  }
787
+ isGroupJid(jid) {
788
+ return jid.endsWith('@g.us') || jid.endsWith('@newsletter');
789
+ }
707
790
  runAdaptiveCheck() {
708
791
  const delivery = this.deliveryTracker.getStats();
709
792
  // Need min sample to be meaningful
@@ -766,6 +849,7 @@ class AntiBan {
766
849
  rateLimiter: this.rateLimiter,
767
850
  timelockGuard: this.timelockGuard,
768
851
  messageRegistry: this.messageTypeRegistry || undefined,
852
+ topologyThrottler: this.topologyThrottlerModule || undefined,
769
853
  instanceId: this.resolvedConfig.instanceId,
770
854
  });
771
855
  }
@@ -780,6 +864,7 @@ class AntiBan {
780
864
  rateLimiter: this.rateLimiter,
781
865
  timelockGuard: this.timelockGuard,
782
866
  messageRegistry: this.messageTypeRegistry || undefined,
867
+ topologyThrottler: this.topologyThrottlerModule || undefined,
783
868
  });
784
869
  }
785
870
  /**
package/dist/cjs/index.js CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.getRetryJitter = exports.getTypingJitter = exports.getMessageSendJitter = exports.applySessionFingerprint = exports.generateSessionFingerprint = exports.proxyRotator = exports.readReceiptVariance = exports.credsSnapshot = exports.applyFingerprint = exports.generateFingerprint = exports.messageRecovery = exports.applyGroupMultiplier = exports.shouldUseGroupProfile = exports.isBroadcast = exports.isNewsletter = exports.isGroup = exports.StateManager = exports.PRESETS = exports.resolveConfig = exports.FileStateAdapter = exports.Scheduler = exports.WebhookAlerts = exports.ContentVariator = exports.MessageQueue = exports.wrapSocketWithFingerprint = exports.wrapSocket = exports.getRetryReasonDescription = exports.isMacError = exports.parseRetryReason = exports.MAC_ERROR_CODES = exports.MessageRetryReason = exports.createLidFirstResolver = exports.LidFirstResolver = exports.DeafSessionDetector = exports.classifyDisconnect = exports.wrapWithSessionStability = exports.SessionHealthMonitor = exports.JidCanonicalizer = exports.LidResolver = exports.PostReconnectThrottle = exports.RetryReasonTracker = exports.getCircadianMultiplier = exports.PresenceChoreographer = exports.ContactGraphWarmer = exports.ReplyRatioGuard = exports.TimelockGuard = exports.HealthMonitor = exports.WarmUp = exports.RateLimiter = exports.AntiBan = void 0;
13
- exports.createPeriodicExporter = exports.createMetricsHandler = exports.exportPrometheusMetrics = exports.createConsoleLogger = exports.importAntibanState = exports.exportAntibanState = exports.MessageTypeRegistry = exports.createHumanEntropyService = exports.HumanEntropyService = exports.createInMemoryEventStoreBackend = exports.createMySQLEventStoreBackend = exports.createFleetEventStore = exports.createJidCircuitBreaker = exports.JidCircuitBreaker = exports.InstanceCoordinator = exports.DeliveryTracker = exports.BanRecoveryOrchestrator = exports.LegitimacySignalInjector = exports.GROUP_OP_ERRORS = exports.extractPrivacyBlock = exports.classifyGroupOpError = exports.GroupOperationGuard = exports.AbortError = exports.STEALTH_BROWSER_POOL = exports.rampPresenceAfterConnect = exports.getStealthSocketConfig = exports.createStealthFingerprint = exports.getBatteryState = exports.getVoiceNoteMetadata = void 0;
13
+ exports.createPeriodicExporter = exports.createMetricsHandler = exports.exportPrometheusMetrics = exports.createConsoleLogger = exports.TopologyThrottler = exports.importAntibanState = exports.exportAntibanState = exports.MessageTypeRegistry = exports.createHumanEntropyService = exports.HumanEntropyService = exports.createInMemoryEventStoreBackend = exports.createMySQLEventStoreBackend = exports.createFleetEventStore = exports.createJidCircuitBreaker = exports.JidCircuitBreaker = exports.InstanceCoordinator = exports.DeliveryTracker = exports.BanRecoveryOrchestrator = exports.LegitimacySignalInjector = exports.GROUP_OP_ERRORS = exports.extractPrivacyBlock = exports.classifyGroupOpError = exports.GroupOperationGuard = exports.AbortError = exports.STEALTH_BROWSER_POOL = exports.rampPresenceAfterConnect = exports.getStealthSocketConfig = exports.createStealthFingerprint = exports.getBatteryState = exports.getVoiceNoteMetadata = void 0;
14
14
  // Core
15
15
  var antiban_js_1 = require("./antiban.js");
16
16
  Object.defineProperty(exports, "AntiBan", { enumerable: true, get: function () { return antiban_js_1.AntiBan; } });
@@ -149,6 +149,8 @@ Object.defineProperty(exports, "MessageTypeRegistry", { enumerable: true, get: f
149
149
  var stateExport_js_1 = require("./stateExport.js");
150
150
  Object.defineProperty(exports, "exportAntibanState", { enumerable: true, get: function () { return stateExport_js_1.exportAntibanState; } });
151
151
  Object.defineProperty(exports, "importAntibanState", { enumerable: true, get: function () { return stateExport_js_1.importAntibanState; } });
152
+ var topologyThrottler_js_1 = require("./topologyThrottler.js");
153
+ Object.defineProperty(exports, "TopologyThrottler", { enumerable: true, get: function () { return topologyThrottler_js_1.TopologyThrottler; } });
152
154
  // Observability
153
155
  var observability_js_1 = require("./observability.js");
154
156
  Object.defineProperty(exports, "createConsoleLogger", { enumerable: true, get: function () { return observability_js_1.createConsoleLogger; } });
@@ -71,6 +71,10 @@ function exportAntibanState(modules) {
71
71
  if (modules.messageRegistry) {
72
72
  snapshot.messageRegistry = modules.messageRegistry.exportState();
73
73
  }
74
+ // Export topology throttler state
75
+ if (modules.topologyThrottler) {
76
+ snapshot.topologyThrottler = modules.topologyThrottler.exportState();
77
+ }
74
78
  // Export engagement scores
75
79
  if (modules.engagementScores) {
76
80
  snapshot.engagementScores = Object.fromEntries(modules.engagementScores.entries());
@@ -144,6 +148,10 @@ function importAntibanState(snapshot, modules) {
144
148
  if (snapshot.messageRegistry && modules.messageRegistry) {
145
149
  modules.messageRegistry.importState(snapshot.messageRegistry);
146
150
  }
151
+ // Import topology throttler state
152
+ if (snapshot.topologyThrottler && modules.topologyThrottler) {
153
+ modules.topologyThrottler.importState(snapshot.topologyThrottler);
154
+ }
147
155
  // Import engagement scores
148
156
  if (snapshot.engagementScores && modules.engagementScores) {
149
157
  for (const [jid, score] of Object.entries(snapshot.engagementScores)) {
@@ -0,0 +1,343 @@
1
+ "use strict";
2
+ /**
3
+ * Topology Throttler — Network topology-based anti-ban enforcement
4
+ *
5
+ * WhatsApp bans based on NETWORK TOPOLOGY, not just message timing:
6
+ * - How fast you expand your contact graph
7
+ * - Cold-contact ratio (strangers vs known contacts)
8
+ * - Reply reciprocity
9
+ * - Group-source clustering (mass-DMing group members)
10
+ *
11
+ * This module enforces graph expansion limits and scores contact risk
12
+ * before each send, acting as the primary enforcement layer for high-risk
13
+ * cold outreach.
14
+ *
15
+ * Key insight: A 30% reply rate is the minimum to unlock more cold sends.
16
+ * Below that, WhatsApp's ML models flag you as a spammer.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.TopologyThrottler = void 0;
20
+ const TIME_CONSTANTS = {
21
+ MS_PER_HOUR: 3600000,
22
+ MS_PER_DAY: 86400000,
23
+ MS_PER_24H: 86400000,
24
+ REPLY_WINDOW_DAYS: 7,
25
+ };
26
+ const DEFAULT_CONFIG = {
27
+ maxNewContactsPerHour: 5,
28
+ maxNewContactsPerDay: 20,
29
+ minReplyRatioForNewContacts: 0.3,
30
+ maxSameGroupContacts: 10,
31
+ maxContactsFromSameSource: 8,
32
+ blockOnLimitReached: true,
33
+ cooldownMs: TIME_CONSTANTS.MS_PER_HOUR,
34
+ riskConfig: {
35
+ firstContactPenalty: 40,
36
+ noReplyPenalty: 20,
37
+ noMutualGroupsPenalty: 15,
38
+ recentContactBonus: -20,
39
+ repliedBeforeBonus: -30,
40
+ delayThreshold: 40,
41
+ abortThreshold: 75,
42
+ },
43
+ };
44
+ const DEFAULT_RISK_CONFIG = {
45
+ firstContactPenalty: 40,
46
+ noReplyPenalty: 20,
47
+ noMutualGroupsPenalty: 15,
48
+ recentContactBonus: -20,
49
+ repliedBeforeBonus: -30,
50
+ delayThreshold: 40,
51
+ abortThreshold: 75,
52
+ };
53
+ class TopologyThrottler {
54
+ config;
55
+ riskConfig;
56
+ contacts = new Map();
57
+ limits;
58
+ sourceGroupCounts = new Map();
59
+ constructor(config) {
60
+ this.config = { ...DEFAULT_CONFIG, ...config };
61
+ this.riskConfig = { ...DEFAULT_RISK_CONFIG, ...this.config.riskConfig };
62
+ this.limits = {
63
+ newContactsThisHour: 0,
64
+ newContactsToday: 0,
65
+ lastHourResetAt: Date.now(),
66
+ lastDayResetAt: Date.now(),
67
+ };
68
+ }
69
+ /**
70
+ * Assess contact risk before sending.
71
+ * This is the main check — call before every send to a new/unknown contact.
72
+ */
73
+ assessContact(jid, context) {
74
+ this.resetLimitsIfNeeded();
75
+ const record = this.contacts.get(jid);
76
+ const now = Date.now();
77
+ let score = 0;
78
+ const reasons = [];
79
+ // First contact penalty
80
+ if (!record) {
81
+ score += this.riskConfig.firstContactPenalty;
82
+ reasons.push('first_contact');
83
+ }
84
+ else {
85
+ // Has record — check reply history
86
+ if (record.replyTimestamps.length === 0 && record.sendTimestamps.length > 0) {
87
+ score += this.riskConfig.noReplyPenalty;
88
+ reasons.push('no_reply_history');
89
+ }
90
+ }
91
+ // No mutual groups penalty
92
+ if (!context.knownGroups || context.knownGroups.length === 0) {
93
+ score += this.riskConfig.noMutualGroupsPenalty;
94
+ reasons.push('no_mutual_groups');
95
+ }
96
+ // Recent contact bonus
97
+ if (context.lastContactAt && (now - context.lastContactAt) < TIME_CONSTANTS.MS_PER_24H) {
98
+ score += this.riskConfig.recentContactBonus;
99
+ reasons.push('recent_contact');
100
+ }
101
+ // Replied before bonus
102
+ if (context.hasReplied || (record && record.replyTimestamps.length > 0)) {
103
+ score += this.riskConfig.repliedBeforeBonus;
104
+ reasons.push('has_replied');
105
+ }
106
+ // Clamp score to 0-100
107
+ score = Math.max(0, Math.min(100, score));
108
+ // Determine risk level
109
+ let risk;
110
+ let recommendation;
111
+ let suggestedDelayMs;
112
+ if (score >= this.riskConfig.abortThreshold) {
113
+ risk = 'CRITICAL';
114
+ recommendation = 'abort';
115
+ reasons.push('risk_too_high');
116
+ }
117
+ else if (score >= this.riskConfig.delayThreshold) {
118
+ risk = score >= 60 ? 'HIGH' : 'MEDIUM';
119
+ recommendation = 'delay';
120
+ // Exponential delay based on score
121
+ const delayMinutes = Math.floor((score - this.riskConfig.delayThreshold) / 10);
122
+ suggestedDelayMs = delayMinutes * 60000;
123
+ reasons.push('recommend_delay');
124
+ }
125
+ else {
126
+ risk = score >= 30 ? 'MEDIUM' : 'LOW';
127
+ recommendation = 'send';
128
+ }
129
+ return {
130
+ jid,
131
+ risk,
132
+ score,
133
+ reasons,
134
+ recommendation,
135
+ suggestedDelayMs,
136
+ };
137
+ }
138
+ /**
139
+ * Record a sent message to this contact.
140
+ */
141
+ recordSent(jid, sourceGroup) {
142
+ this.resetLimitsIfNeeded();
143
+ const now = Date.now();
144
+ let record = this.contacts.get(jid);
145
+ if (!record) {
146
+ // New contact — increment limits
147
+ record = {
148
+ firstContactAt: now,
149
+ sendTimestamps: [],
150
+ replyTimestamps: [],
151
+ blocked: false,
152
+ sourceGroup,
153
+ };
154
+ this.contacts.set(jid, record);
155
+ this.limits.newContactsThisHour++;
156
+ this.limits.newContactsToday++;
157
+ // Track source group
158
+ if (sourceGroup) {
159
+ const count = this.sourceGroupCounts.get(sourceGroup) || 0;
160
+ this.sourceGroupCounts.set(sourceGroup, count + 1);
161
+ }
162
+ }
163
+ // Add send timestamp (keep sliding window)
164
+ record.sendTimestamps.push(now);
165
+ this.cleanupTimestamps(record.sendTimestamps, TIME_CONSTANTS.REPLY_WINDOW_DAYS * TIME_CONSTANTS.MS_PER_DAY);
166
+ }
167
+ /**
168
+ * Record a reply from this contact.
169
+ */
170
+ recordReplied(jid) {
171
+ const record = this.contacts.get(jid);
172
+ if (!record)
173
+ return;
174
+ const now = Date.now();
175
+ record.replyTimestamps.push(now);
176
+ this.cleanupTimestamps(record.replyTimestamps, TIME_CONSTANTS.REPLY_WINDOW_DAYS * TIME_CONSTANTS.MS_PER_DAY);
177
+ }
178
+ /**
179
+ * Record that this contact blocked you.
180
+ */
181
+ recordBlocked(jid) {
182
+ const record = this.contacts.get(jid);
183
+ if (!record)
184
+ return;
185
+ record.blocked = true;
186
+ }
187
+ /**
188
+ * Check if topology limits allow sending to a new contact.
189
+ * Returns whether allowed and reason/retry time if blocked.
190
+ */
191
+ canSendToNewContact() {
192
+ this.resetLimitsIfNeeded();
193
+ const now = Date.now();
194
+ // Check cooldown
195
+ if (this.limits.limitHitAt) {
196
+ const cooldownEndsAt = this.limits.limitHitAt + this.config.cooldownMs;
197
+ if (now < cooldownEndsAt) {
198
+ const retryAfterMs = cooldownEndsAt - now;
199
+ return {
200
+ allowed: false,
201
+ reason: `Cooldown active — limit hit recently`,
202
+ retryAfterMs,
203
+ };
204
+ }
205
+ else {
206
+ // Cooldown expired
207
+ delete this.limits.limitHitAt;
208
+ }
209
+ }
210
+ // Check hourly limit
211
+ if (this.limits.newContactsThisHour >= this.config.maxNewContactsPerHour) {
212
+ this.limits.limitHitAt = now;
213
+ const retryAfterMs = (this.limits.lastHourResetAt + TIME_CONSTANTS.MS_PER_HOUR) - now;
214
+ return {
215
+ allowed: false,
216
+ reason: `Hourly new contact limit reached (${this.config.maxNewContactsPerHour})`,
217
+ retryAfterMs: Math.max(0, retryAfterMs),
218
+ };
219
+ }
220
+ // Check daily limit
221
+ if (this.limits.newContactsToday >= this.config.maxNewContactsPerDay) {
222
+ this.limits.limitHitAt = now;
223
+ const retryAfterMs = (this.limits.lastDayResetAt + TIME_CONSTANTS.MS_PER_DAY) - now;
224
+ return {
225
+ allowed: false,
226
+ reason: `Daily new contact limit reached (${this.config.maxNewContactsPerDay})`,
227
+ retryAfterMs: Math.max(0, retryAfterMs),
228
+ };
229
+ }
230
+ // Check reply ratio requirement
231
+ const replyRatio = this.calculateReplyRatio();
232
+ if (replyRatio !== null && replyRatio < this.config.minReplyRatioForNewContacts) {
233
+ // Poor reply ratio — need to improve engagement before more cold sends
234
+ return {
235
+ allowed: false,
236
+ reason: `Reply ratio too low (${Math.round(replyRatio * 100)}% < ${Math.round(this.config.minReplyRatioForNewContacts * 100)}%)`,
237
+ retryAfterMs: TIME_CONSTANTS.MS_PER_HOUR, // arbitrary — user needs to improve engagement
238
+ };
239
+ }
240
+ return { allowed: true };
241
+ }
242
+ /**
243
+ * Get topology statistics.
244
+ */
245
+ getTopologyStats() {
246
+ this.resetLimitsIfNeeded();
247
+ const replyRatio = this.calculateReplyRatio();
248
+ const blockedRatio = this.calculateBlockedRatio();
249
+ // Get top 5 source group hotspots
250
+ const hotspots = Array.from(this.sourceGroupCounts.entries())
251
+ .map(([sourceGroup, count]) => ({ sourceGroup, count }))
252
+ .sort((a, b) => b.count - a.count)
253
+ .slice(0, 5);
254
+ return {
255
+ newContactsThisHour: this.limits.newContactsThisHour,
256
+ newContactsToday: this.limits.newContactsToday,
257
+ replyRatio,
258
+ blockedRatio,
259
+ hotspots,
260
+ };
261
+ }
262
+ /**
263
+ * Export state for persistence.
264
+ */
265
+ exportState() {
266
+ return {
267
+ contacts: Array.from(this.contacts.entries()),
268
+ limits: { ...this.limits },
269
+ sourceGroupCounts: Array.from(this.sourceGroupCounts.entries()),
270
+ };
271
+ }
272
+ /**
273
+ * Import state from persistence.
274
+ */
275
+ importState(state) {
276
+ if (state.contacts) {
277
+ this.contacts = new Map(state.contacts);
278
+ }
279
+ if (state.limits) {
280
+ this.limits = { ...state.limits };
281
+ }
282
+ if (state.sourceGroupCounts) {
283
+ this.sourceGroupCounts = new Map(state.sourceGroupCounts);
284
+ }
285
+ }
286
+ // Private helpers
287
+ resetLimitsIfNeeded() {
288
+ const now = Date.now();
289
+ // Reset hourly counter
290
+ if (now - this.limits.lastHourResetAt >= TIME_CONSTANTS.MS_PER_HOUR) {
291
+ this.limits.newContactsThisHour = 0;
292
+ this.limits.lastHourResetAt = now;
293
+ }
294
+ // Reset daily counter
295
+ if (now - this.limits.lastDayResetAt >= TIME_CONSTANTS.MS_PER_DAY) {
296
+ this.limits.newContactsToday = 0;
297
+ this.limits.lastDayResetAt = now;
298
+ // Clear source group counts daily
299
+ this.sourceGroupCounts.clear();
300
+ }
301
+ }
302
+ calculateReplyRatio() {
303
+ const now = Date.now();
304
+ const windowMs = TIME_CONSTANTS.REPLY_WINDOW_DAYS * TIME_CONSTANTS.MS_PER_DAY;
305
+ let totalSent = 0;
306
+ let totalReplies = 0;
307
+ for (const record of this.contacts.values()) {
308
+ // Count sends in window
309
+ const recentSends = record.sendTimestamps.filter(t => now - t < windowMs);
310
+ totalSent += recentSends.length;
311
+ // Count replies in window
312
+ const recentReplies = record.replyTimestamps.filter(t => now - t < windowMs);
313
+ totalReplies += recentReplies.length;
314
+ }
315
+ if (totalSent === 0)
316
+ return null; // No data yet
317
+ return totalReplies / totalSent;
318
+ }
319
+ calculateBlockedRatio() {
320
+ const totalContacts = this.contacts.size;
321
+ if (totalContacts === 0)
322
+ return null;
323
+ let blockedCount = 0;
324
+ for (const record of this.contacts.values()) {
325
+ if (record.blocked)
326
+ blockedCount++;
327
+ }
328
+ return blockedCount / totalContacts;
329
+ }
330
+ cleanupTimestamps(timestamps, maxAgeMs) {
331
+ const now = Date.now();
332
+ const cutoff = now - maxAgeMs;
333
+ // Remove old timestamps (in-place filter)
334
+ let writeIdx = 0;
335
+ for (let readIdx = 0; readIdx < timestamps.length; readIdx++) {
336
+ if (timestamps[readIdx] >= cutoff) {
337
+ timestamps[writeIdx++] = timestamps[readIdx];
338
+ }
339
+ }
340
+ timestamps.length = writeIdx;
341
+ }
342
+ }
343
+ exports.TopologyThrottler = TopologyThrottler;
package/dist/index.d.ts CHANGED
@@ -48,4 +48,5 @@ export { createFleetEventStore, createMySQLEventStoreBackend, createInMemoryEven
48
48
  export { HumanEntropyService, createHumanEntropyService, type HumanEntropyConfig, type HumanEntropyStats } from './humanEntropy.js';
49
49
  export { MessageTypeRegistry, type MessageTypeDefinition, type MessageProvenance, type MessageTypeStats, type MessageTypeWarning, type MessageTypeRegistryState } from './messageTypeRegistry.js';
50
50
  export { exportAntibanState, importAntibanState, type AntibanSnapshot, type RateLimiterState, type TimelockGuardState, type CircuitState as CircuitStateExport, type DisconnectEvent } from './stateExport.js';
51
+ export { TopologyThrottler, type TopologyThrottlerConfig, type ContactRisk, type ContactRiskAssessment, type ContactRiskConfig, type TopologyThrottlerState } from './topologyThrottler.js';
51
52
  export { createConsoleLogger, exportPrometheusMetrics, createMetricsHandler, createPeriodicExporter, type AntiBanLogger, type PeriodicExporterConfig, type PeriodicExporterHandle, } from './observability.js';
package/dist/index.js CHANGED
@@ -70,5 +70,6 @@ export { HumanEntropyService, createHumanEntropyService } from './humanEntropy.j
70
70
  // v4.8 new modules
71
71
  export { MessageTypeRegistry } from './messageTypeRegistry.js';
72
72
  export { exportAntibanState, importAntibanState } from './stateExport.js';
73
+ export { TopologyThrottler } from './topologyThrottler.js';
73
74
  // Observability
74
75
  export { createConsoleLogger, exportPrometheusMetrics, createMetricsHandler, createPeriodicExporter, } from './observability.js';
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import type { WarmUpState } from './warmup.js';
14
14
  import type { MessageTypeRegistryState } from './messageTypeRegistry.js';
15
+ import type { TopologyThrottlerState } from './topologyThrottler.js';
15
16
  export interface DisconnectEvent {
16
17
  type: 'disconnect' | 'forbidden' | 'loggedOut' | 'messageFailed' | 'reconnect' | 'reachoutTimelocked';
17
18
  timestamp: number;
@@ -75,6 +76,8 @@ export interface AntibanSnapshot {
75
76
  timelockGuard?: TimelockGuardState;
76
77
  /** Message type registry state */
77
78
  messageRegistry?: MessageTypeRegistryState;
79
+ /** Topology throttler state */
80
+ topologyThrottler?: TopologyThrottlerState;
78
81
  /** Per-JID engagement scores */
79
82
  engagementScores?: Record<string, number>;
80
83
  }
@@ -113,6 +116,9 @@ export declare function exportAntibanState(modules: {
113
116
  messageRegistry?: {
114
117
  exportState: () => MessageTypeRegistryState;
115
118
  };
119
+ topologyThrottler?: {
120
+ exportState: () => TopologyThrottlerState;
121
+ };
116
122
  engagementScores?: Map<string, number>;
117
123
  instanceId?: string;
118
124
  }): AntibanSnapshot;
@@ -141,5 +147,8 @@ export declare function importAntibanState(snapshot: AntibanSnapshot, modules: {
141
147
  messageRegistry?: {
142
148
  importState: (state: MessageTypeRegistryState) => void;
143
149
  };
150
+ topologyThrottler?: {
151
+ importState: (state: TopologyThrottlerState) => void;
152
+ };
144
153
  engagementScores?: Map<string, number>;
145
154
  }): void;
@@ -67,6 +67,10 @@ export function exportAntibanState(modules) {
67
67
  if (modules.messageRegistry) {
68
68
  snapshot.messageRegistry = modules.messageRegistry.exportState();
69
69
  }
70
+ // Export topology throttler state
71
+ if (modules.topologyThrottler) {
72
+ snapshot.topologyThrottler = modules.topologyThrottler.exportState();
73
+ }
70
74
  // Export engagement scores
71
75
  if (modules.engagementScores) {
72
76
  snapshot.engagementScores = Object.fromEntries(modules.engagementScores.entries());
@@ -140,6 +144,10 @@ export function importAntibanState(snapshot, modules) {
140
144
  if (snapshot.messageRegistry && modules.messageRegistry) {
141
145
  modules.messageRegistry.importState(snapshot.messageRegistry);
142
146
  }
147
+ // Import topology throttler state
148
+ if (snapshot.topologyThrottler && modules.topologyThrottler) {
149
+ modules.topologyThrottler.importState(snapshot.topologyThrottler);
150
+ }
143
151
  // Import engagement scores
144
152
  if (snapshot.engagementScores && modules.engagementScores) {
145
153
  for (const [jid, score] of Object.entries(snapshot.engagementScores)) {
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Topology Throttler — Network topology-based anti-ban enforcement
3
+ *
4
+ * WhatsApp bans based on NETWORK TOPOLOGY, not just message timing:
5
+ * - How fast you expand your contact graph
6
+ * - Cold-contact ratio (strangers vs known contacts)
7
+ * - Reply reciprocity
8
+ * - Group-source clustering (mass-DMing group members)
9
+ *
10
+ * This module enforces graph expansion limits and scores contact risk
11
+ * before each send, acting as the primary enforcement layer for high-risk
12
+ * cold outreach.
13
+ *
14
+ * Key insight: A 30% reply rate is the minimum to unlock more cold sends.
15
+ * Below that, WhatsApp's ML models flag you as a spammer.
16
+ */
17
+ export type ContactRisk = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
18
+ export interface ContactRiskAssessment {
19
+ jid: string;
20
+ risk: ContactRisk;
21
+ score: number;
22
+ reasons: string[];
23
+ recommendation: 'send' | 'delay' | 'abort';
24
+ suggestedDelayMs?: number;
25
+ }
26
+ export interface ContactRiskConfig {
27
+ firstContactPenalty?: number;
28
+ noReplyPenalty?: number;
29
+ noMutualGroupsPenalty?: number;
30
+ recentContactBonus?: number;
31
+ repliedBeforeBonus?: number;
32
+ delayThreshold?: number;
33
+ abortThreshold?: number;
34
+ }
35
+ export interface TopologyThrottlerConfig {
36
+ maxNewContactsPerHour?: number;
37
+ maxNewContactsPerDay?: number;
38
+ minReplyRatioForNewContacts?: number;
39
+ maxSameGroupContacts?: number;
40
+ maxContactsFromSameSource?: number;
41
+ blockOnLimitReached?: boolean;
42
+ cooldownMs?: number;
43
+ riskConfig?: ContactRiskConfig;
44
+ }
45
+ interface ContactRecord {
46
+ firstContactAt: number;
47
+ sendTimestamps: number[];
48
+ replyTimestamps: number[];
49
+ blocked: boolean;
50
+ sourceGroup?: string;
51
+ }
52
+ interface TopologyLimits {
53
+ newContactsThisHour: number;
54
+ newContactsToday: number;
55
+ lastHourResetAt: number;
56
+ lastDayResetAt: number;
57
+ limitHitAt?: number;
58
+ }
59
+ export interface TopologyThrottlerState {
60
+ contacts: Array<[string, ContactRecord]>;
61
+ limits: TopologyLimits;
62
+ sourceGroupCounts: Array<[string, number]>;
63
+ }
64
+ export declare class TopologyThrottler {
65
+ private config;
66
+ private riskConfig;
67
+ private contacts;
68
+ private limits;
69
+ private sourceGroupCounts;
70
+ constructor(config?: TopologyThrottlerConfig);
71
+ /**
72
+ * Assess contact risk before sending.
73
+ * This is the main check — call before every send to a new/unknown contact.
74
+ */
75
+ assessContact(jid: string, context: {
76
+ messageType?: 'dm' | 'group' | 'broadcast';
77
+ sourceGroup?: string;
78
+ knownGroups?: string[];
79
+ hasReplied?: boolean;
80
+ lastContactAt?: number;
81
+ lastReplyAt?: number;
82
+ }): ContactRiskAssessment;
83
+ /**
84
+ * Record a sent message to this contact.
85
+ */
86
+ recordSent(jid: string, sourceGroup?: string): void;
87
+ /**
88
+ * Record a reply from this contact.
89
+ */
90
+ recordReplied(jid: string): void;
91
+ /**
92
+ * Record that this contact blocked you.
93
+ */
94
+ recordBlocked(jid: string): void;
95
+ /**
96
+ * Check if topology limits allow sending to a new contact.
97
+ * Returns whether allowed and reason/retry time if blocked.
98
+ */
99
+ canSendToNewContact(): {
100
+ allowed: boolean;
101
+ reason?: string;
102
+ retryAfterMs?: number;
103
+ };
104
+ /**
105
+ * Get topology statistics.
106
+ */
107
+ getTopologyStats(): {
108
+ newContactsThisHour: number;
109
+ newContactsToday: number;
110
+ replyRatio: number | null;
111
+ blockedRatio: number | null;
112
+ hotspots: Array<{
113
+ sourceGroup: string;
114
+ count: number;
115
+ }>;
116
+ };
117
+ /**
118
+ * Export state for persistence.
119
+ */
120
+ exportState(): TopologyThrottlerState;
121
+ /**
122
+ * Import state from persistence.
123
+ */
124
+ importState(state: TopologyThrottlerState): void;
125
+ private resetLimitsIfNeeded;
126
+ private calculateReplyRatio;
127
+ private calculateBlockedRatio;
128
+ private cleanupTimestamps;
129
+ }
130
+ export {};
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Topology Throttler — Network topology-based anti-ban enforcement
3
+ *
4
+ * WhatsApp bans based on NETWORK TOPOLOGY, not just message timing:
5
+ * - How fast you expand your contact graph
6
+ * - Cold-contact ratio (strangers vs known contacts)
7
+ * - Reply reciprocity
8
+ * - Group-source clustering (mass-DMing group members)
9
+ *
10
+ * This module enforces graph expansion limits and scores contact risk
11
+ * before each send, acting as the primary enforcement layer for high-risk
12
+ * cold outreach.
13
+ *
14
+ * Key insight: A 30% reply rate is the minimum to unlock more cold sends.
15
+ * Below that, WhatsApp's ML models flag you as a spammer.
16
+ */
17
+ const TIME_CONSTANTS = {
18
+ MS_PER_HOUR: 3600000,
19
+ MS_PER_DAY: 86400000,
20
+ MS_PER_24H: 86400000,
21
+ REPLY_WINDOW_DAYS: 7,
22
+ };
23
+ const DEFAULT_CONFIG = {
24
+ maxNewContactsPerHour: 5,
25
+ maxNewContactsPerDay: 20,
26
+ minReplyRatioForNewContacts: 0.3,
27
+ maxSameGroupContacts: 10,
28
+ maxContactsFromSameSource: 8,
29
+ blockOnLimitReached: true,
30
+ cooldownMs: TIME_CONSTANTS.MS_PER_HOUR,
31
+ riskConfig: {
32
+ firstContactPenalty: 40,
33
+ noReplyPenalty: 20,
34
+ noMutualGroupsPenalty: 15,
35
+ recentContactBonus: -20,
36
+ repliedBeforeBonus: -30,
37
+ delayThreshold: 40,
38
+ abortThreshold: 75,
39
+ },
40
+ };
41
+ const DEFAULT_RISK_CONFIG = {
42
+ firstContactPenalty: 40,
43
+ noReplyPenalty: 20,
44
+ noMutualGroupsPenalty: 15,
45
+ recentContactBonus: -20,
46
+ repliedBeforeBonus: -30,
47
+ delayThreshold: 40,
48
+ abortThreshold: 75,
49
+ };
50
+ export class TopologyThrottler {
51
+ config;
52
+ riskConfig;
53
+ contacts = new Map();
54
+ limits;
55
+ sourceGroupCounts = new Map();
56
+ constructor(config) {
57
+ this.config = { ...DEFAULT_CONFIG, ...config };
58
+ this.riskConfig = { ...DEFAULT_RISK_CONFIG, ...this.config.riskConfig };
59
+ this.limits = {
60
+ newContactsThisHour: 0,
61
+ newContactsToday: 0,
62
+ lastHourResetAt: Date.now(),
63
+ lastDayResetAt: Date.now(),
64
+ };
65
+ }
66
+ /**
67
+ * Assess contact risk before sending.
68
+ * This is the main check — call before every send to a new/unknown contact.
69
+ */
70
+ assessContact(jid, context) {
71
+ this.resetLimitsIfNeeded();
72
+ const record = this.contacts.get(jid);
73
+ const now = Date.now();
74
+ let score = 0;
75
+ const reasons = [];
76
+ // First contact penalty
77
+ if (!record) {
78
+ score += this.riskConfig.firstContactPenalty;
79
+ reasons.push('first_contact');
80
+ }
81
+ else {
82
+ // Has record — check reply history
83
+ if (record.replyTimestamps.length === 0 && record.sendTimestamps.length > 0) {
84
+ score += this.riskConfig.noReplyPenalty;
85
+ reasons.push('no_reply_history');
86
+ }
87
+ }
88
+ // No mutual groups penalty
89
+ if (!context.knownGroups || context.knownGroups.length === 0) {
90
+ score += this.riskConfig.noMutualGroupsPenalty;
91
+ reasons.push('no_mutual_groups');
92
+ }
93
+ // Recent contact bonus
94
+ if (context.lastContactAt && (now - context.lastContactAt) < TIME_CONSTANTS.MS_PER_24H) {
95
+ score += this.riskConfig.recentContactBonus;
96
+ reasons.push('recent_contact');
97
+ }
98
+ // Replied before bonus
99
+ if (context.hasReplied || (record && record.replyTimestamps.length > 0)) {
100
+ score += this.riskConfig.repliedBeforeBonus;
101
+ reasons.push('has_replied');
102
+ }
103
+ // Clamp score to 0-100
104
+ score = Math.max(0, Math.min(100, score));
105
+ // Determine risk level
106
+ let risk;
107
+ let recommendation;
108
+ let suggestedDelayMs;
109
+ if (score >= this.riskConfig.abortThreshold) {
110
+ risk = 'CRITICAL';
111
+ recommendation = 'abort';
112
+ reasons.push('risk_too_high');
113
+ }
114
+ else if (score >= this.riskConfig.delayThreshold) {
115
+ risk = score >= 60 ? 'HIGH' : 'MEDIUM';
116
+ recommendation = 'delay';
117
+ // Exponential delay based on score
118
+ const delayMinutes = Math.floor((score - this.riskConfig.delayThreshold) / 10);
119
+ suggestedDelayMs = delayMinutes * 60000;
120
+ reasons.push('recommend_delay');
121
+ }
122
+ else {
123
+ risk = score >= 30 ? 'MEDIUM' : 'LOW';
124
+ recommendation = 'send';
125
+ }
126
+ return {
127
+ jid,
128
+ risk,
129
+ score,
130
+ reasons,
131
+ recommendation,
132
+ suggestedDelayMs,
133
+ };
134
+ }
135
+ /**
136
+ * Record a sent message to this contact.
137
+ */
138
+ recordSent(jid, sourceGroup) {
139
+ this.resetLimitsIfNeeded();
140
+ const now = Date.now();
141
+ let record = this.contacts.get(jid);
142
+ if (!record) {
143
+ // New contact — increment limits
144
+ record = {
145
+ firstContactAt: now,
146
+ sendTimestamps: [],
147
+ replyTimestamps: [],
148
+ blocked: false,
149
+ sourceGroup,
150
+ };
151
+ this.contacts.set(jid, record);
152
+ this.limits.newContactsThisHour++;
153
+ this.limits.newContactsToday++;
154
+ // Track source group
155
+ if (sourceGroup) {
156
+ const count = this.sourceGroupCounts.get(sourceGroup) || 0;
157
+ this.sourceGroupCounts.set(sourceGroup, count + 1);
158
+ }
159
+ }
160
+ // Add send timestamp (keep sliding window)
161
+ record.sendTimestamps.push(now);
162
+ this.cleanupTimestamps(record.sendTimestamps, TIME_CONSTANTS.REPLY_WINDOW_DAYS * TIME_CONSTANTS.MS_PER_DAY);
163
+ }
164
+ /**
165
+ * Record a reply from this contact.
166
+ */
167
+ recordReplied(jid) {
168
+ const record = this.contacts.get(jid);
169
+ if (!record)
170
+ return;
171
+ const now = Date.now();
172
+ record.replyTimestamps.push(now);
173
+ this.cleanupTimestamps(record.replyTimestamps, TIME_CONSTANTS.REPLY_WINDOW_DAYS * TIME_CONSTANTS.MS_PER_DAY);
174
+ }
175
+ /**
176
+ * Record that this contact blocked you.
177
+ */
178
+ recordBlocked(jid) {
179
+ const record = this.contacts.get(jid);
180
+ if (!record)
181
+ return;
182
+ record.blocked = true;
183
+ }
184
+ /**
185
+ * Check if topology limits allow sending to a new contact.
186
+ * Returns whether allowed and reason/retry time if blocked.
187
+ */
188
+ canSendToNewContact() {
189
+ this.resetLimitsIfNeeded();
190
+ const now = Date.now();
191
+ // Check cooldown
192
+ if (this.limits.limitHitAt) {
193
+ const cooldownEndsAt = this.limits.limitHitAt + this.config.cooldownMs;
194
+ if (now < cooldownEndsAt) {
195
+ const retryAfterMs = cooldownEndsAt - now;
196
+ return {
197
+ allowed: false,
198
+ reason: `Cooldown active — limit hit recently`,
199
+ retryAfterMs,
200
+ };
201
+ }
202
+ else {
203
+ // Cooldown expired
204
+ delete this.limits.limitHitAt;
205
+ }
206
+ }
207
+ // Check hourly limit
208
+ if (this.limits.newContactsThisHour >= this.config.maxNewContactsPerHour) {
209
+ this.limits.limitHitAt = now;
210
+ const retryAfterMs = (this.limits.lastHourResetAt + TIME_CONSTANTS.MS_PER_HOUR) - now;
211
+ return {
212
+ allowed: false,
213
+ reason: `Hourly new contact limit reached (${this.config.maxNewContactsPerHour})`,
214
+ retryAfterMs: Math.max(0, retryAfterMs),
215
+ };
216
+ }
217
+ // Check daily limit
218
+ if (this.limits.newContactsToday >= this.config.maxNewContactsPerDay) {
219
+ this.limits.limitHitAt = now;
220
+ const retryAfterMs = (this.limits.lastDayResetAt + TIME_CONSTANTS.MS_PER_DAY) - now;
221
+ return {
222
+ allowed: false,
223
+ reason: `Daily new contact limit reached (${this.config.maxNewContactsPerDay})`,
224
+ retryAfterMs: Math.max(0, retryAfterMs),
225
+ };
226
+ }
227
+ // Check reply ratio requirement
228
+ const replyRatio = this.calculateReplyRatio();
229
+ if (replyRatio !== null && replyRatio < this.config.minReplyRatioForNewContacts) {
230
+ // Poor reply ratio — need to improve engagement before more cold sends
231
+ return {
232
+ allowed: false,
233
+ reason: `Reply ratio too low (${Math.round(replyRatio * 100)}% < ${Math.round(this.config.minReplyRatioForNewContacts * 100)}%)`,
234
+ retryAfterMs: TIME_CONSTANTS.MS_PER_HOUR, // arbitrary — user needs to improve engagement
235
+ };
236
+ }
237
+ return { allowed: true };
238
+ }
239
+ /**
240
+ * Get topology statistics.
241
+ */
242
+ getTopologyStats() {
243
+ this.resetLimitsIfNeeded();
244
+ const replyRatio = this.calculateReplyRatio();
245
+ const blockedRatio = this.calculateBlockedRatio();
246
+ // Get top 5 source group hotspots
247
+ const hotspots = Array.from(this.sourceGroupCounts.entries())
248
+ .map(([sourceGroup, count]) => ({ sourceGroup, count }))
249
+ .sort((a, b) => b.count - a.count)
250
+ .slice(0, 5);
251
+ return {
252
+ newContactsThisHour: this.limits.newContactsThisHour,
253
+ newContactsToday: this.limits.newContactsToday,
254
+ replyRatio,
255
+ blockedRatio,
256
+ hotspots,
257
+ };
258
+ }
259
+ /**
260
+ * Export state for persistence.
261
+ */
262
+ exportState() {
263
+ return {
264
+ contacts: Array.from(this.contacts.entries()),
265
+ limits: { ...this.limits },
266
+ sourceGroupCounts: Array.from(this.sourceGroupCounts.entries()),
267
+ };
268
+ }
269
+ /**
270
+ * Import state from persistence.
271
+ */
272
+ importState(state) {
273
+ if (state.contacts) {
274
+ this.contacts = new Map(state.contacts);
275
+ }
276
+ if (state.limits) {
277
+ this.limits = { ...state.limits };
278
+ }
279
+ if (state.sourceGroupCounts) {
280
+ this.sourceGroupCounts = new Map(state.sourceGroupCounts);
281
+ }
282
+ }
283
+ // Private helpers
284
+ resetLimitsIfNeeded() {
285
+ const now = Date.now();
286
+ // Reset hourly counter
287
+ if (now - this.limits.lastHourResetAt >= TIME_CONSTANTS.MS_PER_HOUR) {
288
+ this.limits.newContactsThisHour = 0;
289
+ this.limits.lastHourResetAt = now;
290
+ }
291
+ // Reset daily counter
292
+ if (now - this.limits.lastDayResetAt >= TIME_CONSTANTS.MS_PER_DAY) {
293
+ this.limits.newContactsToday = 0;
294
+ this.limits.lastDayResetAt = now;
295
+ // Clear source group counts daily
296
+ this.sourceGroupCounts.clear();
297
+ }
298
+ }
299
+ calculateReplyRatio() {
300
+ const now = Date.now();
301
+ const windowMs = TIME_CONSTANTS.REPLY_WINDOW_DAYS * TIME_CONSTANTS.MS_PER_DAY;
302
+ let totalSent = 0;
303
+ let totalReplies = 0;
304
+ for (const record of this.contacts.values()) {
305
+ // Count sends in window
306
+ const recentSends = record.sendTimestamps.filter(t => now - t < windowMs);
307
+ totalSent += recentSends.length;
308
+ // Count replies in window
309
+ const recentReplies = record.replyTimestamps.filter(t => now - t < windowMs);
310
+ totalReplies += recentReplies.length;
311
+ }
312
+ if (totalSent === 0)
313
+ return null; // No data yet
314
+ return totalReplies / totalSent;
315
+ }
316
+ calculateBlockedRatio() {
317
+ const totalContacts = this.contacts.size;
318
+ if (totalContacts === 0)
319
+ return null;
320
+ let blockedCount = 0;
321
+ for (const record of this.contacts.values()) {
322
+ if (record.blocked)
323
+ blockedCount++;
324
+ }
325
+ return blockedCount / totalContacts;
326
+ }
327
+ cleanupTimestamps(timestamps, maxAgeMs) {
328
+ const now = Date.now();
329
+ const cutoff = now - maxAgeMs;
330
+ // Remove old timestamps (in-place filter)
331
+ let writeIdx = 0;
332
+ for (let readIdx = 0; readIdx < timestamps.length; readIdx++) {
333
+ if (timestamps[readIdx] >= cutoff) {
334
+ timestamps[writeIdx++] = timestamps[readIdx];
335
+ }
336
+ }
337
+ timestamps.length = writeIdx;
338
+ }
339
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baileys-antiban",
3
- "version": "4.8.0",
3
+ "version": "4.9.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/cjs/index.js",
6
6
  "types": "dist/index.d.ts",