fix: request placeholder resend for messages without encryption (CTWAads) (#2334)

* fix: request placeholder resend for messages without encryption (CTWA ads)

* fix: implement placeholder resend cache management and metadata preservation
This commit is contained in:
João Lucas
2026-02-11 06:56:23 -03:00
committed by GitHub
parent 23156c833e
commit 7a5b090616
3 changed files with 163 additions and 62 deletions
+3
View File
@@ -25,6 +25,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
/** WA Web enforces a 14-day maximum age for placeholder resend requests */
export const PLACEHOLDER_MAX_AGE_SECONDS = 14 * 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])
+121 -53
View File
@@ -3,7 +3,13 @@ import { Boom } from '@hapi/boom'
import { randomBytes } from 'crypto'
import Long from 'long'
import { proto } from '../../WAProto/index.js'
import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT, STATUS_EXPIRY_SECONDS } from '../Defaults'
import {
DEFAULT_CACHE_TTLS,
KEY_BUNDLE_TYPE,
MIN_PREKEY_COUNT,
PLACEHOLDER_MAX_AGE_SECONDS,
STATUS_EXPIRY_SECONDS
} from '../Defaults'
import type {
GroupParticipant,
MessageReceiptType,
@@ -141,7 +147,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return sendPeerDataOperationMessage(pdoMessage)
}
const requestPlaceholderResend = async (messageKey: WAMessageKey): Promise<string | undefined> => {
const requestPlaceholderResend = async (
messageKey: WAMessageKey,
msgData?: Partial<WAMessage>
): Promise<string | undefined> => {
if (!authState.creds.me?.id) {
throw new Boom('Not authenticated')
}
@@ -150,7 +159,9 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
logger.debug({ messageKey }, 'already requested resend')
return
} else {
await placeholderResendCache.set(messageKey?.id!, true)
// Store original message data so PDO response handler can preserve
// metadata (LID details, timestamps, etc.) that the phone may omit
await placeholderResendCache.set(messageKey?.id!, msgData || true)
}
await delay(2000)
@@ -1227,66 +1238,123 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}
if (msg.messageStubParameters?.[0] === NO_MESSAGE_FOUND_ERROR_TEXT) {
return sendMessageAck(node)
}
// Skip retry for expired status messages (>24h old)
if (isJidStatusBroadcast(msg.key.remoteJid!)) {
const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp)
if (messageAge > STATUS_EXPIRY_SECONDS) {
// Message arrived without encryption (e.g. CTWA ads messages).
// Check if this is eligible for placeholder resend (matching WA Web filters).
const unavailableNode = getBinaryNodeChild(node, 'unavailable')
const unavailableType = unavailableNode?.attrs?.type
if (
unavailableType === 'bot_unavailable_fanout' ||
unavailableType === 'hosted_unavailable_fanout' ||
unavailableType === 'view_once_unavailable_fanout'
) {
logger.debug(
{ msgId: msg.key.id, messageAge, remoteJid: msg.key.remoteJid },
'skipping retry for expired status message'
{ msgId: msg.key.id, unavailableType },
'skipping placeholder resend for excluded unavailable type'
)
return sendMessageAck(node)
}
}
const errorMessage = msg?.messageStubParameters?.[0] || ''
const isPreKeyError = errorMessage.includes('PreKey')
const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp)
if (messageAge > PLACEHOLDER_MAX_AGE_SECONDS) {
logger.debug({ msgId: msg.key.id, messageAge }, 'skipping placeholder resend for old message')
return sendMessageAck(node)
}
logger.debug(`[handleMessage] Attempting retry request for failed decryption`)
// Handle both pre-key and normal retries in single mutex
await retryMutex.mutex(async () => {
try {
if (!ws.isOpen) {
logger.debug({ node }, 'Connection closed, skipping retry')
return
}
// Handle pre-key errors with upload and delay
if (isPreKeyError) {
logger.info({ error: errorMessage }, 'PreKey error detected, uploading and retrying')
try {
logger.debug('Uploading pre-keys for error recovery')
await uploadPreKeys(5)
logger.debug('Waiting for server to process new pre-keys')
await delay(1000)
} catch (uploadErr) {
logger.error({ uploadErr }, 'Pre-key upload failed, proceeding with retry anyway')
// Request the real content from the phone via placeholder resend PDO.
// Upsert the CIPHERTEXT stub as a placeholder (like WA Web's processPlaceholderMsg),
// and store the requestId in stubParameters[1] so users can correlate
// with the incoming PDO response event.
const cleanKey: proto.IMessageKey = {
remoteJid: msg.key.remoteJid,
fromMe: msg.key.fromMe,
id: msg.key.id,
participant: msg.key.participant
}
// Cache the original message metadata so the PDO response handler
// can preserve key fields (LID details etc.) that the phone may omit
const msgData: Partial<WAMessage> = {
key: msg.key,
messageTimestamp: msg.messageTimestamp,
pushName: msg.pushName,
participant: msg.participant,
verifiedBizName: msg.verifiedBizName
}
requestPlaceholderResend(cleanKey, msgData)
.then(requestId => {
if (requestId && requestId !== 'RESOLVED') {
logger.debug({ msgId: msg.key.id, requestId }, 'requested placeholder resend for unavailable message')
ev.emit('messages.update', [
{
key: msg.key,
update: { messageStubParameters: [NO_MESSAGE_FOUND_ERROR_TEXT, requestId] }
}
])
}
}
const encNode = getBinaryNodeChild(node, 'enc')
await sendRetryRequest(node, !encNode)
if (retryRequestDelayMs) {
await delay(retryRequestDelayMs)
}
} catch (err) {
logger.error({ err, isPreKeyError }, 'Failed to handle retry, attempting basic retry')
// Still attempt retry even if pre-key upload failed
try {
const encNode = getBinaryNodeChild(node, 'enc')
await sendRetryRequest(node, !encNode)
} catch (retryErr) {
logger.error({ retryErr }, 'Failed to send retry after error handling')
})
.catch(err => {
logger.warn({ err, msgId: msg.key.id }, 'failed to request placeholder resend for unavailable message')
})
await sendMessageAck(node)
// Don't return — fall through to upsertMessage so the stub is emitted
} else {
// Skip retry for expired status messages (>24h old)
if (isJidStatusBroadcast(msg.key.remoteJid!)) {
const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp)
if (messageAge > STATUS_EXPIRY_SECONDS) {
logger.debug(
{ msgId: msg.key.id, messageAge, remoteJid: msg.key.remoteJid },
'skipping retry for expired status message'
)
return sendMessageAck(node)
}
}
await sendMessageAck(node, NACK_REASONS.UnhandledError)
})
const errorMessage = msg?.messageStubParameters?.[0] || ''
const isPreKeyError = errorMessage.includes('PreKey')
logger.debug(`[handleMessage] Attempting retry request for failed decryption`)
// Handle both pre-key and normal retries in single mutex
await retryMutex.mutex(async () => {
try {
if (!ws.isOpen) {
logger.debug({ node }, 'Connection closed, skipping retry')
return
}
// Handle pre-key errors with upload and delay
if (isPreKeyError) {
logger.info({ error: errorMessage }, 'PreKey error detected, uploading and retrying')
try {
logger.debug('Uploading pre-keys for error recovery')
await uploadPreKeys(5)
logger.debug('Waiting for server to process new pre-keys')
await delay(1000)
} catch (uploadErr) {
logger.error({ uploadErr }, 'Pre-key upload failed, proceeding with retry anyway')
}
}
const encNode = getBinaryNodeChild(node, 'enc')
await sendRetryRequest(node, !encNode)
if (retryRequestDelayMs) {
await delay(retryRequestDelayMs)
}
} catch (err) {
logger.error({ err, isPreKeyError }, 'Failed to handle retry, attempting basic retry')
// Still attempt retry even if pre-key upload failed
try {
const encNode = getBinaryNodeChild(node, 'enc')
await sendRetryRequest(node, !encNode)
} catch (retryErr) {
logger.error({ retryErr }, 'Failed to send retry after error handling')
}
}
await sendMessageAck(node, NACK_REASONS.UnhandledError)
})
}
} else {
if (messageRetryManager && msg.key.id) {
messageRetryManager.cancelPendingPhoneRequest(msg.key.id)
+39 -9
View File
@@ -346,21 +346,51 @@ const processMessage = async (
case proto.Message.ProtocolMessage.Type.PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE:
const response = protocolMsg.peerDataOperationRequestResponseMessage!
if (response) {
await placeholderResendCache?.del(response.stanzaId!)
// TODO: IMPLEMENT HISTORY SYNC ETC (sticker uploads etc.).
const { peerDataOperationResult } = response
for (const result of peerDataOperationResult!) {
const { placeholderMessageResendResponse: retryResponse } = result
const peerDataOperationResult = response.peerDataOperationResult || []
for (const result of peerDataOperationResult) {
const retryResponse = result?.placeholderMessageResendResponse
//eslint-disable-next-line max-depth
if (retryResponse) {
const webMessageInfo = proto.WebMessageInfo.decode(retryResponse.webMessageInfoBytes!)
// wait till another upsert event is available, don't want it to be part of the PDO response message
// TODO: parse through proper message handling utilities (to add relevant key fields)
if (!retryResponse?.webMessageInfoBytes) {
continue
}
//eslint-disable-next-line max-depth
try {
const webMessageInfo = proto.WebMessageInfo.decode(retryResponse.webMessageInfoBytes)
const msgId = webMessageInfo.key?.id
// Retrieve cached original message data (preserves LID details,
// timestamps, etc. that the phone may omit in its PDO response)
const cachedData = msgId ? await placeholderResendCache?.get<Partial<WAMessage> | true>(msgId) : undefined
//eslint-disable-next-line max-depth
if (msgId) {
await placeholderResendCache?.del(msgId)
}
let finalMsg: WAMessage
//eslint-disable-next-line max-depth
if (cachedData && typeof cachedData === 'object') {
// Apply decoded message content onto cached metadata (preserves LID etc.)
cachedData.message = webMessageInfo.message
//eslint-disable-next-line max-depth
if (webMessageInfo.messageTimestamp) {
cachedData.messageTimestamp = webMessageInfo.messageTimestamp
}
finalMsg = cachedData as WAMessage
} else {
finalMsg = webMessageInfo as WAMessage
}
logger?.debug({ msgId, requestId: response.stanzaId }, 'received placeholder resend')
ev.emit('messages.upsert', {
messages: [webMessageInfo as WAMessage],
messages: [finalMsg],
type: 'notify',
requestId: response.stanzaId!
})
} catch (err) {
logger?.warn({ err, stanzaId: response.stanzaId }, 'failed to decode placeholder resend response')
}
}
}