382 lines
14 KiB
JavaScript
382 lines
14 KiB
JavaScript
/**
|
|
* ThrottleManager — Smart Anti-Ban Message Throttling Engine for Nabeh WhatsApp Gateway
|
|
*
|
|
* Simulates human-like messaging behavior to prevent WhatsApp bans:
|
|
* - Per-session message queues with intelligent delays
|
|
* - Typing indicators before sending
|
|
* - Random jitter to avoid pattern detection
|
|
* - Rate limiting per minute/hour/day
|
|
* - Automatic slowdown on warnings
|
|
* - Emergency pause on critical risk
|
|
*/
|
|
|
|
class ThrottleManager {
|
|
constructor() {
|
|
// Per-session message queues
|
|
this.queues = new Map(); // sessionKey -> Array<QueueItem>
|
|
this.processing = new Map(); // sessionKey -> boolean (is currently processing)
|
|
this.stats = new Map(); // sessionKey -> { sentLastMinute, sentLastHour, sentToday, warnings }
|
|
this.slowdownLevel = new Map(); // sessionKey -> 0 (normal), 1 (caution), 2 (danger)
|
|
|
|
// Default limits (randomized per session to avoid fingerprinting)
|
|
this.limits = {
|
|
perMinute: { min: 6, max: 10 },
|
|
perHour: { min: 80, max: 120 },
|
|
perDay: { min: 400, max: 600 },
|
|
burstCooldownAfter: 5, // After 5 quick messages, take a break
|
|
burstCooldownMs: { min: 30000, max: 90000 } // 30-90 second break
|
|
};
|
|
|
|
// Cleanup old stats every minute
|
|
this._cleanupInterval = setInterval(() => this._cleanupStats(), 60000);
|
|
}
|
|
|
|
/**
|
|
* Get or initialize stats for a session
|
|
*/
|
|
_getStats(sessionKey) {
|
|
if (!this.stats.has(sessionKey)) {
|
|
this.stats.set(sessionKey, {
|
|
sentTimestamps: [], // Array of timestamps for rate tracking
|
|
warnings: 0,
|
|
lastWarningAt: 0,
|
|
burstCount: 0, // Messages sent in quick succession
|
|
lastSendAt: 0,
|
|
dailyReset: this._todayKey()
|
|
});
|
|
}
|
|
const stats = this.stats.get(sessionKey);
|
|
// Reset daily counter at midnight
|
|
if (stats.dailyReset !== this._todayKey()) {
|
|
stats.sentTimestamps = stats.sentTimestamps.filter(
|
|
ts => Date.now() - ts < 3600000
|
|
);
|
|
stats.dailyReset = this._todayKey();
|
|
stats.warnings = Math.max(0, stats.warnings - 1); // Decay warnings daily
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
_todayKey() {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
/**
|
|
* Calculate messages sent within a time window
|
|
*/
|
|
_countInWindow(timestamps, windowMs) {
|
|
const cutoff = Date.now() - windowMs;
|
|
return timestamps.filter(ts => ts > cutoff).length;
|
|
}
|
|
|
|
/**
|
|
* Clean up old timestamps from stats to prevent memory bloat
|
|
*/
|
|
_cleanupStats() {
|
|
const oneDayAgo = Date.now() - 86400000;
|
|
for (const [key, stats] of this.stats) {
|
|
stats.sentTimestamps = stats.sentTimestamps.filter(ts => ts > oneDayAgo);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Random integer between min and max (inclusive)
|
|
*/
|
|
_randomBetween(min, max) {
|
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
}
|
|
|
|
/**
|
|
* Calculate human-like typing delay based on message length
|
|
* Average human types ~40 words/minute = ~200 chars/minute
|
|
* We simulate faster (AI is "quick") but still believable: ~400 chars/min
|
|
*/
|
|
_calculateTypingDelay(messageLength) {
|
|
if (!messageLength) return this._randomBetween(800, 1500);
|
|
// Base: 150ms per character, capped between 1-5 seconds
|
|
const baseDelay = Math.min(messageLength * 40, 5000);
|
|
return Math.max(800, baseDelay + this._randomBetween(-300, 500));
|
|
}
|
|
|
|
/**
|
|
* Calculate delay before reading/responding to simulate human behavior
|
|
*/
|
|
_calculateReadDelay(messageType) {
|
|
switch (messageType) {
|
|
case 'auto_reply':
|
|
return this._randomBetween(1500, 4000); // 1.5-4s for AI replies
|
|
case 'voice_reply':
|
|
return this._randomBetween(3000, 7000); // 3-7s for voice (longer processing feel)
|
|
case 'broadcast':
|
|
return this._randomBetween(15000, 45000); // 15-45s between broadcast messages
|
|
case 'reminder':
|
|
return this._randomBetween(3000, 8000); // 3-8s for reminders
|
|
default:
|
|
return this._randomBetween(1500, 3500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if we're within rate limits for this session
|
|
*/
|
|
_isWithinLimits(sessionKey) {
|
|
const stats = this._getStats(sessionKey);
|
|
const slowdown = this.slowdownLevel.get(sessionKey) || 0;
|
|
|
|
// Apply multiplier based on slowdown level
|
|
const multiplier = slowdown === 0 ? 1 : (slowdown === 1 ? 0.5 : 0.2);
|
|
|
|
const perMinLimit = Math.floor(this._randomBetween(this.limits.perMinute.min, this.limits.perMinute.max) * multiplier);
|
|
const perHourLimit = Math.floor(this._randomBetween(this.limits.perHour.min, this.limits.perHour.max) * multiplier);
|
|
const perDayLimit = Math.floor(this._randomBetween(this.limits.perDay.min, this.limits.perDay.max) * multiplier);
|
|
|
|
const sentLastMinute = this._countInWindow(stats.sentTimestamps, 60000);
|
|
const sentLastHour = this._countInWindow(stats.sentTimestamps, 3600000);
|
|
const sentToday = this._countInWindow(stats.sentTimestamps, 86400000);
|
|
|
|
if (sentLastMinute >= perMinLimit) {
|
|
console.log(`[Throttle] ${sessionKey} — Rate limit: ${sentLastMinute}/${perMinLimit} per minute. Waiting...`);
|
|
return false;
|
|
}
|
|
if (sentLastHour >= perHourLimit) {
|
|
console.log(`[Throttle] ${sessionKey} — Rate limit: ${sentLastHour}/${perHourLimit} per hour. Waiting...`);
|
|
return false;
|
|
}
|
|
if (sentToday >= perDayLimit) {
|
|
console.log(`[Throttle] ${sessionKey} — Rate limit: ${sentToday}/${perDayLimit} per day. PAUSED.`);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Enqueue a message for throttled sending
|
|
* @param {string} sessionKey - Session identifier
|
|
* @param {object} messageData - { jid, content, type, sendFn }
|
|
* @returns {Promise<object>} - Resolves when the message is actually sent
|
|
*/
|
|
enqueue(sessionKey, messageData) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.queues.has(sessionKey)) {
|
|
this.queues.set(sessionKey, []);
|
|
}
|
|
|
|
const queueItem = {
|
|
...messageData,
|
|
enqueuedAt: Date.now(),
|
|
resolve,
|
|
reject
|
|
};
|
|
|
|
this.queues.get(sessionKey).push(queueItem);
|
|
console.log(`[Throttle] ${sessionKey} — Message queued. Queue size: ${this.queues.get(sessionKey).length}`);
|
|
|
|
// Start processing if not already running
|
|
if (!this.processing.get(sessionKey)) {
|
|
this._processQueue(sessionKey);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Process the message queue for a session sequentially
|
|
*/
|
|
async _processQueue(sessionKey) {
|
|
if (this.processing.get(sessionKey)) return;
|
|
this.processing.set(sessionKey, true);
|
|
|
|
const queue = this.queues.get(sessionKey);
|
|
|
|
while (queue && queue.length > 0) {
|
|
const item = queue[0];
|
|
|
|
// Check rate limits
|
|
if (!this._isWithinLimits(sessionKey)) {
|
|
// Wait and retry
|
|
await this._sleep(this._randomBetween(5000, 15000));
|
|
continue;
|
|
}
|
|
|
|
// Check burst cooldown
|
|
const stats = this._getStats(sessionKey);
|
|
if (stats.burstCount >= this.limits.burstCooldownAfter) {
|
|
const cooldown = this._randomBetween(
|
|
this.limits.burstCooldownMs.min,
|
|
this.limits.burstCooldownMs.max
|
|
);
|
|
console.log(`[Throttle] ${sessionKey} — Burst cooldown: ${Math.round(cooldown / 1000)}s pause after ${stats.burstCount} quick messages.`);
|
|
stats.burstCount = 0;
|
|
await this._sleep(cooldown);
|
|
}
|
|
|
|
// Calculate pre-send delays
|
|
const messageType = item.type || 'auto_reply';
|
|
const readDelay = this._calculateReadDelay(messageType);
|
|
|
|
// 1. Send "read" receipt if applicable
|
|
if (item.sock && item.incomingMsgKey) {
|
|
try {
|
|
await item.sock.readMessages([item.incomingMsgKey]);
|
|
} catch (e) {
|
|
// Non-critical, continue
|
|
}
|
|
}
|
|
|
|
// 2. Wait (simulating reading the message)
|
|
await this._sleep(readDelay);
|
|
|
|
// 3. Send typing indicator
|
|
if (item.sock && item.jid) {
|
|
try {
|
|
await item.sock.sendPresenceUpdate('composing', item.jid);
|
|
} catch (e) {
|
|
// Non-critical
|
|
}
|
|
}
|
|
|
|
// 4. Wait for typing delay
|
|
const textLength = item.content?.text?.length || item.content?.caption?.length || 20;
|
|
const typingDelay = this._calculateTypingDelay(textLength);
|
|
await this._sleep(typingDelay);
|
|
|
|
// 5. Stop typing indicator
|
|
if (item.sock && item.jid) {
|
|
try {
|
|
await item.sock.sendPresenceUpdate('paused', item.jid);
|
|
} catch (e) {
|
|
// Non-critical
|
|
}
|
|
}
|
|
|
|
// 6. Actually send the message
|
|
try {
|
|
const result = await item.sendFn();
|
|
stats.sentTimestamps.push(Date.now());
|
|
stats.lastSendAt = Date.now();
|
|
stats.burstCount++;
|
|
|
|
// Reset burst if last send was more than 10 seconds ago
|
|
if (Date.now() - stats.lastSendAt > 10000) {
|
|
stats.burstCount = 1;
|
|
}
|
|
|
|
queue.shift(); // Remove processed item
|
|
item.resolve(result);
|
|
console.log(`[Throttle] ${sessionKey} — Message sent successfully. Queue remaining: ${queue.length}`);
|
|
} catch (err) {
|
|
queue.shift();
|
|
item.reject(err);
|
|
console.error(`[Throttle] ${sessionKey} — Send failed:`, err.message);
|
|
|
|
// Check if it's a rate-limit related error
|
|
if (err.message?.includes('rate') || err.message?.includes('429') || err.message?.includes('too many')) {
|
|
this.onWarning(sessionKey);
|
|
}
|
|
}
|
|
|
|
// Small random gap between processing queue items
|
|
if (queue.length > 0) {
|
|
await this._sleep(this._randomBetween(500, 2000));
|
|
}
|
|
}
|
|
|
|
this.processing.set(sessionKey, false);
|
|
}
|
|
|
|
/**
|
|
* Called when a warning signal is detected (rate limit, 429, etc.)
|
|
*/
|
|
onWarning(sessionKey) {
|
|
const stats = this._getStats(sessionKey);
|
|
stats.warnings++;
|
|
stats.lastWarningAt = Date.now();
|
|
|
|
const currentLevel = this.slowdownLevel.get(sessionKey) || 0;
|
|
|
|
if (stats.warnings >= 5) {
|
|
// EMERGENCY: Pause all sending for this session
|
|
this.slowdownLevel.set(sessionKey, 2);
|
|
console.warn(`[Throttle] ⛔ ${sessionKey} — EMERGENCY SLOWDOWN! ${stats.warnings} warnings detected. All sending severely throttled.`);
|
|
} else if (stats.warnings >= 2) {
|
|
this.slowdownLevel.set(sessionKey, 1);
|
|
console.warn(`[Throttle] ⚠️ ${sessionKey} — CAUTION slowdown. ${stats.warnings} warnings. Reducing send rate by 50%.`);
|
|
}
|
|
|
|
// Auto-recover after 30 minutes of no warnings
|
|
setTimeout(() => {
|
|
const current = this._getStats(sessionKey);
|
|
if (Date.now() - current.lastWarningAt > 1800000) {
|
|
const level = this.slowdownLevel.get(sessionKey) || 0;
|
|
if (level > 0) {
|
|
this.slowdownLevel.set(sessionKey, Math.max(0, level - 1));
|
|
console.log(`[Throttle] ✅ ${sessionKey} — Slowdown level decreased to ${level - 1} after 30min recovery.`);
|
|
}
|
|
}
|
|
}, 1800000);
|
|
}
|
|
|
|
/**
|
|
* Get queue status for monitoring
|
|
*/
|
|
getStatus(sessionKey) {
|
|
const stats = this._getStats(sessionKey);
|
|
const queue = this.queues.get(sessionKey) || [];
|
|
return {
|
|
queueSize: queue.length,
|
|
isProcessing: this.processing.get(sessionKey) || false,
|
|
slowdownLevel: this.slowdownLevel.get(sessionKey) || 0,
|
|
sentLastMinute: this._countInWindow(stats.sentTimestamps, 60000),
|
|
sentLastHour: this._countInWindow(stats.sentTimestamps, 3600000),
|
|
sentToday: this._countInWindow(stats.sentTimestamps, 86400000),
|
|
warnings: stats.warnings
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get status for all sessions
|
|
*/
|
|
getAllStatus() {
|
|
const result = {};
|
|
for (const sessionKey of this.stats.keys()) {
|
|
result[sessionKey] = this.getStatus(sessionKey);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Clear queue for a session (e.g., when disconnecting)
|
|
*/
|
|
clearQueue(sessionKey) {
|
|
const queue = this.queues.get(sessionKey) || [];
|
|
for (const item of queue) {
|
|
item.reject(new Error('Queue cleared — session disconnecting'));
|
|
}
|
|
this.queues.delete(sessionKey);
|
|
this.processing.delete(sessionKey);
|
|
console.log(`[Throttle] ${sessionKey} — Queue cleared.`);
|
|
}
|
|
|
|
/**
|
|
* Promise-based sleep
|
|
*/
|
|
_sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Cleanup on shutdown
|
|
*/
|
|
destroy() {
|
|
if (this._cleanupInterval) {
|
|
clearInterval(this._cleanupInterval);
|
|
}
|
|
for (const sessionKey of this.queues.keys()) {
|
|
this.clearQueue(sessionKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
const throttleManager = new ThrottleManager();
|
|
module.exports = throttleManager;
|