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:
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user