From 847e213443c7ccf049e350f39b0f867583917688 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Tue, 17 Mar 2026 22:57:34 -0300 Subject: [PATCH] fix: restore corrupted messages.ts from known-good carousel baseline --- src/Utils/messages.ts | 2268 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 2264 insertions(+), 4 deletions(-) diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index c3c0d3ac..d3836990 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -1,4 +1,2264 @@ -// Apply all metadata updates in sequence without nested conditionals -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 \ No newline at end of file +import { Boom } from '@hapi/boom' +import { randomBytes } from 'crypto' +import { promises as fs } from 'fs' +import { type Transform } from 'stream' +import { proto } from '../../WAProto/index.js' +import { + CALL_AUDIO_PREFIX, + CALL_VIDEO_PREFIX, + MEDIA_KEYS, + type MediaType, + URL_REGEX, + WA_DEFAULT_EPHEMERAL +} from '../Defaults' +import type { + AnyMediaMessageContent, + AnyMessageContent, + ButtonMessageOptions, + CarouselMessageOptions, + DownloadableMessage, + ListMessageOptions, + MessageContentGenerationOptions, + MessageGenerationOptions, + MessageGenerationOptionsFromContent, + MessageUserReceipt, + MessageWithContextInfo, + NativeButton, + NativeFlowButton, + ProductCarouselMessageOptions, + ProductListMessageOptions, + WAMediaUpload, + WAMessage, + WAMessageContent, + WAMessageKey, + WATextMessage +} from '../Types' +import { WAMessageStatus, WAProto } from '../Types' +import { isJidGroup, isJidNewsletter, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary' +import { sha256 } from './crypto' +import { generateMessageIDV2, getKeyAuthor, unixTimestampSeconds } from './generics' +import type { ILogger } from './logger' +import { + downloadContentFromMessage, + encryptedStream, + extractImageThumb, + generateThumbnail, + getAudioDuration, + getAudioWaveform, + getStream, + getRawMediaUploadData, + type MediaDownloadOptions +} from './messages-media' +import { shouldIncludeReportingToken } from './reporting-utils' +import { prepareStickerPackMessage } from './sticker-pack.js' + +type ExtractByKey = T extends Record ? T : never +type RequireKey = T & { + [P in K]-?: Exclude +} + +type WithKey = T extends unknown ? (K extends keyof T ? RequireKey : never) : never + +type MediaUploadData = { + media: WAMediaUpload + caption?: string + ptt?: boolean + ptv?: boolean + seconds?: number + gifPlayback?: boolean + fileName?: string + jpegThumbnail?: string + mimetype?: string + width?: number + height?: number + waveform?: Uint8Array + backgroundArgb?: number +} + +const MIMETYPE_MAP: { [T in MediaType]?: string } = { + image: 'image/jpeg', + video: 'video/mp4', + document: 'application/pdf', + audio: 'audio/ogg; codecs=opus', + sticker: 'image/webp', + 'product-catalog-image': 'image/jpeg' +} + +const MessageTypeProto = { + image: WAProto.Message.ImageMessage, + video: WAProto.Message.VideoMessage, + audio: WAProto.Message.AudioMessage, + sticker: WAProto.Message.StickerMessage, + document: WAProto.Message.DocumentMessage +} as const + +/** + * Uses a regex to test whether the string contains a URL, and returns the URL if it does. + * @param text eg. hello https://google.com + * @returns the URL, eg. https://google.com + */ +export const extractUrlFromText = (text: string) => text.match(URL_REGEX)?.[0] + +export const generateLinkPreviewIfRequired = async ( + text: string, + getUrlInfo: MessageGenerationOptions['getUrlInfo'], + logger: MessageGenerationOptions['logger'] +) => { + const url = extractUrlFromText(text) + if (!!getUrlInfo && url) { + try { + const urlInfo = await getUrlInfo(url) + return urlInfo + } catch (error: any) { + // ignore if fails + logger?.warn({ trace: error.stack }, 'url generation failed') + } + } +} + +const assertColor = async (color: any) => { + let assertedColor + if (typeof color === 'number') { + assertedColor = color > 0 ? color : 0xffffffff + Number(color) + 1 + } else { + let hex = color.trim().replace('#', '') + if (hex.length <= 6) { + hex = 'FF' + hex.padStart(6, '0') + } + + assertedColor = parseInt(hex, 16) + return assertedColor + } +} + +export const prepareWAMessageMedia = async ( + message: AnyMediaMessageContent, + options: MessageContentGenerationOptions +) => { + const logger = options.logger + + let mediaType: (typeof MEDIA_KEYS)[number] | undefined + for (const key of MEDIA_KEYS) { + if (key in message) { + mediaType = key + } + } + + if (!mediaType) { + throw new Boom('Invalid media type', { statusCode: 400 }) + } + + const uploadData: MediaUploadData = { + ...message, + media: (message as any)[mediaType] + } + delete (uploadData as any)[mediaType] + // check if cacheable + generate cache key + const cacheableKey = + typeof uploadData.media === 'object' && + 'url' in uploadData.media && + !!uploadData.media.url && + !!options.mediaCache && + mediaType + ':' + uploadData.media.url.toString() + + if (mediaType === 'document' && !uploadData.fileName) { + uploadData.fileName = 'file' + } + + if (!uploadData.mimetype) { + uploadData.mimetype = MIMETYPE_MAP[mediaType] + } + + if (cacheableKey) { + const mediaBuff = await options.mediaCache?.get(cacheableKey) + if (mediaBuff) { + logger?.debug({ cacheableKey }, 'got media cache hit') + + const obj = proto.Message.decode(mediaBuff) + const key = `${mediaType}Message` + + const mediaMsg = obj[key as keyof proto.Message] + if (mediaMsg) { + Object.assign(mediaMsg, { ...uploadData, media: undefined }) + } + + return obj + } + } + + const isNewsletter = !!options.jid && isJidNewsletter(options.jid) + if (isNewsletter) { + logger?.info({ key: cacheableKey }, 'Preparing raw media for newsletter') + const { filePath, fileSha256, fileLength } = await getRawMediaUploadData( + uploadData.media, + options.mediaTypeOverride || mediaType, + logger + ) + + const fileSha256B64 = fileSha256.toString('base64') + const { mediaUrl, directPath } = await options.upload(filePath, { + fileEncSha256B64: fileSha256B64, + mediaType: mediaType, + timeoutMs: options.mediaUploadTimeoutMs + }) + + await fs.unlink(filePath) + + const obj = WAProto.Message.fromObject({ + // todo: add more support here + [`${mediaType}Message`]: (MessageTypeProto as any)[mediaType].fromObject({ + url: mediaUrl, + directPath, + fileSha256, + fileLength, + ...uploadData, + media: undefined + }) + }) + + if (uploadData.ptv) { + obj.ptvMessage = obj.videoMessage + delete obj.videoMessage + } + + if (obj.stickerMessage) { + obj.stickerMessage.stickerSentTs = Date.now() + } + + if (cacheableKey) { + logger?.debug({ cacheableKey }, 'set cache') + await options.mediaCache?.set(cacheableKey, WAProto.Message.encode(obj).finish()) + } + + return obj + } + + const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined' + const requiresThumbnailComputation = + (mediaType === 'image' || mediaType === 'video') && typeof uploadData['jpegThumbnail'] === 'undefined' + const requiresWaveformProcessing = mediaType === 'audio' && uploadData.ptt === true + const requiresAudioBackground = options.backgroundColor && mediaType === 'audio' && uploadData.ptt === true + const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation + const { mediaKey, encFilePath, originalFilePath, fileEncSha256, fileSha256, fileLength } = await encryptedStream( + uploadData.media, + options.mediaTypeOverride || mediaType, + { + logger, + saveOriginalFileIfRequired: requiresOriginalForSomeProcessing, + opts: options.options + } + ) + + const fileEncSha256B64 = fileEncSha256.toString('base64') + const [{ mediaUrl, directPath }] = await Promise.all([ + (async () => { + const result = await options.upload(encFilePath, { + fileEncSha256B64, + mediaType, + timeoutMs: options.mediaUploadTimeoutMs + }) + logger?.debug({ mediaType, cacheableKey }, 'uploaded media') + return result + })(), + (async () => { + try { + if ( + (requiresThumbnailComputation || requiresDurationComputation || requiresWaveformProcessing) && + !originalFilePath + ) { + throw new Boom('Missing file path for processing') + } + + const filePath = originalFilePath! + + if (requiresThumbnailComputation) { + const { thumbnail, originalImageDimensions } = await generateThumbnail( + filePath, + mediaType as 'image' | 'video', + options + ) + uploadData.jpegThumbnail = thumbnail + if (!uploadData.width && originalImageDimensions) { + uploadData.width = originalImageDimensions.width + uploadData.height = originalImageDimensions.height + logger?.debug('set dimensions') + } + + logger?.debug('generated thumbnail') + } + + if (requiresDurationComputation) { + uploadData.seconds = await getAudioDuration(filePath) + logger?.debug('computed audio duration') + } + + if (requiresWaveformProcessing) { + uploadData.waveform = await getAudioWaveform(filePath, logger) + logger?.debug('processed waveform') + } + + if (requiresAudioBackground) { + uploadData.backgroundArgb = await assertColor(options.backgroundColor) + logger?.debug('computed backgroundColor audio status') + } + } catch (error) { + logger?.warn({ trace: error instanceof Error ? error.stack : String(error) }, 'failed to obtain extra info') + } + })() + ]).finally(async () => { + try { + await fs.unlink(encFilePath) + if (originalFilePath) { + await fs.unlink(originalFilePath) + } + + logger?.debug('removed tmp files') + } catch (error) { + logger?.warn('failed to remove tmp file') + } + }) + + const obj = WAProto.Message.fromObject({ + [`${mediaType}Message`]: MessageTypeProto[mediaType as keyof typeof MessageTypeProto].fromObject({ + url: mediaUrl, + directPath, + mediaKey, + fileEncSha256, + fileSha256, + fileLength, + mediaKeyTimestamp: unixTimestampSeconds(), + ...uploadData, + media: undefined + } as any) + }) + + if (uploadData.ptv) { + obj.ptvMessage = obj.videoMessage + delete obj.videoMessage + } + + if (cacheableKey) { + logger?.debug({ cacheableKey }, 'set cache') + await options.mediaCache?.set(cacheableKey, WAProto.Message.encode(obj).finish()) + } + + return obj +} + +export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: number) => { + ephemeralExpiration = ephemeralExpiration || 0 + const content: WAMessageContent = { + ephemeralMessage: { + message: { + protocolMessage: { + type: WAProto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING, + ephemeralExpiration + } + } + } + } + return WAProto.Message.fromObject(content) +} + +/** + * Generate forwarded message content like WA does + * @param message the message to forward + * @param options.forceForward will show the message as forwarded even if it is from you + */ +export const generateForwardMessageContent = (message: WAMessage, forceForward?: boolean) => { + let content = message.message + if (!content) { + throw new Boom('no content in message', { statusCode: 400 }) + } + + // hacky copy + content = normalizeMessageContent(content) + if (!content) { + throw new Boom('no content in message', { statusCode: 400 }) + } + + content = proto.Message.decode(proto.Message.encode(content).finish()) + + let key = Object.keys(content)[0] as keyof proto.IMessage + + let score = (content?.[key] as { contextInfo: proto.IContextInfo })?.contextInfo?.forwardingScore || 0 + score += message.key.fromMe && !forceForward ? 0 : 1 + if (key === 'conversation') { + content.extendedTextMessage = { text: content[key] } + delete content.conversation + + key = 'extendedTextMessage' + } + + const key_ = content?.[key] as { contextInfo: proto.IContextInfo } + if (score > 0) { + key_.contextInfo = { forwardingScore: score, isForwarded: true } + } else { + key_.contextInfo = {} + } + + return content +} + +export const hasNonNullishProperty = ( + message: AnyMessageContent, + key: K +): message is ExtractByKey => { + return ( + typeof message === 'object' && + message !== null && + key in message && + (message as Record)[key] !== null && + (message as Record)[key] !== undefined + ) +} + +// ========== Native Flow Button Utilities ========== + +/** + * Validates that a string is not empty or whitespace-only + */ +const validateNonEmptyString = (value: string | undefined, fieldName: string): void => { + if (!value || value.trim().length === 0) { + throw new Boom(`Button ${fieldName} is required and cannot be empty`, { statusCode: 400 }) + } +} + +/** + * Converts a NativeButton to the WhatsApp Native Flow format + * Includes validation for required fields + */ +export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton => { + // Validate common field + validateNonEmptyString(button.text, 'text') + + switch (button.type) { + case 'url': + validateNonEmptyString(button.url, 'url') + return { + name: 'cta_url', + buttonParamsJson: JSON.stringify({ + display_text: button.text, + url: button.url, + merchant_url: button.merchantUrl || button.url + }) + } + case 'copy': + validateNonEmptyString(button.copyText, 'copyText') + return { + name: 'cta_copy', + buttonParamsJson: JSON.stringify({ + display_text: button.text, + copy_code: button.copyText + }) + } + case 'reply': + validateNonEmptyString(button.id, 'id') + return { + name: 'quick_reply', + buttonParamsJson: JSON.stringify({ + display_text: button.text, + id: button.id + }) + } + case 'call': + validateNonEmptyString(button.phoneNumber, 'phoneNumber') + return { + name: 'cta_call', + buttonParamsJson: JSON.stringify({ + display_text: button.text, + phone_number: button.phoneNumber + }) + } + default: + throw new Boom('Invalid button type', { statusCode: 400 }) + } +} + +/** + * 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 + * + * @example + * ```typescript + * const msg = await generateButtonMessage({ + * buttons: [ + * { type: 'url', text: 'Visit Site', url: 'https://example.com' }, + * { type: 'copy', text: 'Copy Code', copyText: 'ABC123' }, + * { type: 'reply', text: 'Contact Support', id: 'btn_support' } + * ], + * text: 'Choose an option:', + * footer: 'Powered by InfiniteAPI' + * }, options) + * await sock.sendMessage(jid, msg) + * ``` + */ +export const generateButtonMessage = async ( + options: ButtonMessageOptions, + mediaOptions?: MessageContentGenerationOptions +): Promise => { + const { buttons, text, footer, headerTitle, headerImage, headerVideo } = options + + if (!buttons || buttons.length === 0) { + throw new Boom('At least one button is required', { statusCode: 400 }) + } + + if (buttons.length > 3) { + throw new Boom('Maximum 3 buttons allowed', { statusCode: 400 }) + } + + // Validate mutual exclusivity of media types + if (headerImage && headerVideo) { + throw new Boom('Cannot have both headerImage and headerVideo. Choose one.', { statusCode: 400 }) + } + + // Format buttons to Native Flow format + const formattedButtons = buttons.map(formatNativeFlowButton) + + // Determine header configuration + const hasMedia = !!(headerImage || headerVideo) + const header: proto.Message.InteractiveMessage.IHeader = { + title: hasMedia ? '' : headerTitle || '', + hasMediaAttachment: hasMedia + } + + // Process media if present + if (hasMedia && mediaOptions) { + if (headerImage) { + const { imageMessage } = await prepareWAMessageMedia({ image: headerImage }, mediaOptions) + header.imageMessage = imageMessage + } else if (headerVideo) { + const { videoMessage } = await prepareWAMessageMedia({ video: headerVideo }, mediaOptions) + header.videoMessage = videoMessage + } + } else if (hasMedia && !mediaOptions) { + throw new Boom('mediaOptions required for processing header media', { statusCode: 400 }) + } + + // Build the interactive message (used for CTA buttons: url, copy, call) + const interactiveMessage: proto.Message.IInteractiveMessage = { + body: { text: text || '' }, + footer: footer ? { text: footer } : undefined, + header, + nativeFlowMessage: { + buttons: formattedButtons, + messageParamsJson: JSON.stringify({}), + messageVersion: 2 + } + } + + // Wrap in viewOnceMessage for Web/Android compatibility + // NOTE: messageContextInfo removed - breaks iOS delivery + return { + viewOnceMessage: { + message: { + interactiveMessage + } + } + } +} + +/** + * Generates a carousel message with multiple cards, each with their own buttons + * Uses viewOnceMessage wrapper for better iOS/Android compatibility + * + * @example + * ```typescript + * const msg = await generateCarouselMessage({ + * cards: [ + * { + * title: 'Product 1', + * body: 'Amazing product description', + * footer: '$99.00', + * buttons: [ + * { type: 'url', text: 'Buy Now', url: 'https://shop.com/item1' } + * ] + * }, + * { + * title: 'Product 2', + * body: 'Another great product', + * footer: '$149.00', + * buttons: [ + * { type: 'url', text: 'Buy Now', url: 'https://shop.com/item2' } + * ] + * } + * ], + * text: 'Check out our products!', + * footer: 'Swipe to see more' + * }, options) + * await sock.sendMessage(jid, msg) + * ``` + */ +export const generateCarouselMessage = async ( + options: CarouselMessageOptions, + mediaOptions?: MessageContentGenerationOptions +): Promise => { + const { cards, title, text, footer } = options + + if (!cards || cards.length < 2) { + throw new Boom('Carousel requires at least 2 cards', { statusCode: 400 }) + } + + if (cards.length > 10) { + throw new Boom('Maximum 10 cards allowed in carousel', { statusCode: 400 }) + } + + // Validate cards + for (let i = 0; i < cards.length; i++) { + const card = cards[i] + if (!card) continue + + // Validate mutual exclusivity of media types + if (card.image && card.video) { + throw new Boom(`Card ${i}: Cannot have both image and video. Choose one.`, { statusCode: 400 }) + } + + // Validate buttons are not empty + if (!card.buttons || card.buttons.length === 0) { + throw new Boom(`Card ${i}: At least one button is required per card`, { statusCode: 400 }) + } + } + + // Check if any card has media + const hasAnyMedia = cards.some(card => card.image || card.video) + if (hasAnyMedia && !mediaOptions) { + throw new Boom('mediaOptions required for processing card media', { statusCode: 400 }) + } + + // WhatsApp requires ALL carousel cards to have media attachments (image or video) + // Cards without media will not render on WhatsApp Web/Desktop + const cardsWithoutMedia = cards.filter(card => !card.image && !card.video) + if (cardsWithoutMedia.length > 0) { + mediaOptions?.logger?.warn( + { cardsWithoutMedia: cardsWithoutMedia.length, totalCards: cards.length }, + '[CAROUSEL] WARNING: Cards without media will NOT render on WhatsApp Web. Every card MUST have an image or video.' + ) + } + + // Map cards to the carousel format (processing media) + const carouselCards = await Promise.all( + cards.map(async card => { + const hasMedia = !!(card.image || card.video) + + const header: any = { + title: card.title || '', + subtitle: card.footer || '', + hasMediaAttachment: hasMedia + } + + // Process media if present + 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( + { cardTitle: card.title }, + '[CAROUSEL] imageMessage missing jpegThumbnail - Web may not render' + ) + } + + if (imageMessage && (!imageMessage.height || !imageMessage.width)) { + mediaOptions.logger?.warn( + { cardTitle: card.title, height: imageMessage.height, width: imageMessage.width }, + '[CAROUSEL] imageMessage missing dimensions - setting defaults' + ) + if (!imageMessage.height) imageMessage.height = 500 + if (!imageMessage.width) imageMessage.width = 500 + } + + header.imageMessage = imageMessage + } else if (card.video) { + const { videoMessage } = await prepareWAMessageMedia({ video: card.video }, mediaOptions) + header.videoMessage = videoMessage + } + } + + return { + header, + body: { text: card.body || '' }, + footer: card.footer ? { text: card.footer } : undefined, + nativeFlowMessage: { + buttons: card.buttons.map(formatNativeFlowButton) + } + } + }) + ) + + // Build the interactive message with carousel + // carouselMessage first, then header/body/footer (matches working structure) + const interactiveMessage: proto.Message.IInteractiveMessage = { + carouselMessage: { + cards: carouselCards, + messageVersion: 1 + }, + header: { title: title || ' ', hasMediaAttachment: false }, + body: { text: text || '' }, + footer: footer ? { text: footer } : undefined + } + + // Log carousel structure summary for debugging + mediaOptions?.logger?.info( + { + totalCards: carouselCards.length, + cards: carouselCards.map((c: any, i: number) => ({ + index: i, + hasMedia: c.header?.hasMediaAttachment, + mediaType: c.header?.imageMessage ? 'image' : c.header?.videoMessage ? 'video' : 'none', + hasSubtitle: c.header?.subtitle !== undefined, + hasJpegThumbnail: !!c.header?.imageMessage?.jpegThumbnail, + imgHeight: c.header?.imageMessage?.height, + imgWidth: c.header?.imageMessage?.width, + hasBody: !!c.body?.text, + hasFooter: !!c.footer?.text, + buttonsCount: c.nativeFlowMessage?.buttons?.length || 0 + })), + rootHeader: interactiveMessage.header?.title, + rootBody: !!interactiveMessage.body?.text + }, + '[CAROUSEL] Structure summary' + ) + + // Direct interactiveMessage at root (field 45) — no viewOnceMessage wrapper + return { + interactiveMessage + } +} + +/** + * Generates a list message using Native Flow format (single_select) + * Uses viewOnceMessage wrapper for better iOS/Android compatibility + * + * @example + * ```typescript + * const msg = generateListMessage({ + * buttonText: 'View Options', + * text: 'Choose an option:', + * title: 'Menu', + * sections: [ + * { + * title: 'Category 1', + * rows: [ + * { id: 'opt1', title: 'Option 1', description: 'Description 1' }, + * { id: 'opt2', title: 'Option 2', description: 'Description 2' } + * ] + * } + * ], + * footer: 'Select one item' + * }) + * await sock.sendMessage(jid, msg) + * ``` + */ +// ========== List Message Limits (WhatsApp enforced) ========== + +const LIST_LIMITS = { + MAX_SECTIONS: 10, + MAX_ROWS_PER_SECTION: 10, + MAX_TOTAL_ROWS: 30, + MAX_SECTION_TITLE: 24, + MAX_ROW_TITLE: 24, + MAX_ROW_DESCRIPTION: 72, + MAX_ROW_ID: 200, + MAX_BUTTON_TEXT: 20 +} as const + +/** + * Validates and sanitizes list message sections according to WhatsApp limits. + * Truncates strings that exceed max length, throws on structural violations. + */ +const validateListSections = ( + sections: Array<{ title: string; rows: Array<{ id?: string; rowId?: string; title: string; description?: string }> }>, + buttonText?: string +): void => { + if (!sections || sections.length === 0) { + throw new Boom('At least one section is required', { statusCode: 400 }) + } + + if (sections.length > LIST_LIMITS.MAX_SECTIONS) { + throw new Boom(`Maximum ${LIST_LIMITS.MAX_SECTIONS} sections allowed, got ${sections.length}`, { statusCode: 400 }) + } + + if (buttonText && buttonText.length > LIST_LIMITS.MAX_BUTTON_TEXT) { + throw new Boom(`buttonText max ${LIST_LIMITS.MAX_BUTTON_TEXT} characters, got ${buttonText.length}`, { + statusCode: 400 + }) + } + + let totalRows = 0 + for (const section of sections) { + if (section.title && section.title.length > LIST_LIMITS.MAX_SECTION_TITLE) { + throw new Boom( + `Section title max ${LIST_LIMITS.MAX_SECTION_TITLE} characters, got ${section.title.length}: "${section.title}"`, + { statusCode: 400 } + ) + } + + if (!section.rows || section.rows.length === 0) { + throw new Boom('Each section must have at least one row', { statusCode: 400 }) + } + + if (section.rows.length > LIST_LIMITS.MAX_ROWS_PER_SECTION) { + throw new Boom( + `Maximum ${LIST_LIMITS.MAX_ROWS_PER_SECTION} rows per section, got ${section.rows.length} in "${section.title}"`, + { statusCode: 400 } + ) + } + + for (const row of section.rows) { + const rowId = row.id || row.rowId || '' + if (rowId.length > LIST_LIMITS.MAX_ROW_ID) { + throw new Boom(`Row ID max ${LIST_LIMITS.MAX_ROW_ID} characters, got ${rowId.length}`, { statusCode: 400 }) + } + + if (row.title && row.title.length > LIST_LIMITS.MAX_ROW_TITLE) { + throw new Boom( + `Row title max ${LIST_LIMITS.MAX_ROW_TITLE} characters, got ${row.title.length}: "${row.title}"`, + { statusCode: 400 } + ) + } + + if (row.description && row.description.length > LIST_LIMITS.MAX_ROW_DESCRIPTION) { + throw new Boom( + `Row description max ${LIST_LIMITS.MAX_ROW_DESCRIPTION} characters, got ${row.description.length}: "${row.description.substring(0, 30)}..."`, + { statusCode: 400 } + ) + } + } + + totalRows += section.rows.length + } + + if (totalRows > LIST_LIMITS.MAX_TOTAL_ROWS) { + throw new Boom(`Maximum ${LIST_LIMITS.MAX_TOTAL_ROWS} total rows allowed, got ${totalRows}`, { statusCode: 400 }) + } +} + +export const generateListMessage = (options: ListMessageOptions): WAMessageContent => { + const { buttonText, sections, text, title, footer } = options + + validateListSections(sections, buttonText) + + // Build sections for single_select + const formattedSections = sections.map(section => ({ + title: section.title, + rows: section.rows.map(row => ({ + id: row.id, + title: row.title, + description: row.description || '' + })) + })) + + // Create native flow message with single_select button + const nativeFlowMessage = { + buttons: [ + { + name: 'single_select', + buttonParamsJson: JSON.stringify({ + title: buttonText, + sections: formattedSections + }) + } + ], + messageParamsJson: JSON.stringify({}), + messageVersion: 2 + } + + // Build the interactive message + const interactiveMessage: proto.Message.IInteractiveMessage = { + body: { text: text || '' }, + footer: footer ? { text: footer } : undefined, + header: title + ? { + title, + subtitle: '', + hasMediaAttachment: false + } + : undefined, + nativeFlowMessage + } + + // Wrap in viewOnceMessage for better compatibility + return { + viewOnceMessage: { + message: { + messageContextInfo: { + deviceListMetadata: {}, + deviceListMetadataVersion: 2 + }, + interactiveMessage + } + } + } +} + +/** + * Generates a product list message (multi-product) from the WhatsApp Business catalog + * Allows sending multiple products organized in sections + * + * Note: Requires a WhatsApp Business account with a configured catalog. + * Does NOT require Meta Business Manager integration. + * + * @example + * ```typescript + * const msg = generateProductListMessage({ + * title: 'Our Best Sellers', + * description: 'Check out our most popular products!', + * buttonText: 'View Products', + * footerText: 'Tap to browse our catalog', + * businessOwnerJid: '5511999999999@s.whatsapp.net', + * productSections: [ + * { + * title: 'Electronics', + * products: [ + * { productId: 'product_001' }, + * { productId: 'product_002' } + * ] + * }, + * { + * title: 'Accessories', + * products: [ + * { productId: 'product_003' }, + * { productId: 'product_004' } + * ] + * } + * ], + * headerImage: { productId: 'product_001' } + * }) + * await sock.sendMessage(jid, msg) + * ``` + */ +export const generateProductListMessage = (options: ProductListMessageOptions): WAMessageContent => { + const { title, description, buttonText, footerText, businessOwnerJid, productSections, headerImage } = options + + // Validation + if (!title || title.trim().length === 0) { + throw new Boom('Product list title is required', { statusCode: 400 }) + } + + if (!description || description.trim().length === 0) { + throw new Boom('Product list description is required', { statusCode: 400 }) + } + + if (!buttonText || buttonText.trim().length === 0) { + throw new Boom('Product list buttonText is required', { statusCode: 400 }) + } + + if (!businessOwnerJid || businessOwnerJid.trim().length === 0) { + throw new Boom('businessOwnerJid is required (catalog owner JID)', { statusCode: 400 }) + } + + if (!productSections || productSections.length === 0) { + throw new Boom('At least one product section is required', { statusCode: 400 }) + } + + // Validate sections have valid titles and products + for (const section of productSections) { + // Validate section title + if (!section.title || typeof section.title !== 'string' || section.title.trim().length === 0) { + throw new Boom('Each section must have a non-empty title', { statusCode: 400 }) + } + + // Validate section has products + if (!section.products || section.products.length === 0) { + throw new Boom(`Section "${section.title}" must have at least one product`, { statusCode: 400 }) + } + + // Validate each product has a valid productId + for (const product of section.products) { + if (!product || typeof product.productId !== 'string' || product.productId.trim().length === 0) { + throw new Boom(`Each product in section "${section.title}" must have a non-empty productId`, { + statusCode: 400 + }) + } + } + } + + // Count total products (max 30 per WhatsApp limits) + const totalProducts = productSections.reduce((sum, section) => sum + section.products.length, 0) + if (totalProducts > 30) { + throw new Boom(`Maximum 30 products allowed, got ${totalProducts}`, { statusCode: 400 }) + } + + // Build product sections for the protocol + const formattedSections = productSections.map(section => ({ + title: section.title, + products: section.products.map(product => ({ + productId: product.productId + })) + })) + + // Build product list info + const productListInfo: proto.Message.ListMessage.IProductListInfo = { + productSections: formattedSections, + businessOwnerJid: jidNormalizedUser(businessOwnerJid) + } + + // Add header image if provided (with validation) + if (headerImage) { + if ( + !headerImage.productId || + typeof headerImage.productId !== 'string' || + headerImage.productId.trim().length === 0 + ) { + throw new Boom('headerImage.productId must be a non-empty string', { statusCode: 400 }) + } + + productListInfo.headerImage = { + productId: headerImage.productId, + jpegThumbnail: headerImage.jpegThumbnail + } + } + + // Create list message with PRODUCT_LIST type + const listMessage: proto.Message.IListMessage = { + title, + description, + buttonText, + listType: proto.Message.ListMessage.ListType.PRODUCT_LIST, + productListInfo, + footerText: footerText || undefined + } + + return { + listMessage: WAProto.Message.ListMessage.fromObject(listMessage) + } +} + +/** + * Generates a product carousel message using products from WhatsApp Business catalog + * Uses viewOnceMessage wrapper for better iOS/Android compatibility + * + * Each card in the carousel references a product from the business catalog using + * collectionMessage with bizJid (business owner) and id (product ID). + * + * @example + * ```typescript + * const msg = generateProductCarouselMessage({ + * businessOwnerJid: '5511999999999@s.whatsapp.net', + * products: [ + * { productId: 'produto_001' }, + * { productId: 'produto_002' }, + * { productId: 'produto_003' } + * ], + * body: 'Confira nossos produtos em destaque!' + * }) + * await sock.sendMessage(jid, msg) + * ``` + */ +export const generateProductCarouselMessage = (options: ProductCarouselMessageOptions): WAMessageContent => { + const { businessOwnerJid, products, body } = options + + if (!businessOwnerJid || typeof businessOwnerJid !== 'string' || businessOwnerJid.trim().length === 0) { + throw new Boom('businessOwnerJid is required and must be a non-empty string', { statusCode: 400 }) + } + + if (!products || products.length < 2) { + throw new Boom('Product carousel requires at least 2 products', { statusCode: 400 }) + } + + if (products.length > 10) { + throw new Boom('Maximum 10 products allowed in carousel', { statusCode: 400 }) + } + + // Validate each product has a valid productId + for (let i = 0; i < products.length; i++) { + const product = products[i] + if (!product) continue + + if (!product.productId || typeof product.productId !== 'string' || product.productId.trim().length === 0) { + throw new Boom(`Product at index ${i} must have a non-empty productId`, { statusCode: 400 }) + } + } + + // Normalize business owner JID + const normalizedBizJid = jidNormalizedUser(businessOwnerJid) + + // Build cards array - each card is an IInteractiveMessage with collectionMessage + // collectionMessage references a product from the business catalog + const cards: proto.Message.IInteractiveMessage[] = products.map(product => ({ + collectionMessage: { + bizJid: normalizedBizJid, + id: product.productId, + messageVersion: 1 + } + })) + + // Build the interactive message with carousel type + const interactiveMessage: proto.Message.IInteractiveMessage = { + body: { text: body || '' }, + carouselMessage: { + cards, + messageVersion: 1 + } + } + + // Wrap in viewOnceMessage for better compatibility + return { + viewOnceMessage: { + message: { + messageContextInfo: { + deviceListMetadata: {}, + deviceListMetadataVersion: 2 + }, + interactiveMessage + } + } + } +} + +// ========== Legacy Message Functions ========== + +/** + * Generates a button message using the legacy buttonsMessage format + * ⚠️ WARNING: This format is deprecated and may not work on all devices + * + * @deprecated Use generateButtonMessage instead for better compatibility + */ +export const generateButtonMessageLegacy = ( + buttons: Array<{ id?: string; text: string }>, + text: string, + footer?: string +): WAMessageContent => { + const formattedButtons = buttons.map((button, index) => ({ + buttonId: button.id || `btn_${index}`, + buttonText: { displayText: button.text }, + type: proto.Message.ButtonsMessage.Button.Type.RESPONSE + })) + + return { + buttonsMessage: WAProto.Message.ButtonsMessage.fromObject({ + contentText: text, + footerText: footer, + buttons: formattedButtons, + headerType: proto.Message.ButtonsMessage.HeaderType.EMPTY + }) + } +} + +/** + * Generates a list message using the legacy listMessage format + * ⚠️ WARNING: This format is deprecated and may not work on all devices + * + * @deprecated Use generateListMessage instead for better compatibility + */ +export const generateListMessageLegacy = ( + listInfo: { + sections: Array<{ + title: string + rows: Array<{ id?: string; rowId?: string; title: string; description?: string }> + }> + }, + title: string, + description: string, + buttonText: string, + footer?: string +): WAMessageContent => { + validateListSections(listInfo.sections, buttonText) + + return { + listMessage: WAProto.Message.ListMessage.fromObject({ + title, + description, + buttonText, + footerText: footer, + listType: WAProto.Message.ListMessage.ListType.SINGLE_SELECT, + sections: listInfo.sections.map(section => ({ + title: section.title, + rows: section.rows.map(row => ({ + rowId: row.id || row.rowId, + title: row.title, + description: row.description + })) + })) + }) + } +} + +function hasOptionalProperty(obj: T, key: K): obj is WithKey { + return typeof obj === 'object' && obj !== null && key in obj && (obj as Record)[key] !== null +} + +export const generateWAMessageContent = async ( + message: AnyMessageContent, + options: MessageContentGenerationOptions +) => { + let m: WAMessageContent = {} + + // ========== NATIVE FLOW BUTTONS (Modern approach) ========== + // Check for nativeButtons first - this is the recommended modern approach + if (hasNonNullishProperty(message, 'nativeButtons')) { + const nativeMsg = message as any + const buttons = nativeMsg.nativeButtons as any[] + + if (!buttons || buttons.length === 0) { + throw new Boom('nativeButtons requires at least one button', { statusCode: 400 }) + } + + // Check if ALL buttons are quick_reply (type: 'reply') + const allQuickReply = buttons.every((btn: any) => btn.type === 'reply') + + if (allQuickReply) { + // Use legacy buttonsMessage format — works on iOS + Android + Web + const hasHeaderTitle = !!nativeMsg.headerTitle + const buttonsMessage: proto.Message.IButtonsMessage = { + contentText: nativeMsg.text || '', + footerText: nativeMsg.footer || undefined, + headerType: hasHeaderTitle + ? proto.Message.ButtonsMessage.HeaderType.TEXT + : proto.Message.ButtonsMessage.HeaderType.EMPTY, + ...(hasHeaderTitle ? { text: nativeMsg.headerTitle } : {}) + } + buttonsMessage.buttons = buttons.map((btn: any, idx: number) => ({ + buttonId: btn.id || `btn_${idx}`, + buttonText: { displayText: btn.text || `Button ${idx + 1}` }, + type: proto.Message.ButtonsMessage.Button.Type.RESPONSE + })) + m.buttonsMessage = buttonsMessage + options.logger?.info('Sending quick_reply as legacy buttonsMessage (iOS compatible)') + } else { + // CTA buttons (url, copy, call) — use nativeFlowMessage + const buttonOptions: ButtonMessageOptions = { + buttons, + text: nativeMsg.text || '', + footer: nativeMsg.footer, + headerTitle: nativeMsg.headerTitle, + headerImage: nativeMsg.headerImage, + headerVideo: nativeMsg.headerVideo + } + const generated = await generateButtonMessage(buttonOptions, options) + m.viewOnceMessage = generated.viewOnceMessage + options.logger?.info('Sending CTA buttons as nativeFlowMessage with viewOnceMessage wrapper') + } + } + // Check for nativeCarousel + else if (hasNonNullishProperty(message, 'nativeCarousel')) { + const carouselMsg = message as any + const carouselOptions: CarouselMessageOptions = { + cards: carouselMsg.nativeCarousel.cards, + title: carouselMsg.nativeCarousel.title || carouselMsg.title, + text: carouselMsg.text, + footer: carouselMsg.footer + } + // 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 + } + // Check for nativeList + else if (hasNonNullishProperty(message, 'nativeList')) { + const listMsg = message as any + // Use LEGACY format to avoid error 479 + // Modern format (interactiveMessage) causes rejection + // Legacy format (listMessage) works on all platforms + const generated = generateListMessageLegacy( + { sections: listMsg.nativeList.sections }, + listMsg.title || '', + listMsg.text || '', + listMsg.nativeList.buttonText, + listMsg.footer + ) + m.listMessage = generated.listMessage + options.logger?.info('Sending listMessage with LEGACY format (fixes error 479)') + } + // Check for productList (multi-product message from catalog) + else if (hasNonNullishProperty(message, 'productList')) { + const productMsg = message as any + const productListOptions: ProductListMessageOptions = { + title: productMsg.productList.title, + description: productMsg.productList.description, + buttonText: productMsg.productList.buttonText, + footerText: productMsg.productList.footerText, + businessOwnerJid: productMsg.productList.businessOwnerJid, + productSections: productMsg.productList.productSections, + headerImage: productMsg.productList.headerImage + } + const generated = generateProductListMessage(productListOptions) + m.listMessage = generated.listMessage + options.logger?.info('Sending productListMessage (multi-product from catalog)') + } + // Check for productCarousel (WhatsApp Business catalog products) + else if (hasNonNullishProperty(message, 'productCarousel')) { + const productCarouselMsg = message as any + const productCarouselOptions: ProductCarouselMessageOptions = { + businessOwnerJid: productCarouselMsg.productCarousel.businessOwnerJid, + products: productCarouselMsg.productCarousel.products, + body: productCarouselMsg.productCarousel.body + } + const generated = generateProductCarouselMessage(productCarouselOptions) + m.viewOnceMessage = generated.viewOnceMessage + options.logger?.info('Sending productCarouselMessage with viewOnceMessage wrapper') + } + // Interactive messages: buttons, lists, templates + else if (hasNonNullishProperty(message, 'text') && hasNonNullishProperty(message, 'buttons')) { + // Process buttons for text messages + const buttonsMessage: proto.Message.IButtonsMessage = { + contentText: (message as any).text, + footerText: (message as any).footerText, + headerType: (message as any).headerType || proto.Message.ButtonsMessage.HeaderType.EMPTY + } + + buttonsMessage.buttons = ((message as any).buttons as any[]).map((btn: any, idx: number) => ({ + buttonId: btn.buttonId || `btn_${idx}`, + buttonText: { displayText: btn.buttonText?.displayText || btn.displayText || btn.text }, + type: btn.type || proto.Message.ButtonsMessage.Button.Type.RESPONSE + })) + + m.buttonsMessage = buttonsMessage + options.logger?.info('[Interactive] Sending buttonsMessage (legacy format)') + } else if (hasNonNullishProperty(message, 'text') && hasNonNullishProperty(message, 'templateButtons')) { + // Process templateButtons + const hydratedButtons = ((message as any).templateButtons as any[]).map((btn: any) => { + if (btn.quickReplyButton) { + return { index: btn.index, quickReplyButton: btn.quickReplyButton } + } else if (btn.urlButton) { + return { index: btn.index, urlButton: btn.urlButton } + } else if (btn.callButton) { + return { index: btn.index, callButton: btn.callButton } + } + + return btn + }) + + const templateMessage: proto.Message.ITemplateMessage = { + hydratedTemplate: { + hydratedContentText: (message as any).text, + hydratedFooterText: (message as any).footer, + hydratedButtons + } + } + + m.templateMessage = templateMessage + options.logger?.info('[Interactive] Sending templateMessage') + } else if (hasNonNullishProperty(message, 'sections')) { + // Process list messages - validate limits + validateListSections((message as any).sections, (message as any).buttonText) + const listMessage: proto.Message.IListMessage = { + title: (message as any).title, + description: (message as any).text, + buttonText: (message as any).buttonText || 'View options', + footerText: (message as any).footerText, + listType: proto.Message.ListMessage.ListType.SINGLE_SELECT + } + + listMessage.sections = ((message as any).sections as any[]).map((section: any) => ({ + title: section.title, + rows: section.rows.map((row: any) => ({ + rowId: row.rowId || row.id, + title: row.title, + description: row.description + })) + })) + + m.listMessage = listMessage + options.logger?.info( + { sections: listMessage.sections?.length || 0 }, + '[Interactive] Sending listMessage (sections: ' + (listMessage.sections?.length || 0) + ')' + ) + } else if (hasNonNullishProperty(message, 'carousel')) { + // Process carousel/interactive messages with viewOnceMessage wrapper + const carousel = (message as any).carousel + const interactiveMessage: proto.Message.IInteractiveMessage = { + header: carousel.header || { title: carousel.title || 'Carousel', hasMediaAttachment: false }, + body: { text: (message as any).text || carousel.description || '' }, + footer: carousel.footer ? { text: carousel.footer } : undefined, + carouselMessage: { + cards: carousel.cards.map((card: any) => ({ + header: card.header, + body: card.body, + footer: card.footer, + nativeFlowMessage: card.nativeFlowMessage + })), + messageVersion: carousel.messageVersion || 1 + } + } + + // Direct interactiveMessage — no viewOnceMessage wrapper + m.interactiveMessage = interactiveMessage + } else if (hasNonNullishProperty(message, 'album')) { + // Album message validation - actual sending is handled in messages-send.ts + const { medias } = message.album + + // Validate minimum items (WhatsApp requirement) + if (!medias || medias.length < 2) { + throw new Boom('Album must have at least 2 media items', { statusCode: 400 }) + } + + // Validate maximum items (WhatsApp limit) + if (medias.length > 10) { + throw new Boom('Album cannot have more than 10 media items (WhatsApp limit)', { statusCode: 400 }) + } + + // Count and validate each media item + let expectedImageCount = 0 + let expectedVideoCount = 0 + + for (let i = 0; i < medias.length; i++) { + const media = medias[i] + if (!media) continue + + if (hasNonNullishProperty(media as AnyMessageContent, 'image')) { + expectedImageCount++ + } else if (hasNonNullishProperty(media as AnyMessageContent, 'video')) { + expectedVideoCount++ + } else { + throw new Boom(`Album media at index ${i} must have 'image' or 'video' property`, { statusCode: 400 }) + } + } + + // Create album root message + m.albumMessage = WAProto.Message.AlbumMessage.create({ + expectedImageCount, + expectedVideoCount + }) + + options.logger?.info( + { expectedImageCount, expectedVideoCount, totalItems: medias.length }, + 'Album message validated - sending will be handled by relayMessage' + ) + } else if (hasNonNullishProperty(message, 'text')) { + // Normal text message processing + const extContent = { text: message.text } as WATextMessage + + let urlInfo = message.linkPreview + if (typeof urlInfo === 'undefined') { + urlInfo = await generateLinkPreviewIfRequired(message.text, options.getUrlInfo, options.logger) + } + + if (urlInfo) { + extContent.matchedText = urlInfo['matched-text'] + extContent.jpegThumbnail = urlInfo.jpegThumbnail + extContent.description = urlInfo.description + extContent.title = urlInfo.title + extContent.previewType = 0 + + const img = urlInfo.highQualityThumbnail + if (img) { + extContent.thumbnailDirectPath = img.directPath + extContent.mediaKey = img.mediaKey + extContent.mediaKeyTimestamp = img.mediaKeyTimestamp + extContent.thumbnailWidth = img.width + extContent.thumbnailHeight = img.height + extContent.thumbnailSha256 = img.fileSha256 + extContent.thumbnailEncSha256 = img.fileEncSha256 + } + } + + if (options.backgroundColor) { + extContent.backgroundArgb = await assertColor(options.backgroundColor) + } + + if (options.font) { + extContent.font = options.font + } + + m.extendedTextMessage = extContent + } else if (hasNonNullishProperty(message, 'contacts')) { + const contactLen = message.contacts.contacts.length + if (!contactLen) { + throw new Boom('require atleast 1 contact', { statusCode: 400 }) + } + + if (contactLen === 1) { + m.contactMessage = WAProto.Message.ContactMessage.create(message.contacts.contacts[0]) + } else { + m.contactsArrayMessage = WAProto.Message.ContactsArrayMessage.create(message.contacts) + } + } else if (hasNonNullishProperty(message, 'location')) { + m.locationMessage = WAProto.Message.LocationMessage.create(message.location) + } else if (hasNonNullishProperty(message, 'react')) { + if (!message.react.senderTimestampMs) { + message.react.senderTimestampMs = Date.now() + } + + m.reactionMessage = WAProto.Message.ReactionMessage.create(message.react) + } else if (hasNonNullishProperty(message, 'delete')) { + m.protocolMessage = { + key: message.delete, + type: WAProto.Message.ProtocolMessage.Type.REVOKE + } + } else if (hasNonNullishProperty(message, 'forward')) { + m = generateForwardMessageContent(message.forward, message.force) + } else if (hasNonNullishProperty(message, 'disappearingMessagesInChat')) { + const exp = + typeof message.disappearingMessagesInChat === 'boolean' + ? message.disappearingMessagesInChat + ? WA_DEFAULT_EPHEMERAL + : 0 + : message.disappearingMessagesInChat + m = prepareDisappearingMessageSettingContent(exp) + } else if (hasNonNullishProperty(message, 'groupInvite')) { + m.groupInviteMessage = {} + m.groupInviteMessage.inviteCode = message.groupInvite.inviteCode + m.groupInviteMessage.inviteExpiration = message.groupInvite.inviteExpiration + m.groupInviteMessage.caption = message.groupInvite.text + + m.groupInviteMessage.groupJid = message.groupInvite.jid + m.groupInviteMessage.groupName = message.groupInvite.subject + //TODO: use built-in interface and get disappearing mode info etc. + //TODO: cache / use store!? + if (options.getProfilePicUrl) { + const pfpUrl = await options.getProfilePicUrl(message.groupInvite.jid, 'preview') + if (pfpUrl) { + const resp = await fetch(pfpUrl, { method: 'GET', dispatcher: options?.options?.dispatcher }) + if (resp.ok) { + const buf = Buffer.from(await resp.arrayBuffer()) + m.groupInviteMessage.jpegThumbnail = buf + } + } + } + } else if (hasNonNullishProperty(message, 'pin')) { + m.pinInChatMessage = {} + m.messageContextInfo = {} + + m.pinInChatMessage.key = message.pin + m.pinInChatMessage.type = message.type + m.pinInChatMessage.senderTimestampMs = Date.now() + + m.messageContextInfo.messageAddOnDurationInSecs = message.type === 1 ? message.time || 86400 : 0 + } else if (hasNonNullishProperty(message, 'buttonReply')) { + switch (message.type) { + case 'template': + m.templateButtonReplyMessage = { + selectedDisplayText: message.buttonReply.displayText, + selectedId: message.buttonReply.id, + selectedIndex: message.buttonReply.index + } + break + case 'plain': + m.buttonsResponseMessage = { + selectedButtonId: message.buttonReply.id, + selectedDisplayText: message.buttonReply.displayText, + type: proto.Message.ButtonsResponseMessage.Type.DISPLAY_TEXT + } + break + } + } + // Process buttons with media (image/video) + else if ( + (hasNonNullishProperty(message, 'image') || hasNonNullishProperty(message, 'video')) && + hasNonNullishProperty(message, 'buttons') + ) { + const mediaType = hasNonNullishProperty(message, 'image') ? 'image' : 'video' + const headerType = + mediaType === 'image' + ? proto.Message.ButtonsMessage.HeaderType.IMAGE + : proto.Message.ButtonsMessage.HeaderType.VIDEO + + // Extract only media properties to avoid type errors + const mediaContent: AnyMediaMessageContent = + mediaType === 'image' + ? { + image: (message as any).image, + caption: (message as any).caption, + jpegThumbnail: (message as any).jpegThumbnail, + mimetype: (message as any).mimetype + } + : { + video: (message as any).video, + caption: (message as any).caption, + jpegThumbnail: (message as any).jpegThumbnail, + gifPlayback: (message as any).gifPlayback, + ptv: (message as any).ptv, + mimetype: (message as any).mimetype + } + + // Prepare media + const mediaMessage = await prepareWAMessageMedia(mediaContent, options) + + const buttonsMessage: proto.Message.IButtonsMessage = { + contentText: (message as any).caption || (message as any).text || '', + footerText: (message as any).footerText, + headerType: headerType + } + + // Add media + if (mediaType === 'image') { + buttonsMessage.imageMessage = mediaMessage.imageMessage + } else { + buttonsMessage.videoMessage = mediaMessage.videoMessage + } + + // Add buttons + buttonsMessage.buttons = ((message as any).buttons as any[]).map((btn: any, idx: number) => ({ + buttonId: btn.buttonId || `btn_${idx}`, + buttonText: { displayText: btn.buttonText?.displayText || btn.displayText || btn.text }, + type: btn.type || proto.Message.ButtonsMessage.Button.Type.RESPONSE + })) + + m.buttonsMessage = buttonsMessage + options.logger?.info('[Interactive] Sending buttonsMessage with media') + } else if (hasOptionalProperty(message, 'ptv') && message.ptv) { + const { videoMessage } = await prepareWAMessageMedia({ video: message.video }, options) + m.ptvMessage = videoMessage + } else if (hasNonNullishProperty(message, 'product')) { + const { imageMessage } = await prepareWAMessageMedia({ image: message.product.productImage }, options) + m.productMessage = WAProto.Message.ProductMessage.create({ + ...message, + product: { + ...message.product, + productImage: imageMessage + } + }) + } else if (hasNonNullishProperty(message, 'listReply')) { + m.listResponseMessage = { ...message.listReply } + } else if (hasNonNullishProperty(message, 'event')) { + m.eventMessage = {} + const startTime = Math.floor(message.event.startDate.getTime() / 1000) + + if (message.event.call && options.getCallLink) { + const token = await options.getCallLink(message.event.call, { startTime }) + m.eventMessage.joinLink = (message.event.call === 'audio' ? CALL_AUDIO_PREFIX : CALL_VIDEO_PREFIX) + token + } + + m.messageContextInfo = { + // encKey + messageSecret: message.event.messageSecret || randomBytes(32) + } + + m.eventMessage.name = message.event.name + m.eventMessage.description = message.event.description + m.eventMessage.startTime = startTime + m.eventMessage.endTime = message.event.endDate ? message.event.endDate.getTime() / 1000 : undefined + m.eventMessage.isCanceled = message.event.isCancelled ?? false + m.eventMessage.extraGuestsAllowed = message.event.extraGuestsAllowed + m.eventMessage.isScheduleCall = message.event.isScheduleCall ?? false + m.eventMessage.location = message.event.location + } else if (hasNonNullishProperty(message, 'poll')) { + message.poll.selectableCount ||= 0 + message.poll.toAnnouncementGroup ||= false + + if (!Array.isArray(message.poll.values)) { + throw new Boom('Invalid poll values', { statusCode: 400 }) + } + + if (message.poll.selectableCount < 0 || message.poll.selectableCount > message.poll.values.length) { + throw new Boom(`poll.selectableCount in poll should be >= 0 and <= ${message.poll.values.length}`, { + statusCode: 400 + }) + } + + m.messageContextInfo = { + // encKey + messageSecret: message.poll.messageSecret || randomBytes(32) + } + + const pollCreationMessage = { + name: message.poll.name, + selectableOptionsCount: message.poll.selectableCount, + options: message.poll.values.map(optionName => ({ optionName })) + } + + if (message.poll.toAnnouncementGroup) { + // poll v2 is for community announcement groups (single select and multiple) + m.pollCreationMessageV2 = pollCreationMessage + } else { + if (message.poll.selectableCount === 1) { + //poll v3 is for single select polls + m.pollCreationMessageV3 = pollCreationMessage + } else { + // poll for multiple choice polls + m.pollCreationMessage = pollCreationMessage + } + } + } else if (hasNonNullishProperty(message, 'sharePhoneNumber')) { + m.protocolMessage = { + type: proto.Message.ProtocolMessage.Type.SHARE_PHONE_NUMBER + } + } else if (hasNonNullishProperty(message, 'requestPhoneNumber')) { + m.requestPhoneNumberMessage = {} + } else if (hasNonNullishProperty(message, 'stickerPack')) { + // Prepare sticker pack message (3-30 stickers per WhatsApp official spec) + options.logger?.info('Generating sticker pack message') + + const stickerPackMessage = await prepareStickerPackMessage(message.stickerPack, { + upload: options.upload, + logger: options.logger + }) + + m.stickerPackMessage = stickerPackMessage + } else if (hasNonNullishProperty(message, 'limitSharing')) { + m.protocolMessage = { + type: proto.Message.ProtocolMessage.Type.LIMIT_SHARING, + limitSharing: { + sharingLimited: message.limitSharing === true, + trigger: 1, + limitSharingSettingTimestamp: Date.now(), + initiatedByMe: true + } + } + } else { + m = await prepareWAMessageMedia(message, options) + } + + if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) { + m = { viewOnceMessage: { message: m } } + } + + if (hasOptionalProperty(message, 'mentions') && message.mentions?.length) { + const messageType = Object.keys(m)[0] as Extract + if (messageType) { + const key = m[messageType] + if (key && 'contextInfo' in key && !!key.contextInfo) { + key.contextInfo.mentionedJid = message.mentions + } else if (key) { + key.contextInfo = { + mentionedJid: message.mentions + } + } + } + } + + if (hasOptionalProperty(message, 'edit')) { + m = { + protocolMessage: { + key: message.edit, + editedMessage: m, + timestampMs: Date.now(), + type: WAProto.Message.ProtocolMessage.Type.MESSAGE_EDIT + } + } + } + + if (hasOptionalProperty(message, 'contextInfo') && !!message.contextInfo) { + const messageType = Object.keys(m)[0] as Extract + if (messageType) { + const key = m[messageType] + if (key && 'contextInfo' in key && !!key.contextInfo) { + key.contextInfo = { ...key.contextInfo, ...message.contextInfo } + } else if (key) { + key.contextInfo = message.contextInfo + } + } + } + + // Add messageContextInfo with reporting token for non-carousel messages + // Carousel messages return early with fromObject encoding and don't need this + if (shouldIncludeReportingToken(m)) { + m.messageContextInfo = m.messageContextInfo || {} + if (!m.messageContextInfo.messageSecret) { + m.messageContextInfo.messageSecret = randomBytes(32) + } + } + + return WAProto.Message.create(m) +} + +export const generateWAMessageFromContent = ( + jid: string, + message: WAMessageContent, + options: MessageGenerationOptionsFromContent +) => { + // set timestamp to now + // if not specified + if (!options.timestamp) { + options.timestamp = new Date() + } + + const innerMessage = normalizeMessageContent(message) + if (!innerMessage) { + throw new Boom('no content in message', { statusCode: 400 }) + } + + const key = getContentType(innerMessage) + if (!key) { + throw new Boom('unable to determine message content type', { statusCode: 400 }) + } + + const timestamp = unixTimestampSeconds(options.timestamp) + const { quoted, userJid } = options + + if (quoted && !isJidNewsletter(jid)) { + const participant = quoted.key.fromMe + ? userJid // TODO: Add support for LIDs + : quoted.participant || quoted.key.participant || quoted.key.remoteJid + + let quotedMsg = normalizeMessageContent(quoted.message) + if (!quotedMsg) { + throw new Boom('no content in quoted message', { statusCode: 400 }) + } + + const msgType = getContentType(quotedMsg) + if (!msgType) { + throw new Boom('unable to determine quoted message content type', { statusCode: 400 }) + } + + // strip any redundant properties + quotedMsg = proto.Message.create({ [msgType]: quotedMsg[msgType] }) + + const quotedContent = quotedMsg[msgType] + if (typeof quotedContent === 'object' && quotedContent && 'contextInfo' in quotedContent) { + delete quotedContent.contextInfo + } + + const innerContent = innerMessage[key as Exclude] + const contextInfo: proto.IContextInfo = + (innerContent && + typeof innerContent === 'object' && + 'contextInfo' in innerContent && + ((innerContent as Record).contextInfo as proto.IContextInfo)) || + {} + contextInfo.participant = jidNormalizedUser(participant!) + contextInfo.stanzaId = quoted.key.id + contextInfo.quotedMessage = quotedMsg + + // if a participant is quoted, then it must be a group + // hence, remoteJid of group must also be entered + if (jid !== quoted.key.remoteJid) { + contextInfo.remoteJid = quoted.key.remoteJid + } + + if (contextInfo && innerMessage[key]) { + /* @ts-ignore */ + innerMessage[key].contextInfo = contextInfo + } + } + + // Skip ephemeral contextInfo for carousel messages + const isCarouselEphemeral = + !!(message as any)?.interactiveMessage?.carouselMessage || + !!(message as any)?.viewOnceMessage?.message?.interactiveMessage?.carouselMessage + if ( + // if we want to send a disappearing message + !!options?.ephemeralExpiration && + // and it's not a protocol message -- delete, toggle disappear message + key !== 'protocolMessage' && + // already not converted to disappearing message + key !== 'ephemeralMessage' && + // newsletters don't support ephemeral messages + !isJidNewsletter(jid) && + // carousel messages should not have ephemeral contextInfo + !isCarouselEphemeral + ) { + /* @ts-ignore */ + innerMessage[key].contextInfo = { + ...((innerMessage[key] as any).contextInfo || {}), + expiration: options.ephemeralExpiration || WA_DEFAULT_EPHEMERAL + //ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts?.toString() + } + } + + // Skip Message.create() for carousel — InteractiveMessage has oneOf (fields 4-7) + // and create() may corrupt the carouselMessage/nativeFlowMessage oneOf resolution + const isCarouselMsg = + !!(message as any)?.interactiveMessage?.carouselMessage || + !!(message as any)?.viewOnceMessage?.message?.interactiveMessage?.carouselMessage + if (!isCarouselMsg) { + message = WAProto.Message.create(message) + } + + const messageJSON = { + key: { + remoteJid: jid, + fromMe: true, + id: options?.messageId || generateMessageIDV2() + }, + message: message, + messageTimestamp: timestamp, + messageStubParameters: [], + participant: isJidGroup(jid) || isJidStatusBroadcast(jid) ? userJid : undefined, // TODO: Add support for LIDs + status: WAMessageStatus.PENDING + } + return WAProto.WebMessageInfo.fromObject(messageJSON) as WAMessage +} + +export const generateWAMessage = async (jid: string, content: AnyMessageContent, options: MessageGenerationOptions) => { + // ensure msg ID is with every log + options.logger = options?.logger?.child({ msgId: options.messageId }) + // Pass jid in the options to generateWAMessageContent + return generateWAMessageFromContent(jid, await generateWAMessageContent(content, { ...options, jid }), options) +} + +/** Get the key to access the true type of content */ +export const getContentType = (content: proto.IMessage | undefined) => { + if (content) { + const keys = Object.keys(content) + const key = keys.find(k => (k === 'conversation' || k.includes('Message')) && k !== 'senderKeyDistributionMessage') + return key as keyof typeof content + } +} + +/** + * Normalizes ephemeral, view once messages to regular message content + * Eg. image messages in ephemeral messages, in view once messages etc. + * @param content + * @returns + */ +export const normalizeMessageContent = (content: WAMessageContent | null | undefined): WAMessageContent | undefined => { + if (!content) { + return undefined + } + + // set max iterations to prevent an infinite loop + for (let i = 0; i < 5; i++) { + const inner = getFutureProofMessage(content) + if (!inner) { + break + } + + content = inner.message + } + + return content! + + function getFutureProofMessage(message: typeof content) { + return ( + message?.ephemeralMessage || + message?.viewOnceMessage || + message?.documentWithCaptionMessage || + message?.viewOnceMessageV2 || + message?.viewOnceMessageV2Extension || + message?.editedMessage || + message?.associatedChildMessage || + message?.groupStatusMessage || + message?.groupStatusMessageV2 + ) + } +} + +/** + * Extract the true message content from a message + * Eg. extracts the inner message from a disappearing message/view once message + */ +export const extractMessageContent = (content: WAMessageContent | undefined | null): WAMessageContent | undefined => { + const extractFromTemplateMessage = ( + msg: proto.Message.TemplateMessage.IHydratedFourRowTemplate | proto.Message.IButtonsMessage + ) => { + if (msg.imageMessage) { + return { imageMessage: msg.imageMessage } + } else if (msg.documentMessage) { + return { documentMessage: msg.documentMessage } + } else if (msg.videoMessage) { + return { videoMessage: msg.videoMessage } + } else if (msg.locationMessage) { + return { locationMessage: msg.locationMessage } + } else { + return { + conversation: + 'contentText' in msg ? msg.contentText : 'hydratedContentText' in msg ? msg.hydratedContentText : '' + } + } + } + + content = normalizeMessageContent(content) + + if (content?.buttonsMessage) { + return extractFromTemplateMessage(content.buttonsMessage) + } + + if (content?.templateMessage?.hydratedFourRowTemplate) { + return extractFromTemplateMessage(content?.templateMessage?.hydratedFourRowTemplate) + } + + if (content?.templateMessage?.hydratedTemplate) { + return extractFromTemplateMessage(content?.templateMessage?.hydratedTemplate) + } + + if (content?.templateMessage?.fourRowTemplate) { + return extractFromTemplateMessage(content?.templateMessage?.fourRowTemplate) + } + + return content +} + +/** + * Returns the device predicted by message ID + */ +export const getDevice = (id: string) => + /^3A.{18}$/.test(id) + ? 'ios' + : /^3E.{20}$/.test(id) + ? 'web' + : /^(.{21}|.{32})$/.test(id) + ? 'android' + : /^(3F|.{18}$)/.test(id) + ? 'desktop' + : 'unknown' + +/** Upserts a receipt in the message */ +export const updateMessageWithReceipt = (msg: Pick, receipt: MessageUserReceipt) => { + msg.userReceipt = msg.userReceipt || [] + const recp = msg.userReceipt.find(m => m.userJid === receipt.userJid) + if (recp) { + Object.assign(recp, receipt) + } else { + msg.userReceipt.push(receipt) + } +} + +/** Update the message with a new reaction */ +export const updateMessageWithReaction = (msg: Pick, reaction: proto.IReaction) => { + const authorID = getKeyAuthor(reaction.key) + + const reactions = (msg.reactions || []).filter(r => getKeyAuthor(r.key) !== authorID) + reaction.text = reaction.text || '' + reactions.push(reaction) + msg.reactions = reactions +} + +/** Update the message with a new poll update */ +export const updateMessageWithPollUpdate = (msg: Pick, update: proto.IPollUpdate) => { + const authorID = getKeyAuthor(update.pollUpdateMessageKey) + + const reactions = (msg.pollUpdates || []).filter(r => getKeyAuthor(r.pollUpdateMessageKey) !== authorID) + if (update.vote?.selectedOptions?.length) { + reactions.push(update) + } + + msg.pollUpdates = reactions +} + +/** Update the message with a new event response */ +export const updateMessageWithEventResponse = ( + msg: Pick, + update: proto.IEventResponse +) => { + const authorID = getKeyAuthor(update.eventResponseMessageKey) + + const responses = (msg.eventResponses || []).filter(r => getKeyAuthor(r.eventResponseMessageKey) !== authorID) + responses.push(update) + + msg.eventResponses = responses +} + +type VoteAggregation = { + name: string + voters: string[] +} + +/** + * Aggregates all poll updates in a poll. + * @param msg the poll creation message + * @param meId your jid + * @returns A list of options & their voters + */ +export function getAggregateVotesInPollMessage( + { message, pollUpdates }: Pick, + meId?: string +) { + const opts = + message?.pollCreationMessage?.options || + message?.pollCreationMessageV2?.options || + message?.pollCreationMessageV3?.options || + [] + const voteHashMap = opts.reduce( + (acc, opt) => { + const hash = sha256(Buffer.from(opt.optionName || '')).toString() + acc[hash] = { + name: opt.optionName || '', + voters: [] + } + return acc + }, + {} as { [_: string]: VoteAggregation } + ) + + for (const update of pollUpdates || []) { + const { vote } = update + if (!vote) { + continue + } + + for (const option of vote.selectedOptions || []) { + const hash = option.toString() + let data = voteHashMap[hash] + if (!data) { + voteHashMap[hash] = { + name: 'Unknown', + voters: [] + } + data = voteHashMap[hash] + } + + voteHashMap[hash]!.voters.push(getKeyAuthor(update.pollUpdateMessageKey, meId)) + } + } + + return Object.values(voteHashMap) +} + +type ResponseAggregation = { + response: string + responders: string[] +} + +/** + * Aggregates all event responses in an event message. + * @param msg the event creation message + * @param meId your jid + * @returns A list of response types & their responders + */ +export function getAggregateResponsesInEventMessage( + { eventResponses }: Pick, + meId?: string +) { + const responseTypes = ['GOING', 'NOT_GOING', 'MAYBE'] + const responseMap: { [_: string]: ResponseAggregation } = {} + + for (const type of responseTypes) { + responseMap[type] = { + response: type, + responders: [] + } + } + + for (const update of eventResponses || []) { + const responseType = (update as unknown as { eventResponse?: string }).eventResponse || 'UNKNOWN' + if (responseType !== 'UNKNOWN' && responseMap[responseType]) { + responseMap[responseType].responders.push(getKeyAuthor(update.eventResponseMessageKey, meId)) + } + } + + return Object.values(responseMap) +} + +/** Given a list of message keys, aggregates them by chat & sender. Useful for sending read receipts in bulk */ +export const aggregateMessageKeysNotFromMe = (keys: WAMessageKey[]) => { + const keyMap: { [id: string]: { jid: string; participant: string | undefined; messageIds: string[] } } = {} + for (const { remoteJid, id, participant, fromMe } of keys) { + if (!fromMe) { + const uqKey = `${remoteJid}:${participant || ''}` + if (!keyMap[uqKey]) { + keyMap[uqKey] = { + jid: remoteJid!, + participant: participant!, + messageIds: [] + } + } + + keyMap[uqKey].messageIds.push(id!) + } + } + + return Object.values(keyMap) +} + +type DownloadMediaMessageContext = { + reuploadRequest: (msg: WAMessage) => Promise + logger: ILogger +} + +const REUPLOAD_REQUIRED_STATUS = [410, 404] + +/** + * Downloads the given message. Throws an error if it's not a media message + */ +export const downloadMediaMessage = async ( + message: WAMessage, + type: Type, + options: MediaDownloadOptions, + ctx?: DownloadMediaMessageContext +) => { + const result = await downloadMsg().catch(async error => { + if ( + ctx && + typeof error?.status === 'number' && // treat errors with status as HTTP failures requiring reupload + REUPLOAD_REQUIRED_STATUS.includes(error.status as number) + ) { + ctx.logger.info({ key: message.key }, 'sending reupload media request...') + // request reupload + message = await ctx.reuploadRequest(message) + const result = await downloadMsg() + return result + } + + throw error + }) + + return result as Type extends 'buffer' ? Buffer : Transform + + async function downloadMsg() { + const mContent = extractMessageContent(message.message) + if (!mContent) { + throw new Boom('No message present', { statusCode: 400, data: message }) + } + + const contentType = getContentType(mContent) + let mediaType = contentType?.replace('Message', '') as MediaType + const media = mContent[contentType!] + + if (!media || typeof media !== 'object' || (!('url' in media) && !('thumbnailDirectPath' in media))) { + throw new Boom(`"${contentType}" message is not a media message`) + } + + let download: DownloadableMessage + if ('thumbnailDirectPath' in media && !('url' in media)) { + download = { + directPath: media.thumbnailDirectPath, + mediaKey: media.mediaKey + } + mediaType = 'thumbnail-link' + } else { + download = media + } + + const stream = await downloadContentFromMessage(download, mediaType, options) + if (type === 'buffer') { + const bufferArray: Buffer[] = [] + for await (const chunk of stream) { + bufferArray.push(chunk) + } + + return Buffer.concat(bufferArray) + } + + return stream + } +} + +/** Checks whether the given message is a media message; if it is returns the inner content */ +export const assertMediaContent = (content: proto.IMessage | null | undefined) => { + content = extractMessageContent(content) + const mediaContent = + content?.documentMessage || + content?.imageMessage || + content?.videoMessage || + content?.audioMessage || + content?.stickerMessage + if (!mediaContent) { + throw new Boom('given message is not a media message', { statusCode: 400, data: content }) + } + + return mediaContent +}