@whiskeysockets/baileys
Version:
A WebSockets library for interacting with WhatsApp Web
265 lines • 10.4 kB
JavaScript
import { LRUCache } from 'lru-cache';
/** Number of sent messages to cache in memory for handling retry receipts */
const RECENT_MESSAGES_SIZE = 512;
const MESSAGE_KEY_SEPARATOR = '\u0000';
/** Timeout for session recreation - 1 hour */
const RECREATE_SESSION_TIMEOUT = 60 * 60 * 1000; // 1 hour in milliseconds
const PHONE_REQUEST_DELAY = 3000;
// Retry reason codes matching WhatsApp Web's Signal error codes.
export var RetryReason;
(function (RetryReason) {
RetryReason[RetryReason["UnknownError"] = 0] = "UnknownError";
RetryReason[RetryReason["SignalErrorNoSession"] = 1] = "SignalErrorNoSession";
RetryReason[RetryReason["SignalErrorInvalidKey"] = 2] = "SignalErrorInvalidKey";
RetryReason[RetryReason["SignalErrorInvalidKeyId"] = 3] = "SignalErrorInvalidKeyId";
/** MAC verification failed - most common cause of decryption failures */
RetryReason[RetryReason["SignalErrorInvalidMessage"] = 4] = "SignalErrorInvalidMessage";
RetryReason[RetryReason["SignalErrorInvalidSignature"] = 5] = "SignalErrorInvalidSignature";
RetryReason[RetryReason["SignalErrorFutureMessage"] = 6] = "SignalErrorFutureMessage";
/** Explicit MAC failure - session is definitely out of sync */
RetryReason[RetryReason["SignalErrorBadMac"] = 7] = "SignalErrorBadMac";
RetryReason[RetryReason["SignalErrorInvalidSession"] = 8] = "SignalErrorInvalidSession";
RetryReason[RetryReason["SignalErrorInvalidMsgKey"] = 9] = "SignalErrorInvalidMsgKey";
RetryReason[RetryReason["BadBroadcastEphemeralSetting"] = 10] = "BadBroadcastEphemeralSetting";
RetryReason[RetryReason["UnknownCompanionNoPrekey"] = 11] = "UnknownCompanionNoPrekey";
RetryReason[RetryReason["AdvFailure"] = 12] = "AdvFailure";
RetryReason[RetryReason["StatusRevokeDelay"] = 13] = "StatusRevokeDelay";
})(RetryReason || (RetryReason = {}));
/** Error codes that indicate a MAC failure and require immediate session recreation */
const MAC_ERROR_CODES = new Set([RetryReason.SignalErrorInvalidMessage, RetryReason.SignalErrorBadMac]);
export class MessageRetryManager {
constructor(logger, maxMsgRetryCount) {
this.logger = logger;
this.recentMessagesMap = new LRUCache({
max: RECENT_MESSAGES_SIZE,
ttl: 5 * 60 * 1000,
ttlAutopurge: true,
dispose: (_value, key) => {
const separatorIndex = key.lastIndexOf(MESSAGE_KEY_SEPARATOR);
if (separatorIndex > -1) {
const messageId = key.slice(separatorIndex + MESSAGE_KEY_SEPARATOR.length);
this.messageKeyIndex.delete(messageId);
}
}
});
this.messageKeyIndex = new Map();
this.sessionRecreateHistory = new LRUCache({
ttl: RECREATE_SESSION_TIMEOUT * 2,
ttlAutopurge: true
});
this.retryCounters = new LRUCache({
ttl: 15 * 60 * 1000,
ttlAutopurge: true,
updateAgeOnGet: true
}); // 15 minutes TTL
this.baseKeys = new LRUCache({
max: 1024,
ttl: 15 * 60 * 1000,
ttlAutopurge: true
});
this.pendingPhoneRequests = {};
this.maxMsgRetryCount = 5;
this.statistics = {
totalRetries: 0,
successfulRetries: 0,
failedRetries: 0,
mediaRetries: 0,
sessionRecreations: 0,
phoneRequests: 0
};
this.maxMsgRetryCount = maxMsgRetryCount;
}
/**
* Add a recent message to the cache for retry handling
*/
addRecentMessage(to, id, message) {
const key = { to, id };
const keyStr = this.keyToString(key);
// Add new message
this.recentMessagesMap.set(keyStr, {
message,
timestamp: Date.now()
});
this.messageKeyIndex.set(id, keyStr);
this.logger.debug(`Added message to retry cache: ${to}/${id}`);
}
/**
* Get a recent message from the cache
*/
getRecentMessage(to, id) {
const key = { to, id };
const keyStr = this.keyToString(key);
return this.recentMessagesMap.get(keyStr);
}
/**
* Check if a session should be recreated based on retry count, history, and error code.
* MAC errors (codes 4 and 7) trigger immediate session recreation regardless of timeout.
*/
shouldRecreateSession(jid, hasSession, errorCode) {
// If we don't have a session, always recreate
if (!hasSession) {
this.sessionRecreateHistory.set(jid, Date.now());
this.statistics.sessionRecreations++;
return {
reason: "we don't have a Signal session with them",
recreate: true
};
}
// IMMEDIATE recreation for MAC errors - session is definitely out of sync
if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) {
this.sessionRecreateHistory.set(jid, Date.now());
this.statistics.sessionRecreations++;
this.logger.warn({ jid, errorCode: RetryReason[errorCode] }, 'MAC error detected, forcing immediate session recreation');
return {
reason: `MAC error (code ${errorCode}: ${RetryReason[errorCode]}), immediate session recreation`,
recreate: true
};
}
const now = Date.now();
const prevTime = this.sessionRecreateHistory.get(jid);
// If no previous recreation or it's been more than an hour
if (!prevTime || now - prevTime > RECREATE_SESSION_TIMEOUT) {
this.sessionRecreateHistory.set(jid, now);
this.statistics.sessionRecreations++;
return {
reason: 'retry count > 1 and over an hour since last recreation',
recreate: true
};
}
return { reason: '', recreate: false };
}
/**
* Parse error code from retry receipt's retry node.
* Returns undefined if no error code is present.
*/
parseRetryErrorCode(errorAttr) {
if (errorAttr === undefined || errorAttr === '') {
return undefined;
}
const code = parseInt(errorAttr, 10);
if (Number.isNaN(code)) {
return undefined;
}
// Validate it's a known RetryReason
if (code >= RetryReason.UnknownError && code <= RetryReason.StatusRevokeDelay) {
return code;
}
return RetryReason.UnknownError;
}
/**
* Check if an error code indicates a MAC failure
*/
isMacError(errorCode) {
return errorCode !== undefined && MAC_ERROR_CODES.has(errorCode);
}
/**
* Increment retry counter for a message
*/
incrementRetryCount(messageId) {
this.retryCounters.set(messageId, (this.retryCounters.get(messageId) || 0) + 1);
this.statistics.totalRetries++;
return this.retryCounters.get(messageId);
}
/**
* Get retry count for a message
*/
getRetryCount(messageId) {
return this.retryCounters.get(messageId) || 0;
}
/**
* Check if message has exceeded maximum retry attempts
*/
hasExceededMaxRetries(messageId) {
return this.getRetryCount(messageId) >= this.maxMsgRetryCount;
}
/**
* Mark retry as successful
*/
markRetrySuccess(messageId) {
this.statistics.successfulRetries++;
// Clean up retry counter for successful message
this.retryCounters.delete(messageId);
this.cancelPendingPhoneRequest(messageId);
this.removeRecentMessage(messageId);
}
/**
* Mark retry as failed
*/
markRetryFailed(messageId) {
this.statistics.failedRetries++;
this.retryCounters.delete(messageId);
this.cancelPendingPhoneRequest(messageId);
this.removeRecentMessage(messageId);
}
/**
* Schedule a phone request with delay
*/
schedulePhoneRequest(messageId, callback, delay = PHONE_REQUEST_DELAY) {
// Cancel any existing request for this message
this.cancelPendingPhoneRequest(messageId);
this.pendingPhoneRequests[messageId] = setTimeout(() => {
delete this.pendingPhoneRequests[messageId];
this.statistics.phoneRequests++;
callback();
}, delay);
this.logger.debug(`Scheduled phone request for message ${messageId} with ${delay}ms delay`);
}
/**
* Cancel pending phone request
*/
cancelPendingPhoneRequest(messageId) {
const timeout = this.pendingPhoneRequests[messageId];
if (timeout) {
clearTimeout(timeout);
delete this.pendingPhoneRequests[messageId];
this.logger.debug(`Cancelled pending phone request for message ${messageId}`);
}
}
clear() {
this.recentMessagesMap.clear();
this.messageKeyIndex.clear();
this.sessionRecreateHistory.clear();
this.retryCounters.clear();
this.baseKeys.clear();
for (const messageId of Object.keys(this.pendingPhoneRequests)) {
this.cancelPendingPhoneRequest(messageId);
}
this.statistics = {
totalRetries: 0,
successfulRetries: 0,
failedRetries: 0,
mediaRetries: 0,
sessionRecreations: 0,
phoneRequests: 0
};
}
saveBaseKey(addr, msgId, baseKey) {
this.baseKeys.set(`${addr}:${msgId}`, baseKey);
}
hasSameBaseKey(addr, msgId, baseKey) {
const stored = this.baseKeys.get(`${addr}:${msgId}`);
if (!stored || stored.length !== baseKey.length) {
return false;
}
for (let i = 0; i < stored.length; i++) {
if (stored[i] !== baseKey[i])
return false;
}
return true;
}
deleteBaseKey(addr, msgId) {
this.baseKeys.delete(`${addr}:${msgId}`);
}
keyToString(key) {
return `${key.to}${MESSAGE_KEY_SEPARATOR}${key.id}`;
}
removeRecentMessage(messageId) {
const keyStr = this.messageKeyIndex.get(messageId);
if (!keyStr) {
return;
}
this.recentMessagesMap.delete(keyStr);
this.messageKeyIndex.delete(messageId);
}
}
//# sourceMappingURL=message-retry-manager.js.map