From 1595f1d98fc553a23109427f10354a46af2a6028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20De=20La=20Paz?= Date: Tue, 11 Aug 2026 20:06:58 -0500 Subject: [PATCH 1/4] fix(baileys): backport interactive buttons rendering fix from 2.4.0-rc1 - Add interactiveMessage.helper.ts with buildInteractiveBizNode and buildListBizNode - Remove viewOnceMessage wrapper from buttonMessage (PIX + general) - Switch listMessage to legacy format with SINGLE_SELECT listType - Add additionalNodes parameter to sendMessage and sendMessageWithTyping - Inject node for buttons and for lists This fixes button/list rendering on WhatsApp Web and Desktop clients. --- .../helpers/interactiveMessage.helper.ts | 141 ++++++++++++++ .../whatsapp/whatsapp.baileys.service.ts | 181 +++++++++++------- 2 files changed, 256 insertions(+), 66 deletions(-) create mode 100644 src/api/integrations/channel/whatsapp/helpers/interactiveMessage.helper.ts diff --git a/src/api/integrations/channel/whatsapp/helpers/interactiveMessage.helper.ts b/src/api/integrations/channel/whatsapp/helpers/interactiveMessage.helper.ts new file mode 100644 index 0000000000..eb132db27a --- /dev/null +++ b/src/api/integrations/channel/whatsapp/helpers/interactiveMessage.helper.ts @@ -0,0 +1,141 @@ +import { Button, KeyType } from '@api/dto/sendMessage.dto'; +import { BinaryNode } from 'baileys'; + +export function buildInteractiveBizNode(): BinaryNode { + return { + tag: 'biz', + attrs: {}, + content: [ + { + tag: 'interactive', + attrs: { type: 'native_flow', v: '1' }, + content: [{ tag: 'native_flow', attrs: { v: '9', name: 'mixed' } }], + }, + ], + }; +} + +/** + * Biz node específico para `listMessage` legado. + * Necessário para o WhatsApp Web/Desktop renderizar a lista — o moderno + * (`interactiveMessage` + `single_select`) não é renderizado no Web. + */ +export function buildListBizNode(): BinaryNode { + return { + tag: 'biz', + attrs: {}, + content: [{ tag: 'list', attrs: { type: 'product_list', v: '2' } }], + }; +} + +type NativeFlowButton = { name: string; buttonParamsJson: string }; + +type NativeFlowDeps = { + generateRandomId: () => string; + mapKeyType: Map; +}; + +export function toNativeFlowButton(button: Button, deps: NativeFlowDeps): NativeFlowButton { + const displayText = button.displayText ?? ''; + + switch (button.type) { + case 'url': + return { + name: 'cta_url', + buttonParamsJson: JSON.stringify({ + display_text: displayText, + url: button.url, + merchant_url: button.url, + }), + }; + + case 'call': + return { + name: 'cta_call', + buttonParamsJson: JSON.stringify({ + display_text: displayText, + phone_number: button.phoneNumber, + }), + }; + + case 'copy': + return { + name: 'cta_copy', + buttonParamsJson: JSON.stringify({ + display_text: displayText, + copy_code: button.copyCode, + }), + }; + + case 'reply': + return { + name: 'quick_reply', + buttonParamsJson: JSON.stringify({ + display_text: displayText, + id: button.id ?? deps.generateRandomId(), + }), + }; + + case 'pix': + return { + name: 'payment_info', + buttonParamsJson: JSON.stringify({ + currency: button.currency, + total_amount: { value: 0, offset: 100 }, + reference_id: deps.generateRandomId(), + type: 'physical-goods', + order: { + status: 'pending', + subtotal: { value: 0, offset: 100 }, + order_type: 'ORDER', + items: [ + { name: '', amount: { value: 0, offset: 100 }, quantity: 0, sale_amount: { value: 0, offset: 100 } }, + ], + }, + payment_settings: [ + { + type: 'pix_static_code', + pix_static_code: { + merchant_name: button.name, + key: button.key, + key_type: deps.mapKeyType.get(button.keyType), + }, + }, + ], + share_payment_status: false, + }), + }; + + default: + throw new Error(`Unsupported button type: ${(button as Button).type}`); + } +} + +type ListSection = { + title: string; + rows: Array<{ title: string; description?: string; rowId: string }>; +}; + +export function buildSingleSelectButton(buttonText: string, sections: ListSection[]): NativeFlowButton { + const buttonParams = { + title: buttonText || ' ', + sections: (sections || []).map((section) => ({ + title: section.title || ' ', + highlight_label: '', + rows: (section.rows || []).map((row, index) => { + const rowTitle = row.title || ' '; + return { + header: rowTitle, + title: rowTitle, + description: row.description || ' ', + id: row.rowId || `row_${index}`, + }; + }), + })), + }; + + return { + name: 'single_select', + buttonParamsJson: JSON.stringify(buttonParams), + }; +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 60e857fcc1..6aadf582f2 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -53,6 +53,10 @@ import { TypeButton, } from '@api/dto/sendMessage.dto'; import { chatwootImport } from '@api/integrations/chatbot/chatwoot/utils/chatwoot-import-helper'; +import { + buildInteractiveBizNode, + buildListBizNode, +} from './helpers/interactiveMessage.helper'; import * as s3Service from '@api/integrations/storage/s3/libs/minio.server'; import { ProviderFiles } from '@api/provider/sessions'; import { PrismaRepository, Query } from '@api/repository/repository.service'; @@ -92,6 +96,7 @@ import { useMultiFileAuthStateRedisDb } from '@utils/use-multi-file-auth-state-r import axios from 'axios'; import makeWASocket, { AnyMessageContent, + BinaryNode, BufferedEventData, BufferJSON, CacheStore, @@ -2136,6 +2141,7 @@ export class BaileysStartupService extends ChannelStartupService { messageId?: string, ephemeralExpiration?: number, contextInfo?: any, + additionalNodes?: BinaryNode[], // participants?: GroupParticipant[], ) { sender = sender.toLowerCase(); @@ -2155,14 +2161,17 @@ export class BaileysStartupService extends ChannelStartupService { // NOTE: NÃO DEVEMOS GERAR O messageId AQUI, SOMENTE SE VIER INFORMADO POR PARAMETRO. A GERAÇÃO ANTERIOR IMPEDE O WZAP DE IDENTIFICAR A SOURCE. if (messageId) option.messageId = messageId; - if (message['viewOnceMessage']) { + if (message['viewOnceMessage'] || message['interactiveMessage'] || message['listMessage']) { const m = generateWAMessageFromContent(sender, message, { timestamp: new Date(), userJid: this.instance.wuid, messageId, quoted, }); - const id = await this.client.relayMessage(sender, message, { messageId }); + const id = await this.client.relayMessage(sender, message, { + messageId, + ...(additionalNodes?.length ? { additionalNodes } : {}), + }); m.key = { id: id, remoteJid: sender, participant: isPnUser(sender) ? sender : undefined, fromMe: true }; for (const [key, value] of Object.entries(m)) { if (!value || (isArray(value) && value.length) === 0) { @@ -2291,6 +2300,7 @@ export class BaileysStartupService extends ChannelStartupService { message: T, options?: Options, isIntegration = false, + additionalNodes?: BinaryNode[], ) { const isWA = (await this.whatsappNumber({ numbers: [number] }))?.shift(); @@ -2392,6 +2402,7 @@ export class BaileysStartupService extends ChannelStartupService { quoted, null, group?.ephemeralDuration, + additionalNodes, // group?.participants, ); } else { @@ -2415,6 +2426,7 @@ export class BaileysStartupService extends ChannelStartupService { null, undefined, contextInfo, + additionalNodes, ); } @@ -3316,17 +3328,19 @@ export class BaileysStartupService extends ChannelStartupService { } const hasReplyButtons = data.buttons.some((btn) => btn.type === 'reply'); - const hasPixButton = data.buttons.some((btn) => btn.type === 'pix'); + const hasCTAButtons = data.buttons.some((btn) => btn.type === 'url' || btn.type === 'call' || btn.type === 'copy'); - const hasOtherButtons = data.buttons.some((btn) => btn.type !== 'reply' && btn.type !== 'pix'); + /* ========================= + * REGRAS DE VALIDAÇÃO + * ========================= */ if (hasReplyButtons) { if (data.buttons.length > 3) { throw new BadRequestException('Maximum of 3 reply buttons allowed'); } - if (hasOtherButtons) { - throw new BadRequestException('Reply buttons cannot be mixed with other button types'); + if (hasCTAButtons || hasPixButton) { + throw new BadRequestException('Reply buttons cannot be mixed with CTA or PIX buttons'); } } @@ -3334,30 +3348,49 @@ export class BaileysStartupService extends ChannelStartupService { if (data.buttons.length > 1) { throw new BadRequestException('Only one PIX button is allowed'); } - if (hasOtherButtons) { + if (hasReplyButtons || hasCTAButtons) { throw new BadRequestException('PIX button cannot be mixed with other button types'); } const message: proto.IMessage = { - viewOnceMessage: { - message: { - interactiveMessage: { - nativeFlowMessage: { - buttons: [{ name: this.mapType.get('pix'), buttonParamsJson: this.toJSONString(data.buttons[0]) }], - messageParamsJson: JSON.stringify({ from: 'api', templateId: v4() }), + interactiveMessage: { + nativeFlowMessage: { + buttons: [ + { + name: this.mapType.get('pix'), + buttonParamsJson: this.toJSONString(data.buttons[0]), }, - }, + ], + messageParamsJson: JSON.stringify({ + from: 'api', + templateId: v4(), + }), }, }, }; - return await this.sendMessageWithTyping(data.number, message, { - delay: data?.delay, - presence: 'composing', - quoted: data?.quoted, - mentionsEveryOne: data?.mentionsEveryOne, - mentioned: data?.mentioned, - }); + return await this.sendMessageWithTyping( + data.number, + message, + { + delay: data?.delay, + presence: 'composing', + quoted: data?.quoted, + mentionsEveryOne: data?.mentionsEveryOne, + mentioned: data?.mentioned, + }, + false, + [buildInteractiveBizNode()], + ); + } + + if (hasCTAButtons) { + if (data.buttons.length > 2) { + throw new BadRequestException('Maximum of 2 CTA buttons allowed'); + } + if (hasReplyButtons) { + throw new BadRequestException('CTA buttons cannot be mixed with reply buttons'); + } } const generate = await (async () => { @@ -3371,45 +3404,47 @@ export class BaileysStartupService extends ChannelStartupService { }); const message: proto.IMessage = { - viewOnceMessage: { - message: { - interactiveMessage: { - body: { - text: (() => { - let t = '*' + data.title + '*'; - if (data?.description) { - t += '\n\n'; - t += data.description; - t += '\n'; - } - return t; - })(), - }, - footer: { text: data?.footer }, - header: (() => { - if (generate?.message?.imageMessage) { - return { - hasMediaAttachment: !!generate.message.imageMessage, - imageMessage: generate.message.imageMessage, - }; - } - })(), - nativeFlowMessage: { - buttons: buttons, - messageParamsJson: JSON.stringify({ from: 'api', templateId: v4() }), - }, - }, + interactiveMessage: { + body: { + text: (() => { + let t = '*' + data.title + '*'; + if (data?.description) { + t += '\n\n'; + t += data.description; + t += '\n'; + } + return t; + })(), + }, + footer: { text: data?.footer }, + header: (() => { + if (generate?.message?.imageMessage) { + return { + hasMediaAttachment: !!generate.message.imageMessage, + imageMessage: generate.message.imageMessage, + }; + } + })(), + nativeFlowMessage: { + buttons: buttons, + messageParamsJson: JSON.stringify({ from: 'api', templateId: v4() }), }, }, }; - return await this.sendMessageWithTyping(data.number, message, { - delay: data?.delay, - presence: 'composing', - quoted: data?.quoted, - mentionsEveryOne: data?.mentionsEveryOne, - mentioned: data?.mentioned, - }); + return await this.sendMessageWithTyping( + data.number, + message, + { + delay: data?.delay, + presence: 'composing', + quoted: data?.quoted, + mentionsEveryOne: data?.mentionsEveryOne, + mentioned: data?.mentioned, + }, + false, + [buildInteractiveBizNode()], + ); } public async locationMessage(data: SendLocationDto) { @@ -3434,18 +3469,30 @@ export class BaileysStartupService extends ChannelStartupService { } public async listMessage(data: SendListDto) { + // Formato LEGADO (`listMessage` com listType SINGLE_SELECT) — funciona em Web, iOS e Android. + // O formato moderno (interactiveMessage + nativeFlowMessage com single_select) só renderiza + // em mobile recente; no WhatsApp Web/Desktop a mensagem chega vazia. + const message: proto.IMessage = { + listMessage: { + title: data.title || '', + description: data.description || '', + buttonText: data.buttonText || 'Ver Menu', + footerText: data.footerText || '', + listType: proto.Message.ListMessage.ListType.SINGLE_SELECT, + sections: (data.sections || []).map((section) => ({ + title: section.title || '', + rows: (section.rows || []).map((row) => ({ + title: row.title || '', + description: row.description || '', + rowId: row.rowId || '', + })), + })), + }, + }; + return await this.sendMessageWithTyping( data.number, - { - listMessage: { - title: data.title, - description: data.description, - buttonText: data?.buttonText, - footerText: data?.footerText, - sections: data.sections, - listType: 2, - }, - }, + message, { delay: data?.delay, presence: 'composing', @@ -3453,6 +3500,8 @@ export class BaileysStartupService extends ChannelStartupService { mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, }, + false, + [buildListBizNode()], ); } From fe195916799ddc6ff3a6f083170af92463f39952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20De=20La=20Paz?= Date: Tue, 11 Aug 2026 23:50:36 -0500 Subject: [PATCH 2/4] fix(docker): skip tsc --noEmit in build to avoid OOM in low-memory environments --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 24c4e3bc7f..a1f29aba40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ RUN chmod +x ./Docker/scripts/* && dos2unix ./Docker/scripts/* RUN ./Docker/scripts/generate_database.sh -RUN npm run build +RUN npx tsup FROM node:24-alpine AS final From 7824636bad85a6b1c3bfb35f67d51f5a18aebd72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20De=20La=20Paz?= Date: Wed, 12 Aug 2026 00:03:34 -0500 Subject: [PATCH 3/4] fix(docker): add fast build config without minification for low-memory environments --- Dockerfile | 4 ++-- tsup.config.fast.ts | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 tsup.config.fast.ts diff --git a/Dockerfile b/Dockerfile index a1f29aba40..15454329d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ WORKDIR /evolution COPY ./package*.json ./ COPY ./tsconfig.json ./ -COPY ./tsup.config.ts ./ +COPY ./tsup.config.fast.ts ./ RUN npm ci --silent @@ -28,7 +28,7 @@ RUN chmod +x ./Docker/scripts/* && dos2unix ./Docker/scripts/* RUN ./Docker/scripts/generate_database.sh -RUN npx tsup +RUN npx tsup --config tsup.config.fast.ts FROM node:24-alpine AS final diff --git a/tsup.config.fast.ts b/tsup.config.fast.ts new file mode 100644 index 0000000000..c0863ad18c --- /dev/null +++ b/tsup.config.fast.ts @@ -0,0 +1,20 @@ +import { cpSync } from 'node:fs'; + +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src'], + outDir: 'dist', + splitting: false, + sourcemap: false, + clean: true, + minify: false, + format: ['cjs', 'esm'], + onSuccess: async () => { + cpSync('src/utils/translations', 'dist/translations', { recursive: true }); + }, + loader: { + '.json': 'file', + '.yml': 'file', + }, +}); From d789e411aa45117ec52f69c16a1023e3abe7f9b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20De=20La=20Paz?= Date: Fri, 23 Oct 2026 01:48:34 -0500 Subject: [PATCH 4/4] =?UTF-8?q?fix(tsup):=20Correcci=C3=B3n=20de=20tsup=20?= =?UTF-8?q?en=20compilaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 15454329d3..f1896b60e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,7 +51,7 @@ COPY --from=builder /evolution/public ./public COPY --from=builder /evolution/.env ./.env COPY --from=builder /evolution/Docker ./Docker COPY --from=builder /evolution/runWithProvider.js ./runWithProvider.js -COPY --from=builder /evolution/tsup.config.ts ./tsup.config.ts +COPY --from=builder /evolution/tsup.config.fast.ts ./tsup.config.fast.ts ENV DOCKER_ENV=true