feat: add automatic CTWA (Click-to-WhatsApp) ads message recovery

Messages from Facebook/Instagram ads (Click-to-WhatsApp) don't arrive on
linked devices because Meta's ads endpoint doesn't encrypt for multi-device.
They arrive as "Message absent from node" placeholders.

This change automatically requests the message from the primary phone via
PDO (Peer Data Operation) when a CTWA placeholder is detected.

Changes:
- Add enableCTWARecovery config option (default: true)
- Trigger requestPlaceholderResend() for "Message absent from node" errors
- Add Prometheus metrics for CTWA recovery tracking
- Add comprehensive unit tests for CTWA recovery functionality

Resolves: https://github.com/WhiskeySockets/Baileys/issues/1723
Resolves: https://github.com/WhiskeySockets/Baileys/issues/1034
This commit is contained in:
Claude
2026-01-21 14:56:39 +00:00
parent af6f2eede7
commit 5a36ae20d4
6 changed files with 362 additions and 6 deletions
+24
View File
@@ -16,6 +16,7 @@ import type {
WAMessage,
WAMessageKey
} from '../Types'
import { metrics } from './prometheus-metrics.js'
import { WAMessageStubType } from '../Types'
import { getContentType, normalizeMessageContent } from '../Utils/messages'
import {
@@ -402,11 +403,24 @@ const processMessage = async (
await placeholderResendCache?.del(response.stanzaId!)
// TODO: IMPLEMENT HISTORY SYNC ETC (sticker uploads etc.).
const { peerDataOperationResult } = response
let recoveredCount = 0
for (const result of peerDataOperationResult!) {
const { placeholderMessageResendResponse: retryResponse } = result
//eslint-disable-next-line max-depth
if (retryResponse) {
const webMessageInfo = proto.WebMessageInfo.decode(retryResponse.webMessageInfoBytes!)
// Track CTWA message recovery success
recoveredCount++
logger?.info(
{
msgId: webMessageInfo.key?.id,
remoteJid: webMessageInfo.key?.remoteJid,
requestId: response.stanzaId
},
'CTWA: Successfully recovered message via placeholder resend'
)
// 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)
ev.emit('messages.upsert', {
@@ -416,6 +430,16 @@ const processMessage = async (
})
}
}
// Update metrics for recovered messages
if (recoveredCount > 0) {
metrics.ctwaMessagesRecovered.inc(recoveredCount)
metrics.ctwaRecoveryRequests.inc({ status: 'success' })
logger?.debug(
{ recoveredCount, requestId: response.stanzaId },
'CTWA: Placeholder resend response processed'
)
}
}
break
+18
View File
@@ -1385,6 +1385,24 @@ export const metrics = {
new Gauge('messages_queued', 'Current number of messages in queue', ['priority'])
),
// ========== CTWA (Click-to-WhatsApp Ads) Metrics ==========
/**
* Messages from Facebook/Instagram ads that arrive as placeholder messages
* and are recovered via PDO (Peer Data Operation) request
*/
ctwaRecoveryRequests: baileysMetrics.register(
new Counter('ctwa_recovery_requests_total', 'Total CTWA placeholder resend requests', ['status'])
),
ctwaMessagesRecovered: baileysMetrics.register(
new Counter('ctwa_messages_recovered_total', 'Total CTWA messages successfully recovered')
),
ctwaRecoveryLatency: baileysMetrics.register(
new Histogram('ctwa_recovery_latency_ms', 'CTWA message recovery latency in ms', [], [500, 1000, 2000, 3000, 5000, 8000, 10000])
),
ctwaRecoveryFailures: baileysMetrics.register(
new Counter('ctwa_recovery_failures_total', 'Total CTWA recovery failures', ['reason'])
),
// ========== Media Metrics ==========
mediaUploads: baileysMetrics.register(
new Counter('media_uploads_total', 'Total media uploads', ['type', 'status'])