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 */
|
/** 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])
|
||||||
|
|||||||
+121
-53
@@ -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,66 +1238,123 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.messageStubParameters?.[0] === NO_MESSAGE_FOUND_ERROR_TEXT) {
|
if (msg.messageStubParameters?.[0] === NO_MESSAGE_FOUND_ERROR_TEXT) {
|
||||||
return sendMessageAck(node)
|
// 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')
|
||||||
// Skip retry for expired status messages (>24h old)
|
const unavailableType = unavailableNode?.attrs?.type
|
||||||
if (isJidStatusBroadcast(msg.key.remoteJid!)) {
|
if (
|
||||||
const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp)
|
unavailableType === 'bot_unavailable_fanout' ||
|
||||||
if (messageAge > STATUS_EXPIRY_SECONDS) {
|
unavailableType === 'hosted_unavailable_fanout' ||
|
||||||
|
unavailableType === 'view_once_unavailable_fanout'
|
||||||
|
) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ msgId: msg.key.id, messageAge, remoteJid: msg.key.remoteJid },
|
{ msgId: msg.key.id, unavailableType },
|
||||||
'skipping retry for expired status message'
|
'skipping placeholder resend for excluded unavailable type'
|
||||||
)
|
)
|
||||||
return sendMessageAck(node)
|
return sendMessageAck(node)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage = msg?.messageStubParameters?.[0] || ''
|
const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp)
|
||||||
const isPreKeyError = errorMessage.includes('PreKey')
|
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`)
|
// Request the real content from the phone via placeholder resend PDO.
|
||||||
|
// Upsert the CIPHERTEXT stub as a placeholder (like WA Web's processPlaceholderMsg),
|
||||||
// Handle both pre-key and normal retries in single mutex
|
// and store the requestId in stubParameters[1] so users can correlate
|
||||||
await retryMutex.mutex(async () => {
|
// with the incoming PDO response event.
|
||||||
try {
|
const cleanKey: proto.IMessageKey = {
|
||||||
if (!ws.isOpen) {
|
remoteJid: msg.key.remoteJid,
|
||||||
logger.debug({ node }, 'Connection closed, skipping retry')
|
fromMe: msg.key.fromMe,
|
||||||
return
|
id: msg.key.id,
|
||||||
}
|
participant: msg.key.participant
|
||||||
|
}
|
||||||
// Handle pre-key errors with upload and delay
|
// Cache the original message metadata so the PDO response handler
|
||||||
if (isPreKeyError) {
|
// can preserve key fields (LID details etc.) that the phone may omit
|
||||||
logger.info({ error: errorMessage }, 'PreKey error detected, uploading and retrying')
|
const msgData: Partial<WAMessage> = {
|
||||||
|
key: msg.key,
|
||||||
try {
|
messageTimestamp: msg.messageTimestamp,
|
||||||
logger.debug('Uploading pre-keys for error recovery')
|
pushName: msg.pushName,
|
||||||
await uploadPreKeys(5)
|
participant: msg.participant,
|
||||||
logger.debug('Waiting for server to process new pre-keys')
|
verifiedBizName: msg.verifiedBizName
|
||||||
await delay(1000)
|
}
|
||||||
} catch (uploadErr) {
|
requestPlaceholderResend(cleanKey, msgData)
|
||||||
logger.error({ uploadErr }, 'Pre-key upload failed, proceeding with retry anyway')
|
.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 => {
|
||||||
const encNode = getBinaryNodeChild(node, 'enc')
|
logger.warn({ err, msgId: msg.key.id }, 'failed to request placeholder resend for unavailable message')
|
||||||
await sendRetryRequest(node, !encNode)
|
})
|
||||||
if (retryRequestDelayMs) {
|
await sendMessageAck(node)
|
||||||
await delay(retryRequestDelayMs)
|
// Don't return — fall through to upsertMessage so the stub is emitted
|
||||||
}
|
} else {
|
||||||
} catch (err) {
|
// Skip retry for expired status messages (>24h old)
|
||||||
logger.error({ err, isPreKeyError }, 'Failed to handle retry, attempting basic retry')
|
if (isJidStatusBroadcast(msg.key.remoteJid!)) {
|
||||||
// Still attempt retry even if pre-key upload failed
|
const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp)
|
||||||
try {
|
if (messageAge > STATUS_EXPIRY_SECONDS) {
|
||||||
const encNode = getBinaryNodeChild(node, 'enc')
|
logger.debug(
|
||||||
await sendRetryRequest(node, !encNode)
|
{ msgId: msg.key.id, messageAge, remoteJid: msg.key.remoteJid },
|
||||||
} catch (retryErr) {
|
'skipping retry for expired status message'
|
||||||
logger.error({ retryErr }, 'Failed to send retry after error handling')
|
)
|
||||||
|
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 {
|
} else {
|
||||||
if (messageRetryManager && msg.key.id) {
|
if (messageRetryManager && msg.key.id) {
|
||||||
messageRetryManager.cancelPendingPhoneRequest(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:
|
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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user