Deploy: 2026-07-30 11:05:21
This commit is contained in:
@@ -178,6 +178,7 @@ const sessions = new Map(); // Store active sockets in memory
|
|||||||
const retryCounters = new Map(); // Track reconnection attempts per session
|
const retryCounters = new Map(); // Track reconnection attempts per session
|
||||||
const recentMessages = new Map(); // Cache of recent messages in memory to serve getMessage callback
|
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 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 sessionStores = new Map(); // Store active stores in memory
|
||||||
const storeIntervals = new Map(); // Store save intervals 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}`);
|
console.log(`[LID] Routing reply to ${phone} via LID: ${lid}`);
|
||||||
return lid;
|
return lid;
|
||||||
}
|
}
|
||||||
|
if (resolvedJidCache.has(phone)) {
|
||||||
|
return resolvedJidCache.get(phone);
|
||||||
|
}
|
||||||
// Query WhatsApp to resolve the correct JID/LID for this phone number.
|
// Query WhatsApp to resolve the correct JID/LID for this phone number.
|
||||||
try {
|
try {
|
||||||
console.log(`[JID Resolve] Resolving JID for ${phone}...`);
|
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}`);
|
console.log(`[JID Resolve] Successfully resolved JID for ${phone}: ${result.jid}`);
|
||||||
if (result.jid.endsWith('@lid')) {
|
if (result.jid.endsWith('@lid')) {
|
||||||
phoneToLid.set(phone, result.jid);
|
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;
|
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
|
* Send a message using an active session — routed through ThrottleManager
|
||||||
* for human-like delays, typing indicators, and anti-ban protection.
|
* 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);
|
const sock = sessions.get(session_key);
|
||||||
if (!sock) {
|
if (!sock) {
|
||||||
throw new Error(`Session ${session_key} is not active or connected`);
|
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);
|
const jid = await resolveJid(sock, phone);
|
||||||
|
|
||||||
// Determine message type for throttle delay calculation
|
// Determine message type for throttle delay calculation. An explicit type from the
|
||||||
let msgType = 'auto_reply';
|
// caller wins — that's how transactional sends (OTP codes, order confirmations) opt out
|
||||||
if (audioBase64) msgType = 'voice_reply';
|
// of the human-behaviour simulation.
|
||||||
|
let msgType = type || 'auto_reply';
|
||||||
|
if (!type && audioBase64) msgType = 'voice_reply';
|
||||||
|
|
||||||
// Build the content descriptor for typing delay calculation
|
// Build the content descriptor for typing delay calculation
|
||||||
const content = { text: message || '' };
|
const content = { text: message || '' };
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ app.post('/api/chats/export', async (req, res) => {
|
|||||||
|
|
||||||
// Send outbound message
|
// Send outbound message
|
||||||
app.post('/api/messages/send', async (req, res) => {
|
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) {
|
if (!session_key || !phone) {
|
||||||
return res.status(400).json({ error: 'Missing session_key or 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)'}`);
|
console.log(`[API] Received request to send message to ${phone}: ${message ? message.substring(0, 50) + '...' : '(no text)'}`);
|
||||||
|
|
||||||
try {
|
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 });
|
res.json({ status: 'success', data: result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Error sending message via ${session_key} to ${phone}:`, err);
|
console.error(`Error sending message via ${session_key} to ${phone}:`, err);
|
||||||
|
|||||||
@@ -18,13 +18,17 @@ class ThrottleManager {
|
|||||||
this.stats = new Map(); // sessionKey -> { sentLastMinute, sentLastHour, sentToday, warnings }
|
this.stats = new Map(); // sessionKey -> { sentLastMinute, sentLastHour, sentToday, warnings }
|
||||||
this.slowdownLevel = new Map(); // sessionKey -> 0 (normal), 1 (caution), 2 (danger)
|
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)
|
// Default limits (randomized per session to avoid fingerprinting)
|
||||||
this.limits = {
|
this.limits = {
|
||||||
perMinute: { min: 6, max: 10 },
|
perMinute: { min: 6, max: 10 },
|
||||||
perHour: { min: 80, max: 120 },
|
perHour: { min: 80, max: 120 },
|
||||||
perDay: { min: 400, max: 600 },
|
perDay: { min: 400, max: 600 },
|
||||||
burstCooldownAfter: 5, // After 5 quick messages, take a break
|
burstCooldownAfter: 10, // After 10 back-to-back messages, take a break
|
||||||
burstCooldownMs: { min: 30000, max: 90000 } // 30-90 second 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
|
// Cleanup old stats every minute
|
||||||
@@ -91,11 +95,14 @@ class ThrottleManager {
|
|||||||
* Average human types ~40 words/minute = ~200 chars/minute
|
* Average human types ~40 words/minute = ~200 chars/minute
|
||||||
* We simulate faster (AI is "quick") but still believable: ~400 chars/min
|
* We simulate faster (AI is "quick") but still believable: ~400 chars/min
|
||||||
*/
|
*/
|
||||||
_calculateTypingDelay(messageLength) {
|
_calculateTypingDelay(messageLength, messageType) {
|
||||||
if (!messageLength) return this._randomBetween(800, 1500);
|
// Transactional sends (OTP codes, order confirmations, explicit API sends) are
|
||||||
// Base: 150ms per character, capped between 1-5 seconds
|
// expected to be instant by the user — no typing simulation at all.
|
||||||
const baseDelay = Math.min(messageLength * 40, 5000);
|
if (messageType === 'transactional') return 0;
|
||||||
return Math.max(800, baseDelay + this._randomBetween(-300, 500));
|
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) {
|
_calculateReadDelay(messageType) {
|
||||||
switch (messageType) {
|
switch (messageType) {
|
||||||
|
case 'transactional':
|
||||||
|
return 0; // OTP / codes / explicit API sends — send now
|
||||||
case 'auto_reply':
|
case 'auto_reply':
|
||||||
return this._randomBetween(1500, 4000); // 1.5-4s for AI replies
|
return this._randomBetween(400, 1200); // AI replies
|
||||||
case 'voice_reply':
|
case 'voice_reply':
|
||||||
return this._randomBetween(3000, 7000); // 3-7s for voice (longer processing feel)
|
return this._randomBetween(800, 2000); // voice notes
|
||||||
case 'broadcast':
|
case 'broadcast':
|
||||||
return this._randomBetween(15000, 45000); // 15-45s between broadcast messages
|
return this._randomBetween(15000, 45000); // 15-45s between broadcast messages
|
||||||
case 'reminder':
|
case 'reminder':
|
||||||
return this._randomBetween(3000, 8000); // 3-8s for reminders
|
return this._randomBetween(1000, 2500); // reminders
|
||||||
default:
|
default:
|
||||||
return this._randomBetween(1500, 3500);
|
return this._randomBetween(400, 1200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,9 +135,21 @@ class ThrottleManager {
|
|||||||
// Apply multiplier based on slowdown level
|
// Apply multiplier based on slowdown level
|
||||||
const multiplier = slowdown === 0 ? 1 : (slowdown === 1 ? 0.5 : 0.2);
|
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);
|
// Limits are randomized ONCE per session. Re-rolling them on every check made the
|
||||||
const perHourLimit = Math.floor(this._randomBetween(this.limits.perHour.min, this.limits.perHour.max) * multiplier);
|
// effective ceiling jump around (a low roll would block a send that the previous
|
||||||
const perDayLimit = Math.floor(this._randomBetween(this.limits.perDay.min, this.limits.perDay.max) * multiplier);
|
// 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 sentLastMinute = this._countInWindow(stats.sentTimestamps, 60000);
|
||||||
const sentLastHour = this._countInWindow(stats.sentTimestamps, 3600000);
|
const sentLastHour = this._countInWindow(stats.sentTimestamps, 3600000);
|
||||||
@@ -197,9 +218,15 @@ class ThrottleManager {
|
|||||||
continue;
|
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
|
// Check burst cooldown
|
||||||
const stats = this._getStats(sessionKey);
|
const stats = this._getStats(sessionKey);
|
||||||
if (stats.burstCount >= this.limits.burstCooldownAfter) {
|
if (simulateHuman && stats.burstCount >= this.limits.burstCooldownAfter) {
|
||||||
const cooldown = this._randomBetween(
|
const cooldown = this._randomBetween(
|
||||||
this.limits.burstCooldownMs.min,
|
this.limits.burstCooldownMs.min,
|
||||||
this.limits.burstCooldownMs.max
|
this.limits.burstCooldownMs.max
|
||||||
@@ -210,55 +237,46 @@ class ThrottleManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate pre-send delays
|
// Calculate pre-send delays
|
||||||
const messageType = item.type || 'auto_reply';
|
|
||||||
const readDelay = this._calculateReadDelay(messageType);
|
const readDelay = this._calculateReadDelay(messageType);
|
||||||
|
|
||||||
// 1. Send "read" receipt if applicable
|
// 1. Send "read" receipt if applicable. Fire-and-forget — awaiting this added a
|
||||||
if (item.sock && item.incomingMsgKey) {
|
// full network roundtrip to every send for a receipt nobody blocks on.
|
||||||
try {
|
if (simulateHuman && item.sock && item.incomingMsgKey) {
|
||||||
await item.sock.readMessages([item.incomingMsgKey]);
|
item.sock.readMessages([item.incomingMsgKey]).catch(() => { });
|
||||||
} catch (e) {
|
|
||||||
// Non-critical, continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Wait (simulating reading the message)
|
// 2. Wait (simulating reading the message)
|
||||||
await this._sleep(readDelay);
|
if (readDelay > 0) await this._sleep(readDelay);
|
||||||
|
|
||||||
// 3. Send typing indicator
|
// 3. Send typing indicator (also fire-and-forget)
|
||||||
if (item.sock && item.jid) {
|
if (simulateHuman && item.sock && item.jid) {
|
||||||
try {
|
item.sock.sendPresenceUpdate('composing', item.jid).catch(() => { });
|
||||||
await item.sock.sendPresenceUpdate('composing', item.jid);
|
|
||||||
} catch (e) {
|
|
||||||
// Non-critical
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Wait for typing delay
|
// 4. Wait for typing delay
|
||||||
const textLength = item.content?.text?.length || item.content?.caption?.length || 20;
|
const textLength = item.content?.text?.length || item.content?.caption?.length || 20;
|
||||||
const typingDelay = this._calculateTypingDelay(textLength);
|
const typingDelay = this._calculateTypingDelay(textLength, messageType);
|
||||||
await this._sleep(typingDelay);
|
if (typingDelay > 0) await this._sleep(typingDelay);
|
||||||
|
|
||||||
// 5. Stop typing indicator
|
// 5. Stop typing indicator (fire-and-forget)
|
||||||
if (item.sock && item.jid) {
|
if (simulateHuman && item.sock && item.jid) {
|
||||||
try {
|
item.sock.sendPresenceUpdate('paused', item.jid).catch(() => { });
|
||||||
await item.sock.sendPresenceUpdate('paused', item.jid);
|
|
||||||
} catch (e) {
|
|
||||||
// Non-critical
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Actually send the message
|
// 6. Actually send the message
|
||||||
try {
|
try {
|
||||||
const result = await item.sendFn();
|
const result = await item.sendFn();
|
||||||
stats.sentTimestamps.push(Date.now());
|
const now = Date.now();
|
||||||
stats.lastSendAt = Date.now();
|
|
||||||
stats.burstCount++;
|
|
||||||
|
|
||||||
// Reset burst if last send was more than 10 seconds ago
|
// Reset the burst counter when the previous send was a while ago — this must be
|
||||||
if (Date.now() - stats.lastSendAt > 10000) {
|
// evaluated against the OLD lastSendAt. Comparing after overwriting lastSendAt
|
||||||
stats.burstCount = 1;
|
// 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
|
queue.shift(); // Remove processed item
|
||||||
item.resolve(result);
|
item.resolve(result);
|
||||||
@@ -276,7 +294,7 @@ class ThrottleManager {
|
|||||||
|
|
||||||
// Small random gap between processing queue items
|
// Small random gap between processing queue items
|
||||||
if (queue.length > 0) {
|
if (queue.length > 0) {
|
||||||
await this._sleep(this._randomBetween(500, 2000));
|
await this._sleep(simulateHuman ? this._randomBetween(300, 800) : this._randomBetween(80, 200));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user