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 +18 -0
- package/dist/antiban.js +85 -0
- package/dist/cjs/antiban.js +85 -0
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/stateExport.js +8 -0
- package/dist/cjs/topologyThrottler.js +343 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/stateExport.d.ts +9 -0
- package/dist/stateExport.js +8 -0
- package/dist/topologyThrottler.d.ts +130 -0
- package/dist/topologyThrottler.js +339 -0
- package/package.json +1 -1
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
|
/**
|
package/dist/cjs/antiban.js
CHANGED
|
@@ -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; } });
|
package/dist/cjs/stateExport.js
CHANGED
|
@@ -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';
|
package/dist/stateExport.d.ts
CHANGED
|
@@ -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;
|
package/dist/stateExport.js
CHANGED
|
@@ -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.
|
|
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",
|