feat(ctwa): enhance placeholder resend with metadata preservation

feat(ctwa): enhance placeholder resend with metadata preservation
This commit is contained in:
Renato Alcara
2026-02-11 12:31:19 -03:00
committed by GitHub
4 changed files with 131 additions and 16 deletions
+3
View File
@@ -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])
+64 -15
View File
@@ -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<string | undefined> => {
const requestPlaceholderResend = async (
messageKey: WAMessageKey,
msgData?: PlaceholderMessageData
): Promise<string | undefined> => {
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<PlaceholderMessageData | boolean>(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<PlaceholderMessageData | boolean>(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<PlaceholderMessageData | boolean>(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 },
+11
View File
@@ -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
+53 -1
View File
@@ -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<PlaceholderMessageData | boolean>(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'
)