43f519e7aa
- 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>
438 lines
15 KiB
TypeScript
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()}`
|
|
}
|