diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index b4a99b05..92cceb76 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -27,6 +27,9 @@ export const WA_DEFAULT_EPHEMERAL = 7 * 24 * 60 * 60 /** Status messages older than 24 hours are considered expired */ export const STATUS_EXPIRY_SECONDS = 24 * 60 * 60 +/** CTWA placeholder messages older than 7 days won't be requested from phone */ +export const PLACEHOLDER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 + export const NOISE_MODE = 'Noise_XX_25519_AESGCM_SHA256\0\0\0\0' export const DICT_VERSION = 3 export const KEY_BUNDLE_TYPE = Buffer.from([5]) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index de15ad01..d5772020 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -3,13 +3,14 @@ import { Boom } from '@hapi/boom' import { randomBytes } from 'crypto' import Long from 'long' import { proto } from '../../WAProto/index.js' -import { DEFAULT_CACHE_TTLS, DEFAULT_SESSION_CLEANUP_CONFIG, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT, STATUS_EXPIRY_SECONDS } from '../Defaults' +import { DEFAULT_CACHE_TTLS, DEFAULT_SESSION_CLEANUP_CONFIG, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT, PLACEHOLDER_MAX_AGE_SECONDS, STATUS_EXPIRY_SECONDS } from '../Defaults' import { metrics, recordMessageReceived, recordHistorySyncMessages, recordMessageRetry, recordMessageFailure } from '../Utils/prometheus-metrics.js' import type { GroupParticipant, MessageReceiptType, MessageRelayOptions, MessageUserReceipt, + PlaceholderMessageData, SocketConfig, WACallEvent, WAMessage, @@ -155,21 +156,28 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { return sendPeerDataOperationMessage(pdoMessage) } - const requestPlaceholderResend = async (messageKey: WAMessageKey): Promise => { + const requestPlaceholderResend = async ( + messageKey: WAMessageKey, + msgData?: PlaceholderMessageData + ): Promise => { if (!authState.creds.me?.id) { throw new Boom('Not authenticated') } - if (await placeholderResendCache.get(messageKey?.id!)) { + // Check if already requested using message ID to prevent duplicate PDO requests + const alreadyRequested = await placeholderResendCache.get(messageKey?.id!) + if (alreadyRequested) { logger.debug({ messageKey }, 'already requested resend') return - } else { - await placeholderResendCache.set(messageKey?.id!, true) } + // Temporarily mark as requested using message ID to prevent race conditions + await placeholderResendCache.set(messageKey?.id!, true) + await delay(2000) - if (!(await placeholderResendCache.get(messageKey?.id!))) { + // Check if message was received during delay + if (!(await placeholderResendCache.get(messageKey?.id!))) { logger.debug({ messageKey }, 'message received while resend requested') return 'RESOLVED' } @@ -183,14 +191,31 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { peerDataOperationRequestType: proto.Message.PeerDataOperationRequestType.PLACEHOLDER_MESSAGE_RESEND } + // Send PDO and get stanzaId (PDO request ID) + const stanzaId = await sendPeerDataOperationMessage(pdoMessage) + + // CRITICAL FIX: Store metadata using stanzaId (not messageKey.id) + // The PDO response will use stanzaId to identify which request it's responding to + if (msgData && stanzaId) { + await placeholderResendCache.set(stanzaId, msgData) + logger.debug( + { messageKey: messageKey.id, stanzaId }, + 'CTWA: Cached metadata using stanzaId for PDO response lookup' + ) + } + + // Clean up message ID marker after storing with stanzaId + await placeholderResendCache.del(messageKey?.id!) + + // Cleanup timeout: if no response after 8s, assume phone is offline setTimeout(async () => { - if (await placeholderResendCache.get(messageKey?.id!)) { - logger.debug({ messageKey }, 'PDO message without response after 8 seconds. Phone possibly offline') - await placeholderResendCache.del(messageKey?.id!) + if (await placeholderResendCache.get(stanzaId)) { + logger.debug({ stanzaId }, 'PDO message without response after 8 seconds. Phone possibly offline') + await placeholderResendCache.del(stanzaId) } }, 8_000) - return sendPeerDataOperationMessage(pdoMessage) + return stanzaId } // Handles mex newsletter notifications @@ -1313,13 +1338,26 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { // These messages are only encrypted for the primary phone, not linked devices // We need to request the message content from the phone via PDO (Peer Data Operation) if (msg.messageStubParameters?.[0] === NO_MESSAGE_FOUND_ERROR_TEXT) { + // Skip unavailable fanout types - these messages will never have content available + // These are system messages that cannot be decrypted or retrieved + const messageType = msg.messageStubParameters?.[2] + if (messageType === 'bot_unavailable_fanout' || + messageType === 'hosted_unavailable_fanout' || + messageType === 'view_once_unavailable_fanout') { + logger.debug( + { msgId: msg.key?.id, messageType }, + 'CTWA: Skipping placeholder resend for unavailable fanout type' + ) + metrics.ctwaRecoveryFailures.inc({ reason: 'unavailable_fanout' }) + return sendMessageAck(node) + } + // Skip old messages - don't request resend for messages older than 7 days const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp) - const MAX_PLACEHOLDER_RESEND_AGE = 7 * 24 * 60 * 60 // 7 days in seconds - if (messageAge > MAX_PLACEHOLDER_RESEND_AGE) { + if (messageAge > PLACEHOLDER_MAX_AGE_SECONDS) { logger.debug( - { msgId: msg.key?.id, messageAge, maxAge: MAX_PLACEHOLDER_RESEND_AGE }, + { msgId: msg.key?.id, messageAge, maxAge: PLACEHOLDER_MAX_AGE_SECONDS }, 'CTWA: Skipping placeholder resend for old message' ) metrics.ctwaRecoveryFailures.inc({ reason: 'message_too_old' }) @@ -1331,6 +1369,17 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const msgId = msg.key.id! const msgKey = msg.key + // Prepare metadata to preserve original message details + // The phone may not send all metadata in PDO response (e.g., pushName, participantAlt) + // Caching these ensures we don't lose critical information like sender name and LID mappings + const msgData: PlaceholderMessageData = { + key: { ...msgKey }, + pushName: msg.pushName, + messageTimestamp: msg.messageTimestamp, + participant: msg.key.participant, + participantAlt: msg.key.participantAlt + } + logger.info( { msgId, remoteJid: msgKey.remoteJid, messageAge }, 'CTWA: Message absent from node detected, scheduling placeholder resend from phone' @@ -1344,7 +1393,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { messageRetryManager.schedulePhoneRequest(msgId, async () => { try { - const requestId = await requestPlaceholderResend(msgKey) + const requestId = await requestPlaceholderResend(msgKey, msgData) if (requestId && requestId !== 'RESOLVED') { logger.debug( { msgId, requestId }, @@ -1382,7 +1431,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { metrics.ctwaRecoveryRequests.inc({ status: 'requested' }) try { - const requestId = await requestPlaceholderResend(msgKey) + const requestId = await requestPlaceholderResend(msgKey, msgData) if (requestId && requestId !== 'RESOLVED') { logger.debug( { msgId, requestId }, diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 66a3c602..4b3acb3d 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -1,5 +1,6 @@ import type { Readable } from 'stream' import type { URL } from 'url' +import Long from 'long' import { proto } from '../../WAProto/index.js' import type { MediaType } from '../Defaults' import type { BinaryNode } from '../WABinary' @@ -24,6 +25,16 @@ export type WAMessageKey = proto.IMessageKey & { addressingMode?: string isViewOnce?: boolean // TODO: remove out of the message key, place in WebMessageInfo } + +/** Metadata cached for CTWA placeholder resend to preserve original message details */ +export type PlaceholderMessageData = { + key: WAMessageKey + pushName?: string | null + messageTimestamp?: number | Long | null + participant?: string | null + participantAlt?: string | null +} + export type WATextMessage = proto.Message.IExtendedTextMessage export type WAContextInfo = proto.IContextInfo export type WALocationMessage = proto.Message.ILocationMessage diff --git a/src/Utils/process-message.ts b/src/Utils/process-message.ts index 78a27a7b..55ca3c7e 100644 --- a/src/Utils/process-message.ts +++ b/src/Utils/process-message.ts @@ -1,3 +1,4 @@ +import Long from 'long' import { proto } from '../../WAProto/index.js' import type { AuthenticationCreds, @@ -8,6 +9,7 @@ import type { GroupParticipant, LIDMapping, ParticipantAction, + PlaceholderMessageData, RequestJoinAction, RequestJoinMethod, SignalKeyStoreWithTransaction, @@ -440,9 +442,17 @@ const processMessage = async ( case proto.Message.ProtocolMessage.Type.PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE: const response = protocolMsg.peerDataOperationRequestResponseMessage if (response) { + // Retrieve cached metadata BEFORE deletion + // This preserves original message details that the phone might not send + const cachedData = response.stanzaId + ? await placeholderResendCache?.get(response.stanzaId) + : undefined + + // Clean up cache after retrieving data if (response.stanzaId) { await placeholderResendCache?.del(response.stanzaId) } + // TODO: IMPLEMENT HISTORY SYNC ETC (sticker uploads etc.). const { peerDataOperationResult } = response if (!peerDataOperationResult) { @@ -460,13 +470,55 @@ const processMessage = async ( const webMessageInfo = proto.WebMessageInfo.decode(retryResponse.webMessageInfoBytes) + // Merge cached metadata with decoded message + // This ensures we don't lose critical information like pushName and LID mappings + if (cachedData && typeof cachedData === 'object') { + // Preserve pushName if not present in PDO response + if (cachedData.pushName && !webMessageInfo.pushName) { + webMessageInfo.pushName = cachedData.pushName + logger?.debug( + { msgId: webMessageInfo.key?.id }, + 'CTWA: Restored pushName from cached metadata' + ) + } + + // Preserve participantAlt (LID) if not present in PDO response + // This is critical for maintaining LID/PN mapping in groups + if (cachedData.participantAlt && webMessageInfo.key) { + const msgKey = webMessageInfo.key as WAMessageKey + if (!msgKey.participantAlt) { + msgKey.participantAlt = cachedData.participantAlt + logger?.debug( + { msgId: webMessageInfo.key?.id, participantAlt: cachedData.participantAlt }, + 'CTWA: Restored participantAlt (LID) from cached metadata' + ) + } + } + + // Preserve original participant if not in PDO response + if (cachedData.participant && webMessageInfo.key && !webMessageInfo.key.participant) { + webMessageInfo.key.participant = cachedData.participant + logger?.debug( + { msgId: webMessageInfo.key?.id }, + 'CTWA: Restored participant from cached metadata' + ) + } + + // Only use cached timestamp if PDO response doesn't have one + // PDO response timestamp is more authoritative if present + if (!webMessageInfo.messageTimestamp && cachedData.messageTimestamp) { + webMessageInfo.messageTimestamp = cachedData.messageTimestamp + } + } + // Track CTWA message recovery success recoveredCount++ logger?.info( { msgId: webMessageInfo.key?.id, remoteJid: webMessageInfo.key?.remoteJid, - requestId: response.stanzaId + requestId: response.stanzaId, + hasMetadata: !!cachedData && typeof cachedData === 'object' }, 'CTWA: Successfully recovered message via placeholder resend' )