/** * rich-message.ts — Construção e envio de mensagens ricas via Baileys padrão * * O pacote @whiskeysockets/baileys não suporta os campos `nativeButtons`, * `nativeList` e `nativeCarousel` que são específicos do fork InfiniteAPI. * Este módulo replica a lógica de transformação do InfiniteAPI para que * o motor newwhats possa enviar botões, listas e carrosséis usando o * sock.relayMessage() com o proto correto. * * Referência: github:rsalcara/InfiniteAPI — lib/Socket/messages-send.js * * IMPORTANTE: O WhatsApp Web NÃO suporta viewOnceMessage para mensagens interativas. * Por isso usamos interactiveMessage DIRETO (sem wrapper viewOnce) + additionalNodes. * * O que é necessário para botões/listas/carrosséis funcionarem no WhatsApp * (celular E web): * * 1. interactiveMessage direto (NÃO viewOnceMessage) — viewOnce não carrega no Web * * 2. Nó adicional * injetado no stanza via relayMessage(additionalNodes) * (sem isso o WhatsApp Web exibe "Não foi possível carregar") * * 3. Nó para chats 1-a-1 * (necessário para interactive messages em conversas privadas) * * Lista (single_select) também usa o mesmo fluxo — InfiniteAPI converte * o viewOnceMessage > nativeFlowMessage de volta para listMessage no relayMessage, * mas testamos que a forma direta com nó biz > list também funciona. */ import { generateMessageIDV2, generateWAMessageFromContent, isJidGroup, isJidStatusBroadcast } from './engine' import { logger } from '../../config/logger' import { proto } from './engine' import type { WASocket, BinaryNode } from './engine' // ─── Tipos de botão (espelho do formato nativo InfiniteAPI) ────────────────── type NativeBtn = | { type: 'reply'; id: string; text: string } | { type: 'url'; text: string; url: string } | { type: 'copy'; text: string; copyText: string } | { type: 'call'; text: string; phoneNumber: string } // ─── Payload rico de entrada (o que o frontend/ext-api envia) ──────────────── export interface RichPayload { text?: string footer?: string nativeButtons?: NativeBtn[] nativeList?: { buttonText: string sections: Array<{ title: string rows: Array<{ id: string; title: string; description?: string }> }> } nativeCarousel?: { cards: Array<{ title?: string body?: string footer?: string image?: { url: string } buttons: Array<{ type?: string; id?: string; text: string }> }> } poll?: { name: string values: string[] selectableCount?: number } } // ─── Converte um botão nativo para o formato buttonParamsJson do WhatsApp ───── function formatNativeFlowButton(btn: NativeBtn): { name: string; buttonParamsJson: string } { switch (btn.type) { case 'url': return { name: 'cta_url', buttonParamsJson: JSON.stringify({ display_text: btn.text, url: btn.url, merchant_url: btn.url, }), } case 'copy': return { name: 'cta_copy', buttonParamsJson: JSON.stringify({ display_text: btn.text, copy_code: btn.copyText, }), } case 'reply': return { name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: btn.text, id: btn.id, }), } case 'call': return { name: 'cta_call', buttonParamsJson: JSON.stringify({ display_text: btn.text, phone_number: btn.phoneNumber, }), } } } // ─── Nó biz > interactive > native_flow (obrigatório para renderizar no Web) ── const BIZ_NATIVE_FLOW_NODE: BinaryNode = { tag: 'biz', attrs: {}, content: [ { tag: 'interactive', attrs: { type: 'native_flow', v: '1' }, content: [ { tag: 'native_flow', attrs: { v: '9', name: 'mixed' }, }, ], }, ], } // ─── Nó biz para listMessage legado (formato que renderiza no Web) ─────────── // InfiniteAPI usa com proto.listMessage // (não interactiveMessage). Comentário do fork: "Modern format causes rejection // (error 479) — Legacy format works on all platforms". const BIZ_LIST_NODE: BinaryNode = { tag: 'biz', attrs: {}, content: [ { tag: 'list', attrs: { type: 'product_list', v: '2' }, }, ], } // ─── Nó bot (necessário para chats 1-a-1 com interactive messages) ──────────── const BOT_NODE: BinaryNode = { tag: 'bot', attrs: { biz_bot: '1' }, } // ─── Helper: jid é chat privado (não grupo, não status, não bot) ───────────── function isPrivateChat(jid: string): boolean { return ( !isJidGroup(jid) && !isJidStatusBroadcast(jid) && !jid.includes('newsletter') && !jid.includes('broadcast') ) } // ─── Helper: envia proto pré-construído pelo MESMO caminho do sendMessage ──── // Diferença crítica vs `sock.relayMessage` direto: // sendMessage() → generateWAMessage() → WAProto.Message.fromObject() → relayMessage() // ↑ normalização proto que o WA Web exige // Sem esse passo, o Web descarta a mensagem ("Não foi possível carregar"). // É o mesmo motivo pelo qual nossas enquetes (via sendMessage) funcionam no Web // e os botões (via relayMessage direto) não funcionavam. async function sendBuiltProto( sock: WASocket, jid: string, protoMsg: any, msgId: string, additionalNodes: BinaryNode[], quoted?: { key: any; message: any }, ): Promise { const fullMsg = generateWAMessageFromContent(jid, protoMsg, { userJid: sock.user?.id, messageId: msgId, quoted, } as any) await sock.relayMessage(jid, fullMsg.message as any, { messageId: fullMsg.key.id!, additionalNodes, }) return fullMsg.key.id! } // ─── Helper: monta interactiveMessage DIRETO (sem viewOnceMessage wrapper) ──── // viewOnceMessage wrapper faz o WhatsApp Web exibir "Não foi possível carregar": // o Web trata viewOnce como mensagem efêmera e recusa conteúdo interativo dentro. // Baileys padrão não adiciona messageContextInfo para interactiveMessage // (só faz isso para poll, com messageSecret). Usar interactiveMessage direto // + nó no additionalNodes é o caminho correto com @whiskeysockets/baileys. function makeInteractiveProto(interactiveMsg: any): any { return { interactiveMessage: interactiveMsg, } } /** * Envia uma mensagem rica pelo socket Baileys padrão. * * - Buttons / CTAs: interactiveMessage direto + nó biz>interactive>native_flow + nó bot * - Carousel: interactiveMessage direto + nó biz>interactive>native_flow * - List: interactiveMessage direto + nó biz>interactive>native_flow + nó bot * - Poll: sock.sendMessage (requer encriptação especial do Baileys) * - Text: sock.sendMessage normal * * Retorna o messageId gerado. */ export async function sendRichMessage( sock: WASocket, jid: string, payload: RichPayload, quoted?: { key: any; message: any }, engineType?: string, ): Promise { // Monitora erros ESPECIFICAMENTE quando a mensagem tem botões/lista/carrossel, // para diagnosticar quando o WhatsApp rejeita a renderização de mensagens // interativas (varia por engine e por tipo de conta). const hasInteractive = !!(payload.nativeButtons || payload.nativeList || payload.nativeCarousel) const eng = engineType ?? process.env.BAILEYS_ENGINE ?? 'infinite' try { return await sendRichMessageInner(sock, jid, payload, quoted, engineType) } catch (err) { if (hasInteractive) { const tipo = payload.nativeButtons ? 'botões' : payload.nativeList ? 'lista' : 'carrossel' logger.error({ err, jid, engine: eng, tipo }, `[rich-message] FALHA ao enviar mensagem interativa (${tipo}) — engine="${eng}". ` + `Botões podem não ser suportados nesta conta/engine.`) } throw err } } async function sendRichMessageInner( sock: WASocket, jid: string, payload: RichPayload, quoted?: { key: any; message: any }, engineType?: string, ): Promise { const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = payload const engine = engineType ?? process.env.BAILEYS_ENGINE ?? 'infinite' // Engines v7 (infinite/baileys7) têm suporte nativo a botões/listas/carrossel if (engine === 'infinite' || engine === 'baileys7') { // ── Poll: delegado ao sendMessage padrão (requer encriptação especial do Baileys) ── if (poll) { const sent = await sock.sendMessage( jid, { poll: { name: poll.name, values: poll.values, selectableCount: Math.min(Math.max(1, poll.selectableCount ?? 1), poll.values.length), }, }, quoted ? { quoted } : undefined, ) return sent?.key?.id ?? `poll-${Date.now()}` } // ── Botões / CTAs nativos (InfiniteAPI) ── if (nativeButtons) { const sent = await sock.sendMessage( jid, { text: text ?? '', footer, nativeButtons, } as any, quoted ? { quoted } : undefined, ) return sent?.key?.id ?? `btn-${Date.now()}` } // ── Carrossel nativo (InfiniteAPI) ── if (nativeCarousel) { const sent = await sock.sendMessage( jid, { text: text ?? '', footer, nativeCarousel, } as any, quoted ? { quoted } : undefined, ) return sent?.key?.id ?? `carousel-${Date.now()}` } // ── Lista nativa (InfiniteAPI) ── if (nativeList) { const sent = await sock.sendMessage( jid, { text: text ?? '', footer, nativeList, } as any, quoted ? { quoted } : undefined, ) return sent?.key?.id ?? `list-${Date.now()}` } // ── Texto simples (InfiniteAPI) ── const sent = await sock.sendMessage( jid, { text: text ?? '' }, quoted ? { quoted } : undefined, ) return sent?.key?.id ?? `text-${Date.now()}` } // FALLBACK: Lógica de montagem manual de nós binários para o Baileys oficial // ── Poll: delegado ao sendMessage padrão (requer messageSecret) ─────────── if (poll) { const sent = await sock.sendMessage( jid, { poll: { name: poll.name, values: poll.values, selectableCount: Math.min(Math.max(1, poll.selectableCount ?? 1), poll.values.length), }, }, quoted ? { quoted } : undefined, ) return sent?.key?.id ?? `poll-${Date.now()}` } // ── Texto simples: delegado ao sendMessage padrão ───────────────────────── if (!nativeButtons && !nativeList && !nativeCarousel) { const sent = await sock.sendMessage( jid, { text: text ?? '' }, quoted ? { quoted } : undefined, ) return sent?.key?.id ?? `text-${Date.now()}` } const msgId = generateMessageIDV2(sock.user?.id) const isPrivate = isPrivateChat(jid) // ── Botões / CTAs: interactiveMessage + nativeFlowMessage ──────────────────── if (nativeButtons) { const formattedButtons = nativeButtons.map(formatNativeFlowButton) const protoMsg = makeInteractiveProto({ body: { text: text ?? '' }, footer: footer ? { text: footer } : undefined, header: { title: '', subtitle: '', hasMediaAttachment: false }, nativeFlowMessage: { buttons: formattedButtons, messageParamsJson: JSON.stringify({}), messageVersion: 2, }, }) const additionalNodes: BinaryNode[] = [BIZ_NATIVE_FLOW_NODE] return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted) } // ── Carrossel: viewOnceMessage > interactiveMessage > carouselMessage ───── if (nativeCarousel) { const cards = nativeCarousel.cards if (cards.length < 2) throw new Error('Carrossel requer no mínimo 2 cards') if (cards.length > 10) throw new Error('Carrossel suporta no máximo 10 cards') const carouselCards: any[] = cards.map((card) => { const rawButtons = card.buttons ?? [] const cardButtons = rawButtons.length > 0 ? rawButtons.map((btn) => formatNativeFlowButton({ type: (btn.type as any) ?? 'reply', id: btn.id ?? btn.text.toLowerCase().replace(/\s+/g, '_'), text: btn.text, } as any)) : [{ name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: 'Ver mais', id: 'see_more' }) }] return { header: { title: card.title ?? '', subtitle: '', hasMediaAttachment: !!(card.image?.url), }, body: { text: card.body ?? '' }, footer: card.footer ? { text: card.footer } : undefined, nativeFlowMessage: { buttons: cardButtons, messageParamsJson: JSON.stringify({}), }, } }) const protoMsg = makeInteractiveProto({ body: { text: text ?? '' }, footer: footer ? { text: footer } : undefined, header: { title: '', subtitle: '', hasMediaAttachment: false }, carouselMessage: { cards: carouselCards, messageVersion: 1, }, }) const additionalNodes: BinaryNode[] = [BIZ_NATIVE_FLOW_NODE] return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted) } // ── Lista: proto.listMessage LEGADO + nó ── if (nativeList) { const protoMsg: any = { listMessage: { title: '', description: text ?? '', buttonText: nativeList.buttonText, footerText: footer ?? '', listType: 2, sections: nativeList.sections.map((s) => ({ title: s.title, rows: s.rows.map((r) => ({ rowId: r.id, title: r.title, description: r.description ?? '', })), })), }, } const additionalNodes: BinaryNode[] = [BIZ_LIST_NODE] if (isPrivate) additionalNodes.push(BOT_NODE) return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted) } // Fallback (não deve chegar aqui) const sent = await sock.sendMessage(jid, { text: text ?? '' }) return sent?.key?.id ?? `fallback-${Date.now()}` }