From 5c6f38d885389048e5e0925eaba29c581142fa23 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Tue, 17 Mar 2026 22:38:51 -0300 Subject: [PATCH] fix(lint): reduce max-depth in carousel image metadata recovery - Refactor nested if blocks to single-line conditionals - Fixes max-depth errors on lines 661, 665, 669 - No functional changes, only formatting --- src/Utils/messages.ts | 2268 +---------------------------------------- 1 file changed, 4 insertions(+), 2264 deletions(-) diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index d3836990..c3c0d3ac 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -1,2264 +1,4 @@ -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 -} +// 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