From efae3b751df90aff4b7e8d2e5c322c143be0ed54 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sat, 21 Feb 2026 00:36:13 -0300 Subject: [PATCH] fix(carousel): fix carousel rendering on Android, iOS and Web fix(carousel): fix carousel rendering on Android, iOS and Web --- src/Socket/messages-send.ts | 71 +++---------------------------------- src/Utils/messages.ts | 56 ++++++++++------------------- 2 files changed, 24 insertions(+), 103 deletions(-) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 5a54c976..0cad8af7 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -29,7 +29,6 @@ import { generateMessageIDV2, generateParticipantHashV2, generateWAMessage, - generateWAMessageContent, getStatusCodeForMediaRetry, getUrlFromDirectPath, getWAUploadToServer, @@ -1146,9 +1145,6 @@ export const makeMessagesSocket = (config: SocketConfig) => { const { user: mePnUser } = jidDecode(meId)! const { user: meLidUser } = meLid ? jidDecode(meLid)! : { user: null } - // Carousel in DSM wrapper causes error 479 on own devices - const isCarouselMsg = isCarouselMessage(message) - for (const { user, jid } of devices) { const isExactSenderDevice = jid === meId || (meLid && jid === meLid) if (isExactSenderDevice) { @@ -1160,11 +1156,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { const isMe = user === mePnUser || user === meLidUser if (isMe) { - if (isCarouselMsg) { - logger.debug({ jid }, '[CAROUSEL] Skipping own device - DSM carousel causes error 479') - continue - } - + // Send DSM to ALL own companion devices including carousel + // Error 479 on companion devices is non-fatal meRecipients.push(jid) } else { otherRecipients.push(jid) @@ -1200,8 +1193,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { const isParticipantLid = isLidUser(participant.jid) const isMe = areJidsSameUser(participant.jid, isParticipantLid ? meLid : meId) - // Skip DSM for carousel - own devices reject it with error 479 - const usesDSM = isMe && !isCarouselMessage(message) + // Send DSM for all message types including carousel + const usesDSM = isMe const encodedMessageToSend = usesDSM ? encodeWAMessage({ deviceSentMessage: { @@ -1265,8 +1258,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { // The biz node MUST be last for WhatsApp Web carousel rendering const deferredNodes: BinaryNode[] = [] - // EXPERIMENTAL: Try biz node injection for ALL interactive messages including catalog - // Previously we skipped catalog messages but they weren't rendering properly + // Inject biz node for interactive messages (including carousel) if (buttonType && enableInteractiveMessages) { const startTime = Date.now() @@ -2188,59 +2180,6 @@ export const makeMessagesSocket = (config: SocketConfig) => { const userJid = authState.creds.me!.id - // Special path for carousel: call relayMessage directly with plain JS object - // This matches the known working approach: - // 1. Build message as plain JS object (not proto instance) - // 2. Pass directly to relayMessage (no WAProto.Message.fromObject/create) - // 3. protobuf encode() handles plain objects correctly during serialization - // Going through fromObject() can corrupt nested carousel InteractiveMessage oneOf fields - if (typeof content === 'object' && 'nativeCarousel' in content && (content as any).nativeCarousel) { - const messageId = generateMessageIDV2(sock.user?.id) - // generateWAMessageContent returns a plain JS object for carousel - // (not a proto instance) to preserve nested structure integrity - const msgContent = await generateWAMessageContent(content, { - logger, - upload: waUploadToServer, - mediaCache: config.mediaCache, - options: config.options, - jid - }) - - // Pass the plain JS object directly to relayMessage - // This avoids proto.WebMessageInfo.fromObject() which would call - // Message.fromObject() on the nested message, potentially corrupting it - await relayMessage(jid, msgContent, { - messageId, - useCachedGroupMetadata: options.useCachedGroupMetadata, - additionalAttributes: {}, - statusJidList: options.statusJidList - }) - - // Build WebMessageInfo only for event emission and return value - const timestamp = unixTimestampSeconds() - const fullMsg = proto.WebMessageInfo.fromObject({ - key: { - remoteJid: jid, - fromMe: true, - id: messageId - }, - message: msgContent, - messageTimestamp: timestamp, - messageStubParameters: [], - participant: isJidGroup(jid) ? userJid : undefined, - status: proto.WebMessageInfo.Status.PENDING - }) as WAMessage - - if (config.emitOwnEvents) { - process.nextTick(async () => { - const mutexKey = fullMsg.key.remoteJid || fullMsg.key.id || 'unknown' - await messageMutex.mutex(mutexKey, () => upsertMessage(fullMsg, 'append')) - }) - } - - return fullMsg - } - if ( typeof content === 'object' && 'disappearingMessagesInChat' in content && diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index b78fcdfb..b5304220 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -640,6 +640,7 @@ export const generateCarouselMessage = async ( const header: any = { title: card.title || '', + subtitle: card.footer || '', hasMediaAttachment: hasMedia } @@ -683,19 +684,15 @@ export const generateCarouselMessage = async ( ) // Build the interactive message with carousel - // Known working structure: - // - Direct interactiveMessage at root (NO viewOnceMessage wrapper) - // - Root header with title + hasMediaAttachment: false - // - messageVersion: 1 in carouselMessage - // - Body and footer at root level + // carouselMessage first, then header/body/footer (matches working structure) const interactiveMessage: proto.Message.IInteractiveMessage = { - header: { title: title || ' ', hasMediaAttachment: false }, - body: { text: text || '' }, - footer: footer ? { text: footer } : undefined, carouselMessage: { cards: carouselCards, messageVersion: 1 - } + }, + header: { title: title || ' ', hasMediaAttachment: false }, + body: { text: text || '' }, + footer: footer ? { text: footer } : undefined } // Log carousel structure summary for debugging @@ -720,9 +717,7 @@ export const generateCarouselMessage = async ( '[CAROUSEL] Structure summary' ) - // Direct interactiveMessage at root - NO viewOnceMessage, NO messageContextInfo - // viewOnceMessage breaks iOS, messageContextInfo breaks iOS - // Web shows header-only fallback (carousel is mobile-only on WhatsApp) + // Direct interactiveMessage at root (field 45) — no viewOnceMessage wrapper return { interactiveMessage } @@ -1244,12 +1239,9 @@ export const generateWAMessageContent = async ( } // Pass options for media processing if cards have images/videos const generated = await generateCarouselMessage(carouselOptions, options) - // Send carousel as direct interactiveMessage (no viewOnceMessage wrapper) - // viewOnceMessage breaks iOS; messageContextInfo breaks iOS delivery + // Direct interactiveMessage — no viewOnceMessage wrapper, no root header/body/footer m.interactiveMessage = generated.interactiveMessage - options.logger?.info('Sending carousel as direct interactiveMessage') - // Return plain JS object - no fromObject() to avoid corrupting nested carousel structures - return m + return m as proto.IMessage } // Check for nativeList else if (hasNonNullishProperty(message, 'nativeList')) { @@ -1377,17 +1369,8 @@ export const generateWAMessageContent = async ( } } - // Wrap in viewOnceMessage for better iOS/Android compatibility - m.viewOnceMessage = { - message: { - messageContextInfo: { - deviceListMetadata: {}, - deviceListMetadataVersion: 2 - }, - interactiveMessage - } - } - options.logger?.warn('[EXPERIMENTAL] Sending carouselMessage with viewOnceMessage wrapper') + // Direct interactiveMessage — no viewOnceMessage wrapper + m.interactiveMessage = interactiveMessage } else if (hasNonNullishProperty(message, 'album')) { // Album message validation - actual sending is handled in messages-send.ts const { medias } = message.album @@ -1827,10 +1810,9 @@ export const generateWAMessageFromContent = ( } } - // Skip ephemeral contextInfo for carousel messages - carousel was already - // processed with fromObject and adding contextInfo.expiration may cause - // error 479 on linked devices (Web/Desktop) - const isCarouselEphemeral = !!(message as any)?.viewOnceMessage?.message?.interactiveMessage?.carouselMessage + // Skip ephemeral contextInfo for carousel messages + const isCarouselEphemeral = !!(message as any)?.interactiveMessage?.carouselMessage || + !!(message as any)?.viewOnceMessage?.message?.interactiveMessage?.carouselMessage if ( // if we want to send a disappearing message !!options?.ephemeralExpiration && @@ -1851,11 +1833,11 @@ export const generateWAMessageFromContent = ( } } - // For carousel messages already processed with fromObject, skip create() to - // preserve the deep protobuf conversion. create() does shallow assignment which - // may not properly handle deeply nested carousel structures (cards > headers > media) - const isCarouselVOM = !!(message as any)?.viewOnceMessage?.message?.interactiveMessage?.carouselMessage - if (!isCarouselVOM) { + // Skip Message.create() for carousel — InteractiveMessage has oneOf (fields 4-7) + // and create() may corrupt the carouselMessage/nativeFlowMessage oneOf resolution + const isCarouselMsg = !!(message as any)?.interactiveMessage?.carouselMessage || + !!(message as any)?.viewOnceMessage?.message?.interactiveMessage?.carouselMessage + if (!isCarouselMsg) { message = WAProto.Message.create(message) }