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 */ /** Status messages older than 24 hours are considered expired */
export const STATUS_EXPIRY_SECONDS = 24 * 60 * 60 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 NOISE_MODE = 'Noise_XX_25519_AESGCM_SHA256\0\0\0\0'
export const DICT_VERSION = 3 export const DICT_VERSION = 3
export const KEY_BUNDLE_TYPE = Buffer.from([5]) export const KEY_BUNDLE_TYPE = Buffer.from([5])
+71 -3
View File
@@ -3,7 +3,13 @@ import { Boom } from '@hapi/boom'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import Long from 'long' import Long from 'long'
import { proto } from '../../WAProto/index.js' 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 { import type {
GroupParticipant, GroupParticipant,
MessageReceiptType, MessageReceiptType,
@@ -141,7 +147,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return sendPeerDataOperationMessage(pdoMessage) 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) { if (!authState.creds.me?.id) {
throw new Boom('Not authenticated') throw new Boom('Not authenticated')
} }
@@ -150,7 +159,9 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
logger.debug({ messageKey }, 'already requested resend') logger.debug({ messageKey }, 'already requested resend')
return return
} else { } 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) await delay(2000)
@@ -1227,9 +1238,65 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
if (msg.messageStubParameters?.[0] === NO_MESSAGE_FOUND_ERROR_TEXT) { if (msg.messageStubParameters?.[0] === NO_MESSAGE_FOUND_ERROR_TEXT) {
// 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, unavailableType },
'skipping placeholder resend for excluded unavailable type'
)
return sendMessageAck(node) return sendMessageAck(node)
} }
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)
}
// 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] }
}
])
}
})
.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) // Skip retry for expired status messages (>24h old)
if (isJidStatusBroadcast(msg.key.remoteJid!)) { if (isJidStatusBroadcast(msg.key.remoteJid!)) {
const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp) const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp)
@@ -1287,6 +1354,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await sendMessageAck(node, NACK_REASONS.UnhandledError) await sendMessageAck(node, NACK_REASONS.UnhandledError)
}) })
}
} else { } else {
if (messageRetryManager && msg.key.id) { if (messageRetryManager && msg.key.id) {
messageRetryManager.cancelPendingPhoneRequest(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: case proto.Message.ProtocolMessage.Type.PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE:
const response = protocolMsg.peerDataOperationRequestResponseMessage! const response = protocolMsg.peerDataOperationRequestResponseMessage!
if (response) { if (response) {
await placeholderResendCache?.del(response.stanzaId!)
// TODO: IMPLEMENT HISTORY SYNC ETC (sticker uploads etc.). // TODO: IMPLEMENT HISTORY SYNC ETC (sticker uploads etc.).
const { peerDataOperationResult } = response const peerDataOperationResult = response.peerDataOperationResult || []
for (const result of peerDataOperationResult!) { for (const result of peerDataOperationResult) {
const { placeholderMessageResendResponse: retryResponse } = result const retryResponse = result?.placeholderMessageResendResponse
//eslint-disable-next-line max-depth //eslint-disable-next-line max-depth
if (retryResponse) { if (!retryResponse?.webMessageInfoBytes) {
const webMessageInfo = proto.WebMessageInfo.decode(retryResponse.webMessageInfoBytes!) continue
// 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)
//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', { ev.emit('messages.upsert', {
messages: [webMessageInfo as WAMessage], messages: [finalMsg],
type: 'notify', type: 'notify',
requestId: response.stanzaId! requestId: response.stanzaId!
}) })
} catch (err) {
logger?.warn({ err, stanzaId: response.stanzaId }, 'failed to decode placeholder resend response')
} }
} }
} }