Compare commits

...

4 Commits

Author SHA1 Message Date
Renato Alcara e152aee740 Fix/pn lid session reuse (#294)
* fix: unify PN and LID session reuse for 1:1 sends

* fix: limit PN/LID reuse changes to session cache
2026-03-16 23:22:05 -03:00
Renato Alcara ff9b84c561 fix: unify PN and LID session reuse for 1:1 sends (#293)
fix: unify PN and LID session reuse for 1:1 sends (#293)
2026-03-16 22:59:28 -03:00
Renato Alcara 50c13398c4 fix: harden carousel live rendering path (#292)
* fix: harden carousel live rendering path

* fix: flatten carousel thumbnail fallback
2026-03-16 22:09:14 -03:00
Renato Alcara d991d0212d fix: add biz node injection and quality_control for carousel WhatsApp Web rendering (#291)
- Add interactive message detection helpers (getButtonType, isCarouselMessage, etc.)
- Inject biz node with interactive/native_flow for non-carousel interactive messages
- Inject biz + quality_control with decision_id for carousel messages
- Skip bot node for native_flow/carousel/catalog (breaks Web rendering)
- Force device-identity inclusion for carousel messages
- Append deferred biz/bot/quality_control nodes after device-identity and tctoken
- Store tctoken under both LID and PN for reliable lookup
2026-03-16 21:25:29 -03:00
3 changed files with 315 additions and 20 deletions
+249 -7
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'crypto'
import NodeCache from '@cacheable/node-cache'
import { Boom } from '@hapi/boom'
import { proto } from '../../WAProto/index.js'
@@ -45,6 +46,7 @@ import {
getBinaryNodeChildren,
isHostedLidUser,
isHostedPnUser,
isJidBot,
isJidGroup,
isLidUser,
isPnUser,
@@ -66,7 +68,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
patchMessageBeforeSending,
cachedGroupMetadata,
enableRecentMessageCache,
maxMsgRetryCount
maxMsgRetryCount,
enableInteractiveMessages
} = config
const sock = makeNewsletterSocket(config)
const {
@@ -414,6 +417,39 @@ 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
@@ -423,22 +459,32 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Check peerSessionsCache and validate sessions using libsignal loadSession
for (const jid of uniqueJids) {
const signalId = signalRepository.jidToSignalProtocolAddress(jid)
const cachedSession = peerSessionsCache.get(signalId)
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)
if (cachedSession !== undefined) {
if (cachedSession && !force) {
continue // Session exists in cache
}
} else {
const sessionValidation = await signalRepository.validateSession(jid)
const sessionValidation = await signalRepository.validateSession(canonicalJid)
const hasSession = sessionValidation.exists
peerSessionsCache.set(signalId, hasSession)
for (const signalId of signalIds) {
peerSessionsCache.set(signalId, hasSession)
}
if (hasSession && !force) {
continue
}
}
jidsRequiringFetch.push(jid)
jidsRequiringFetch.push(canonicalJid)
}
if (jidsRequiringFetch.length) {
@@ -600,6 +646,85 @@ export const makeMessagesSocket = (config: SocketConfig) => {
return { nodes, shouldIncludeDeviceIdentity }
}
/** Detect interactive/button message type for biz node injection */
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'
if (message.interactiveMessage.carouselMessage?.cards?.length) {
const hasNativeFlow = message.interactiveMessage.carouselMessage.cards.some(
(card: proto.Message.IInteractiveMessage) => card?.nativeFlowMessage?.buttons?.length
)
if (hasNativeFlow) return 'native_flow'
}
return 'interactive'
}
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.interactiveMessage.carouselMessage?.cards?.length) {
const hasNativeFlow = innerMessage.interactiveMessage.carouselMessage.cards.some(
(card: any) => card?.nativeFlowMessage?.buttons?.length
)
if (hasNativeFlow) return 'native_flow'
}
return 'interactive'
}
}
return undefined
}
/** Check if message is a carousel (media or product carousel) */
const isCarouselMessage = (message: proto.IMessage): boolean => {
const interactiveMsg =
message.interactiveMessage ||
message.viewOnceMessage?.message?.interactiveMessage ||
message.viewOnceMessageV2?.message?.interactiveMessage
return !!(interactiveMsg?.carouselMessage?.cards?.length)
}
/** Check if message is a catalog/product message */
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) {
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) */
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) {
return nativeFlow.buttons.some((btn: any) => btn?.name === 'single_select' || btn?.name === 'multi_select')
}
return false
}
const relayMessage = async (
jid: string,
message: proto.IMessage,
@@ -886,7 +1011,13 @@ export const makeMessagesSocket = (config: SocketConfig) => {
allRecipients.push(jid)
}
await assertSessions(allRecipients)
const effectiveMeRecipients = await canonicalizeSessionRecipients(meRecipients)
const effectiveOtherRecipients = await canonicalizeSessionRecipients(otherRecipients)
const effectiveAllRecipients = [...effectiveMeRecipients, ...effectiveOtherRecipients]
await assertSessions(effectiveAllRecipients)
const [
{ nodes: meNodes, shouldIncludeDeviceIdentity: s1 },
@@ -962,6 +1093,110 @@ export const makeMessagesSocket = (config: SocketConfig) => {
content: binaryNodeContent
}
// Detect interactive message type for biz node injection
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)
const deferredNodes: BinaryNode[] = []
if (buttonType && enableInteractiveMessages && !isCarousel) {
const interactiveMsg =
message.interactiveMessage ||
message.viewOnceMessage?.message?.interactiveMessage ||
message.viewOnceMessageV2?.message?.interactiveMessage
let nativeFlowButtons = interactiveMsg?.nativeFlowMessage?.buttons || []
if (nativeFlowButtons.length === 0 && interactiveMsg?.carouselMessage?.cards?.length) {
nativeFlowButtons = interactiveMsg.carouselMessage.cards.flatMap(
(card: proto.Message.IInteractiveMessage) => card?.nativeFlowMessage?.buttons || []
)
}
const isListDetected = isListNativeFlow(message)
logger.info(
{ msgId, buttonType, to: destinationJid, isListDetected, isCatalog },
'[Interactive] Preparing biz node'
)
try {
const allButtonNames = nativeFlowButtons.map((b: any) => b?.name).filter(Boolean)
const isNativeFlowButtons = buttonType === 'native_flow'
if (buttonType === 'list') {
deferredNodes.push({
tag: 'biz',
attrs: {},
content: [
{ tag: 'list', attrs: { type: 'product_list', v: '2' } }
]
})
} else {
const SPECIAL_FLOW_NAMES: Record<string, string> = {
review_and_pay: 'payment_info',
payment_info: 'payment_info',
mpm: 'mpm',
review_order: 'order_details'
}
const firstButtonName = allButtonNames[0] || ''
const nativeFlowName = SPECIAL_FLOW_NAMES[firstButtonName] || 'mixed'
deferredNodes.push({
tag: 'biz',
attrs: {},
content: [
{
tag: 'interactive',
attrs: { type: 'native_flow', v: '1' },
content: [
{ tag: 'native_flow', attrs: { v: '9', name: nativeFlowName } }
]
}
]
})
}
// Bot node — skip for native_flow, carousel, catalog
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' } })
}
} catch (error) {
logger.error({ error, msgId, buttonType }, '[BIZ NODE] Failed to inject biz node')
}
} 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')
}
// if the participant to send to is explicitly specified (generally retry recp)
// ensure the message is only sent to that person
// if a retry receipt is sent to everyone -- it'll fail decryption for everyone else who received the msg
@@ -979,7 +1214,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
stanza.attrs.to = destinationJid
}
if (shouldIncludeDeviceIdentity) {
// Force device-identity for carousel (ensures Web can decrypt)
if (shouldIncludeDeviceIdentity || isCarousel) {
;(stanza.content as BinaryNode[]).push({
tag: 'device-identity',
attrs: {},
@@ -1030,6 +1266,12 @@ export const makeMessagesSocket = (config: SocketConfig) => {
;(stanza.content as BinaryNode[]).push(...additionalNodes)
}
// Append deferred biz/bot/quality_control nodes LAST
// Stanza order: participants → device-identity → tctoken → biz → quality_control
if (deferredNodes.length > 0) {
;(stanza.content as BinaryNode[]).push(...deferredNodes)
}
logger.debug({ msgId }, `sending message to ${participants.length} devices`)
await sendNode(stanza)
+48 -1
View File
@@ -41,10 +41,12 @@ import type { ILogger } from './logger'
import {
downloadContentFromMessage,
encryptedStream,
extractImageThumb,
generateThumbnail,
getAudioDuration,
getAudioWaveform,
getRawMediaUploadData,
getStream,
type MediaDownloadOptions
} from './messages-media'
import { shouldIncludeReportingToken } from './reporting-utils'
@@ -473,6 +475,48 @@ 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
@@ -648,7 +692,10 @@ export const generateCarouselMessage = async (
if (hasMedia && mediaOptions) {
if (card.image) {
const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, mediaOptions)
// Validate image fields needed for WhatsApp rendering
if (imageMessage) {
await recoverCarouselImageMetadata(card, imageMessage, mediaOptions)
}
if (imageMessage && !imageMessage.jpegThumbnail) {
mediaOptions.logger?.warn(
{ cardTitle: card.title },
+18 -12
View File
@@ -154,18 +154,24 @@ 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,
// Resets real_issue_timestamp to null when storing a new token
realIssueTimestamp: null
}
// Store under resolved storageJid AND under fallbackJid (PN) for reliable lookup
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)
}
}