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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user