diff --git a/whatsapp-gateway/baileys-client.js b/whatsapp-gateway/baileys-client.js index 29419b6..3673a54 100644 --- a/whatsapp-gateway/baileys-client.js +++ b/whatsapp-gateway/baileys-client.js @@ -178,6 +178,7 @@ const sessions = new Map(); // Store active sockets in memory const retryCounters = new Map(); // Track reconnection attempts per session const recentMessages = new Map(); // Cache of recent messages in memory to serve getMessage callback const phoneToLid = new Map(); // Map phone numbers to LID JIDs for correct E2EE routing +const resolvedJidCache = new Map(); // Map phone numbers to plain resolved JIDs (avoids re-querying on every send) const sessionStores = new Map(); // Store active stores in memory const storeIntervals = new Map(); // Store save intervals in memory @@ -586,6 +587,9 @@ async function resolveJid(sock, phone) { console.log(`[LID] Routing reply to ${phone} via LID: ${lid}`); return lid; } + if (resolvedJidCache.has(phone)) { + return resolvedJidCache.get(phone); + } // Query WhatsApp to resolve the correct JID/LID for this phone number. try { console.log(`[JID Resolve] Resolving JID for ${phone}...`); @@ -594,6 +598,13 @@ async function resolveJid(sock, phone) { console.log(`[JID Resolve] Successfully resolved JID for ${phone}: ${result.jid}`); if (result.jid.endsWith('@lid')) { phoneToLid.set(phone, result.jid); + } else { + // Cache plain JIDs too — this lookup is a network roundtrip that previously + // ran on every single send to the same number. + resolvedJidCache.set(phone, result.jid); + if (resolvedJidCache.size > 5000) { + resolvedJidCache.delete(resolvedJidCache.keys().next().value); + } } return result.jid; } @@ -669,7 +680,7 @@ async function _rawSend(sock, jid, message, mediaUrl, audioBase64, mimetype, ima * Send a message using an active session — routed through ThrottleManager * for human-like delays, typing indicators, and anti-ban protection. */ -async function sendMessage(session_key, phone, message, mediaUrl = null, audioBase64 = null, mimetype = null, imageBase64 = null) { +async function sendMessage(session_key, phone, message, mediaUrl = null, audioBase64 = null, mimetype = null, imageBase64 = null, type = null) { const sock = sessions.get(session_key); if (!sock) { throw new Error(`Session ${session_key} is not active or connected`); @@ -677,9 +688,11 @@ async function sendMessage(session_key, phone, message, mediaUrl = null, audioBa const jid = await resolveJid(sock, phone); - // Determine message type for throttle delay calculation - let msgType = 'auto_reply'; - if (audioBase64) msgType = 'voice_reply'; + // Determine message type for throttle delay calculation. An explicit type from the + // caller wins — that's how transactional sends (OTP codes, order confirmations) opt out + // of the human-behaviour simulation. + let msgType = type || 'auto_reply'; + if (!type && audioBase64) msgType = 'voice_reply'; // Build the content descriptor for typing delay calculation const content = { text: message || '' }; diff --git a/whatsapp-gateway/server.js b/whatsapp-gateway/server.js index a201542..cad40dc 100644 --- a/whatsapp-gateway/server.js +++ b/whatsapp-gateway/server.js @@ -128,7 +128,7 @@ app.post('/api/chats/export', async (req, res) => { // Send outbound message app.post('/api/messages/send', async (req, res) => { - const { session_key, phone, message, media_url, audio, mimetype, image } = req.body; + const { session_key, phone, message, media_url, audio, mimetype, image, type } = req.body; if (!session_key || !phone) { return res.status(400).json({ error: 'Missing session_key or phone' }); @@ -141,7 +141,7 @@ app.post('/api/messages/send', async (req, res) => { console.log(`[API] Received request to send message to ${phone}: ${message ? message.substring(0, 50) + '...' : '(no text)'}`); try { - const result = await sendMessage(session_key, phone, message, media_url, audio, mimetype, image); + const result = await sendMessage(session_key, phone, message, media_url, audio, mimetype, image, type); res.json({ status: 'success', data: result }); } catch (err) { console.error(`Error sending message via ${session_key} to ${phone}:`, err); diff --git a/whatsapp-gateway/throttle-manager.js b/whatsapp-gateway/throttle-manager.js index 9f67ce3..44a8e22 100644 --- a/whatsapp-gateway/throttle-manager.js +++ b/whatsapp-gateway/throttle-manager.js @@ -18,13 +18,17 @@ class ThrottleManager { this.stats = new Map(); // sessionKey -> { sentLastMinute, sentLastHour, sentToday, warnings } this.slowdownLevel = new Map(); // sessionKey -> 0 (normal), 1 (caution), 2 (danger) + // Per-session randomized limits, computed once so they don't flap between checks + this.sessionLimits = new Map(); // sessionKey -> { perMinute, perHour, perDay } + // 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 + burstCooldownAfter: 10, // After 10 back-to-back messages, take a break + burstCooldownMs: { min: 8000, max: 20000 }, // 8-20 second break + burstGapMs: 3000 // Sends more than 3s apart don't count as a burst }; // Cleanup old stats every minute @@ -91,11 +95,14 @@ class ThrottleManager { * 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)); + _calculateTypingDelay(messageLength, messageType) { + // Transactional sends (OTP codes, order confirmations, explicit API sends) are + // expected to be instant by the user — no typing simulation at all. + if (messageType === 'transactional') return 0; + if (!messageLength) return this._randomBetween(300, 700); + // ~15ms per character, capped at 2s — still reads as human without stalling the API. + const baseDelay = Math.min(messageLength * 15, 2000); + return Math.max(400, baseDelay + this._randomBetween(-150, 250)); } /** @@ -103,16 +110,18 @@ class ThrottleManager { */ _calculateReadDelay(messageType) { switch (messageType) { + case 'transactional': + return 0; // OTP / codes / explicit API sends — send now case 'auto_reply': - return this._randomBetween(1500, 4000); // 1.5-4s for AI replies + return this._randomBetween(400, 1200); // AI replies case 'voice_reply': - return this._randomBetween(3000, 7000); // 3-7s for voice (longer processing feel) + return this._randomBetween(800, 2000); // voice notes case 'broadcast': return this._randomBetween(15000, 45000); // 15-45s between broadcast messages case 'reminder': - return this._randomBetween(3000, 8000); // 3-8s for reminders + return this._randomBetween(1000, 2500); // reminders default: - return this._randomBetween(1500, 3500); + return this._randomBetween(400, 1200); } } @@ -126,9 +135,21 @@ class ThrottleManager { // 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); + // Limits are randomized ONCE per session. Re-rolling them on every check made the + // effective ceiling jump around (a low roll would block a send that the previous + // check had allowed), producing unpredictable multi-second stalls. + if (!this.sessionLimits.has(sessionKey)) { + this.sessionLimits.set(sessionKey, { + perMinute: this._randomBetween(this.limits.perMinute.min, this.limits.perMinute.max), + perHour: this._randomBetween(this.limits.perHour.min, this.limits.perHour.max), + perDay: this._randomBetween(this.limits.perDay.min, this.limits.perDay.max) + }); + } + const base = this.sessionLimits.get(sessionKey); + + const perMinLimit = Math.max(1, Math.floor(base.perMinute * multiplier)); + const perHourLimit = Math.max(1, Math.floor(base.perHour * multiplier)); + const perDayLimit = Math.max(1, Math.floor(base.perDay * multiplier)); const sentLastMinute = this._countInWindow(stats.sentTimestamps, 60000); const sentLastHour = this._countInWindow(stats.sentTimestamps, 3600000); @@ -197,9 +218,15 @@ class ThrottleManager { continue; } + const messageType = item.type || 'auto_reply'; + // Transactional traffic (OTP codes, confirmations) skips the human-behaviour + // simulation entirely: no read pause, no typing indicator, no burst cooldown. + // It is still counted against the rate limits above. + const simulateHuman = messageType !== 'transactional'; + // Check burst cooldown const stats = this._getStats(sessionKey); - if (stats.burstCount >= this.limits.burstCooldownAfter) { + if (simulateHuman && stats.burstCount >= this.limits.burstCooldownAfter) { const cooldown = this._randomBetween( this.limits.burstCooldownMs.min, this.limits.burstCooldownMs.max @@ -210,55 +237,46 @@ class ThrottleManager { } // 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 - } + // 1. Send "read" receipt if applicable. Fire-and-forget — awaiting this added a + // full network roundtrip to every send for a receipt nobody blocks on. + if (simulateHuman && item.sock && item.incomingMsgKey) { + item.sock.readMessages([item.incomingMsgKey]).catch(() => { }); } // 2. Wait (simulating reading the message) - await this._sleep(readDelay); + if (readDelay > 0) 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 - } + // 3. Send typing indicator (also fire-and-forget) + if (simulateHuman && item.sock && item.jid) { + item.sock.sendPresenceUpdate('composing', item.jid).catch(() => { }); } // 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); + const typingDelay = this._calculateTypingDelay(textLength, messageType); + if (typingDelay > 0) 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 - } + // 5. Stop typing indicator (fire-and-forget) + if (simulateHuman && item.sock && item.jid) { + item.sock.sendPresenceUpdate('paused', item.jid).catch(() => { }); } // 6. Actually send the message try { const result = await item.sendFn(); - stats.sentTimestamps.push(Date.now()); - stats.lastSendAt = Date.now(); - stats.burstCount++; + const now = Date.now(); - // Reset burst if last send was more than 10 seconds ago - if (Date.now() - stats.lastSendAt > 10000) { - stats.burstCount = 1; - } + // Reset the burst counter when the previous send was a while ago — this must be + // evaluated against the OLD lastSendAt. Comparing after overwriting lastSendAt + // made the condition always false, so burstCount never reset and the 30-90s + // burst cooldown fired on every 5th message. + const idleSincePreviousSend = stats.lastSendAt ? now - stats.lastSendAt : Infinity; + stats.burstCount = idleSincePreviousSend > this.limits.burstGapMs ? 1 : stats.burstCount + 1; + + stats.sentTimestamps.push(now); + stats.lastSendAt = now; queue.shift(); // Remove processed item item.resolve(result); @@ -276,7 +294,7 @@ class ThrottleManager { // Small random gap between processing queue items if (queue.length > 0) { - await this._sleep(this._randomBetween(500, 2000)); + await this._sleep(simulateHuman ? this._randomBetween(300, 800) : this._randomBetween(80, 200)); } }