Deploy: 2026-07-30 11:05:21

This commit is contained in:
Hamza-Ayed
2026-07-30 11:05:21 +03:00
parent e6417f2883
commit f38d01a0e0
3 changed files with 85 additions and 54 deletions
+17 -4
View File
@@ -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 || '' };
+2 -2
View File
@@ -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);
+66 -48
View File
@@ -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));
}
}