diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index d78025d9..cb2ec727 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -4,6 +4,10 @@ import { Boom } from '@hapi/boom' import { proto } from '../../WAProto/index.js' import { DEFAULT_CACHE_TTLS, WA_DEFAULT_EPHEMERAL } from '../Defaults' import type { + AlbumMediaItem, + AlbumMediaResult, + AlbumMessageOptions, + AlbumSendResult, AnyMessageContent, MediaConnInfo, MessageReceiptType, @@ -29,14 +33,23 @@ import { getStatusCodeForMediaRetry, getUrlFromDirectPath, getWAUploadToServer, + hasNonNullishProperty, MessageRetryManager, normalizeMessageContent, parseAndInjectE2ESessions, unixTimestampSeconds } from '../Utils' +import { logMessageSent, logTcToken } from '../Utils/baileys-logger' import { getUrlInfo } from '../Utils/link-preview' import { makeKeyedMutex } from '../Utils/make-mutex' +import { metrics, recordMessageFailure, recordMessageSent } from '../Utils/prometheus-metrics' import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils' +import { + isTcTokenExpired, + resolveTcTokenJid, + shouldSendNewTcToken, + storeTcTokensFromIqResult +} from '../Utils/tc-token-utils' import { areJidsSameUser, type BinaryNode, @@ -44,6 +57,8 @@ import { type FullJid, getBinaryNodeChild, getBinaryNodeChildren, + isAnyLidUser, + isAnyPnUser, isHostedLidUser, isHostedPnUser, isJidBot, @@ -77,6 +92,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { authState, messageMutex, signalRepository, + sessionActivityTracker, upsertMessage, query, fetchPrivacySettings, @@ -85,6 +101,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { groupToggleEphemeral } = sock + const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping) + const userDevicesCache = config.userDevicesCache || new NodeCache({ @@ -103,6 +121,9 @@ export const makeMessagesSocket = (config: SocketConfig) => { // Prevent race conditions in Signal session encryption by user const encryptionMutex = makeKeyedMutex() + // Tracks JIDs with an in-flight getPrivacyTokens IQ to avoid duplicate concurrent fetches + const tcTokenFetchingJids = new Set() + let mediaConn: Promise const refreshMediaConn = async (forceGet = false) => { const media = await mediaConn @@ -117,7 +138,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { }, content: [{ tag: 'media_conn', attrs: {} }] }) - const mediaConnNode = getBinaryNodeChild(result, 'media_conn')! + const mediaConnNode = getBinaryNodeChild(result, 'media_conn') + if (!mediaConnNode) throw new Boom('Missing media_conn node') // TODO: explore full length of data that whatsapp provides const node: MediaConnInfo = { hosts: getBinaryNodeChildren(mediaConnNode, 'host').map(({ attrs }) => ({ @@ -162,8 +184,9 @@ export const makeMessagesSocket = (config: SocketConfig) => { } if (type === 'sender' && (isPnUser(jid) || isLidUser(jid))) { + if (!participant) throw new Boom('Missing participant for sender receipt') node.attrs.recipient = jid - node.attrs.to = participant! + node.attrs.to = participant } else { node.attrs.to = jid if (participant) { @@ -283,7 +306,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { const requestedLidUsers = new Set() for (const jid of toFetch) { - if (isLidUser(jid) || isHostedLidUser(jid)) { + if (isAnyLidUser(jid)) { const user = jidDecode(jid)?.user if (user) requestedLidUsers.add(user) } @@ -315,12 +338,11 @@ export const makeMessagesSocket = (config: SocketConfig) => { } } - const extracted = extractDeviceJids( - result?.list, - authState.creds.me!.id, - authState.creds.me!.lid!, - ignoreZeroDevices - ) + const meId = authState.creds.me?.id + if (!meId) throw new Boom('Not authenticated', { statusCode: 401 }) + const meLid = authState.creds.me?.lid || '' + + const extracted = extractDeviceJids(result?.list, meId, meLid, ignoreZeroDevices) const deviceMap: { [_: string]: FullJid[] } = {} for (const item of extracted) { @@ -417,39 +439,6 @@ export const makeMessagesSocket = (config: SocketConfig) => { ) } - const resolveSessionJid = async (jid: string) => { - if (isLidUser(jid) || isHostedLidUser(jid)) { - return jid - } - - if (isPnUser(jid) || isHostedPnUser(jid)) { - return await signalRepository.lidMapping.getLIDForPN(jid) || jid - } - - return jid - } - - const canonicalizeSessionRecipients = async (recipientJids: string[]) => { - const uniqueRecipients = [...new Set(recipientJids)] - const canonicalRecipients: string[] = [] - - for (const jid of uniqueRecipients) { - const canonicalJid = await resolveSessionJid(jid) - if (!canonicalRecipients.includes(canonicalJid)) { - canonicalRecipients.push(canonicalJid) - } - } - - if (canonicalRecipients.length !== uniqueRecipients.length || canonicalRecipients.some((jid, index) => jid !== uniqueRecipients[index])) { - logger.debug( - { before: uniqueRecipients, after: canonicalRecipients }, - '[SESSION] Canonicalized recipient addressing for session reuse' - ) - } - - return canonicalRecipients - } - const assertSessions = async (jids: string[], force?: boolean) => { let didFetchNewSession = false const uniqueJids = [...new Set(jids)] // Deduplicate JIDs @@ -459,42 +448,30 @@ export const makeMessagesSocket = (config: SocketConfig) => { // Check peerSessionsCache and validate sessions using libsignal loadSession for (const jid of uniqueJids) { - const canonicalJid = await resolveSessionJid(jid) - const signalIds = [ - signalRepository.jidToSignalProtocolAddress(jid), - signalRepository.jidToSignalProtocolAddress(canonicalJid) - ] - const cachedSession = signalIds - .map(signalId => peerSessionsCache.get(signalId)) - .find(session => session !== undefined) - + const signalId = signalRepository.jidToSignalProtocolAddress(jid) + const cachedSession = peerSessionsCache.get(signalId) if (cachedSession !== undefined) { if (cachedSession && !force) { continue // Session exists in cache } } else { - const sessionValidation = await signalRepository.validateSession(canonicalJid) + const sessionValidation = await signalRepository.validateSession(jid) const hasSession = sessionValidation.exists - for (const signalId of signalIds) { - peerSessionsCache.set(signalId, hasSession) - } - + peerSessionsCache.set(signalId, hasSession) if (hasSession && !force) { continue } } - jidsRequiringFetch.push(canonicalJid) + jidsRequiringFetch.push(jid) } if (jidsRequiringFetch.length) { // LID if mapped, otherwise original const wireJids = [ - ...jidsRequiringFetch.filter(jid => !!isLidUser(jid) || !!isHostedLidUser(jid)), + ...jidsRequiringFetch.filter(jid => isAnyLidUser(jid)), ...( - (await signalRepository.lidMapping.getLIDsForPNs( - jidsRequiringFetch.filter(jid => !!isPnUser(jid) || !!isHostedPnUser(jid)) - )) || [] + (await signalRepository.lidMapping.getLIDsForPNs(jidsRequiringFetch.filter(jid => isAnyPnUser(jid)))) || [] ).map(a => a.lid) ] @@ -531,6 +508,38 @@ export const makeMessagesSocket = (config: SocketConfig) => { return didFetchNewSession } + const canonicalizeCarouselRecipients = async (recipientJids: string[]): Promise => { + if (!recipientJids.length) { + return recipientJids + } + + const pnRecipients = [...new Set(recipientJids.filter(jid => isAnyPnUser(jid)))] + if (!pnRecipients.length) { + return recipientJids + } + + const mappings = (await signalRepository.lidMapping.getLIDsForPNs(pnRecipients)) || [] + if (!mappings.length) { + logger.debug({ recipientCount: recipientJids.length }, '[CAROUSEL] No PN→LID recipient mappings found') + return recipientJids + } + + const pnToLid = new Map(mappings.map(item => [item.pn, item.lid])) + const mapped = recipientJids.map(jid => pnToLid.get(jid) || jid) + const changed = mapped.filter((jid, index) => jid !== recipientJids[index]).length + + logger.info( + { + recipientCount: recipientJids.length, + pnRecipients: pnRecipients.length, + mappedRecipients: changed + }, + '[CAROUSEL] Canonicalized recipient addressing to LID before session assert/encrypt' + ) + + return mapped + } + const sendPeerDataOperationMessage = async ( pdoMessage: proto.Message.IPeerDataOperationRequestMessage ): Promise => { @@ -565,6 +574,82 @@ export const makeMessagesSocket = (config: SocketConfig) => { return msgId } + const resolveDsmMessageForRecipient = ( + jid: string, + patchedMessage: proto.IMessage, + dsmMessage: proto.IMessage | undefined, + meId: string, + meLid: string | undefined, + meLidUser: string | null | undefined + ) => { + if (!dsmMessage) { + return patchedMessage + } + + const { user: targetUser } = jidDecode(jid)! + const { user: ownPnUser } = jidDecode(meId)! + const isOwnUser = targetUser === ownPnUser || (meLidUser && targetUser === meLidUser) + const isExactSenderDevice = jid === meId || (meLid && jid === meLid) + + if (isOwnUser && !isExactSenderDevice) { + logger.debug({ jid, targetUser }, 'Using DSM for own device') + return dsmMessage + } + + return patchedMessage + } + + const encryptPatchedMessageForRecipient = async ({ + jid, + patchedMessage, + dsmMessage, + meId, + meLid, + meLidUser, + extraAttrs, + onPkmsg + }: { + jid: string + patchedMessage: proto.IMessage + dsmMessage: proto.IMessage | undefined + meId: string + meLid: string | undefined + meLidUser: string | null | undefined + extraAttrs: BinaryNode['attrs'] | undefined + onPkmsg: () => void + }) => { + if (!jid) return null + + try { + const msgToEncrypt = resolveDsmMessageForRecipient(jid, patchedMessage, dsmMessage, meId, meLid, meLidUser) + const bytes = encodeWAMessage(msgToEncrypt) + const mutexKey = jid + + return await encryptionMutex.mutex(mutexKey, async () => { + const { type, ciphertext } = await signalRepository.encryptMessage({ jid, data: bytes }) + + if (type === 'pkmsg') { + onPkmsg() + } + + return { + tag: 'to', + attrs: { jid }, + content: [ + { + tag: 'enc', + attrs: { v: '2', type, ...(extraAttrs || {}) }, + content: ciphertext + } + ] + } as BinaryNode + }) + } catch (err) { + logger.error({ jid, err }, 'Failed to encrypt for recipient') + return null + } + } + const createParticipantNodes = async ( recipientJids: string[], message: proto.IMessage, @@ -581,106 +666,132 @@ export const makeMessagesSocket = (config: SocketConfig) => { : recipientJids.map(jid => ({ recipientJid: jid, message: patched })) let shouldIncludeDeviceIdentity = false - const meId = authState.creds.me!.id + const meId = authState.creds.me?.id + if (!meId) throw new Boom('Not authenticated', { statusCode: 401 }) const meLid = authState.creds.me?.lid - const meLidUser = meLid ? jidDecode(meLid)?.user : null + const meLidUser = meLid ? (jidDecode(meLid)?.user ?? null) : null + const onPkmsg = () => { + shouldIncludeDeviceIdentity = true + } - const encryptionPromises = (patchedMessages as any).map( - async ({ recipientJid: jid, message: patchedMessage }: any) => { - try { - if (!jid) return null - - let msgToEncrypt = patchedMessage - - if (dsmMessage) { - const { user: targetUser } = jidDecode(jid)! - const { user: ownPnUser } = jidDecode(meId)! - const ownLidUser = meLidUser - - const isOwnUser = targetUser === ownPnUser || (ownLidUser && targetUser === ownLidUser) - const isExactSenderDevice = jid === meId || (meLid && jid === meLid) - - if (isOwnUser && !isExactSenderDevice) { - msgToEncrypt = dsmMessage - logger.debug({ jid, targetUser }, 'Using DSM for own device') - } - } - - const bytes = encodeWAMessage(msgToEncrypt) - const mutexKey = jid - - const node = await encryptionMutex.mutex(mutexKey, async () => { - const { type, ciphertext } = await signalRepository.encryptMessage({ jid, data: bytes }) - - if (type === 'pkmsg') { - shouldIncludeDeviceIdentity = true - } - - return { - tag: 'to', - attrs: { jid }, - content: [ - { - tag: 'enc', - attrs: { v: '2', type, ...(extraAttrs || {}) }, - content: ciphertext - } - ] - } - }) - - return node - } catch (err) { - logger.error({ jid, err }, 'Failed to encrypt for recipient') - return null - } - } + const encryptionPromises = (patchedMessages as { recipientJid: string; message: proto.IMessage }[]).map( + ({ recipientJid: jid, message: patchedMessage }: { recipientJid: string; message: proto.IMessage }) => + encryptPatchedMessageForRecipient({ + jid, + patchedMessage, + dsmMessage, + meId, + meLid, + meLidUser, + extraAttrs, + onPkmsg + }) ) const nodes = (await Promise.all(encryptionPromises)).filter(node => node !== null) as BinaryNode[] if (recipientJids.length > 0 && nodes.length === 0) { + recordMessageFailure('send', 'encryption_failed') throw new Boom('All encryptions failed', { statusCode: 500 }) } return { nodes, shouldIncludeDeviceIdentity } } - /** Detect interactive/button message type for biz node injection */ + // Interactive message detection and binary node injection + + /** + * Detects the type of interactive message and returns the appropriate binary node tag + * Returns 'native_flow' for modern nativeFlowMessage format (recommended) + * Returns legacy types for older button formats + */ const getButtonType = (message: proto.IMessage): string | undefined => { - if (message.buttonsMessage) return 'buttons' - if (message.templateMessage) return 'template' - if (message.listMessage) return 'list' - if (message.buttonsResponseMessage) return 'buttons_response' - if (message.listResponseMessage) return 'list_response' - if (message.templateButtonReplyMessage) return 'template_reply' - if (message.interactiveMessage) { - if (message.interactiveMessage.nativeFlowMessage) return 'native_flow' + // Check direct message types (legacy formats) + if (message.buttonsMessage) { + return 'buttons' + } else if (message.templateMessage) { + return 'template' + } else if (message.listMessage) { + // All listMessages (SINGLE_SELECT and PRODUCT_LIST) need biz > list node + return 'list' + } else if (message.buttonsResponseMessage) { + return 'buttons_response' + } else if (message.listResponseMessage) { + return 'list_response' + } else if (message.templateButtonReplyMessage) { + return 'template_reply' + } else if (message.interactiveMessage) { + // Check if it has nativeFlowMessage (modern format) + if (message.interactiveMessage.nativeFlowMessage) { + return 'native_flow' + } + + // Check if it's a carousel with nativeFlowMessage buttons in cards if (message.interactiveMessage.carouselMessage?.cards?.length) { - const hasNativeFlow = message.interactiveMessage.carouselMessage.cards.some( + const hasNativeFlowButtons = message.interactiveMessage.carouselMessage.cards.some( (card: proto.Message.IInteractiveMessage) => card?.nativeFlowMessage?.buttons?.length ) - if (hasNativeFlow) return 'native_flow' + if (hasNativeFlowButtons) { + return 'native_flow' + } } + + // Check if it's a collection/product carousel + if (message.interactiveMessage.carouselMessage?.cards?.length) { + const hasCollectionCards = message.interactiveMessage.carouselMessage.cards.some( + (card: any) => card?.collectionMessage + ) + if (hasCollectionCards) { + return 'native_flow' + } + } + return 'interactive' } + // Check inside viewOnceMessage/viewOnceMessageV2 wrapper (modern nativeFlowMessage format) + // V2 is the recommended format for interactive messages (carousel, buttons) const innerMessage = message.viewOnceMessage?.message || message.viewOnceMessageV2?.message if (innerMessage) { - if (innerMessage.buttonsMessage) return 'buttons' - if (innerMessage.templateMessage) return 'template' - if (innerMessage.listMessage) return 'list' - if (innerMessage.buttonsResponseMessage) return 'buttons_response' - if (innerMessage.listResponseMessage) return 'list_response' - if (innerMessage.templateButtonReplyMessage) return 'template_reply' - if (innerMessage.interactiveMessage) { - if (innerMessage.interactiveMessage.nativeFlowMessage) return 'native_flow' + if (innerMessage.buttonsMessage) { + return 'buttons' + } else if (innerMessage.templateMessage) { + return 'template' + } else if (innerMessage.listMessage) { + // All listMessages (SINGLE_SELECT and PRODUCT_LIST) need biz > list node + return 'list' + } else if (innerMessage.buttonsResponseMessage) { + return 'buttons_response' + } else if (innerMessage.listResponseMessage) { + return 'list_response' + } else if (innerMessage.templateButtonReplyMessage) { + return 'template_reply' + } else if (innerMessage.interactiveMessage) { + // Check if it has nativeFlowMessage (modern format) + if (innerMessage.interactiveMessage.nativeFlowMessage) { + return 'native_flow' + } + + // Check if it's a carousel with nativeFlowMessage buttons in cards if (innerMessage.interactiveMessage.carouselMessage?.cards?.length) { - const hasNativeFlow = innerMessage.interactiveMessage.carouselMessage.cards.some( + const hasNativeFlowButtons = innerMessage.interactiveMessage.carouselMessage.cards.some( (card: any) => card?.nativeFlowMessage?.buttons?.length ) - if (hasNativeFlow) return 'native_flow' + if (hasNativeFlowButtons) { + return 'native_flow' + } } + + // Check if it's a collection/product carousel + if (innerMessage.interactiveMessage.carouselMessage?.cards?.length) { + const hasCollectionCards = innerMessage.interactiveMessage.carouselMessage.cards.some( + (card: any) => card?.collectionMessage + ) + if (hasCollectionCards) { + return 'native_flow' + } + } + return 'interactive' } } @@ -688,40 +799,97 @@ export const makeMessagesSocket = (config: SocketConfig) => { return undefined } - /** Check if message is a carousel (media or product carousel) */ + /** + * Returns the attributes for the interactive binary node based on message type + * For native_flow: returns { v: '4', name: '' } or special attributes for payment flows + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _getButtonArgs = (message: proto.IMessage): BinaryNodeAttributes => { + const buttonType = getButtonType(message) + + // For native_flow messages, check for special button types that need specific attributes + if (buttonType === 'native_flow') { + const interactiveMsg = + message.interactiveMessage || + message.viewOnceMessage?.message?.interactiveMessage || + message.viewOnceMessageV2?.message?.interactiveMessage + + if (interactiveMsg?.nativeFlowMessage?.buttons?.[0]) { + const firstButtonName = interactiveMsg.nativeFlowMessage.buttons[0].name + + // Special button types that require specific attributes + // Based on official WhatsApp client traffic + if (firstButtonName === 'review_and_pay' || firstButtonName === 'payment_info') { + return { v: '4', name: 'payment_info' } + } else if (firstButtonName === 'mpm') { + return { v: '4', name: 'mpm' } + } else if (firstButtonName === 'review_order') { + return { v: '4', name: 'order_details' } + } + } + + // Default native_flow attributes + return { v: '4', name: '' } + } + + // For other button types, return empty attributes + return {} + } + + /** + * Checks if the message is a carousel (media carousel or product carousel) + * Carousels should NOT have the bot node injected as they are not bot messages + */ const isCarouselMessage = (message: proto.IMessage): boolean => { const interactiveMsg = message.interactiveMessage || message.viewOnceMessage?.message?.interactiveMessage || message.viewOnceMessageV2?.message?.interactiveMessage - return !!(interactiveMsg?.carouselMessage?.cards?.length) + + if (interactiveMsg?.carouselMessage?.cards?.length) { + return true + } + + return false } - /** Check if message is a catalog/product message */ + /** + * Checks if the message is a catalog/product message (catalog_message, single_product) + * These messages may need different biz node handling or no biz node at all + */ const isCatalogMessage = (message: proto.IMessage): boolean => { const interactiveMsg = message.interactiveMessage || message.viewOnceMessage?.message?.interactiveMessage || message.viewOnceMessageV2?.message?.interactiveMessage + const nativeFlow = interactiveMsg?.nativeFlowMessage if (nativeFlow?.buttons?.length) { + // Check if any button is a catalog-type button return nativeFlow.buttons.some( (btn: any) => btn?.name === 'catalog_message' || btn?.name === 'single_product' || btn?.name === 'product_list' ) } + return false } - /** Check if nativeFlowMessage is a list (single_select button) */ + /** + * Checks if the nativeFlowMessage is a list (single_select button) + * Lists need type='list' in the biz node instead of type='native_flow' + */ const isListNativeFlow = (message: proto.IMessage): boolean => { const interactiveMsg = message.interactiveMessage || message.viewOnceMessage?.message?.interactiveMessage || message.viewOnceMessageV2?.message?.interactiveMessage + const nativeFlow = interactiveMsg?.nativeFlowMessage if (nativeFlow?.buttons?.length) { + // Check if any button is a list-type button (single_select or multi_select) return nativeFlow.buttons.some((btn: any) => btn?.name === 'single_select' || btn?.name === 'multi_select') } + return false } @@ -738,13 +906,16 @@ export const makeMessagesSocket = (config: SocketConfig) => { statusJidList }: MessageRelayOptions ) => { - const meId = authState.creds.me!.id + const meId = authState.creds.me?.id + if (!meId) throw new Boom('Not authenticated', { statusCode: 401 }) const meLid = authState.creds.me?.lid const isRetryResend = Boolean(participant?.jid) let shouldIncludeDeviceIdentity = isRetryResend const statusJid = 'status@broadcast' - const { user, server } = jidDecode(jid)! + const jidDecoded = jidDecode(jid) + if (!jidDecoded) throw new Boom('Invalid JID') + const { user, server } = jidDecoded const isGroup = server === 'g.us' const isStatus = jid === statusJid const isLid = server === 'lid' @@ -756,6 +927,60 @@ export const makeMessagesSocket = (config: SocketConfig) => { useUserDevicesCache = useUserDevicesCache !== false useCachedGroupMetadata = useCachedGroupMetadata !== false && !isStatus + // Convert nativeFlowMessage with single_select to direct listMessage (legacy format) + // This is required because WhatsApp expects listMessage format with biz > list node + // The viewOnceMessage > interactiveMessage > nativeFlowMessage wrapper causes error 479 + // Reference: direct listMessage works on phone + web + const innerMsg = message.viewOnceMessage?.message + const nativeFlow = innerMsg?.interactiveMessage?.nativeFlowMessage + if (nativeFlow?.buttons?.length) { + const singleSelectBtn = nativeFlow.buttons.find((btn: any) => btn?.name === 'single_select') + if (singleSelectBtn?.buttonParamsJson) { + try { + const params = JSON.parse(singleSelectBtn.buttonParamsJson) + const sections = params.sections?.map((section: any) => ({ + title: section.title, + rows: section.rows?.map((row: any) => ({ + rowId: row.id || row.rowId, + title: row.title, + description: row.description || '' + })) + })) + + if (sections?.length) { + // Build direct listMessage (legacy format that works) + const listMessage = proto.Message.ListMessage.fromObject({ + title: innerMsg?.interactiveMessage?.header?.title || '', + description: innerMsg?.interactiveMessage?.body?.text || '', + buttonText: params.title || 'Menu', + footerText: innerMsg?.interactiveMessage?.footer?.text || '', + listType: proto.Message.ListMessage.ListType.SINGLE_SELECT, + sections + }) + + // Mutate message in-place: remove viewOnceMessage, add listMessage + delete message.viewOnceMessage + message.listMessage = listMessage + // Keep messageContextInfo if it was nested + // eslint-disable-next-line max-depth + if (!message.messageContextInfo && innerMsg?.messageContextInfo) { + message.messageContextInfo = innerMsg.messageContextInfo + } + + logger.info( + { msgId, sectionsCount: sections.length, buttonText: params.title }, + '[LIST CONVERT] Converted nativeFlowMessage(single_select) to direct listMessage format' + ) + } + } catch (err) { + logger.warn( + { msgId, error: (err as Error).message }, + '[LIST CONVERT] Failed to convert nativeFlowMessage to listMessage, sending as-is' + ) + } + } + } + const participants: BinaryNode[] = [] const destinationJid = !isStatus ? finalJid : statusJid const binaryNodeContent: BinaryNode[] = [] @@ -777,7 +1002,9 @@ export const makeMessagesSocket = (config: SocketConfig) => { additionalAttributes = { ...additionalAttributes, device_fanout: 'false' } } - const { user, device } = jidDecode(participant.jid)! + const participantDecoded = jidDecode(participant.jid) + if (!participantDecoded) throw new Boom('Invalid participant JID') + const { user, device } = participantDecoded devices.push({ user, device, @@ -1003,6 +1230,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { const isMe = user === mePnUser || user === meLidUser if (isMe) { + // Send DSM to ALL own companion devices including carousel + // Error 479 on companion devices is non-fatal meRecipients.push(jid) } else { otherRecipients.push(jid) @@ -1011,10 +1240,13 @@ export const makeMessagesSocket = (config: SocketConfig) => { allRecipients.push(jid) } - - await assertSessions(allRecipients) - const effectiveMeRecipients = await canonicalizeSessionRecipients(meRecipients) - const effectiveOtherRecipients = await canonicalizeSessionRecipients(otherRecipients) + const isCarouselFanout = isCarouselMessage(message) + const effectiveMeRecipients = isCarouselFanout + ? await canonicalizeCarouselRecipients(meRecipients) + : meRecipients + const effectiveOtherRecipients = isCarouselFanout + ? await canonicalizeCarouselRecipients(otherRecipients) + : otherRecipients const effectiveAllRecipients = [...effectiveMeRecipients, ...effectiveOtherRecipients] await assertSessions(effectiveAllRecipients) @@ -1023,25 +1255,30 @@ export const makeMessagesSocket = (config: SocketConfig) => { { nodes: meNodes, shouldIncludeDeviceIdentity: s1 }, { nodes: otherNodes, shouldIncludeDeviceIdentity: s2 } ] = await Promise.all([ - // For own devices: use DSM if available (1:1 chats only) - createParticipantNodes(meRecipients, meMsg || message, extraAttrs), - createParticipantNodes(otherRecipients, message, extraAttrs, meMsg) + // For own devices: use DSM (deviceSentMessage) wrapper + createParticipantNodes(effectiveMeRecipients, meMsg || message, extraAttrs), + createParticipantNodes(effectiveOtherRecipients, message, extraAttrs) ]) participants.push(...meNodes) participants.push(...otherNodes) - if (meRecipients.length > 0 || otherRecipients.length > 0) { - extraAttrs['phash'] = generateParticipantHashV2([...meRecipients, ...otherRecipients]) + if (effectiveMeRecipients.length > 0 || effectiveOtherRecipients.length > 0) { + extraAttrs['phash'] = generateParticipantHashV2(effectiveAllRecipients) } shouldIncludeDeviceIdentity = shouldIncludeDeviceIdentity || s1 || s2 } if (isRetryResend) { - const isParticipantLid = isLidUser(participant!.jid) - const isMe = areJidsSameUser(participant!.jid, isParticipantLid ? meLid : meId) + if (!participant) throw new Boom('Missing participant for retry resend') + // Only check for regular LID users, NOT hosted LID users + // Hosted LID users should use meId for comparison, not meLid + const isParticipantLid = isLidUser(participant.jid) + const isMe = areJidsSameUser(participant.jid, isParticipantLid ? meLid : meId) - const encodedMessageToSend = isMe + // Send DSM for all message types including carousel + const usesDSM = isMe + const encodedMessageToSend = usesDSM ? encodeWAMessage({ deviceSentMessage: { destinationJid, @@ -1052,7 +1289,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { const { type, ciphertext: encryptedContent } = await signalRepository.encryptMessage({ data: encodedMessageToSend, - jid: participant!.jid + jid: participant.jid }) binaryNodeContent.push({ @@ -1060,7 +1297,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { attrs: { v: '2', type, - count: participant!.count.toString() + count: participant.count.toString() }, content: encryptedContent }) @@ -1093,22 +1330,32 @@ export const makeMessagesSocket = (config: SocketConfig) => { content: binaryNodeContent } - // Detect interactive message type for biz node injection + // Inject 'biz' node for interactive messages const buttonType = getButtonType(message) const isCatalog = isCatalogMessage(message) const isCarousel = isCarouselMessage(message) // Collect biz/bot nodes to append AFTER device-identity and tctoken - // Stanza order: participants → device-identity → tctoken → biz - // Carousel messages must NOT have biz node (breaks WhatsApp Web rendering) + // Stanza order: participants → device-identity → tctoken → biz (when applicable) + // CDP capture confirms Pastorini INCLUDES biz node for carousel (native_flow v=9, name=mixed) const deferredNodes: BinaryNode[] = [] - if (buttonType && enableInteractiveMessages && !isCarousel) { + // Inject biz node for interactive messages (including carousel — CDP evidence from Pastorini) + if ((buttonType || isCarousel) && enableInteractiveMessages) { + const startTime = Date.now() + // When entering via isCarousel, buttonType may be undefined — default to 'native_flow' + const effectiveButtonType = buttonType || 'native_flow' + + // Debug: Log message structure to diagnose list detection const interactiveMsg = message.interactiveMessage || message.viewOnceMessage?.message?.interactiveMessage || message.viewOnceMessageV2?.message?.interactiveMessage - + const listMsg = + message.listMessage || + message.viewOnceMessage?.message?.listMessage || + message.viewOnceMessageV2?.message?.listMessage + // For carousel messages, buttons are inside each card's nativeFlowMessage let nativeFlowButtons = interactiveMsg?.nativeFlowMessage?.buttons || [] if (nativeFlowButtons.length === 0 && interactiveMsg?.carouselMessage?.cards?.length) { nativeFlowButtons = interactiveMsg.carouselMessage.cards.flatMap( @@ -1119,22 +1366,49 @@ export const makeMessagesSocket = (config: SocketConfig) => { const isListDetected = isListNativeFlow(message) logger.info( - { msgId, buttonType, to: destinationJid, isListDetected, isCatalog }, + { + msgId, + buttonType, + to: destinationJid, + hasListMessage: !!listMsg, + hasInteractiveMessage: !!interactiveMsg, + hasNativeFlow: !!interactiveMsg?.nativeFlowMessage, + nativeFlowButtonNames: nativeFlowButtons.map((b: any) => b?.name), + isListDetected, + isCatalog, + isCarousel + }, '[Interactive] Preparing biz node' ) - try { - const allButtonNames = nativeFlowButtons.map((b: any) => b?.name).filter(Boolean) - const isNativeFlowButtons = buttonType === 'native_flow' + // Track that we're sending an interactive message + metrics.interactiveMessagesSent.inc({ type: effectiveButtonType }) + try { + // Classify button types for native_flow name and bot node decisions + const CTA_BUTTON_NAMES = new Set(['cta_url', 'cta_copy', 'cta_call']) + const allButtonNames = nativeFlowButtons.map((b: any) => b?.name).filter(Boolean) + const hasCTA = allButtonNames.some((name: string) => CTA_BUTTON_NAMES.has(name)) + const hasQuickReply = allButtonNames.some((name: string) => name === 'quick_reply') + const isCTAOnly = hasCTA && !hasQuickReply + + // For listMessage (legacy format), use direct tag + // This matches the known working implementation if (buttonType === 'list') { deferredNodes.push({ tag: 'biz', attrs: {}, content: [ - { tag: 'list', attrs: { type: 'product_list', v: '2' } } + { + tag: 'list', + attrs: { + type: 'product_list', + v: '2' + } + } ] }) + logger.info({ msgId, to: destinationJid }, '[BIZ NODE] Injected biz > list (product_list, v=2)') } else { const SPECIAL_FLOW_NAMES: Record = { review_and_pay: 'payment_info', @@ -1145,56 +1419,92 @@ export const makeMessagesSocket = (config: SocketConfig) => { const firstButtonName = allButtonNames[0] || '' const nativeFlowName = SPECIAL_FLOW_NAMES[firstButtonName] || 'mixed' + logger.info( + { msgId, buttonNames: allButtonNames, hasCTA, hasQuickReply, isCTAOnly, nativeFlowName }, + '[BIZ NODE] Injected biz > interactive(native_flow, v=1) > native_flow(v=9, name=' + nativeFlowName + ')' + ) + + const interactiveType = 'native_flow' + const bizContent: BinaryNode[] = [ + { + tag: 'interactive', + attrs: { + type: interactiveType, + v: '1' + }, + content: [ + { + tag: interactiveType, + attrs: { + v: '9', + name: nativeFlowName + } + } + ] + } + ] + + // CDP capture shows Pastorini includes quality_control inside biz for carousel + if (isCarousel) { + const decisionId = randomBytes(20).toString('hex') + bizContent.push({ + tag: 'quality_control', + attrs: { + decision_id: decisionId + }, + content: [ + { + tag: 'decision_source', + attrs: { + value: 'df' + } + } + ] + }) + logger.info({ msgId, decisionId }, '[BIZ NODE] Added quality_control for carousel') + } + deferredNodes.push({ tag: 'biz', attrs: {}, - content: [ - { - tag: 'interactive', - attrs: { type: 'native_flow', v: '1' }, - content: [ - { tag: 'native_flow', attrs: { v: '9', name: nativeFlowName } } - ] - } - ] + content: bizContent }) } - // Bot node — skip for native_flow, carousel, catalog + // Bot node — skip for native_flow buttons (breaks Web rendering) + const isNativeFlowButtons = buttonType === 'native_flow' + const isPrivateUserChat = (isPnUser(destinationJid) || isLidUser(destinationJid) || destinationJid?.endsWith('@c.us')) && !isJidBot(destinationJid) - if (isPrivateUserChat && !isCatalog && buttonType !== 'list' && !isNativeFlowButtons) { - deferredNodes.push({ tag: 'bot', attrs: { biz_bot: '1' } }) + if (isPrivateUserChat && !isCarousel && !isCatalog && buttonType !== 'list' && !isNativeFlowButtons) { + deferredNodes.push({ + tag: 'bot', + attrs: { biz_bot: '1' } + }) + logger.info({ msgId, to: destinationJid }, '[BOT NODE] Added bot node (biz_bot=1)') + } else if (isNativeFlowButtons) { + logger.debug({ msgId, to: destinationJid }, '[BOT NODE] Skipped — native_flow (Web compatibility)') + } else if (isCarousel) { + logger.debug({ msgId, to: destinationJid }, '[BOT NODE] Skipped — carousel message') + } else if (isCatalog) { + logger.debug({ msgId, to: destinationJid }, '[BOT NODE] Skipped — catalog message') } + + // Track success and latency after message is sent + metrics.interactiveMessagesSuccess.inc({ type: effectiveButtonType }) + metrics.interactiveMessagesLatency.observe({ type: effectiveButtonType }, Date.now() - startTime) } catch (error) { - logger.error({ error, msgId, buttonType }, '[BIZ NODE] Failed to inject biz node') + logger.error({ error, msgId, buttonType: effectiveButtonType }, '[BIZ NODE] Failed to inject biz node') + metrics.interactiveMessagesFailures.inc({ type: effectiveButtonType, reason: 'injection_failed' }) } - } else if (isCarousel && enableInteractiveMessages) { - // Carousel: inject quality_control with decision_id (required by WhatsApp Web) - const decisionId = randomBytes(20).toString('hex') - deferredNodes.push({ - tag: 'biz', - attrs: {}, - content: [ - { - tag: 'interactive', - attrs: { type: 'native_flow', v: '1' }, - content: [ - { tag: 'native_flow', attrs: { v: '9', name: 'mixed' } } - ] - } - ] - }) - deferredNodes.push({ - tag: 'quality_control', - attrs: { decision_id: decisionId }, - content: [ - { tag: 'decision_source', attrs: { value: 'df' } } - ] - }) - logger.info({ msgId, to: destinationJid, decisionId }, '[CAROUSEL] Injected biz + quality_control') + } else if (buttonType && !enableInteractiveMessages) { + logger.warn( + { msgId, buttonType }, + '[Interactive] Message detected but feature disabled (enableInteractiveMessages=false)' + ) + metrics.interactiveMessagesFailures.inc({ type: buttonType, reason: 'feature_disabled' }) } // if the participant to send to is explicitly specified (generally retry recp) @@ -1214,7 +1524,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { stanza.attrs.to = destinationJid } - // Force device-identity for carousel (ensures Web can decrypt) + // Always include device-identity for carousel (Pastorini stanza always has it) if (shouldIncludeDeviceIdentity || isCarousel) { ;(stanza.content as BinaryNode[]).push({ tag: 'device-identity', @@ -1249,36 +1559,281 @@ export const makeMessagesSocket = (config: SocketConfig) => { } } - const contactTcTokenData = - !isGroup && !isRetryResend && !isStatus ? await authState.keys.get('tctoken', [destinationJid]) : {} + // tctoken lifecycle: fetch, validate expiry, proactive re-fetch if missing/expired + // WA Web never attaches tctoken to peer (AppStateSync) messages — server + const isPeerMessage = additionalAttributes?.['category'] === 'peer' + const is1on1Send = !isGroup && !isRetryResend && !isStatus && !isNewsletter && !isPeerMessage - const tcTokenBuffer = contactTcTokenData[destinationJid]?.token + // Resolve destination to LID for tctoken storage — matches Signal session key pattern + const tcTokenJid = is1on1Send ? await resolveTcTokenJid(destinationJid, getLIDForPN) : destinationJid + const contactTcTokenData = is1on1Send ? await authState.keys.get('tctoken', [tcTokenJid]) : {} + const existingTokenEntry = contactTcTokenData[tcTokenJid] + let tcTokenBuffer = existingTokenEntry?.token - if (tcTokenBuffer) { + // Treat expired tokens the same as missing — re-fetch from server + if (tcTokenBuffer?.length && isTcTokenExpired(existingTokenEntry?.timestamp)) { + logTcToken('expired', { jid: destinationJid, timestamp: existingTokenEntry?.timestamp }) + tcTokenBuffer = undefined + // Opportunistic cleanup: remove expired token from store + try { + await authState.keys.set({ tctoken: { [tcTokenJid]: null } }) + } catch { + /* ignore cleanup errors */ + } + } + + // If tctoken is missing for a 1:1 send, fetch it + // CDP capture confirms Pastorini stanza includes tctoken for carousel + if (!tcTokenBuffer?.length && is1on1Send && !tcTokenFetchingJids.has(tcTokenJid)) { + tcTokenFetchingJids.add(tcTokenJid) + logTcToken('fetch', { jid: destinationJid }) + + if (isCarousel) { + // BLOCKING fetch for carousel — tctoken is required (Pastorini stanza has it) + try { + const fetchResult = await getPrivacyTokens([destinationJid]) + + // Direct extraction from IQ result — bypass store/read key mismatch + const tokensNode = getBinaryNodeChild(fetchResult, 'tokens') + if (tokensNode) { + const tokenNodes = getBinaryNodeChildren(tokensNode, 'token') + for (const tokenNode of tokenNodes) { + if (tokenNode.attrs.type === 'trusted_contact' && tokenNode.content instanceof Uint8Array) { + tcTokenBuffer = Buffer.from(tokenNode.content) + logger.info( + { + jid: destinationJid, + tokenLen: tcTokenBuffer.length, + tokenJid: tokenNode.attrs.jid, + timestamp: tokenNode.attrs.t + }, + '[CAROUSEL] tctoken extracted directly from IQ result' + ) + break + } + } + } + + if (!tcTokenBuffer?.length) { + // Debug: dump the IQ result structure + const childTags = Array.isArray(fetchResult.content) + ? (fetchResult.content as BinaryNode[]).map(n => `${n.tag}(${JSON.stringify(n.attrs)})`) + : [] + logger.warn( + { jid: destinationJid, resultTag: fetchResult.tag, resultAttrs: fetchResult.attrs, childTags }, + '[CAROUSEL] tctoken fetch completed but NO valid token in IQ result' + ) + } + + // Also store for future use + await storeTcTokensFromIqResult({ + result: fetchResult, + fallbackJid: destinationJid, + keys: authState.keys, + getLIDForPN + }).catch(() => {}) + } catch (err: any) { + logger.warn({ jid: destinationJid, err: err?.message }, '[CAROUSEL] Blocking tctoken fetch failed') + } finally { + tcTokenFetchingJids.delete(tcTokenJid) + } + } else { + // Fire-and-forget for non-carousel + getPrivacyTokens([destinationJid]) + .then(async fetchResult => { + await storeTcTokensFromIqResult({ + result: fetchResult, + fallbackJid: destinationJid, + keys: authState.keys, + getLIDForPN + }) + }) + .catch(err => { + logger.debug({ jid: destinationJid, err: err?.message }, 'fire-and-forget tctoken fetch failed') + }) + .finally(() => { + tcTokenFetchingJids.delete(tcTokenJid) + }) + } + } + + if (tcTokenBuffer?.length) { ;(stanza.content as BinaryNode[]).push({ tag: 'tctoken', attrs: {}, content: tcTokenBuffer }) + logTcToken('attached', { jid: destinationJid }) } if (additionalNodes && additionalNodes.length > 0) { ;(stanza.content as BinaryNode[]).push(...additionalNodes) } - // Append deferred biz/bot/quality_control nodes LAST - // Stanza order: participants → device-identity → tctoken → biz → quality_control + // Append deferred biz/bot nodes LAST (after device-identity, tctoken) + // Stanza order: participants → device-identity → tctoken → biz if (deferredNodes.length > 0) { ;(stanza.content as BinaryNode[]).push(...deferredNodes) } + // Log stanza structure for interactive messages + if (buttonType || isCarousel) { + const contentTags = Array.isArray(stanza.content) ? stanza.content.map((n: BinaryNode) => n.tag) : [] + logger.info({ msgId, to: destinationJid, contentTags }, '[STANZA] Content tags: ' + JSON.stringify(contentTags)) + } + logger.debug({ msgId }, `sending message to ${participants.length} devices`) + // ======= PROTOBUF ROUNDTRIP TEST: Verify encoding preserves carousel ======= + if (isCarousel) { + try { + const encoded = proto.Message.encode(message).finish() + const decoded = proto.Message.decode(encoded) + const decodedInteractive = decoded.interactiveMessage || decoded.viewOnceMessage?.message?.interactiveMessage + const cardsCount = decodedInteractive?.carouselMessage?.cards?.length || 0 + const card0 = decodedInteractive?.carouselMessage?.cards?.[0] + const card0Header = card0?.header + + logger.info( + { + msgId, + encodedSize: encoded.length, + messageKeys: Object.keys(message), + hasInteractive: !!message.interactiveMessage, + hasViewOnce: !!message.viewOnceMessage, + hasCarouselAfterDecode: !!decodedInteractive?.carouselMessage, + cardsCount, + card0Title: card0Header?.title, + card0HasImage: !!card0Header?.imageMessage, + card0Buttons: card0?.nativeFlowMessage?.buttons?.length || 0, + messageVersion: decodedInteractive?.carouselMessage?.messageVersion + }, + '[ROUNDTRIP] Protobuf encode→decode verification' + ) + } catch (err) { + logger.error({ msgId, err: (err as Error).message }, '[ROUNDTRIP] Failed to verify protobuf encoding') + } + } + + // ======= PROTOCOL INTERCEPTOR: Dump complete stanza for debugging ======= + if (buttonType || isCarousel) { + const dumpBinaryNode = (node: BinaryNode, indent = 0): string => { + if (!node) return '' + const pad = ' '.repeat(indent) + const tag = node.tag || '?' + const filteredAttrs = ([, v]: [string, unknown]) => v !== undefined && v !== null + const attrEntries = node.attrs ? Object.entries(node.attrs).filter(filteredAttrs) : [] + const attrStr = attrEntries.length > 0 ? ' ' + attrEntries.map(([k, v]) => `${k}="${v}"`).join(' ') : '' + + if (!node.content) return `${pad}<${tag}${attrStr}/>` + + if (Buffer.isBuffer(node.content) || node.content instanceof Uint8Array) { + return `${pad}<${tag}${attrStr}>[binary ${node.content.length} bytes]` + } + + if (Array.isArray(node.content)) { + const children = node.content.map((c: BinaryNode) => dumpBinaryNode(c, indent + 1)).join('\n') + return `${pad}<${tag}${attrStr}>\n${children}\n${pad}` + } + + return `${pad}<${tag}${attrStr}>${String(node.content).slice(0, 100)}` + } + + logger.debug( + { + msgId, + to: destinationJid, + buttonType: buttonType || 'carousel', + stanzaXML: '\n' + dumpBinaryNode(stanza) + }, + '[PROTOCOL-DUMP] Stanza structure before send' + ) + } + // ======= END PROTOCOL INTERCEPTOR ======= + await sendNode(stanza) + // Fire-and-forget: issue our token to the contact (like WA Web's sendTcToken). + // Gated only by shouldSendNewTcToken — removed tcTokenBuffer?.length guard so + // issuance fires even when we don't yet hold a token (bucket boundary crossed). + // IMPORTANT: must run AFTER sendNode — issuing before the message causes error 463. + if (is1on1Send && shouldSendNewTcToken(existingTokenEntry?.senderTimestamp)) { + const issueTimestamp = unixTimestampSeconds() + logTcToken('reissue', { jid: destinationJid }) + getPrivacyTokens([destinationJid], issueTimestamp) + .then(async result => { + // Store any tokens received in the IQ response. + // onNewJidStored not passed — pruning index lives in messages-recv (higher layer). + await storeTcTokensFromIqResult({ + result, + fallbackJid: tcTokenJid, + keys: authState.keys, + getLIDForPN + }) + + // Persist senderTimestamp unconditionally — WA Web stores it in the chat table + // regardless of whether a token exists. Spread preserves token+timestamp if present. + // WABA Android: INSERT INTO wa_trusted_contacts_send (jid, sent_tc_token_timestamp, real_issue_timestamp) + // VALUES (?, ?, 0) — realIssueTimestamp=0 means issued but not yet confirmed by server + const currentData = await authState.keys.get('tctoken', [tcTokenJid]) + const currentEntry = currentData[tcTokenJid] + await authState.keys.set({ + tctoken: { + [tcTokenJid]: { + ...currentEntry, + token: currentEntry?.token ?? Buffer.alloc(0), + senderTimestamp: issueTimestamp, + realIssueTimestamp: 0 + } + } + }) + + logTcToken('reissue_ok', { jid: destinationJid }) + }) + .catch(err => { + logTcToken('reissue_fail', { jid: destinationJid, error: err?.message }) + }) + } + + // Log with [BAILEYS] prefix + logMessageSent(msgId, destinationJid) + + // Record message sent metric + const msgType = message.conversation + ? 'text' + : message.imageMessage + ? 'image' + : message.videoMessage + ? 'video' + : message.audioMessage + ? 'audio' + : message.documentMessage + ? 'document' + : message.stickerMessage + ? 'sticker' + : message.stickerPackMessage + ? 'sticker_pack' + : message.reactionMessage + ? 'reaction' + : 'other' + recordMessageSent(msgType) + // Add message to retry cache if enabled if (messageRetryManager && !participant) { - messageRetryManager.addRecentMessage(destinationJid, msgId, message) + messageRetryManager.addRecentMessage(jidNormalizedUser(destinationJid), msgId, message) + } + + // Track session activity for cleanup (all target JIDs) + if (sessionActivityTracker) { + // Record activity for destination JID + sessionActivityTracker.recordActivity(destinationJid) + + // For groups, also record activity for all participants who received the message + if (isGroup || isStatus) { + for (const device of devices) { + sessionActivityTracker.recordActivity(device.jid) + } + } } }, meId) @@ -1310,11 +1865,6 @@ export const makeMessagesSocket = (config: SocketConfig) => { return 'media' } - // Carousel with images → media type for Web real-time rendering - if (normalizedMessage.interactiveMessage?.carouselMessage) { - return 'media' - } - return 'text' } @@ -1354,8 +1904,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { return '' } - const getPrivacyTokens = async (jids: string[], senderTs?: number) => { - const t = (senderTs || unixTimestampSeconds()).toString() + const getPrivacyTokens = async (jids: string[], timestamp?: number) => { + const t = (timestamp ?? unixTimestampSeconds()).toString() const result = await query({ tag: 'iq', attrs: { @@ -1404,7 +1954,11 @@ export const makeMessagesSocket = (config: SocketConfig) => { updateMemberLabel, updateMediaMessage: async (message: WAMessage) => { const content = assertMediaContent(message.message) - const mediaKey = content.mediaKey! + const mediaKey = content.mediaKey + if (!mediaKey) { + throw new Boom('Missing media key for update', { statusCode: 400 }) + } + const meId = authState.creds.me!.id const node = encryptMediaRetryRequest(message.key, mediaKey, meId) @@ -1449,8 +2003,325 @@ export const makeMessagesSocket = (config: SocketConfig) => { return message }, - sendMessage: async (jid: string, content: AnyMessageContent, options: MiscMessageGenerationOptions = {}) => { + + /** + * Send an album message (multiple images/videos grouped together) + * + * @param jid - The recipient JID + * @param album - Album configuration with media items + * @param options - Additional message generation options + * @returns Complete result with status of each media item + * + * @example + * ```typescript + * const result = await sock.sendAlbumMessage('1234567890@s.whatsapp.net', { + * medias: [ + * { image: { url: './photo1.jpg' }, caption: 'First photo' }, + * { image: { url: './photo2.jpg' }, caption: 'Second photo' }, + * { video: { url: './video.mp4' }, caption: 'A video' } + * ], + * delay: 'adaptive', // or fixed ms like 500 + * retryCount: 3, + * continueOnFailure: true + * }) + * + * if (!result.success) { + * console.log('Failed items:', result.failedIndices) + * } + * ``` + */ + sendAlbumMessage: async ( + jid: string, + album: AlbumMessageOptions, + options: MiscMessageGenerationOptions = {} + ): Promise => { + const startTime = Date.now() const userJid = authState.creds.me!.id + + const { medias, delay: delayConfig = 'adaptive', retryCount = 3, continueOnFailure = true } = album + + // Validation (also done in generateWAMessageContent, but double-check here) + if (!medias || medias.length < 2) { + throw new Boom('Album must have at least 2 media items', { statusCode: 400 }) + } + + if (medias.length > 10) { + throw new Boom('Album cannot have more than 10 media items (WhatsApp limit)', { statusCode: 400 }) + } + + // Count media types for album root + // Use hasNonNullishProperty for consistency with generateWAMessageContent validation + const imageCount = medias.filter(m => hasNonNullishProperty(m as AnyMessageContent, 'image')).length + const videoCount = medias.filter(m => hasNonNullishProperty(m as AnyMessageContent, 'video')).length + + logger.info( + { jid, totalItems: medias.length, imageCount, videoCount, delayConfig, retryCount }, + 'Starting album message send' + ) + + // Generate album root message first (with counts of expected media) + const albumRootMsg = await generateWAMessage( + jid, + { + album: { medias, delay: delayConfig, retryCount, continueOnFailure } + }, + { + logger, + userJid, + getUrlInfo: text => + getUrlInfo(text, { + thumbnailWidth: linkPreviewImageThumbnailWidth, + fetchOpts: { timeout: 3_000, ...(httpRequestOptions || {}) }, + logger, + uploadImage: generateHighQualityLinkPreview ? waUploadToServer : undefined + }), + upload: waUploadToServer, + mediaCache: config.mediaCache, + // Don't spread options here to avoid messageId collision + timestamp: options.timestamp, + quoted: options.quoted, + ephemeralExpiration: options.ephemeralExpiration, + mediaUploadTimeoutMs: options.mediaUploadTimeoutMs + } + ) + + const albumKey = albumRootMsg.key + + // CRITICAL: Relay album root message to server first + // Without this, child media items reference a non-existent album key + await relayMessage(jid, albumRootMsg.message!, { + messageId: albumRootMsg.key.id!, + useCachedGroupMetadata: options.useCachedGroupMetadata + }) + + // Emit own event for album root if configured + if (config.emitOwnEvents) { + process.nextTick(async () => { + let mutexKey = albumRootMsg.key.remoteJid + if (!mutexKey) { + logger.warn( + { msgId: albumRootMsg.key.id }, + 'Missing remoteJid in albumRootMsg, using msg.key.id as fallback' + ) + mutexKey = albumRootMsg.key.id || 'unknown' + } + + await messageMutex.mutex(mutexKey, () => upsertMessage(albumRootMsg, 'append')) + }) + } + + logger.debug({ albumKeyId: albumKey.id }, 'Album root message relayed') + + const results: AlbumMediaResult[] = [] + + /** + * Calculate adaptive delay based on media characteristics + * Videos get more delay (2x), later items get slightly more delay, + * plus random jitter to prevent predictable patterns + */ + const calculateAdaptiveDelay = (media: AlbumMediaItem, index: number): number => { + const baseDelay = 500 // Base delay in ms + + // Videos get more delay + const isVideo = 'video' in media + const mediaTypeMultiplier = isVideo ? 2.0 : 1.0 + + // Later items in album get slightly more delay (cumulative load) + const positionMultiplier = 1 + index * 0.1 + + // Add some jitter to prevent predictable patterns + const jitter = Math.random() * 200 + + return Math.round(baseDelay * mediaTypeMultiplier * positionMultiplier + jitter) + } + + /** + * Get delay for this media item + */ + const getDelay = (media: AlbumMediaItem, index: number): number => { + if (delayConfig === 'adaptive') { + return calculateAdaptiveDelay(media, index) + } + + return delayConfig + } + + /** + * Send a single media item with retry logic + */ + const sendMediaWithRetry = async (media: AlbumMediaItem, index: number): Promise => { + const itemStartTime = Date.now() + let lastError: Error | undefined + let attempts = 0 + + for (let attempt = 0; attempt <= retryCount; attempt++) { + attempts = attempt + 1 + try { + // Generate message for this media item + // NOTE: Each item needs its own unique messageId, so we don't spread options.messageId + const mediaMsg = await generateWAMessage(jid, media as AnyMessageContent, { + logger, + userJid, + getUrlInfo: text => + getUrlInfo(text, { + thumbnailWidth: linkPreviewImageThumbnailWidth, + fetchOpts: { timeout: 3_000, ...(httpRequestOptions || {}) }, + logger, + uploadImage: generateHighQualityLinkPreview ? waUploadToServer : undefined + }), + upload: waUploadToServer, + mediaCache: config.mediaCache, + // Don't spread ...options to avoid messageId collision + // Each item gets a fresh ID from generateWAMessage + timestamp: options.timestamp, + quoted: options.quoted, + ephemeralExpiration: options.ephemeralExpiration, + mediaUploadTimeoutMs: options.mediaUploadTimeoutMs + }) + + // Attach to parent album via messageAssociation (correct proto structure) + // Uses AssociationType.MEDIA_ALBUM and parentMessageKey as per WhatsApp protocol + if (!mediaMsg.message) { + throw new Boom('Missing message content for album media item') + } + + if (!mediaMsg.message.messageContextInfo) { + mediaMsg.message.messageContextInfo = {} + } + + mediaMsg.message.messageContextInfo.messageAssociation = { + associationType: proto.MessageAssociation.AssociationType.MEDIA_ALBUM, + parentMessageKey: albumKey + } + + // Relay the message + await relayMessage(jid, mediaMsg.message, { + messageId: mediaMsg.key.id!, + useCachedGroupMetadata: options.useCachedGroupMetadata + }) + + // Emit own event if configured + if (config.emitOwnEvents) { + process.nextTick(async () => { + let mutexKey = mediaMsg.key.remoteJid + if (!mutexKey) { + logger.warn({ msgId: mediaMsg.key.id }, 'Missing remoteJid in mediaMsg, using msg.key.id as fallback') + mutexKey = mediaMsg.key.id || 'unknown' + } + + await messageMutex.mutex(mutexKey, () => upsertMessage(mediaMsg, 'append')) + }) + } + + logger.debug({ index, msgId: mediaMsg.key.id, attempts }, 'Album media item sent successfully') + + return { + index, + success: true, + message: mediaMsg, + retryAttempts: attempts, + latencyMs: Date.now() - itemStartTime + } + } catch (error) { + lastError = error as Error + logger.warn( + { index, attempt: attempts, error: lastError.message }, + 'Album media item send failed, will retry' + ) + + // Exponential backoff for retries + if (attempt < retryCount) { + const backoffDelay = Math.min(1000 * Math.pow(2, attempt), 10000) + await new Promise(resolve => setTimeout(resolve, backoffDelay)) + } + } + } + + // All retries exhausted + logger.error({ index, attempts, error: lastError?.message }, 'Album media item failed after all retries') + + return { + index, + success: false, + error: lastError, + retryAttempts: attempts, + latencyMs: Date.now() - itemStartTime + } + } + + // Send each media item sequentially + for (let i = 0; i < medias.length; i++) { + const media = medias[i]! + + const result = await sendMediaWithRetry(media, i) + results.push(result) + + // Check if we should stop on failure + if (!result.success && !continueOnFailure) { + logger.warn( + { index: i, totalItems: medias.length }, + 'Album send stopped due to failure (continueOnFailure=false)' + ) + break + } + + // Apply delay before next item (except for last item) + if (i < medias.length - 1) { + const delay = getDelay(media, i) + logger.trace({ index: i, delayMs: delay }, 'Waiting before next album item') + await new Promise(resolve => setTimeout(resolve, delay)) + } + } + + // Calculate final results + const attemptedItems = results.length + const stoppedEarly = attemptedItems < medias.length + const successCount = results.filter(r => r.success).length + const failedCount = results.filter(r => !r.success).length + const failedIndices = results.filter(r => !r.success).map(r => r.index) + const totalLatencyMs = Date.now() - startTime + + const finalResult: AlbumSendResult = { + albumKey, + results, + totalItems: medias.length, + attemptedItems, + successCount, + failedCount, + failedIndices, + success: failedCount === 0 && !stoppedEarly, + stoppedEarly, + totalLatencyMs + } + + logger.info( + { + albumKeyId: albumKey.id, + totalItems: medias.length, + attemptedItems, + successCount, + failedCount, + stoppedEarly, + totalLatencyMs + }, + 'Album message send completed' + ) + + return finalResult + }, + + sendMessage: async (jid: string, content: AnyMessageContent, options: MiscMessageGenerationOptions = {}) => { + // Check for album misuse - must use sendAlbumMessage instead + if (typeof content === 'object' && 'album' in content) { + throw new Boom( + 'Cannot send album messages with sendMessage(). Use sendAlbumMessage() instead, ' + + 'which properly sends the album root and individual media items.', + { statusCode: 400 } + ) + } + + const userJid = authState.creds.me!.id + if ( typeof content === 'object' && 'disappearingMessagesInChat' in content && @@ -1532,7 +2403,13 @@ export const makeMessagesSocket = (config: SocketConfig) => { }) if (config.emitOwnEvents) { process.nextTick(async () => { - await messageMutex.mutex('upsert', () => upsertMessage(fullMsg, 'append')) + let mutexKey = fullMsg.key.remoteJid + if (!mutexKey) { + logger.warn({ msgId: fullMsg.key.id }, 'Missing remoteJid in fullMsg, using msg.key.id as fallback') + mutexKey = fullMsg.key.id || 'unknown' + } + + await messageMutex.mutex(mutexKey, () => upsertMessage(fullMsg, 'append')) }) } diff --git a/src/Utils/messages-media.ts b/src/Utils/messages-media.ts index fe10f21d..5d232796 100644 --- a/src/Utils/messages-media.ts +++ b/src/Utils/messages-media.ts @@ -160,7 +160,7 @@ export const extractImageThumb = async (bufferOrFilePath: Readable | Buffer | st height: dimensions.height } } - } else if ('jimp' in lib && (typeof lib.jimp?.Jimp === 'object' || typeof lib.jimp?.Jimp === 'function')) { + } else if ('jimp' in lib && typeof lib.jimp?.Jimp === 'function') { const jimp = await (lib.jimp.Jimp as any).read(bufferOrFilePath) const dimensions = { width: jimp.width, diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 468498df..782682db 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -45,8 +45,8 @@ import { generateThumbnail, getAudioDuration, getAudioWaveform, - getRawMediaUploadData, getStream, + getRawMediaUploadData, type MediaDownloadOptions } from './messages-media' import { shouldIncludeReportingToken } from './reporting-utils' @@ -475,48 +475,6 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton = } } -const recoverCarouselImageMetadata = async ( - card: CarouselMessageOptions['cards'][number], - imageMessage: proto.Message.IImageMessage, - mediaOptions: MessageContentGenerationOptions -) => { - if (imageMessage.jpegThumbnail && imageMessage.height && imageMessage.width) { - return - } - - try { - const { stream } = await getStream(card.image!, mediaOptions.options) - const thumb = await extractImageThumb(stream) - - if (!imageMessage.jpegThumbnail) { - imageMessage.jpegThumbnail = thumb.buffer - } - - if (!imageMessage.width && thumb.original.width) { - imageMessage.width = thumb.original.width - } - - if (!imageMessage.height && thumb.original.height) { - imageMessage.height = thumb.original.height - } - - mediaOptions.logger?.info( - { - cardTitle: card.title, - hasJpegThumbnail: !!imageMessage.jpegThumbnail, - width: imageMessage.width, - height: imageMessage.height - }, - '[CAROUSEL] Recovered image thumbnail/dimensions from source media' - ) - } catch (error) { - mediaOptions.logger?.warn( - { cardTitle: card.title, error }, - '[CAROUSEL] Failed to recover image thumbnail/dimensions from source media' - ) - } -} - /** * Generates a button message using Native Flow format wrapped in viewOnceMessage * This is the modern approach for button messages that works on iOS and Android @@ -677,6 +635,59 @@ export const generateCarouselMessage = async ( ) } + const recoverCarouselImageMetadata = async ( + imageMessage: proto.Message.IImageMessage | null | undefined, + cardImage: WAMediaUpload, + cardTitle: string | undefined, + uploadOptions: MessageContentGenerationOptions + ) => { + if (!imageMessage) { + return + } + + const missingThumbnail = !imageMessage.jpegThumbnail + const missingWidth = !imageMessage.width + const missingHeight = !imageMessage.height + if (!missingThumbnail && !missingWidth && !missingHeight) { + return + } + + try { + const { stream } = await getStream(cardImage, uploadOptions.options) + const { buffer, original } = await extractImageThumb(stream) + + if (missingThumbnail) { + imageMessage.jpegThumbnail = buffer.toString('base64') + } + + if (missingWidth && original.width) { + imageMessage.width = original.width + } + + if (missingHeight && original.height) { + imageMessage.height = original.height + } + + uploadOptions.logger?.info( + { + cardTitle, + recoveredThumbnail: !!imageMessage.jpegThumbnail, + width: imageMessage.width, + height: imageMessage.height + }, + '[CAROUSEL] Recovered image metadata from source media' + ) + } catch (error) { + uploadOptions.logger?.warn( + { + cardTitle, + trace: error instanceof Error ? error.stack : String(error) + }, + '[CAROUSEL] Failed source-media thumbnail fallback' + ) + } + } + // Map cards to the carousel format (processing media) const carouselCards = await Promise.all( cards.map(async card => { @@ -692,10 +703,12 @@ export const generateCarouselMessage = async ( if (hasMedia && mediaOptions) { if (card.image) { const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, mediaOptions) - if (imageMessage) { - await recoverCarouselImageMetadata(card, imageMessage, mediaOptions) - } + // Mirror the working Pastorini-style result: every carousel image card should + // carry a jpegThumbnail and dimensions before it reaches the Web live renderer. + await recoverCarouselImageMetadata(imageMessage, card.image, card.title, mediaOptions) + + // Validate image fields needed for WhatsApp rendering if (imageMessage && !imageMessage.jpegThumbnail) { mediaOptions.logger?.warn( { cardTitle: card.title }, @@ -1275,57 +1288,20 @@ export const generateWAMessageContent = async ( options.logger?.info('Sending CTA buttons as nativeFlowMessage with viewOnceMessage wrapper') } } - // Check for nativeCarousel — inline handler (validated on Android, iOS, Web) - // Direct interactiveMessage at root (field 45), NO viewOnceMessage wrapper, - // NO messageContextInfo, NO biz/bot stanza nodes needed + // Check for nativeCarousel else if (hasNonNullishProperty(message, 'nativeCarousel')) { const carouselMsg = message as any - const cards = carouselMsg.nativeCarousel.cards || [] - const title = carouselMsg.nativeCarousel.title || carouselMsg.title - const text = carouselMsg.text - const footer = carouselMsg.footer - - const carouselCards = await Promise.all( - cards.map(async (card: any) => { - const hasMedia = !!(card.image || card.video) - const header: any = { - title: card.title || '', - subtitle: card.footer || '', - hasMediaAttachment: hasMedia - } - if (hasMedia && card.image) { - const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, options) - if (imageMessage && !imageMessage.height) imageMessage.height = 500 - if (imageMessage && !imageMessage.width) imageMessage.width = 500 - header.imageMessage = imageMessage - } - return { - header, - body: { text: card.body || '' }, - footer: card.footer ? { text: card.footer } : undefined, - nativeFlowMessage: { - buttons: (card.buttons || []).map((btn: any) => { - switch (btn.type) { - case 'url': return { name: 'cta_url', buttonParamsJson: JSON.stringify({ display_text: btn.text, url: btn.url, merchant_url: btn.url }) } - case 'copy': return { name: 'cta_copy', buttonParamsJson: JSON.stringify({ display_text: btn.text, copy_code: btn.copyText }) } - case 'call': return { name: 'cta_call', buttonParamsJson: JSON.stringify({ display_text: btn.text, phone_number: btn.phoneNumber }) } - default: return { name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: btn.text, id: btn.id }) } - } - }) - } - } - }) - ) - - m.interactiveMessage = { - header: { title: title || ' ', hasMediaAttachment: false }, - body: { text: text || '' }, - footer: footer ? { text: footer } : undefined, - carouselMessage: { - cards: carouselCards, - messageVersion: 1 - } + const carouselOptions: CarouselMessageOptions = { + cards: carouselMsg.nativeCarousel.cards, + title: carouselMsg.nativeCarousel.title || carouselMsg.title, + text: carouselMsg.text, + footer: carouselMsg.footer } + // Pass options for media processing if cards have images/videos + const generated = await generateCarouselMessage(carouselOptions, options) + // Frida capture shows interactiveMessage DIRECT (field 45) in DSM — no viewOnceMessage wrapper + // Testing without wrapper: biz node + quality_control already match Pastorini CDP stanza + m.interactiveMessage = generated.interactiveMessage return m } // Check for nativeList diff --git a/src/Utils/tc-token-utils.ts b/src/Utils/tc-token-utils.ts index c7d5d0e9..b2a68fcc 100644 --- a/src/Utils/tc-token-utils.ts +++ b/src/Utils/tc-token-utils.ts @@ -158,11 +158,13 @@ export async function storeTcTokensFromIqResult({ ...existingEntry, token: Buffer.from(tokenNode.content), timestamp: tokenNode.attrs.t, - // Resets real_issue_timestamp to null when storing a new token + // WABA Android: resets real_issue_timestamp to null when storing a new token + // (UPDATE wa_trusted_contacts_send SET real_issue_timestamp=null) realIssueTimestamp: null } // Store under resolved storageJid AND under fallbackJid (PN) for reliable lookup + // The read path may resolve to a different LID than the store path const normalizedFallback = jidNormalizedUser(fallbackJid) const keysToStore: Record = { [storageJid]: tokenEntry