diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 78cce3b7..8236a116 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -70,6 +70,9 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { generateHighQualityLinkPreview: false, enableAutoSessionRecreation: true, enableRecentMessageCache: true, + // Enable automatic recovery of Click-to-WhatsApp ads messages + // These arrive as "placeholder messages" and need to be requested from the phone + enableCTWARecovery: true, options: {}, appStateMacVerification: { patch: false, diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index c7844c1d..707a884d 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -4,6 +4,7 @@ import { randomBytes } from 'crypto' import Long from 'long' import { proto } from '../../WAProto/index.js' import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT } from '../Defaults' +import { metrics } from '../Utils/prometheus-metrics.js' import type { GroupParticipant, MessageReceiptType, @@ -67,8 +68,15 @@ import { extractGroupMetadata } from './groups' import { makeMessagesSocket } from './messages-send' export const makeMessagesRecvSocket = (config: SocketConfig) => { - const { logger, retryRequestDelayMs, maxMsgRetryCount, getMessage, shouldIgnoreJid, enableAutoSessionRecreation } = - config + const { + logger, + retryRequestDelayMs, + maxMsgRetryCount, + getMessage, + shouldIgnoreJid, + enableAutoSessionRecreation, + enableCTWARecovery + } = config const sock = makeMessagesSocket(config) const { ev, @@ -1227,10 +1235,63 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { await decrypt() // message failed to decrypt if (msg.messageStubType === proto.WebMessageInfo.StubType.CIPHERTEXT && msg.category !== 'peer') { - if ( - msg?.messageStubParameters?.[0] === MISSING_KEYS_ERROR_TEXT || - msg.messageStubParameters?.[0] === NO_MESSAGE_FOUND_ERROR_TEXT - ) { + // Handle "Missing keys" - standard decryption failure, just ACK + if (msg?.messageStubParameters?.[0] === MISSING_KEYS_ERROR_TEXT) { + return sendMessageAck(node) + } + + // Handle "Message absent from node" - likely a CTWA (Click-to-WhatsApp) ads message + // 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) { + if (enableCTWARecovery && msg.key) { + const startTime = Date.now() + logger.info( + { msgId: msg.key.id, remoteJid: msg.key.remoteJid }, + 'CTWA: Message absent from node detected, requesting placeholder resend from phone' + ) + + try { + metrics.ctwaRecoveryRequests.inc({ status: 'requested' }) + + const requestId = await requestPlaceholderResend(msg.key) + if (requestId) { + logger.debug( + { msgId: msg.key.id, requestId }, + 'CTWA: Placeholder resend request sent successfully' + ) + // Note: The actual message will be emitted via 'messages.upsert' + // when the PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE is processed + // in process-message.ts:399-421 + } else if (requestId === 'RESOLVED') { + // Message was received while we were waiting + logger.debug( + { msgId: msg.key.id }, + 'CTWA: Message received during resend delay' + ) + metrics.ctwaMessagesRecovered.inc() + metrics.ctwaRecoveryLatency.observe(Date.now() - startTime) + } else { + // Already requested (duplicate request prevented by cache) + logger.debug( + { msgId: msg.key.id }, + 'CTWA: Resend already requested, skipping duplicate' + ) + } + } catch (error) { + logger.warn( + { error, msgId: msg.key.id }, + 'CTWA: Failed to request placeholder resend' + ) + metrics.ctwaRecoveryFailures.inc({ reason: 'request_failed' }) + } + } else { + logger.debug( + { msgId: msg.key?.id, enableCTWARecovery }, + 'CTWA recovery disabled or missing key, skipping placeholder resend' + ) + } + return sendMessageAck(node) } diff --git a/src/Types/Socket.ts b/src/Types/Socket.ts index a8299896..4a3f492c 100644 --- a/src/Types/Socket.ts +++ b/src/Types/Socket.ts @@ -107,6 +107,21 @@ export type SocketConfig = { /** Enable recent message caching for retry handling */ enableRecentMessageCache: boolean + /** + * Enable automatic recovery of Click-to-WhatsApp (CTWA) ads messages. + * + * When enabled, messages from Facebook/Instagram ads that arrive as + * "placeholder messages" (Message absent from node) will be automatically + * recovered by requesting resend from the primary phone device. + * + * This is necessary because Meta's ads endpoint doesn't encrypt messages + * for linked devices - they only arrive on the primary phone. + * + * @default true + * @see https://github.com/WhiskeySockets/Baileys/issues/1723 + */ + enableCTWARecovery: boolean + /** * Returns if a jid should be ignored, * no event for that jid will be triggered. diff --git a/src/Utils/process-message.ts b/src/Utils/process-message.ts index 969ec3f2..3487232b 100644 --- a/src/Utils/process-message.ts +++ b/src/Utils/process-message.ts @@ -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 diff --git a/src/Utils/prometheus-metrics.ts b/src/Utils/prometheus-metrics.ts index 619edebc..27480cb1 100644 --- a/src/Utils/prometheus-metrics.ts +++ b/src/Utils/prometheus-metrics.ts @@ -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']) diff --git a/src/__tests__/Utils/ctwa-recovery.test.ts b/src/__tests__/Utils/ctwa-recovery.test.ts new file mode 100644 index 00000000..fc038494 --- /dev/null +++ b/src/__tests__/Utils/ctwa-recovery.test.ts @@ -0,0 +1,235 @@ +/** + * Tests for CTWA (Click-to-WhatsApp) Ads Message Recovery + * + * This tests the functionality that recovers messages from Facebook/Instagram ads + * that arrive as "placeholder messages" because Meta's ads endpoint doesn't + * encrypt messages for linked devices. + * + * @see https://github.com/WhiskeySockets/Baileys/issues/1723 + */ + +import { proto } from '../../../WAProto/index.js' +import { metrics } from '../../Utils/prometheus-metrics.js' +import { NO_MESSAGE_FOUND_ERROR_TEXT } from '../../Utils/decode-wa-message.js' + +describe('CTWA Recovery', () => { + describe('Message Detection', () => { + it('should correctly identify NO_MESSAGE_FOUND_ERROR_TEXT constant', () => { + expect(NO_MESSAGE_FOUND_ERROR_TEXT).toBe('Message absent from node') + }) + + it('should match the stubType for CIPHERTEXT messages', () => { + const stubType = proto.WebMessageInfo.StubType.CIPHERTEXT + expect(stubType).toBeDefined() + expect(typeof stubType).toBe('number') + }) + }) + + describe('Placeholder Resend Protocol', () => { + it('should have PLACEHOLDER_MESSAGE_RESEND in PeerDataOperationRequestType', () => { + const requestType = proto.Message.PeerDataOperationRequestType.PLACEHOLDER_MESSAGE_RESEND + expect(requestType).toBeDefined() + expect(typeof requestType).toBe('number') + }) + + it('should have PEER_DATA_OPERATION_REQUEST_MESSAGE in ProtocolMessage.Type', () => { + const messageType = proto.Message.ProtocolMessage.Type.PEER_DATA_OPERATION_REQUEST_MESSAGE + expect(messageType).toBeDefined() + expect(typeof messageType).toBe('number') + }) + + it('should have PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE in ProtocolMessage.Type', () => { + const responseType = proto.Message.ProtocolMessage.Type.PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE + expect(responseType).toBeDefined() + expect(typeof responseType).toBe('number') + }) + }) + + describe('PDO Request Structure', () => { + it('should create valid PDO message structure for placeholder resend', () => { + const messageKey = { + remoteJid: 'user@s.whatsapp.net', + fromMe: false, + id: 'TEST_MSG_ID' + } + + const pdoMessage: proto.Message.IPeerDataOperationRequestMessage = { + placeholderMessageResendRequest: [ + { + messageKey + } + ], + peerDataOperationRequestType: proto.Message.PeerDataOperationRequestType.PLACEHOLDER_MESSAGE_RESEND + } + + expect(pdoMessage.placeholderMessageResendRequest).toHaveLength(1) + expect(pdoMessage.placeholderMessageResendRequest?.[0]?.messageKey).toEqual(messageKey) + expect(pdoMessage.peerDataOperationRequestType).toBe( + proto.Message.PeerDataOperationRequestType.PLACEHOLDER_MESSAGE_RESEND + ) + }) + }) + + describe('PDO Response Processing', () => { + it('should decode webMessageInfoBytes from placeholder resend response', () => { + // Create a minimal WebMessageInfo + const originalMessage: proto.IWebMessageInfo = { + key: { + remoteJid: 'user@s.whatsapp.net', + fromMe: false, + id: 'RECOVERED_MSG_ID' + }, + message: { + conversation: 'Hello from Facebook ad!' + }, + messageTimestamp: Math.floor(Date.now() / 1000) + } + + // Encode it + const encoded = proto.WebMessageInfo.encode(originalMessage).finish() + + // Decode it back (simulating what process-message.ts does) + const decoded = proto.WebMessageInfo.decode(encoded) + + expect(decoded.key?.id).toBe('RECOVERED_MSG_ID') + expect(decoded.key?.remoteJid).toBe('user@s.whatsapp.net') + expect(decoded.message?.conversation).toBe('Hello from Facebook ad!') + }) + + it('should handle empty webMessageInfoBytes gracefully', () => { + const emptyBuffer = Buffer.alloc(0) + + // Empty buffer returns empty object (protobuf default behavior) + const result = proto.WebMessageInfo.decode(emptyBuffer) + expect(result).toBeDefined() + // Key fields will be null for empty buffer (protobuf default) + expect(result.key).toBeNull() + }) + }) + + describe('Metrics Integration', () => { + it('should have all required CTWA metrics defined', () => { + expect(metrics.ctwaRecoveryRequests).toBeDefined() + expect(typeof metrics.ctwaRecoveryRequests.inc).toBe('function') + + expect(metrics.ctwaMessagesRecovered).toBeDefined() + expect(typeof metrics.ctwaMessagesRecovered.inc).toBe('function') + + expect(metrics.ctwaRecoveryLatency).toBeDefined() + expect(typeof metrics.ctwaRecoveryLatency.observe).toBe('function') + + expect(metrics.ctwaRecoveryFailures).toBeDefined() + expect(typeof metrics.ctwaRecoveryFailures.inc).toBe('function') + }) + + it('should be able to call recovery request metric with status label', () => { + // Should not throw when called with valid labels + expect(() => { + metrics.ctwaRecoveryRequests.inc({ status: 'requested' }) + }).not.toThrow() + }) + + it('should be able to call recovered messages counter', () => { + // Should not throw when called + expect(() => { + metrics.ctwaMessagesRecovered.inc() + }).not.toThrow() + }) + + it('should be able to observe recovery latency', () => { + const latencyMs = 2500 + // Should not throw when called with valid latency + expect(() => { + metrics.ctwaRecoveryLatency.observe(latencyMs) + }).not.toThrow() + }) + + it('should be able to call failure counter with reason', () => { + // Should not throw when called with valid labels + expect(() => { + metrics.ctwaRecoveryFailures.inc({ reason: 'request_failed' }) + }).not.toThrow() + }) + }) + + describe('Configuration', () => { + it('should have enableCTWARecovery as a valid configuration option', () => { + // This tests that the type system accepts enableCTWARecovery + const config = { + enableCTWARecovery: true + } + + expect(config.enableCTWARecovery).toBe(true) + }) + + it('should default enableCTWARecovery to true', async () => { + // Import the defaults + const { DEFAULT_CONNECTION_CONFIG } = await import('../../Defaults/index.js') + + // The config should have enableCTWARecovery defaulting to true + expect(DEFAULT_CONNECTION_CONFIG.enableCTWARecovery).toBe(true) + }) + }) +}) + +describe('CTWA Message Scenarios', () => { + describe('Facebook Ads Click-to-WhatsApp', () => { + it('should identify a typical CTWA stub message', () => { + // A typical CTWA message arrives like this + const ctwaStubMessage = { + key: { + remoteJid: 'lead@s.whatsapp.net', + fromMe: false, + id: 'CTWA_MSG_12345' + }, + messageStubType: proto.WebMessageInfo.StubType.CIPHERTEXT, + messageStubParameters: ['Message absent from node'], + messageTimestamp: Math.floor(Date.now() / 1000) + } + + // Verify it matches the detection criteria + expect(ctwaStubMessage.messageStubType).toBe(proto.WebMessageInfo.StubType.CIPHERTEXT) + expect(ctwaStubMessage.messageStubParameters?.[0]).toBe(NO_MESSAGE_FOUND_ERROR_TEXT) + }) + + it('should differentiate CTWA from other CIPHERTEXT errors', () => { + const preKeyError = { + messageStubType: proto.WebMessageInfo.StubType.CIPHERTEXT, + messageStubParameters: ['PreKey not found'] + } + + const missingKeysError = { + messageStubType: proto.WebMessageInfo.StubType.CIPHERTEXT, + messageStubParameters: ['Missing keys for decryption'] + } + + const ctwaError = { + messageStubType: proto.WebMessageInfo.StubType.CIPHERTEXT, + messageStubParameters: [NO_MESSAGE_FOUND_ERROR_TEXT] + } + + // CTWA detection should only match "Message absent from node" + expect(ctwaError.messageStubParameters[0]).toBe(NO_MESSAGE_FOUND_ERROR_TEXT) + expect(preKeyError.messageStubParameters[0]).not.toBe(NO_MESSAGE_FOUND_ERROR_TEXT) + expect(missingKeysError.messageStubParameters[0]).not.toBe(NO_MESSAGE_FOUND_ERROR_TEXT) + }) + }) + + describe('Instagram Ads Click-to-WhatsApp', () => { + it('should handle Instagram ad messages the same way', () => { + // Instagram ads use the same CTWA mechanism + const instagramAdMessage = { + key: { + remoteJid: 'instagram_lead@s.whatsapp.net', + fromMe: false, + id: 'IG_CTWA_MSG_67890' + }, + messageStubType: proto.WebMessageInfo.StubType.CIPHERTEXT, + messageStubParameters: ['Message absent from node'], + messageTimestamp: Math.floor(Date.now() / 1000) + } + + expect(instagramAdMessage.messageStubParameters?.[0]).toBe(NO_MESSAGE_FOUND_ERROR_TEXT) + }) + }) +})