Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5854a5098d | |||
| 20b5a4703d |
+1125
-42
File diff suppressed because it is too large
Load Diff
@@ -160,7 +160,7 @@ export const extractImageThumb = async (bufferOrFilePath: Readable | Buffer | st
|
|||||||
height: dimensions.height
|
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 jimp = await (lib.jimp.Jimp as any).read(bufferOrFilePath)
|
||||||
const dimensions = {
|
const dimensions = {
|
||||||
width: jimp.width,
|
width: jimp.width,
|
||||||
|
|||||||
+53
-48
@@ -41,9 +41,11 @@ import type { ILogger } from './logger'
|
|||||||
import {
|
import {
|
||||||
downloadContentFromMessage,
|
downloadContentFromMessage,
|
||||||
encryptedStream,
|
encryptedStream,
|
||||||
|
extractImageThumb,
|
||||||
generateThumbnail,
|
generateThumbnail,
|
||||||
getAudioDuration,
|
getAudioDuration,
|
||||||
getAudioWaveform,
|
getAudioWaveform,
|
||||||
|
getStream,
|
||||||
getRawMediaUploadData,
|
getRawMediaUploadData,
|
||||||
type MediaDownloadOptions
|
type MediaDownloadOptions
|
||||||
} from './messages-media'
|
} from './messages-media'
|
||||||
@@ -648,6 +650,46 @@ export const generateCarouselMessage = async (
|
|||||||
if (hasMedia && mediaOptions) {
|
if (hasMedia && mediaOptions) {
|
||||||
if (card.image) {
|
if (card.image) {
|
||||||
const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, mediaOptions)
|
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
|
// Validate image fields needed for WhatsApp rendering
|
||||||
if (imageMessage && !imageMessage.jpegThumbnail) {
|
if (imageMessage && !imageMessage.jpegThumbnail) {
|
||||||
mediaOptions.logger?.warn(
|
mediaOptions.logger?.warn(
|
||||||
@@ -1228,57 +1270,20 @@ 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)
|
||||||
|
// 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
|
return m
|
||||||
}
|
}
|
||||||
// Check for nativeList
|
// Check for nativeList
|
||||||
|
|||||||
@@ -154,9 +154,7 @@ export async function storeTcTokensFromIqResult({
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
await keys.set({
|
const tokenEntry = {
|
||||||
tctoken: {
|
|
||||||
[storageJid]: {
|
|
||||||
...existingEntry,
|
...existingEntry,
|
||||||
token: Buffer.from(tokenNode.content),
|
token: Buffer.from(tokenNode.content),
|
||||||
timestamp: tokenNode.attrs.t,
|
timestamp: tokenNode.attrs.t,
|
||||||
@@ -164,8 +162,18 @@ export async function storeTcTokensFromIqResult({
|
|||||||
// (UPDATE wa_trusted_contacts_send SET real_issue_timestamp=null)
|
// (UPDATE wa_trusted_contacts_send SET real_issue_timestamp=null)
|
||||||
realIssueTimestamp: 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)
|
onNewJidStored?.(storageJid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user