fix: carousel WhatsApp Web real-time rendering (no F5 needed)

Root cause: WhatsApp Web stores pkmsg as ciphertext first, then decrypts
to interactive. React does not re-render the ciphertext→interactive
transition, requiring F5. When enc type is msg (established session),
Web receives as interactive directly and renders in real-time.

Changes:
- messages-send.ts: re-enable biz node for carousel (CDP evidence from
  Pastorini confirms native_flow v=9 name=mixed + quality_control),
  LID-based addressing for session reuse, blocking tctoken fetch,
  force device-identity, skip bot node for carousel
- messages.ts: interactiveMessage direct (no viewOnceMessage wrapper),
  skip Message.create() for carousel (oneOf corruption), skip ephemeral
  contextInfo for carousel
- messages-media.ts: fix jimp type check (function not object)
- tc-token-utils.ts: dual storage under LID and PN for reliable lookup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Renato Alcara
2026-03-16 20:13:19 -03:00
parent 20b5a4703d
commit 5854a5098d
4 changed files with 236 additions and 64 deletions
+1 -1
View File
@@ -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') {
} 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,
+44 -1
View File
@@ -41,9 +41,11 @@ import type { ILogger } from './logger'
import {
downloadContentFromMessage,
encryptedStream,
extractImageThumb,
generateThumbnail,
getAudioDuration,
getAudioWaveform,
getStream,
getRawMediaUploadData,
type MediaDownloadOptions
} from './messages-media'
@@ -648,6 +650,46 @@ export const generateCarouselMessage = async (
if (hasMedia && mediaOptions) {
if (card.image) {
const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, mediaOptions)
// Mirror the working Pastorini-style result: every carousel image card should
// carry a jpegThumbnail and dimensions before it reaches the Web live renderer.
if (imageMessage && (!imageMessage.jpegThumbnail || !imageMessage.height || !imageMessage.width)) {
try {
const { stream } = await getStream(card.image, mediaOptions.options)
const { buffer, original } = await extractImageThumb(stream)
if (!imageMessage.jpegThumbnail) {
imageMessage.jpegThumbnail = buffer.toString('base64')
}
if (!imageMessage.width && original.width) {
imageMessage.width = original.width
}
if (!imageMessage.height && original.height) {
imageMessage.height = original.height
}
mediaOptions.logger?.info(
{
cardTitle: card.title,
recoveredThumbnail: !!imageMessage.jpegThumbnail,
width: imageMessage.width,
height: imageMessage.height
},
'[CAROUSEL] Recovered image metadata from source media'
)
} catch (error) {
mediaOptions.logger?.warn(
{
cardTitle: card.title,
trace: error instanceof Error ? error.stack : String(error)
},
'[CAROUSEL] Failed source-media thumbnail fallback'
)
}
}
// Validate image fields needed for WhatsApp rendering
if (imageMessage && !imageMessage.jpegThumbnail) {
mediaOptions.logger?.warn(
@@ -1239,7 +1281,8 @@ export const generateWAMessageContent = async (
}
// Pass options for media processing if cards have images/videos
const generated = await generateCarouselMessage(carouselOptions, options)
// Direct interactiveMessage — no viewOnceMessage wrapper, no root header/body/footer
// 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
}
+20 -12
View File
@@ -154,18 +154,26 @@ export async function storeTcTokensFromIqResult({
continue
}
await keys.set({
tctoken: {
[storageJid]: {
...existingEntry,
token: Buffer.from(tokenNode.content),
timestamp: tokenNode.attrs.t,
// 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
}
}
})
const tokenEntry = {
...existingEntry,
token: Buffer.from(tokenNode.content),
timestamp: tokenNode.attrs.t,
// 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<string, typeof tokenEntry | null> = {
[storageJid]: tokenEntry
}
if (normalizedFallback !== storageJid) {
keysToStore[normalizedFallback] = tokenEntry
}
await keys.set({ tctoken: keysToStore })
onNewJidStored?.(storageJid)
}
}