@prototypevip/baileys 0.0.5 → 0.0.7
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/lib/AntiBan/antiban.js +922 -0
- package/lib/AntiBan/banRecoveryOrchestrator.js +284 -0
- package/lib/AntiBan/contactGraph.js +236 -0
- package/lib/AntiBan/contentVariator.js +150 -0
- package/lib/AntiBan/credsSnapshot.js +121 -0
- package/lib/AntiBan/deliveryTracker.js +107 -0
- package/lib/AntiBan/deviceFingerprint.js +106 -0
- package/lib/AntiBan/fleetEventStore.js +157 -0
- package/lib/AntiBan/groupOperationGuard.js +116 -0
- package/lib/AntiBan/health.js +207 -0
- package/lib/AntiBan/humanEntropy.js +337 -0
- package/lib/AntiBan/index.js +77 -0
- package/lib/AntiBan/instanceCoordinator.js +175 -0
- package/lib/AntiBan/jidCanonicalizer.js +256 -0
- package/lib/AntiBan/jidCircuitBreaker.js +186 -0
- package/lib/AntiBan/legitimacySignalInjector.js +231 -0
- package/lib/AntiBan/lidFirstResolver.js +174 -0
- package/lib/AntiBan/lidResolver.js +324 -0
- package/lib/AntiBan/messageQueue.js +187 -0
- package/lib/AntiBan/messageRecovery.js +283 -0
- package/lib/AntiBan/messageTypeRegistry.js +369 -0
- package/lib/AntiBan/observability.js +145 -0
- package/lib/AntiBan/patch.js +302 -0
- package/lib/AntiBan/persist.js +87 -0
- package/lib/AntiBan/presenceChoreographer.js +430 -0
- package/lib/AntiBan/presets.js +105 -0
- package/lib/AntiBan/profiles.js +31 -0
- package/lib/AntiBan/proxyRotator.js +306 -0
- package/lib/AntiBan/rateLimiter.js +261 -0
- package/lib/AntiBan/readReceiptVariance.js +88 -0
- package/lib/AntiBan/reconnectThrottle.js +180 -0
- package/lib/AntiBan/replyRatio.js +161 -0
- package/lib/AntiBan/reputationVoucher.js +373 -0
- package/lib/AntiBan/retryReason.js +91 -0
- package/lib/AntiBan/retryTracker.js +172 -0
- package/lib/AntiBan/scheduler.js +111 -0
- package/lib/AntiBan/sessionFingerprint.js +248 -0
- package/lib/AntiBan/sessionStability.js +330 -0
- package/lib/AntiBan/stateAdapter.js +73 -0
- package/lib/AntiBan/stateExport.js +152 -0
- package/lib/AntiBan/stealthConnect.js +130 -0
- package/lib/AntiBan/timelockGuard.js +181 -0
- package/lib/AntiBan/topologyThrottler.js +339 -0
- package/lib/AntiBan/warmup.js +140 -0
- package/lib/AntiBan/webhooks.js +80 -0
- package/lib/AntiBan/wrapper.js +456 -0
- package/lib/Socket/chats.js +29 -4
- package/lib/Socket/groups.js +7 -2
- package/lib/Socket/index.js +68 -3
- package/lib/Socket/messages-send.js +44 -8
- package/lib/Socket/socket.js +2 -2
- package/lib/Store/prototype-store.js +10 -39
- package/lib/index.js +5 -0
- package/package.json +7 -3
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BanRecoveryOrchestrator — Structured recovery after ban/restriction events
|
|
3
|
+
*
|
|
4
|
+
* When WhatsApp restricts your account, the worst thing to do is immediately
|
|
5
|
+
* resume normal activity. This module provides a evidence-based recovery protocol:
|
|
6
|
+
*
|
|
7
|
+
* - Timelocked (reachout_restricted): 24h pause, resume at 10% rate, ramp 15%/week
|
|
8
|
+
* - Rate-overlimit (429): 4h pause, resume at 25% rate, ramp 25%/week
|
|
9
|
+
* - Soft-ban (repeated disconnects): 48h pause, resume at 5% rate, ramp 10%/week
|
|
10
|
+
* - Hard-ban (loggedOut): account is dead, signal for replacement
|
|
11
|
+
*
|
|
12
|
+
* Based on observed recovery times from community reports. Not guaranteed —
|
|
13
|
+
* WA's enforcement is non-deterministic. Treat as best-effort guidance.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_PLANS = {
|
|
16
|
+
timelock: {
|
|
17
|
+
eventType: 'timelock',
|
|
18
|
+
pauseDurationMs: 24 * 60 * 60 * 1000, // 24 hours
|
|
19
|
+
resumeRateMultiplier: 0.10, // 10% of normal
|
|
20
|
+
weeklyRampPercent: 15, // +15% per week
|
|
21
|
+
estimatedRecoveryDays: 14,
|
|
22
|
+
description: 'WA reachout timelock — 24h pause then slow ramp',
|
|
23
|
+
},
|
|
24
|
+
rate_overlimit: {
|
|
25
|
+
eventType: 'rate_overlimit',
|
|
26
|
+
pauseDurationMs: 4 * 60 * 60 * 1000, // 4 hours
|
|
27
|
+
resumeRateMultiplier: 0.25, // 25% of normal
|
|
28
|
+
weeklyRampPercent: 25, // +25% per week
|
|
29
|
+
estimatedRecoveryDays: 7,
|
|
30
|
+
description: 'Rate limit hit — 4h pause then moderate ramp',
|
|
31
|
+
},
|
|
32
|
+
soft_ban: {
|
|
33
|
+
eventType: 'soft_ban',
|
|
34
|
+
pauseDurationMs: 48 * 60 * 60 * 1000, // 48 hours
|
|
35
|
+
resumeRateMultiplier: 0.05, // 5% of normal
|
|
36
|
+
weeklyRampPercent: 10, // +10% per week
|
|
37
|
+
estimatedRecoveryDays: 21,
|
|
38
|
+
description: 'Soft ban detected — 48h pause then very slow ramp',
|
|
39
|
+
},
|
|
40
|
+
hard_ban: {
|
|
41
|
+
eventType: 'hard_ban',
|
|
42
|
+
pauseDurationMs: Infinity,
|
|
43
|
+
resumeRateMultiplier: 0,
|
|
44
|
+
weeklyRampPercent: 0,
|
|
45
|
+
estimatedRecoveryDays: Infinity,
|
|
46
|
+
description: 'Hard ban — number is dead, replace SIM',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
export class BanRecoveryOrchestrator {
|
|
50
|
+
config;
|
|
51
|
+
state;
|
|
52
|
+
plans;
|
|
53
|
+
constructor(config = {}) {
|
|
54
|
+
this.config = {
|
|
55
|
+
plans: config.plans || {},
|
|
56
|
+
onPhaseChange: config.onPhaseChange || (() => { }),
|
|
57
|
+
onHardBan: config.onHardBan || (() => { }),
|
|
58
|
+
maxRecoveryWeeks: config.maxRecoveryWeeks ?? 8,
|
|
59
|
+
};
|
|
60
|
+
// Merge custom plans with defaults
|
|
61
|
+
this.plans = { ...DEFAULT_PLANS };
|
|
62
|
+
if (config.plans) {
|
|
63
|
+
for (const eventType in config.plans) {
|
|
64
|
+
const customPlan = config.plans[eventType];
|
|
65
|
+
if (customPlan) {
|
|
66
|
+
this.plans[eventType] = {
|
|
67
|
+
...this.plans[eventType],
|
|
68
|
+
...customPlan,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
this.state = {
|
|
74
|
+
active: false,
|
|
75
|
+
phase: 'graduated',
|
|
76
|
+
currentRateMultiplier: 1.0,
|
|
77
|
+
weeksSinceResume: 0,
|
|
78
|
+
banCount30d: 0,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Record a ban event and start recovery protocol
|
|
83
|
+
*/
|
|
84
|
+
recordBanEvent(eventType) {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
const plan = this.plans[eventType];
|
|
87
|
+
// Update ban count (reset if >30 days since last ban)
|
|
88
|
+
if (this.state.lastBanAt) {
|
|
89
|
+
const daysSinceLastBan = (now - this.state.lastBanAt) / (86400000);
|
|
90
|
+
if (daysSinceLastBan > 30) {
|
|
91
|
+
this.state.banCount30d = 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
this.state.banCount30d++;
|
|
95
|
+
this.state.lastBanAt = now;
|
|
96
|
+
// Upgrade to hard_ban if 3+ bans in 30 days (unless already hard_ban)
|
|
97
|
+
if (this.state.banCount30d >= 3 && eventType !== 'hard_ban') {
|
|
98
|
+
eventType = 'hard_ban';
|
|
99
|
+
}
|
|
100
|
+
this.state.active = true;
|
|
101
|
+
this.state.eventType = eventType;
|
|
102
|
+
this.state.banDetectedAt = now;
|
|
103
|
+
this.state.pauseUntil = plan.pauseDurationMs === Infinity ? Infinity : now + plan.pauseDurationMs;
|
|
104
|
+
this.state.currentRateMultiplier = plan.resumeRateMultiplier;
|
|
105
|
+
this.state.weeksSinceResume = 0;
|
|
106
|
+
this.state.phase = eventType === 'hard_ban' ? 'dead' : 'paused';
|
|
107
|
+
this.config.onPhaseChange(this.state.phase, plan);
|
|
108
|
+
if (eventType === 'hard_ban') {
|
|
109
|
+
this.config.onHardBan();
|
|
110
|
+
}
|
|
111
|
+
return this.getStatus();
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Get current recovery status (call before sending to check rate)
|
|
115
|
+
*/
|
|
116
|
+
getStatus() {
|
|
117
|
+
if (!this.state.active || !this.state.eventType) {
|
|
118
|
+
return {
|
|
119
|
+
phase: 'graduated',
|
|
120
|
+
rateMultiplier: 1.0,
|
|
121
|
+
recommendation: 'No active recovery — operating normally',
|
|
122
|
+
shouldReplaceNumber: false,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
const plan = this.plans[this.state.eventType];
|
|
127
|
+
// Check if pause period has ended
|
|
128
|
+
if (this.state.phase === 'paused' && this.state.pauseUntil !== undefined && this.state.pauseUntil !== Infinity && now >= this.state.pauseUntil) {
|
|
129
|
+
this.state.phase = 'recovering';
|
|
130
|
+
this.config.onPhaseChange(this.state.phase, plan);
|
|
131
|
+
}
|
|
132
|
+
// Check if we've exceeded max recovery weeks (give up)
|
|
133
|
+
if (this.state.phase === 'recovering' || this.state.phase === 'ramping') {
|
|
134
|
+
if (this.state.weeksSinceResume >= this.config.maxRecoveryWeeks) {
|
|
135
|
+
this.state.phase = 'dead';
|
|
136
|
+
this.config.onPhaseChange(this.state.phase, plan);
|
|
137
|
+
this.config.onHardBan();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const shouldReplaceNumber = this.state.phase === 'dead' || this.state.banCount30d >= 3;
|
|
141
|
+
let recommendation;
|
|
142
|
+
switch (this.state.phase) {
|
|
143
|
+
case 'dead':
|
|
144
|
+
recommendation = 'Account is permanently restricted. Replace number and start fresh.';
|
|
145
|
+
break;
|
|
146
|
+
case 'paused':
|
|
147
|
+
if (this.state.pauseUntil === Infinity) {
|
|
148
|
+
recommendation = 'Account is dead. Do not attempt to send messages.';
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
const remainingMs = this.state.pauseUntil - now;
|
|
152
|
+
const remainingHours = Math.ceil(remainingMs / 3600000);
|
|
153
|
+
recommendation = `Pause period active. Wait ${remainingHours}h before resuming. ${plan.description}`;
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
case 'recovering':
|
|
157
|
+
recommendation = `Recovery phase. Operating at ${Math.round(this.state.currentRateMultiplier * 100)}% capacity. Ramp: ${plan.weeklyRampPercent}%/week.`;
|
|
158
|
+
break;
|
|
159
|
+
case 'ramping':
|
|
160
|
+
recommendation = `Ramping phase. Operating at ${Math.round(this.state.currentRateMultiplier * 100)}% capacity. Ramp: ${plan.weeklyRampPercent}%/week.`;
|
|
161
|
+
break;
|
|
162
|
+
case 'graduated':
|
|
163
|
+
recommendation = 'Recovery complete. Operating at full capacity.';
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
const pauseRemainingMs = this.state.pauseUntil !== undefined && this.state.pauseUntil !== Infinity && now < this.state.pauseUntil
|
|
167
|
+
? this.state.pauseUntil - now
|
|
168
|
+
: undefined;
|
|
169
|
+
let estimatedFullRecoveryDate;
|
|
170
|
+
if (this.state.phase === 'recovering' || this.state.phase === 'ramping') {
|
|
171
|
+
const rateToGain = 1.0 - this.state.currentRateMultiplier;
|
|
172
|
+
const weeksNeeded = Math.ceil((rateToGain / (plan.weeklyRampPercent / 100)) * (1 / this.state.currentRateMultiplier));
|
|
173
|
+
estimatedFullRecoveryDate = now + weeksNeeded * 7 * 86400000;
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
phase: this.state.phase,
|
|
177
|
+
rateMultiplier: this.state.currentRateMultiplier,
|
|
178
|
+
pauseRemainingMs,
|
|
179
|
+
estimatedFullRecoveryDate,
|
|
180
|
+
recommendation,
|
|
181
|
+
shouldReplaceNumber,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Current rate multiplier — multiply your normal limits by this
|
|
186
|
+
*/
|
|
187
|
+
getRateMultiplier() {
|
|
188
|
+
return this.state.currentRateMultiplier;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Should be called daily/weekly to advance the ramp
|
|
192
|
+
*/
|
|
193
|
+
tick() {
|
|
194
|
+
if (!this.state.active || !this.state.eventType) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
const plan = this.plans[this.state.eventType];
|
|
199
|
+
// Transition from paused to recovering
|
|
200
|
+
if (this.state.phase === 'paused' && this.state.pauseUntil !== undefined && this.state.pauseUntil !== Infinity && now >= this.state.pauseUntil) {
|
|
201
|
+
this.state.phase = 'recovering';
|
|
202
|
+
this.config.onPhaseChange(this.state.phase, plan);
|
|
203
|
+
}
|
|
204
|
+
// Advance the ramp if in recovering/ramping phase
|
|
205
|
+
if (this.state.phase === 'recovering' || this.state.phase === 'ramping') {
|
|
206
|
+
this.state.weeksSinceResume++;
|
|
207
|
+
// Check if exceeded max recovery weeks
|
|
208
|
+
if (this.state.weeksSinceResume >= this.config.maxRecoveryWeeks) {
|
|
209
|
+
this.state.phase = 'dead';
|
|
210
|
+
this.state.currentRateMultiplier = 0;
|
|
211
|
+
this.config.onPhaseChange(this.state.phase, plan);
|
|
212
|
+
this.config.onHardBan();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Apply weekly ramp
|
|
216
|
+
const rampMultiplier = 1 + (plan.weeklyRampPercent / 100);
|
|
217
|
+
const newMultiplier = this.state.currentRateMultiplier * rampMultiplier;
|
|
218
|
+
if (newMultiplier >= 1.0) {
|
|
219
|
+
// Graduated
|
|
220
|
+
this.state.currentRateMultiplier = 1.0;
|
|
221
|
+
this.state.phase = 'graduated';
|
|
222
|
+
this.state.active = false;
|
|
223
|
+
this.config.onPhaseChange(this.state.phase, plan);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// Still ramping
|
|
227
|
+
this.state.currentRateMultiplier = newMultiplier;
|
|
228
|
+
this.state.phase = 'ramping';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Classify a raw error into a BanEventType
|
|
234
|
+
*/
|
|
235
|
+
static classifyError(err) {
|
|
236
|
+
if (!err)
|
|
237
|
+
return null;
|
|
238
|
+
const errorStr = String(err).toLowerCase();
|
|
239
|
+
const errorMsg = err.message?.toLowerCase() || '';
|
|
240
|
+
const errorCode = err.code || '';
|
|
241
|
+
// Timelock patterns
|
|
242
|
+
if (errorStr.includes('reachout') ||
|
|
243
|
+
errorStr.includes('account_reachout_restricted') ||
|
|
244
|
+
errorMsg.includes('reachout') ||
|
|
245
|
+
errorCode === 463 ||
|
|
246
|
+
errorCode === '463') {
|
|
247
|
+
return 'timelock';
|
|
248
|
+
}
|
|
249
|
+
// Rate overlimit patterns
|
|
250
|
+
if (errorStr.includes('rate-overlimit') ||
|
|
251
|
+
errorStr.includes('rate_overlimit') ||
|
|
252
|
+
errorStr.includes('429') ||
|
|
253
|
+
errorCode === 429 ||
|
|
254
|
+
errorCode === '429') {
|
|
255
|
+
return 'rate_overlimit';
|
|
256
|
+
}
|
|
257
|
+
// Hard ban patterns
|
|
258
|
+
if (errorStr.includes('loggedout') ||
|
|
259
|
+
errorStr.includes('logged_out') ||
|
|
260
|
+
errorStr.includes('logged out') ||
|
|
261
|
+
errorMsg.includes('loggedout') ||
|
|
262
|
+
errorCode === 401 ||
|
|
263
|
+
errorCode === '401') {
|
|
264
|
+
return 'hard_ban';
|
|
265
|
+
}
|
|
266
|
+
// Note: soft_ban detection requires context (multiple disconnects)
|
|
267
|
+
// and should be determined by the caller using HealthMonitor
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Serializable state for persistence
|
|
272
|
+
*/
|
|
273
|
+
getState() {
|
|
274
|
+
return { ...this.state };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Restore from persisted state
|
|
278
|
+
*/
|
|
279
|
+
static fromState(state, config) {
|
|
280
|
+
const orchestrator = new BanRecoveryOrchestrator(config);
|
|
281
|
+
orchestrator.state = { ...state };
|
|
282
|
+
return orchestrator;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contact Graph Warmer — Requires 1:1 handshake before group/bulk sends
|
|
3
|
+
*
|
|
4
|
+
* WhatsApp's ML models weight "social graph distance" heavily. Accounts that
|
|
5
|
+
* message strangers (contacts who never replied) have higher ban risk.
|
|
6
|
+
*
|
|
7
|
+
* This module:
|
|
8
|
+
* - Tracks contact state: stranger → handshake_sent → handshake_complete → known
|
|
9
|
+
* - Blocks sends to strangers unless handshake completed
|
|
10
|
+
* - Enforces group lurk period (don't send immediately after joining)
|
|
11
|
+
* - Caps daily new-contact messaging (prevent spray-and-pray patterns)
|
|
12
|
+
* - Auto-registers inbound senders as "known contacts"
|
|
13
|
+
*
|
|
14
|
+
* Research: 2025 ban waves correlated with accounts joining groups + spamming
|
|
15
|
+
* instantly. 12-24h lurk period significantly reduced bans.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_CONFIG = {
|
|
18
|
+
enabled: false,
|
|
19
|
+
requireHandshakeBeforeGroupSend: true,
|
|
20
|
+
handshakeMinDelayMs: 3600000, // 1 hour
|
|
21
|
+
groupLurkPeriodMs: 43200000, // 12 hours
|
|
22
|
+
maxStrangerMessagesPerDay: 5,
|
|
23
|
+
autoRegisterOnIncoming: true,
|
|
24
|
+
};
|
|
25
|
+
export class ContactGraphWarmer {
|
|
26
|
+
config;
|
|
27
|
+
contacts = new Map();
|
|
28
|
+
groups = new Map();
|
|
29
|
+
strangerMessagesToday = 0;
|
|
30
|
+
lastStrangerResetDay = this.getCurrentDay();
|
|
31
|
+
constructor(config = {}) {
|
|
32
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Check if message can be sent to this contact/group.
|
|
36
|
+
* Returns { allowed: false, needsHandshake: true } if handshake required.
|
|
37
|
+
*/
|
|
38
|
+
canMessage(jid) {
|
|
39
|
+
if (!this.config.enabled) {
|
|
40
|
+
return { allowed: true };
|
|
41
|
+
}
|
|
42
|
+
// Reset daily stranger counter at UTC midnight
|
|
43
|
+
const currentDay = this.getCurrentDay();
|
|
44
|
+
if (currentDay !== this.lastStrangerResetDay) {
|
|
45
|
+
this.strangerMessagesToday = 0;
|
|
46
|
+
this.lastStrangerResetDay = currentDay;
|
|
47
|
+
}
|
|
48
|
+
// Handle groups
|
|
49
|
+
if (this.isGroup(jid)) {
|
|
50
|
+
return this.checkGroupMessage(jid);
|
|
51
|
+
}
|
|
52
|
+
// Handle individual contacts
|
|
53
|
+
return this.checkIndividualMessage(jid);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Mark handshake as sent to this contact.
|
|
57
|
+
*/
|
|
58
|
+
markHandshakeSent(jid) {
|
|
59
|
+
if (!this.config.enabled)
|
|
60
|
+
return;
|
|
61
|
+
if (this.isGroup(jid))
|
|
62
|
+
return;
|
|
63
|
+
const record = this.contacts.get(jid) || { state: 'stranger' };
|
|
64
|
+
record.state = 'handshake_sent';
|
|
65
|
+
record.handshakeSentAt = Date.now();
|
|
66
|
+
this.contacts.set(jid, record);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Mark handshake as complete with this contact.
|
|
70
|
+
*/
|
|
71
|
+
markHandshakeComplete(jid) {
|
|
72
|
+
if (!this.config.enabled)
|
|
73
|
+
return;
|
|
74
|
+
if (this.isGroup(jid))
|
|
75
|
+
return;
|
|
76
|
+
const record = this.contacts.get(jid) || { state: 'stranger' };
|
|
77
|
+
record.state = 'handshake_complete';
|
|
78
|
+
this.contacts.set(jid, record);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Register a contact as known (skip handshake requirement).
|
|
82
|
+
*/
|
|
83
|
+
registerKnownContact(jid) {
|
|
84
|
+
if (!this.config.enabled)
|
|
85
|
+
return;
|
|
86
|
+
if (this.isGroup(jid))
|
|
87
|
+
return;
|
|
88
|
+
const record = this.contacts.get(jid) || { state: 'stranger' };
|
|
89
|
+
record.state = 'known';
|
|
90
|
+
this.contacts.set(jid, record);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Register a group join event.
|
|
94
|
+
*/
|
|
95
|
+
registerGroupJoin(groupJid) {
|
|
96
|
+
if (!this.config.enabled)
|
|
97
|
+
return;
|
|
98
|
+
if (!this.isGroup(groupJid))
|
|
99
|
+
return;
|
|
100
|
+
this.groups.set(groupJid, { joinedAt: Date.now() });
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Get contact state.
|
|
104
|
+
*/
|
|
105
|
+
getContactState(jid) {
|
|
106
|
+
if (this.isGroup(jid))
|
|
107
|
+
return 'known'; // Groups don't have handshake states
|
|
108
|
+
return this.contacts.get(jid)?.state || 'stranger';
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Handle incoming message — auto-register if enabled.
|
|
112
|
+
*/
|
|
113
|
+
onIncomingMessage(jid) {
|
|
114
|
+
if (!this.config.enabled)
|
|
115
|
+
return;
|
|
116
|
+
if (this.isGroup(jid))
|
|
117
|
+
return;
|
|
118
|
+
if (this.config.autoRegisterOnIncoming) {
|
|
119
|
+
this.registerKnownContact(jid);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Get statistics.
|
|
124
|
+
*/
|
|
125
|
+
getStats() {
|
|
126
|
+
const knownContacts = Array.from(this.contacts.values()).filter(c => c.state === 'known').length;
|
|
127
|
+
const pendingHandshakes = Array.from(this.contacts.values()).filter(c => c.state === 'handshake_sent').length;
|
|
128
|
+
const groupsJoined = Array.from(this.groups.entries()).map(([groupJid, record]) => ({
|
|
129
|
+
groupJid,
|
|
130
|
+
joinedAt: record.joinedAt,
|
|
131
|
+
firstSendUnlocksAt: record.joinedAt + this.config.groupLurkPeriodMs,
|
|
132
|
+
}));
|
|
133
|
+
return {
|
|
134
|
+
knownContacts,
|
|
135
|
+
pendingHandshakes,
|
|
136
|
+
strangersToday: this.strangerMessagesToday,
|
|
137
|
+
groupsJoined,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Reset all state.
|
|
142
|
+
*/
|
|
143
|
+
reset() {
|
|
144
|
+
this.contacts.clear();
|
|
145
|
+
this.groups.clear();
|
|
146
|
+
this.strangerMessagesToday = 0;
|
|
147
|
+
this.lastStrangerResetDay = this.getCurrentDay();
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Export state for persistence.
|
|
151
|
+
*/
|
|
152
|
+
exportState() {
|
|
153
|
+
return {
|
|
154
|
+
contacts: Array.from(this.contacts.entries()),
|
|
155
|
+
groups: Array.from(this.groups.entries()),
|
|
156
|
+
strangerMessagesToday: this.strangerMessagesToday,
|
|
157
|
+
lastStrangerResetDay: this.lastStrangerResetDay,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Restore state from persistence.
|
|
162
|
+
*/
|
|
163
|
+
restoreState(state) {
|
|
164
|
+
if (state?.contacts && Array.isArray(state.contacts)) {
|
|
165
|
+
this.contacts = new Map(state.contacts);
|
|
166
|
+
}
|
|
167
|
+
if (state?.groups && Array.isArray(state.groups)) {
|
|
168
|
+
this.groups = new Map(state.groups);
|
|
169
|
+
}
|
|
170
|
+
if (typeof state?.strangerMessagesToday === 'number') {
|
|
171
|
+
this.strangerMessagesToday = state.strangerMessagesToday;
|
|
172
|
+
}
|
|
173
|
+
if (typeof state?.lastStrangerResetDay === 'number') {
|
|
174
|
+
this.lastStrangerResetDay = state.lastStrangerResetDay;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// Private helpers
|
|
178
|
+
isGroup(jid) {
|
|
179
|
+
return jid.endsWith('@g.us');
|
|
180
|
+
}
|
|
181
|
+
getCurrentDay() {
|
|
182
|
+
return Math.floor(Date.now() / 86400000);
|
|
183
|
+
}
|
|
184
|
+
checkGroupMessage(groupJid) {
|
|
185
|
+
const record = this.groups.get(groupJid);
|
|
186
|
+
if (!record) {
|
|
187
|
+
// Group not registered — allow (assume old membership)
|
|
188
|
+
return { allowed: true };
|
|
189
|
+
}
|
|
190
|
+
const lurkEndsAt = record.joinedAt + this.config.groupLurkPeriodMs;
|
|
191
|
+
if (Date.now() < lurkEndsAt) {
|
|
192
|
+
const minutesLeft = Math.ceil((lurkEndsAt - Date.now()) / 60000);
|
|
193
|
+
return {
|
|
194
|
+
allowed: false,
|
|
195
|
+
reason: `Group lurk period not elapsed — wait ${minutesLeft} minutes`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return { allowed: true };
|
|
199
|
+
}
|
|
200
|
+
checkIndividualMessage(jid) {
|
|
201
|
+
const record = this.contacts.get(jid);
|
|
202
|
+
// Unknown contact (stranger)
|
|
203
|
+
if (!record || record.state === 'stranger') {
|
|
204
|
+
if (this.config.requireHandshakeBeforeGroupSend) {
|
|
205
|
+
// Check daily stranger quota
|
|
206
|
+
if (this.strangerMessagesToday >= this.config.maxStrangerMessagesPerDay) {
|
|
207
|
+
return {
|
|
208
|
+
allowed: false,
|
|
209
|
+
reason: `Daily new-contact limit reached (${this.config.maxStrangerMessagesPerDay})`,
|
|
210
|
+
needsHandshake: true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// Allow but increment counter
|
|
214
|
+
this.strangerMessagesToday++;
|
|
215
|
+
}
|
|
216
|
+
return { allowed: true, needsHandshake: true };
|
|
217
|
+
}
|
|
218
|
+
// Handshake sent — check delay
|
|
219
|
+
if (record.state === 'handshake_sent') {
|
|
220
|
+
if (!record.handshakeSentAt) {
|
|
221
|
+
// No timestamp — shouldn't happen, but allow
|
|
222
|
+
return { allowed: true };
|
|
223
|
+
}
|
|
224
|
+
const elapsed = Date.now() - record.handshakeSentAt;
|
|
225
|
+
if (elapsed < this.config.handshakeMinDelayMs) {
|
|
226
|
+
const minutesLeft = Math.ceil((this.config.handshakeMinDelayMs - elapsed) / 60000);
|
|
227
|
+
return {
|
|
228
|
+
allowed: false,
|
|
229
|
+
reason: `Handshake too recent — wait ${minutesLeft} minutes`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// handshake_complete or known — allow
|
|
234
|
+
return { allowed: true };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Variator — Auto-vary messages to avoid spam detection
|
|
3
|
+
*
|
|
4
|
+
* WhatsApp flags identical messages sent to multiple recipients.
|
|
5
|
+
* This module adds invisible variations so each message is technically unique.
|
|
6
|
+
*/
|
|
7
|
+
const DEFAULT_CONFIG = {
|
|
8
|
+
zeroWidthChars: true,
|
|
9
|
+
punctuationVariation: true,
|
|
10
|
+
emojiPadding: false,
|
|
11
|
+
synonyms: false,
|
|
12
|
+
};
|
|
13
|
+
// Zero-width characters invisible to users
|
|
14
|
+
const ZERO_WIDTH = [
|
|
15
|
+
'\u200B', // zero-width space
|
|
16
|
+
'\u200C', // zero-width non-joiner
|
|
17
|
+
'\u200D', // zero-width joiner
|
|
18
|
+
'\uFEFF', // zero-width no-break space
|
|
19
|
+
];
|
|
20
|
+
const SYNONYMS = {
|
|
21
|
+
'hello': ['hi', 'hey', 'howdy'],
|
|
22
|
+
'hi': ['hello', 'hey', 'howdy'],
|
|
23
|
+
'thanks': ['thank you', 'thx', 'cheers'],
|
|
24
|
+
'please': ['kindly', 'pls'],
|
|
25
|
+
'great': ['awesome', 'excellent', 'wonderful'],
|
|
26
|
+
'good': ['great', 'nice', 'fine'],
|
|
27
|
+
'buy': ['purchase', 'get', 'grab'],
|
|
28
|
+
'sell': ['offer', 'list'],
|
|
29
|
+
'price': ['cost', 'amount', 'value'],
|
|
30
|
+
'available': ['in stock', 'on offer'],
|
|
31
|
+
'check': ['look at', 'see', 'view'],
|
|
32
|
+
'join': ['participate', 'enter', 'come to'],
|
|
33
|
+
'start': ['begin', 'kick off', 'commence'],
|
|
34
|
+
'end': ['finish', 'close', 'conclude'],
|
|
35
|
+
'bid': ['offer', 'place a bid'],
|
|
36
|
+
'win': ['secure', 'take home'],
|
|
37
|
+
'item': ['lot', 'piece', 'product'],
|
|
38
|
+
};
|
|
39
|
+
export class ContentVariator {
|
|
40
|
+
config;
|
|
41
|
+
counter = 0;
|
|
42
|
+
constructor(config = {}) {
|
|
43
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Create a unique variation of a message
|
|
47
|
+
* Each call produces a slightly different version
|
|
48
|
+
*/
|
|
49
|
+
vary(text) {
|
|
50
|
+
let result = text;
|
|
51
|
+
this.counter++;
|
|
52
|
+
if (this.config.customVariator) {
|
|
53
|
+
return this.config.customVariator(result, this.counter);
|
|
54
|
+
}
|
|
55
|
+
if (this.config.synonyms) {
|
|
56
|
+
result = this.applySynonyms(result);
|
|
57
|
+
}
|
|
58
|
+
if (this.config.zeroWidthChars) {
|
|
59
|
+
result = this.addZeroWidth(result);
|
|
60
|
+
}
|
|
61
|
+
if (this.config.punctuationVariation) {
|
|
62
|
+
result = this.varyPunctuation(result);
|
|
63
|
+
}
|
|
64
|
+
if (this.config.emojiPadding) {
|
|
65
|
+
result = this.addEmojiPadding(result);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Create N unique variations of a message
|
|
71
|
+
*/
|
|
72
|
+
varyBulk(text, count) {
|
|
73
|
+
const results = [];
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
for (let i = 0; i < count; i++) {
|
|
76
|
+
let variation = this.vary(text);
|
|
77
|
+
// Ensure uniqueness
|
|
78
|
+
let attempts = 0;
|
|
79
|
+
while (seen.has(variation) && attempts < 10) {
|
|
80
|
+
variation = this.vary(text);
|
|
81
|
+
attempts++;
|
|
82
|
+
}
|
|
83
|
+
seen.add(variation);
|
|
84
|
+
results.push(variation);
|
|
85
|
+
}
|
|
86
|
+
return results;
|
|
87
|
+
}
|
|
88
|
+
addZeroWidth(text) {
|
|
89
|
+
const words = text.split(' ');
|
|
90
|
+
if (words.length < 2)
|
|
91
|
+
return text;
|
|
92
|
+
// Insert 1-2 zero-width chars at random positions between words
|
|
93
|
+
const positions = this.randomPositions(words.length - 1, Math.min(2, words.length - 1));
|
|
94
|
+
return words.map((word, i) => {
|
|
95
|
+
if (positions.includes(i)) {
|
|
96
|
+
const zwc = ZERO_WIDTH[Math.floor(Math.random() * ZERO_WIDTH.length)];
|
|
97
|
+
return word + zwc;
|
|
98
|
+
}
|
|
99
|
+
return word;
|
|
100
|
+
}).join(' ');
|
|
101
|
+
}
|
|
102
|
+
varyPunctuation(text) {
|
|
103
|
+
const variations = [
|
|
104
|
+
// Trailing space variations
|
|
105
|
+
() => text + ' ',
|
|
106
|
+
() => text + ' ',
|
|
107
|
+
// Period variations
|
|
108
|
+
() => text.endsWith('.') ? text.slice(0, -1) : text + '.',
|
|
109
|
+
// Nothing
|
|
110
|
+
() => text,
|
|
111
|
+
// Capitalize first letter variation
|
|
112
|
+
() => text.charAt(0) === text.charAt(0).toUpperCase()
|
|
113
|
+
? text.charAt(0).toLowerCase() + text.slice(1)
|
|
114
|
+
: text,
|
|
115
|
+
];
|
|
116
|
+
return variations[this.counter % variations.length]();
|
|
117
|
+
}
|
|
118
|
+
addEmojiPadding(text) {
|
|
119
|
+
const emojis = ['', ' 👍', ' ✅', ' 📌', ' 💬', ' 📢'];
|
|
120
|
+
return text + emojis[this.counter % emojis.length];
|
|
121
|
+
}
|
|
122
|
+
applySynonyms(text) {
|
|
123
|
+
const words = text.split(/\b/);
|
|
124
|
+
let replaced = false;
|
|
125
|
+
return words.map(word => {
|
|
126
|
+
if (replaced)
|
|
127
|
+
return word;
|
|
128
|
+
const lower = word.toLowerCase();
|
|
129
|
+
const synonymList = SYNONYMS[lower];
|
|
130
|
+
if (synonymList && Math.random() > 0.5) {
|
|
131
|
+
replaced = true; // Only replace one word per message
|
|
132
|
+
const synonym = synonymList[Math.floor(Math.random() * synonymList.length)];
|
|
133
|
+
// Preserve original casing
|
|
134
|
+
return word[0] === word[0].toUpperCase()
|
|
135
|
+
? synonym.charAt(0).toUpperCase() + synonym.slice(1)
|
|
136
|
+
: synonym;
|
|
137
|
+
}
|
|
138
|
+
return word;
|
|
139
|
+
}).join('');
|
|
140
|
+
}
|
|
141
|
+
randomPositions(max, count) {
|
|
142
|
+
const positions = [];
|
|
143
|
+
while (positions.length < count) {
|
|
144
|
+
const pos = Math.floor(Math.random() * max);
|
|
145
|
+
if (!positions.includes(pos))
|
|
146
|
+
positions.push(pos);
|
|
147
|
+
}
|
|
148
|
+
return positions;
|
|
149
|
+
}
|
|
150
|
+
}
|