Compare commits

..

1 Commits

Author SHA1 Message Date
Renato Alcara 20b5a4703d fix: skip biz node for carousel messages (fixes WhatsApp Web rendering)
Carousel messages must NOT include the biz XML node in the stanza.
The biz node causes WhatsApp Web to render only the header instead
of the full carousel with cards. Android and iOS are unaffected.

Confirmed via Frida capture of working implementation (Pastorini):
carousel stanza has NO biz node, NO bot node.

Change: add `&& !isCarousel` to biz node injection condition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:25:52 -03:00
3 changed files with 1051 additions and 102 deletions
File diff suppressed because it is too large Load Diff
+10 -48
View File
@@ -1228,57 +1228,19 @@ export const generateWAMessageContent = async (
options.logger?.info('Sending CTA buttons as nativeFlowMessage with viewOnceMessage wrapper') options.logger?.info('Sending CTA buttons as nativeFlowMessage with viewOnceMessage wrapper')
} }
} }
// Check for nativeCarousel — inline handler (validated on Android, iOS, Web) // Check for nativeCarousel
// Direct interactiveMessage at root (field 45), NO viewOnceMessage wrapper,
// NO messageContextInfo, NO biz/bot stanza nodes needed
else if (hasNonNullishProperty(message, 'nativeCarousel')) { else if (hasNonNullishProperty(message, 'nativeCarousel')) {
const carouselMsg = message as any const carouselMsg = message as any
const cards = carouselMsg.nativeCarousel.cards || [] const carouselOptions: CarouselMessageOptions = {
const title = carouselMsg.nativeCarousel.title || carouselMsg.title cards: carouselMsg.nativeCarousel.cards,
const text = carouselMsg.text title: carouselMsg.nativeCarousel.title || carouselMsg.title,
const footer = carouselMsg.footer text: carouselMsg.text,
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
}
} }
// 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
m.interactiveMessage = generated.interactiveMessage
return m return m
} }
// Check for nativeList // Check for nativeList
+42 -17
View File
@@ -14,19 +14,24 @@ import { encodeBigEndian } from './generics'
import { createSignalIdentity } from './signal' import { createSignalIdentity } from './signal'
const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => { const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => {
// Always use MACOS/Desktop identity for UserAgent — we connect via web
// protocol (WA\x06\x03) so the server expects a web-like UserAgent.
// Using SMB_ANDROID here causes pair code registration to fail with
// "não é possível conectar" even though the phone shows the confirmation.
// Android identity is only set in DeviceProps (registration node) which
// determines the display name in "Linked Devices".
return { return {
appVersion: { appVersion: {
primary: config.version[0], primary: config.version[0],
secondary: config.version[1], secondary: config.version[1],
tertiary: config.version[2] tertiary: config.version[2]
}, },
platform: proto.ClientPayload.UserAgent.Platform.WEB, platform: proto.ClientPayload.UserAgent.Platform.MACOS,
releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE, releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
osVersion: '0.1', osVersion: '0.1',
device: 'Desktop', device: 'Desktop',
osBuildNumber: '0.1', osBuildNumber: '0.1',
localeLanguageIso6391: 'en', localeLanguageIso6391: 'en',
mnc: '000', mnc: '000',
mcc: '000', mcc: '000',
localeCountryIso31661Alpha2: config.countryCode localeCountryIso31661Alpha2: config.countryCode
@@ -55,16 +60,20 @@ const getClientPayload = (config: SocketConfig) => {
const payload: proto.IClientPayload = { const payload: proto.IClientPayload = {
connectType: proto.ClientPayload.ConnectType.WIFI_UNKNOWN, connectType: proto.ClientPayload.ConnectType.WIFI_UNKNOWN,
connectReason: proto.ClientPayload.ConnectReason.USER_ACTIVATED, connectReason: proto.ClientPayload.ConnectReason.USER_ACTIVATED,
userAgent: getUserAgent(config) userAgent: getUserAgent(config),
webInfo: getWebInfo(config)
} }
payload.webInfo = getWebInfo(config)
return payload return payload
} }
export const generateLoginNode = (userJid: string, config: SocketConfig): proto.IClientPayload => { export const generateLoginNode = (userJid: string, config: SocketConfig): proto.IClientPayload => {
const { user, device } = jidDecode(userJid)! const decoded = jidDecode(userJid)
if (!decoded) {
throw new Boom('Invalid user JID', { statusCode: 400 })
}
const { user, device } = decoded
const payload: proto.IClientPayload = { const payload: proto.IClientPayload = {
...getClientPayload(config), ...getClientPayload(config),
passive: true, passive: true,
@@ -79,6 +88,10 @@ export const generateLoginNode = (userJid: string, config: SocketConfig): proto.
const getPlatformType = (platform: string): proto.DeviceProps.PlatformType => { const getPlatformType = (platform: string): proto.DeviceProps.PlatformType => {
const platformType = platform.toUpperCase() const platformType = platform.toUpperCase()
if (platformType === 'ANDROID') {
return proto.DeviceProps.PlatformType.ANDROID_PHONE
}
return ( return (
proto.DeviceProps.PlatformType[platformType as keyof typeof proto.DeviceProps.PlatformType] || proto.DeviceProps.PlatformType[platformType as keyof typeof proto.DeviceProps.PlatformType] ||
proto.DeviceProps.PlatformType.CHROME proto.DeviceProps.PlatformType.CHROME
@@ -153,6 +166,9 @@ export const configureSuccessfulPairing = (
}: Pick<AuthenticationCreds, 'advSecretKey' | 'signedIdentityKey' | 'signalIdentities'> }: Pick<AuthenticationCreds, 'advSecretKey' | 'signedIdentityKey' | 'signalIdentities'>
) => { ) => {
const msgId = stanza.attrs.id const msgId = stanza.attrs.id
if (!msgId) {
throw new Boom('Missing message ID', { statusCode: 400 })
}
const pairSuccessNode = getBinaryNodeChild(stanza, 'pair-success') const pairSuccessNode = getBinaryNodeChild(stanza, 'pair-success')
@@ -168,42 +184,51 @@ export const configureSuccessfulPairing = (
const bizName = businessNode?.attrs.name const bizName = businessNode?.attrs.name
const jid = deviceNode.attrs.jid const jid = deviceNode.attrs.jid
const lid = deviceNode.attrs.lid const lid = deviceNode.attrs.lid
if (!jid || !lid) {
throw new Boom('Missing JID or LID in device node', { statusCode: 400 })
}
const { details, hmac, accountType } = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentityNode.content as Buffer) const { details, hmac, accountType } = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentityNode.content as Buffer)
if (!details || !hmac) {
throw new Boom('Missing ADV signature fields', { statusCode: 400 })
}
let hmacPrefix = Buffer.from([]) let hmacPrefix = Buffer.from([])
if (accountType !== undefined && accountType === proto.ADVEncryptionType.HOSTED) { if (accountType !== undefined && accountType === proto.ADVEncryptionType.HOSTED) {
hmacPrefix = WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX hmacPrefix = WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
} }
const advSign = hmacSign(Buffer.concat([hmacPrefix, details!]), Buffer.from(advSecretKey, 'base64')) const advSign = hmacSign(Buffer.concat([hmacPrefix, details]), Buffer.from(advSecretKey, 'base64'))
if (Buffer.compare(hmac!, advSign) !== 0) { if (Buffer.compare(hmac, advSign) !== 0) {
throw new Boom('Invalid account signature') throw new Boom('Invalid account signature')
} }
const account = proto.ADVSignedDeviceIdentity.decode(details!) const account = proto.ADVSignedDeviceIdentity.decode(details)
const { accountSignatureKey, accountSignature, details: deviceDetails } = account const { accountSignatureKey, accountSignature, details: deviceDetails } = account
if (!accountSignatureKey || !accountSignature || !deviceDetails) {
throw new Boom('Missing ADV account fields', { statusCode: 400 })
}
const deviceIdentity = proto.ADVDeviceIdentity.decode(deviceDetails!) const deviceIdentity = proto.ADVDeviceIdentity.decode(deviceDetails)
const accountSignaturePrefix = const accountSignaturePrefix =
deviceIdentity.deviceType === proto.ADVEncryptionType.HOSTED deviceIdentity.deviceType === proto.ADVEncryptionType.HOSTED
? WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX ? WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
: WA_ADV_ACCOUNT_SIG_PREFIX : WA_ADV_ACCOUNT_SIG_PREFIX
const accountMsg = Buffer.concat([accountSignaturePrefix, deviceDetails!, signedIdentityKey.public]) const accountMsg = Buffer.concat([accountSignaturePrefix, deviceDetails, signedIdentityKey.public])
if (!Curve.verify(accountSignatureKey!, accountMsg, accountSignature!)) { if (!Curve.verify(accountSignatureKey, accountMsg, accountSignature)) {
throw new Boom('Failed to verify account signature') throw new Boom('Failed to verify account signature')
} }
const deviceMsg = Buffer.concat([ const deviceMsg = Buffer.concat([
WA_ADV_DEVICE_SIG_PREFIX, WA_ADV_DEVICE_SIG_PREFIX,
deviceDetails!, deviceDetails,
signedIdentityKey.public, signedIdentityKey.public,
accountSignatureKey! accountSignatureKey
]) ])
account.deviceSignature = Curve.sign(signedIdentityKey.private, deviceMsg) account.deviceSignature = Curve.sign(signedIdentityKey.private, deviceMsg)
const identity = createSignalIdentity(lid!, accountSignatureKey!) const identity = createSignalIdentity(lid, accountSignatureKey)
const accountEnc = encodeSignedDeviceIdentity(account, false) const accountEnc = encodeSignedDeviceIdentity(account, false)
const reply: BinaryNode = { const reply: BinaryNode = {
@@ -211,7 +236,7 @@ export const configureSuccessfulPairing = (
attrs: { attrs: {
to: S_WHATSAPP_NET, to: S_WHATSAPP_NET,
type: 'result', type: 'result',
id: msgId! id: msgId
}, },
content: [ content: [
{ {
@@ -230,7 +255,7 @@ export const configureSuccessfulPairing = (
const authUpdate: Partial<AuthenticationCreds> = { const authUpdate: Partial<AuthenticationCreds> = {
account, account,
me: { id: jid!, name: bizName, lid }, me: { id: jid, name: bizName, lid },
signalIdentities: [...(signalIdentities || []), identity], signalIdentities: [...(signalIdentities || []), identity],
platform: platformNode?.attrs.name platform: platformNode?.attrs.name
} }