Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/backend/src/modules/whatsapp/rich-message.ts
T
VPS 4 Deploy Agent 43f519e7aa feat(settings): selo Conta Business, aviso de re-scan ao trocar API, log de erro de botões
- Detecta WhatsApp Business (app) via getBusinessProfile no pós-conexão e
  persiste em instances.isBusiness; painel mostra "Conta Business: sim/não"
  (com nota Business App ≠ Business API).
- Painel avisa que trocar de API desconecta e exige escanear o QR novamente,
  com confirmação antes de aplicar a troca.
- sendRichMessage agora monitora e loga erros especificamente quando a mensagem
  tem botões/lista/carrossel (diagnóstico de rejeição de interativos por engine).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 05:45:34 +02:00

438 lines
15 KiB
TypeScript

/**
* 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 <biz><interactive type="native_flow" v="1"><native_flow v="9" name="mixed"/></interactive></biz>
* injetado no stanza via relayMessage(additionalNodes)
* (sem isso o WhatsApp Web exibe "Não foi possível carregar")
*
* 3. Nó <bot biz_bot="1"/> 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 <biz><list type="product_list" v="2"/></biz> 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<string> {
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ó <biz> 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<string> {
// 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<string> {
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ó <biz><list type="product_list" v="2"/></biz> ──
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()}`
}