From 35a4bd44940ce4ed3570e2b6c05b79293adcb6c3 Mon Sep 17 00:00:00 2001 From: Matheus Pastorini Date: Wed, 29 Apr 2026 15:34:47 -0300 Subject: [PATCH] feat(messages): add carousel + fix interactive rendering on WhatsApp Web/Desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds carousel message support and fixes button/list rendering on WhatsApp Web/Desktop and iOS by injecting the required node into the relayMessage stanza via the official Baileys additionalNodes option. Changes: - New endpoint POST /message/sendCarousel/{instance} (interactiveMessage with carouselMessage; single-card-without-image is sent as nativeFlowMessage for iOS compatibility) - buttonMessage: removed viewOnceMessage wrapper that prevented button rendering on Web/Desktop; added node - listMessage: switched to legacy listMessage with SINGLE_SELECT listType (the modern interactiveMessage+single_select format does not render on Web/Desktop) and added the node - sendMessage / sendMessageWithTyping: forward an optional additionalNodes parameter, and route top-level interactiveMessage / listMessage through client.relayMessage so the biz node reaches the stanza - POST /instance/logout/{instance}: idempotent when the instance is already closed (returns SUCCESS instead of 400) so the manager UI delete flow (logout-then-delete) does not surface a misleading error - DTO/schema/controller/router: SendCarouselDto, CarouselCard, carouselMessageSchema, sendCarousel handler and route - Manager UI: small vanilla helper script (test-interactive.js) injected via index.html to add a "Test Interactive" button per instance card with an editable JSON modal for the 5 message kinds (Reply / CTA / PIX / List / Carousel) - Drive-by fix: undefined `maxRetries` reference in a verbose log inside the messages.update handler Tested manually on WhatsApp Web, Desktop, iOS and Android — all five message kinds render correctly across clients. --- manager/dist/assets/test-interactive.js | 448 ++++++++++++++++++ manager/dist/index.html | 1 + src/api/controllers/instance.controller.ts | 4 +- src/api/controllers/sendMessage.controller.ts | 5 + src/api/dto/sendMessage.dto.ts | 13 + .../helpers/interactiveMessage.helper.ts | 141 ++++++ .../whatsapp/whatsapp.baileys.service.ts | 288 +++++++---- .../chatwoot/services/chatwoot.service.ts | 142 +++--- src/api/routes/sendMessage.router.ts | 12 + src/validate/message.schema.ts | 59 +++ 10 files changed, 951 insertions(+), 162 deletions(-) create mode 100644 manager/dist/assets/test-interactive.js create mode 100644 src/api/integrations/channel/whatsapp/helpers/interactiveMessage.helper.ts diff --git a/manager/dist/assets/test-interactive.js b/manager/dist/assets/test-interactive.js new file mode 100644 index 0000000000..5f2adee99b --- /dev/null +++ b/manager/dist/assets/test-interactive.js @@ -0,0 +1,448 @@ +/* eslint-disable */ +/** + * Painel de testes para envio de mensagens interativas (Botões, Lista, Carrossel). + * Injeta um botão "Testar Interativo" em cada card de instância no manager. + * Fallback: se nenhum card for detectado, mostra um botão flutuante (FAB). + */ +(function () { + 'use strict'; + + if (window.__evoTestInteractive) return; + window.__evoTestInteractive = true; + + const STYLE = ` + .evo-test-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 10px; margin: 4px; + font-size: 12px; font-weight: 600; line-height: 1; + background: #6e44ff; color: #fff; border: none; border-radius: 6px; + cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.15); + transition: background .15s; + } + .evo-test-btn:hover { background: #5a36d6; } + .evo-test-fab { + position: fixed; right: 24px; bottom: 24px; z-index: 999998; + padding: 12px 16px; font-size: 14px; font-weight: 700; + background: #6e44ff; color: #fff; border: none; border-radius: 999px; + cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,.25); + } + .evo-test-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,.55); + z-index: 999999; display: flex; align-items: center; justify-content: center; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + } + .evo-test-modal { + width: min(720px, 92vw); max-height: 92vh; overflow: auto; + background: #fff; color: #1a1a1a; border-radius: 12px; + padding: 20px; box-shadow: 0 12px 40px rgba(0,0,0,.4); + } + .evo-test-modal h2 { margin: 0 0 4px; font-size: 18px; } + .evo-test-modal .evo-sub { color: #666; font-size: 12px; margin-bottom: 14px; } + .evo-test-tabs { display: flex; gap: 4px; border-bottom: 1px solid #e5e5e5; margin-bottom: 14px; } + .evo-test-tab { + padding: 8px 14px; border: none; background: transparent; cursor: pointer; + font-size: 13px; font-weight: 600; color: #666; border-bottom: 2px solid transparent; + } + .evo-test-tab.active { color: #6e44ff; border-color: #6e44ff; } + .evo-test-form label { display: block; font-size: 12px; font-weight: 600; margin: 10px 0 4px; color: #444; } + .evo-test-form input, .evo-test-form textarea { + width: 100%; padding: 8px 10px; border: 1px solid #d0d0d0; border-radius: 6px; + font-size: 13px; font-family: inherit; box-sizing: border-box; + } + .evo-test-form textarea { font-family: ui-monospace, "SF Mono", Consolas, monospace; min-height: 180px; resize: vertical; } + .evo-test-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; } + .evo-test-actions button { + padding: 8px 14px; font-size: 13px; font-weight: 600; border-radius: 6px; cursor: pointer; border: none; + } + .evo-test-cancel { background: #eee; color: #333; } + .evo-test-send { background: #6e44ff; color: #fff; } + .evo-test-send:disabled { opacity: .6; cursor: not-allowed; } + .evo-test-toast { + position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); + padding: 12px 18px; border-radius: 8px; font-size: 13px; color: #fff; + z-index: 9999999; box-shadow: 0 4px 12px rgba(0,0,0,.3); + } + .evo-test-toast.ok { background: #16a34a; } + .evo-test-toast.err { background: #dc2626; } + .evo-test-fab-menu { + position: fixed; right: 24px; bottom: 80px; z-index: 999998; + background: #fff; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,.2); + max-height: 320px; overflow: auto; min-width: 220px; + } + .evo-test-fab-menu button { + display: block; width: 100%; padding: 10px 14px; text-align: left; + border: none; background: transparent; font-size: 13px; cursor: pointer; border-bottom: 1px solid #f0f0f0; + } + .evo-test-fab-menu button:hover { background: #f5f3ff; } + `; + + const styleEl = document.createElement('style'); + styleEl.textContent = STYLE; + document.head.appendChild(styleEl); + + // Os tipos de botão NÃO PODEM SER MISTURADOS no mesmo envio. + // Regras da API: + // - reply: 1 a 3 botões, sem misturar com CTA ou PIX + // - CTA (url/call/copy): 1 a 2 botões, sem misturar com reply ou PIX + // - pix: exatamente 1 botão isolado (payment_info) + // Por isso há abas separadas. + const TEMPLATES = { + reply: { + number: '', + title: 'Resposta Rápida', + description: 'Escolha uma das opções abaixo:', + footer: 'Evolution API', + buttons: [ + { type: 'reply', displayText: '✅ Confirmar', id: 'opt_confirm' }, + { type: 'reply', displayText: '❌ Cancelar', id: 'opt_cancel' }, + { type: 'reply', displayText: '🤔 Talvez', id: 'opt_maybe' }, + ], + }, + cta: { + number: '', + title: 'Botões CTA', + description: 'Botões de URL e copia-código (cta_url + cta_copy):', + footer: 'Máx. 2 botões CTA por mensagem', + buttons: [ + { type: 'url', displayText: '🌐 Abrir site', url: 'https://example.com' }, + { type: 'copy', displayText: '📋 Copiar PIX', copyCode: '00020126580014BR.GOV.BCB.PIX0136abc12345-6789-0000-aaaa-bbbbccccdddd5204000053039865802BR5913FULANO DE TAL6009SAO PAULO62070503***6304ABCD' }, + ], + }, + pix: { + number: '', + title: 'Pagamento via PIX', + description: 'Toque para pagar via PIX (payment_info)', + footer: 'WhatsApp Pay', + buttons: [ + { + type: 'pix', + currency: 'BRL', + name: 'Empresa Exemplo', + keyType: 'random', + key: 'abc12345-6789-0000-aaaa-bbbbccccdddd', + }, + ], + }, + list: { + number: '', + title: 'Cardápio de Teste', + description: 'Escolha um item abaixo', + footerText: 'Validade hoje', + buttonText: 'Ver opções', + sections: [ + { + title: 'Bebidas', + rows: [ + { title: 'Coca-Cola', description: 'Lata 350ml', rowId: 'coca' }, + { title: 'Suco de Laranja', description: '300ml natural', rowId: 'suco' }, + ], + }, + { + title: 'Lanches', + rows: [ + { title: 'X-Burger', description: 'Pão, carne 150g, queijo', rowId: 'xburger' }, + ], + }, + ], + }, + carousel: { + number: '', + body: 'Catálogo da semana', + cards: [ + { + body: 'Produto A', + footer: 'R$ 99,90', + imageUrl: 'https://picsum.photos/seed/a/600/400', + buttons: [{ type: 'url', displayText: 'Comprar', url: 'https://exemplo.com/a' }], + }, + { + body: 'Produto B', + footer: 'R$ 149,90', + imageUrl: 'https://picsum.photos/seed/b/600/400', + buttons: [{ type: 'url', displayText: 'Comprar', url: 'https://exemplo.com/b' }], + }, + { + body: 'Produto C', + footer: 'R$ 199,90', + imageUrl: 'https://picsum.photos/seed/c/600/400', + buttons: [{ type: 'reply', displayText: 'Quero!', id: 'prod_c' }], + }, + ], + }, + }; + + const ENDPOINT = { + reply: 'sendButtons', + cta: 'sendButtons', + pix: 'sendButtons', + list: 'sendList', + carousel: 'sendCarousel', + }; + const TAB_LABEL = { + reply: 'Reply', + cta: 'CTA', + pix: 'PIX', + list: 'Lista', + carousel: 'Carrossel', + }; + + function getApiKey() { + return localStorage.getItem('accessToken') || localStorage.getItem('token') || ''; + } + + function getApiUrl() { + const u = localStorage.getItem('apiUrl'); + return u && u !== 'undefined' && u !== 'null' ? u.replace(/\/+$/, '') : window.location.origin; + } + + async function fetchInstances() { + const apikey = getApiKey(); + if (!apikey) return []; + try { + const res = await fetch(getApiUrl() + '/instance/fetchInstances', { headers: { apikey } }); + if (!res.ok) return []; + const data = await res.json(); + const list = Array.isArray(data) ? data : (data?.instances || []); + return list + .map((it) => { + const name = it?.name || it?.instanceName || it?.instance?.instanceName; + const token = it?.token || it?.hash || it?.instance?.hash || it?.apikey; + return name ? { name, token } : null; + }) + .filter(Boolean); + } catch (e) { + console.error('[evo-test] fetchInstances error', e); + return []; + } + } + + function showToast(msg, type) { + const el = document.createElement('div'); + el.className = 'evo-test-toast ' + (type || 'ok'); + el.textContent = msg; + document.body.appendChild(el); + setTimeout(() => el.remove(), 4000); + } + + function openModal(instance) { + const overlay = document.createElement('div'); + overlay.className = 'evo-test-overlay'; + + const modal = document.createElement('div'); + modal.className = 'evo-test-modal'; + + let activeTab = 'reply'; + + const render = () => { + const tpl = TEMPLATES[activeTab]; + if (!tpl.number) tpl.number = ''; + + modal.innerHTML = ` +

Teste Interativo

+
Instância: ${instance.name} · Endpoint: POST /message/${ENDPOINT[activeTab]}/${instance.name}
+
+ ${Object.keys(ENDPOINT).map((k) => ``).join('')} +
+
+ + + + +
+
+ + +
+ `; + + const payloadCopy = { ...tpl }; + delete payloadCopy.number; + modal.querySelector('.evo-payload').value = JSON.stringify(payloadCopy, null, 2); + + modal.querySelectorAll('.evo-test-tab').forEach((btn) => { + btn.onclick = () => { + activeTab = btn.dataset.tab; + render(); + }; + }); + + modal.querySelector('.evo-test-cancel').onclick = () => overlay.remove(); + modal.querySelector('.evo-test-send').onclick = async (ev) => { + const sendBtn = ev.currentTarget; + const number = (modal.querySelector('.evo-number').value || '').replace(/\D/g, ''); + if (!number) { + showToast('Informe o número de destino', 'err'); + return; + } + let payload; + try { + payload = JSON.parse(modal.querySelector('.evo-payload').value); + } catch (e) { + showToast('JSON inválido: ' + e.message, 'err'); + return; + } + payload.number = number; + TEMPLATES[activeTab].number = number; + + sendBtn.disabled = true; + sendBtn.textContent = 'Enviando...'; + try { + const apikey = instance.token || getApiKey(); + const url = getApiUrl() + '/message/' + ENDPOINT[activeTab] + '/' + encodeURIComponent(instance.name); + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', apikey }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + if (res.ok) { + showToast('Mensagem enviada (id ' + (data?.key?.id || data?.messageId || 'ok') + ')', 'ok'); + overlay.remove(); + } else { + const msg = data?.response?.message || data?.message || res.statusText; + showToast('Erro ' + res.status + ': ' + (Array.isArray(msg) ? msg.join('; ') : msg), 'err'); + sendBtn.disabled = false; + sendBtn.textContent = 'Enviar'; + } + } catch (e) { + showToast('Falha de rede: ' + e.message, 'err'); + sendBtn.disabled = false; + sendBtn.textContent = 'Enviar'; + } + }; + }; + + render(); + overlay.appendChild(modal); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) overlay.remove(); + }); + document.body.appendChild(overlay); + } + + // -- Detector de cards via MutationObserver ----------------------------------- + + let knownInstances = []; + let lastInstanceFetch = 0; + + async function refreshInstancesIfStale() { + if (Date.now() - lastInstanceFetch > 8000) { + knownInstances = await fetchInstances(); + lastInstanceFetch = Date.now(); + } + } + + function findCardForInstance(name) { + // Procura elementos folha que contenham EXATAMENTE o nome da instância + const candidates = []; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null); + let node; + while ((node = walker.nextNode())) { + if (node.nodeValue && node.nodeValue.trim() === name) { + candidates.push(node.parentElement); + } + } + + for (const el of candidates) { + let ancestor = el; + let depth = 0; + while (ancestor && depth < 6) { + const hasButton = ancestor.querySelector('button, a[role="button"]'); + const rect = ancestor.getBoundingClientRect && ancestor.getBoundingClientRect(); + if (hasButton && rect && rect.width > 180 && rect.height > 80) { + return ancestor; + } + ancestor = ancestor.parentElement; + depth++; + } + } + return null; + } + + function injectIntoCard(card, instance) { + if (card.querySelector('.evo-test-btn[data-evo-instance="' + instance.name + '"]')) return; + const btn = document.createElement('button'); + btn.className = 'evo-test-btn'; + btn.dataset.evoInstance = instance.name; + btn.type = 'button'; + btn.textContent = '🧪 Testar Interativo'; + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + openModal(instance); + }); + // injeta perto dos outros botões (no final do card) + const buttonContainer = card.querySelector('button')?.parentElement || card; + buttonContainer.appendChild(btn); + card.dataset.evoTestInjected = '1'; + } + + async function scan() { + await refreshInstancesIfStale(); + if (!knownInstances.length) return; + for (const inst of knownInstances) { + const card = findCardForInstance(inst.name); + if (card) injectIntoCard(card, inst); + } + ensureFab(); + } + + function ensureFab() { + // Se NENHUM card foi injetado, mostra o FAB. Se houver pelo menos um, remove o FAB. + const anyInjected = document.querySelector('.evo-test-btn[data-evo-instance]'); + const existingFab = document.querySelector('.evo-test-fab'); + if (anyInjected) { + existingFab && existingFab.remove(); + return; + } + if (existingFab) return; + const fab = document.createElement('button'); + fab.className = 'evo-test-fab'; + fab.textContent = '🧪 Testar Interativo'; + fab.onclick = () => openFabMenu(fab); + document.body.appendChild(fab); + } + + function openFabMenu(fab) { + document.querySelectorAll('.evo-test-fab-menu').forEach((m) => m.remove()); + if (!knownInstances.length) { + showToast('Nenhuma instância encontrada. Verifique sua API key.', 'err'); + return; + } + const menu = document.createElement('div'); + menu.className = 'evo-test-fab-menu'; + knownInstances.forEach((inst) => { + const b = document.createElement('button'); + b.textContent = inst.name; + b.onclick = () => { + menu.remove(); + openModal(inst); + }; + menu.appendChild(b); + }); + document.body.appendChild(menu); + setTimeout(() => { + const closer = (e) => { + if (!menu.contains(e.target) && e.target !== fab) { + menu.remove(); + document.removeEventListener('click', closer); + } + }; + document.addEventListener('click', closer); + }, 0); + } + + let scanTimer = null; + function scheduleScan() { + clearTimeout(scanTimer); + scanTimer = setTimeout(scan, 300); + } + + const observer = new MutationObserver(scheduleScan); + observer.observe(document.body, { childList: true, subtree: true }); + + // primeira tentativa após 1s para dar tempo do React renderizar + setTimeout(scan, 1000); + setTimeout(scan, 3000); + setInterval(scan, 10000); +})(); diff --git a/manager/dist/index.html b/manager/dist/index.html index 01a75fd5a6..20c0919f8c 100644 --- a/manager/dist/index.html +++ b/manager/dist/index.html @@ -10,5 +10,6 @@
+ diff --git a/src/api/controllers/instance.controller.ts b/src/api/controllers/instance.controller.ts index 6a69106881..fcbf8017b6 100644 --- a/src/api/controllers/instance.controller.ts +++ b/src/api/controllers/instance.controller.ts @@ -436,8 +436,10 @@ export class InstanceController { public async logout({ instanceName }: InstanceDto) { const { instance } = await this.connectionState({ instanceName }); + // Idempotente: se já está desconectada, retorna sucesso silenciosamente. + // Evita falhar o fluxo de delete do painel, que sempre chama logout antes do delete. if (instance.state === 'close') { - throw new BadRequestException('The "' + instanceName + '" instance is not connected'); + return { status: 'SUCCESS', error: false, response: { message: 'Instance was already disconnected' } }; } try { diff --git a/src/api/controllers/sendMessage.controller.ts b/src/api/controllers/sendMessage.controller.ts index 64aa1c8468..8414c67db8 100644 --- a/src/api/controllers/sendMessage.controller.ts +++ b/src/api/controllers/sendMessage.controller.ts @@ -2,6 +2,7 @@ import { InstanceDto } from '@api/dto/instance.dto'; import { SendAudioDto, SendButtonsDto, + SendCarouselDto, SendContactDto, SendListDto, SendLocationDto, @@ -86,6 +87,10 @@ export class SendMessageController { return await this.waMonitor.waInstances[instanceName].listMessage(data); } + public async sendCarousel({ instanceName }: InstanceDto, data: SendCarouselDto) { + return await this.waMonitor.waInstances[instanceName].carouselMessage(data); + } + public async sendContact({ instanceName }: InstanceDto, data: SendContactDto) { return await this.waMonitor.waInstances[instanceName].contactMessage(data); } diff --git a/src/api/dto/sendMessage.dto.ts b/src/api/dto/sendMessage.dto.ts index 797ca111ba..da71494b9e 100644 --- a/src/api/dto/sendMessage.dto.ts +++ b/src/api/dto/sendMessage.dto.ts @@ -169,3 +169,16 @@ export class SendReactionDto { key: proto.IMessageKey; reaction: string; } + +export class CarouselCard { + title?: string; + body: string; + footer?: string; + imageUrl?: string; + buttons: Button[]; +} + +export class SendCarouselDto extends Metadata { + body: string; + cards: CarouselCard[]; +} 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 aec1806fb0..5aeeac1755 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -33,12 +33,14 @@ import { InstanceDto, SetPresenceDto } from '@api/dto/instance.dto'; import { HandleLabelDto, LabelDto } from '@api/dto/label.dto'; import { Button, + CarouselCard, ContactMessage, KeyType, MediaMessage, Options, SendAudioDto, SendButtonsDto, + SendCarouselDto, SendContactDto, SendListDto, SendLocationDto, @@ -88,10 +90,11 @@ import { sendTelemetry } from '@utils/sendTelemetry'; import useMultiFileAuthStatePrisma from '@utils/use-multi-file-auth-state-prisma'; import { AuthStateProvider } from '@utils/use-multi-file-auth-state-provider-files'; import { useMultiFileAuthStateRedisDb } from '@utils/use-multi-file-auth-state-redis-db'; -import axios from 'axios'; import audioDecode from 'audio-decode'; +import axios from 'axios'; import makeWASocket, { AnyMessageContent, + BinaryNode, BufferedEventData, BufferJSON, CacheStore, @@ -153,6 +156,7 @@ import { PassThrough, Readable } from 'stream'; import { v4 } from 'uuid'; import { BaileysMessageProcessor } from './baileysMessage.processor'; +import { buildInteractiveBizNode, buildListBizNode, toNativeFlowButton } from './helpers/interactiveMessage.helper'; import { useVoiceCallsBaileys } from './voiceCalls/useVoiceCallsBaileys'; export interface ExtendedIMessageKey extends proto.IMessageKey { @@ -442,7 +446,7 @@ export class BaileysStartupService extends ChannelStartupService { qrcodeTerminal.generate(qr, { small: true }, (qrcode) => this.logger.log( `\n{ instance: ${this.instance.name} pairingCode: ${this.instance.qrcode.pairingCode}, qrcodeCount: ${this.instance.qrcode.count} }\n` + - qrcode, + qrcode, ), ); @@ -468,16 +472,16 @@ export class BaileysStartupService extends ChannelStartupService { const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406]; - + // FIX: Do not reconnect if it's the initial connection (waiting for QR code) // This prevents infinite loop that blocks QR code generation const isInitialConnection = !this.instance.wuid && (this.instance.qrcode?.count ?? 0) === 0; - + if (isInitialConnection) { this.logger.info('Initial connection closed, waiting for QR code generation...'); return; } - + const shouldReconnect = !codesToNotReconnect.includes(statusCode); this.logger.info({ @@ -494,9 +498,7 @@ export class BaileysStartupService extends ChannelStartupService { await this.connectToWhatsapp(this.phoneNumber); }, 3000); } else { - this.logger.info( - `Skipping reconnection for status code ${statusCode} (code is in codesToNotReconnect list)`, - ); + this.logger.info(`Skipping reconnection for status code ${statusCode} (code is in codesToNotReconnect list)`); this.sendDataWebhook(Events.STATUS_INSTANCE, { instance: this.instance.name, status: 'closed', @@ -1129,16 +1131,16 @@ export class BaileysStartupService extends ChannelStartupService { const messagesRepository: Set = new Set( chatwootImport.getRepositoryMessagesCache(instance) ?? - ( - await this.prismaRepository.message.findMany({ - select: { key: true }, - where: { instanceId: this.instanceId }, - }) - ).map((message) => { - const key = message.key as { id: string }; + ( + await this.prismaRepository.message.findMany({ + select: { key: true }, + where: { instanceId: this.instanceId }, + }) + ).map((message) => { + const key = message.key as { id: string }; - return key.id; - }), + return key.id; + }), ); if (chatwootImport.getRepositoryMessagesCache(instance) === null) { @@ -1826,7 +1828,7 @@ export class BaileysStartupService extends ChannelStartupService { if (!findMessage?.id) { this.logger.verbose( - `Original message not found for update after ${maxRetries} retries. Skipping. This is expected for protocol messages or ephemeral events not saved to the database. Key: ${JSON.stringify(key)}`, + `Original message not found for update. Skipping. This is expected for protocol messages or ephemeral events not saved to the database. Key: ${JSON.stringify(key)}`, ); continue; } @@ -2337,12 +2339,12 @@ export class BaileysStartupService extends ChannelStartupService { const url = match[0].replace(/[.,);\]]+$/u, ''); if (!url) return undefined; - const previewData = await getLinkPreview(url, { + const previewData = (await getLinkPreview(url, { imagesPropertyType: 'og', // fetches only open-graph images headers: { 'user-agent': 'googlebot', // fetches with googlebot to prevent login pages }, - }) as any; + })) as any; if (!previewData || !previewData.title) return undefined; @@ -2356,9 +2358,9 @@ export class BaileysStartupService extends ChannelStartupService { thumbnailUrl: image, sourceUrl: url, mediaUrl: url, - renderLargerThumbnail: true + renderLargerThumbnail: true, // showAdAttribution: true // Removed to prevent "Sent via ad" label - } + }, }; } catch (error) { this.logger.error(`Error generating link preview: ${error}`); @@ -2375,6 +2377,7 @@ export class BaileysStartupService extends ChannelStartupService { messageId?: string, ephemeralExpiration?: number, contextInfo?: any, + additionalNodes?: BinaryNode[], // participants?: GroupParticipant[], ) { sender = sender.toLowerCase(); @@ -2394,14 +2397,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) { @@ -2530,6 +2536,7 @@ export class BaileysStartupService extends ChannelStartupService { message: T, options?: Options, isIntegration = false, + additionalNodes?: BinaryNode[], ) { const isWA = (await this.whatsappNumber({ numbers: [number] }))?.shift(); @@ -2637,6 +2644,7 @@ export class BaileysStartupService extends ChannelStartupService { options?.messageId ?? null, group?.ephemeralDuration, previewContext, + additionalNodes, // group?.participants, ); } else { @@ -2661,6 +2669,7 @@ export class BaileysStartupService extends ChannelStartupService { options?.messageId ?? null, undefined, contextInfo, + additionalNodes, ); } @@ -3661,33 +3670,35 @@ export class BaileysStartupService extends ChannelStartupService { } 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()], + ); } // CTA (url / call / copy) @@ -3722,44 +3733,46 @@ export class BaileysStartupService extends ChannelStartupService { * ========================= */ const message: proto.IMessage = { - viewOnceMessage: { - message: { - interactiveMessage: { - body: { - text: (() => { - let text = `*${data.title}*`; - if (data?.description) { - text += `\n\n${data.description}`; - } - return text; - })(), - }, - footer: data?.footer ? { text: data.footer } : undefined, - header: generatedMedia?.message?.imageMessage - ? { - hasMediaAttachment: true, - imageMessage: generatedMedia.message.imageMessage, - } - : undefined, - nativeFlowMessage: { - buttons, - messageParamsJson: JSON.stringify({ - from: 'api', - templateId: v4(), - }), - }, - }, + interactiveMessage: { + body: { + text: (() => { + let text = `*${data.title}*`; + if (data?.description) { + text += `\n\n${data.description}`; + } + return text; + })(), + }, + footer: data?.footer ? { text: data.footer } : undefined, + header: generatedMedia?.message?.imageMessage + ? { + hasMediaAttachment: true, + imageMessage: generatedMedia.message.imageMessage, + } + : undefined, + nativeFlowMessage: { + 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) { @@ -3784,18 +3797,115 @@ 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, + message, { - listMessage: { - title: data.title, - description: data.description, - buttonText: data?.buttonText, - footerText: data?.footerText, - sections: data.sections, - listType: 2, - }, + delay: data?.delay, + presence: 'composing', + quoted: data?.quoted, + mentionsEveryOne: data?.mentionsEveryOne, + mentioned: data?.mentioned, }, + false, + [buildListBizNode()], + ); + } + + public async carouselMessage(data: SendCarouselDto) { + if (!data.cards?.length) { + throw new BadRequestException('At least one card is required'); + } + if (data.cards.length > 10) { + throw new BadRequestException('Maximum of 10 cards allowed'); + } + + for (const card of data.cards) { + if (!card.buttons?.length) { + throw new BadRequestException('Each card must have at least one button'); + } + if (card.buttons.length > 3) { + throw new BadRequestException('Maximum of 3 buttons per card'); + } + if (card.buttons.some((b) => b.type === 'pix')) { + throw new BadRequestException('PIX buttons are not supported in carousel'); + } + } + + const buildCardButtons = (card: CarouselCard) => + card.buttons.map((btn) => + toNativeFlowButton(btn, { + generateRandomId: this.generateRandomId.bind(this), + mapKeyType: this.mapKeyType, + }), + ); + + // Otimização iOS: 1 card sem imagem → nativeFlowMessage direto (sem carouselMessage wrapper) + const isSingleNoImage = data.cards.length === 1 && !data.cards[0].imageUrl; + + let interactiveMessage: proto.Message.IInteractiveMessage; + + if (isSingleNoImage) { + const card = data.cards[0]; + interactiveMessage = { + body: { text: card.body }, + footer: card.footer ? { text: card.footer } : undefined, + nativeFlowMessage: { + buttons: buildCardButtons(card), + messageParamsJson: JSON.stringify({ from: 'api', templateId: v4() }), + }, + }; + } else { + const cards = await Promise.all( + data.cards.map(async (card) => { + let header: proto.Message.InteractiveMessage.IHeader | undefined; + if (card.imageUrl) { + const prepared = await this.prepareMediaMessage({ mediatype: 'image', media: card.imageUrl }); + if (prepared?.message?.imageMessage) { + header = { hasMediaAttachment: true, imageMessage: prepared.message.imageMessage }; + } + } + return { + header, + body: { text: card.body }, + footer: card.footer ? { text: card.footer } : undefined, + nativeFlowMessage: { buttons: buildCardButtons(card) }, + } as proto.Message.IInteractiveMessage; + }), + ); + + interactiveMessage = { + body: { text: data.body }, + carouselMessage: { cards, messageVersion: 1 }, + }; + } + + const message: proto.IMessage = { interactiveMessage }; + + return await this.sendMessageWithTyping( + data.number, + message, { delay: data?.delay, presence: 'composing', @@ -3803,6 +3913,8 @@ export class BaileysStartupService extends ChannelStartupService { mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, }, + false, + [buildInteractiveBizNode()], ); } diff --git a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts index 5c2e038917..42fdabd631 100644 --- a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts @@ -641,16 +641,16 @@ export class ChatwootService { const isLid = body.key.addressingMode === 'lid'; const isGroup = body.key.remoteJid.endsWith('@g.us'); let phoneNumber = isLid && !isGroup ? body.key.remoteJidAlt : body.key.remoteJid; - let { remoteJid } = body.key; + const { remoteJid } = body.key; // CORREÇÃO LID: Resolve LID para número normal antes de processar if (isLid && !isGroup) { const resolvedPhone = await this.resolveLidToPhone(instance, body.key); - + if (resolvedPhone && resolvedPhone !== remoteJid) { this.logger.verbose(`LID detected and resolved: ${remoteJid} → ${resolvedPhone}`); phoneNumber = resolvedPhone; - + // Salva mapeamento se temos remoteJidAlt if (body.key.remoteJidAlt) { this.saveLidMapping(remoteJid, body.key.remoteJidAlt); @@ -974,9 +974,7 @@ export class ChatwootService { const sourceReplyId = quotedMsg?.chatwootMessageId || null; // Filtra valores null/undefined do content_attributes para evitar erro 406 - const filteredReplyToIds = Object.fromEntries( - Object.entries(replyToIds).filter(([_, value]) => value != null) - ); + const filteredReplyToIds = Object.fromEntries(Object.entries(replyToIds).filter(([, value]) => value != null)); // Monta o objeto data, incluindo content_attributes apenas se houver dados válidos const messageData: any = { @@ -1132,9 +1130,7 @@ export class ChatwootService { const replyToIds = await this.getReplyToIds(messageBody, instance); // Filtra valores null/undefined antes de enviar - const filteredReplyToIds = Object.fromEntries( - Object.entries(replyToIds).filter(([_, value]) => value != null) - ); + const filteredReplyToIds = Object.fromEntries(Object.entries(replyToIds).filter(([, value]) => value != null)); if (Object.keys(filteredReplyToIds).length > 0) { const contentAttrs = JSON.stringify(filteredReplyToIds); @@ -1841,32 +1837,32 @@ export class ChatwootService { } private getTypeMessage(msg: any) { - const types = { - conversation: msg.conversation, - imageMessage: msg.imageMessage?.caption, - videoMessage: msg.videoMessage?.caption, - extendedTextMessage: msg.extendedTextMessage?.text, - messageContextInfo: msg.messageContextInfo?.stanzaId, - stickerMessage: undefined, - documentMessage: msg.documentMessage?.caption, - documentWithCaptionMessage: msg.documentWithCaptionMessage?.message?.documentMessage?.caption, - audioMessage: msg.audioMessage ? (msg.audioMessage.caption ?? '') : undefined, - contactMessage: msg.contactMessage?.vcard, - contactsArrayMessage: msg.contactsArrayMessage, - locationMessage: msg.locationMessage, - liveLocationMessage: msg.liveLocationMessage, - listMessage: msg.listMessage, - listResponseMessage: msg.listResponseMessage, - orderMessage: msg.orderMessage, - quotedProductMessage: msg.contextInfo?.quotedMessage?.productMessage, - viewOnceMessageV2: - msg?.message?.viewOnceMessageV2?.message?.imageMessage?.url || - msg?.message?.viewOnceMessageV2?.message?.videoMessage?.url || - msg?.message?.viewOnceMessageV2?.message?.audioMessage?.url, - }; - - return types; -} + const types = { + conversation: msg.conversation, + imageMessage: msg.imageMessage?.caption, + videoMessage: msg.videoMessage?.caption, + extendedTextMessage: msg.extendedTextMessage?.text, + messageContextInfo: msg.messageContextInfo?.stanzaId, + stickerMessage: undefined, + documentMessage: msg.documentMessage?.caption, + documentWithCaptionMessage: msg.documentWithCaptionMessage?.message?.documentMessage?.caption, + audioMessage: msg.audioMessage ? (msg.audioMessage.caption ?? '') : undefined, + contactMessage: msg.contactMessage?.vcard, + contactsArrayMessage: msg.contactsArrayMessage, + locationMessage: msg.locationMessage, + liveLocationMessage: msg.liveLocationMessage, + listMessage: msg.listMessage, + listResponseMessage: msg.listResponseMessage, + orderMessage: msg.orderMessage, + quotedProductMessage: msg.contextInfo?.quotedMessage?.productMessage, + viewOnceMessageV2: + msg?.message?.viewOnceMessageV2?.message?.imageMessage?.url || + msg?.message?.viewOnceMessageV2?.message?.videoMessage?.url || + msg?.message?.viewOnceMessageV2?.message?.audioMessage?.url, + }; + + return types; + } private getMessageContent(types: any) { const typeKey = Object.keys(types).find((key) => types[key] !== undefined); @@ -1894,39 +1890,39 @@ export class ChatwootService { this.processedOrderIds.set(result.orderId, now); } // Tratamento de Produto citado (WhatsApp Desktop) -if (typeKey === 'quotedProductMessage' && result?.product) { - const product = result.product; - - // Extrai preço - let rawPrice = 0; - const amount = product.priceAmount1000; - - if (Long.isLong(amount)) { - rawPrice = amount.toNumber(); - } else if (amount && typeof amount === 'object' && 'low' in amount) { - rawPrice = Long.fromValue(amount).toNumber(); - } else if (typeof amount === 'number') { - rawPrice = amount; - } + if (typeKey === 'quotedProductMessage' && result?.product) { + const product = result.product; - const price = (rawPrice / 1000).toLocaleString('pt-BR', { - style: 'currency', - currency: product.currencyCode || 'BRL', - }); - - const productTitle = product.title || 'Produto do catálogo'; - const productId = product.productId || 'N/A'; - - return ( - `🛒 *PRODUTO DO CATÁLOGO (Desktop)*\n` + - `━━━━━━━━━━━━━━━━━━━━━\n` + - `📦 *Produto:* ${productTitle}\n` + - `💰 *Preço:* ${price}\n` + - `🆔 *Código:* ${productId}\n` + - `━━━━━━━━━━━━━━━━━━━━━\n` + - `_Cliente perguntou: "${types.conversation || 'Me envia este produto?'}"_` - ); -} + // Extrai preço + let rawPrice = 0; + const amount = product.priceAmount1000; + + if (Long.isLong(amount)) { + rawPrice = amount.toNumber(); + } else if (amount && typeof amount === 'object' && 'low' in amount) { + rawPrice = Long.fromValue(amount).toNumber(); + } else if (typeof amount === 'number') { + rawPrice = amount; + } + + const price = (rawPrice / 1000).toLocaleString('pt-BR', { + style: 'currency', + currency: product.currencyCode || 'BRL', + }); + + const productTitle = product.title || 'Produto do catálogo'; + const productId = product.productId || 'N/A'; + + return ( + `🛒 *PRODUTO DO CATÁLOGO (Desktop)*\n` + + `━━━━━━━━━━━━━━━━━━━━━\n` + + `📦 *Produto:* ${productTitle}\n` + + `💰 *Preço:* ${price}\n` + + `🆔 *Código:* ${productId}\n` + + `━━━━━━━━━━━━━━━━━━━━━\n` + + `_Cliente perguntou: "${types.conversation || 'Me envia este produto?'}"_` + ); + } if (typeKey === 'orderMessage') { // Extrai o valor - pode ser Long, objeto {low, high}, ou número direto let rawPrice = 0; @@ -2166,11 +2162,11 @@ if (typeKey === 'quotedProductMessage' && result?.product) { if (body?.key?.remoteJid && body.key.remoteJid.includes('@lid') && !body.key.remoteJid.endsWith('@g.us')) { const originalJid = body.key.remoteJid; const resolvedPhone = await this.resolveLidToPhone(instance, body.key); - + if (resolvedPhone && resolvedPhone !== originalJid) { this.logger.verbose(`Event LID resolved: ${originalJid} → ${resolvedPhone}`); body.key.remoteJid = resolvedPhone; - + // Salva mapeamento se temos remoteJidAlt if (body.key.remoteJidAlt) { this.saveLidMapping(originalJid, body.key.remoteJidAlt); @@ -2748,13 +2744,13 @@ if (typeKey === 'quotedProductMessage' && result?.product) { if (!lid || !phoneNumber || !lid.includes('@lid')) { return; } - + this.cleanLidCache(); this.lidToPhoneMap.set(lid, { phone: phoneNumber, timestamp: Date.now(), }); - + this.logger.verbose(`LID mapping saved: ${lid} → ${phoneNumber}`); } @@ -2764,7 +2760,7 @@ if (typeKey === 'quotedProductMessage' && result?.product) { */ private async resolveLidToPhone(instance: InstanceDto, messageKey: any): Promise { const { remoteJid, remoteJidAlt } = messageKey; - + // Se não for LID, retorna o próprio remoteJid if (!remoteJid || !remoteJid.includes('@lid')) { return remoteJid; @@ -2788,7 +2784,7 @@ if (typeKey === 'quotedProductMessage' && result?.product) { try { const lidIdentifier = this.normalizeJidIdentifier(remoteJid); const contact = await this.findContactByIdentifier(instance, lidIdentifier); - + if (contact && contact.phone_number) { // Converte +554498860240 → 554498860240@s.whatsapp.net const phoneNumber = contact.phone_number.replace('+', '') + '@s.whatsapp.net'; diff --git a/src/api/routes/sendMessage.router.ts b/src/api/routes/sendMessage.router.ts index cd073dba3d..51876483d0 100644 --- a/src/api/routes/sendMessage.router.ts +++ b/src/api/routes/sendMessage.router.ts @@ -2,6 +2,7 @@ import { RouterBroker } from '@api/abstract/abstract.router'; import { SendAudioDto, SendButtonsDto, + SendCarouselDto, SendContactDto, SendListDto, SendLocationDto, @@ -18,6 +19,7 @@ import { sendMessageController } from '@api/server.module'; import { audioMessageSchema, buttonsMessageSchema, + carouselMessageSchema, contactMessageSchema, listMessageSchema, locationMessageSchema, @@ -180,6 +182,16 @@ export class MessageRouter extends RouterBroker { execute: (instance, data) => sendMessageController.sendButtons(instance, data), }); + return res.status(HttpStatus.CREATED).json(response); + }) + .post(this.routerPath('sendCarousel'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: carouselMessageSchema, + ClassRef: SendCarouselDto, + execute: (instance, data) => sendMessageController.sendCarousel(instance, data), + }); + return res.status(HttpStatus.CREATED).json(response); }); } diff --git a/src/validate/message.schema.ts b/src/validate/message.schema.ts index 6970fd9b09..5d6a31116e 100644 --- a/src/validate/message.schema.ts +++ b/src/validate/message.schema.ts @@ -448,6 +448,65 @@ export const buttonsMessageSchema: JSONSchema7 = { required: ['number'], }; +export const carouselMessageSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + number: { ...numberDefinition }, + body: { type: 'string', minLength: 1 }, + cards: { + type: 'array', + minItems: 1, + maxItems: 10, + items: { + type: 'object', + properties: { + title: { type: 'string' }, + body: { type: 'string', minLength: 1 }, + footer: { type: 'string' }, + imageUrl: { type: 'string' }, + buttons: { + type: 'array', + minItems: 1, + maxItems: 3, + items: { + type: 'object', + properties: { + type: { type: 'string', enum: ['reply', 'copy', 'url', 'call'] }, + displayText: { type: 'string' }, + id: { type: 'string' }, + url: { type: 'string' }, + copyCode: { type: 'string' }, + phoneNumber: { type: 'string' }, + }, + required: ['type'], + ...isNotEmpty('id', 'url', 'phoneNumber'), + }, + }, + }, + required: ['body', 'buttons'], + }, + }, + delay: { + type: 'integer', + description: 'Enter a value in milliseconds', + }, + quoted: { ...quotedOptionsSchema }, + everyOne: { type: 'boolean', enum: [true, false] }, + mentioned: { + type: 'array', + minItems: 1, + uniqueItems: true, + items: { + type: 'string', + pattern: '^\\d+', + description: '"mentioned" must be an array of numeric strings', + }, + }, + }, + required: ['number', 'body', 'cards'], +}; + export const decryptPollVoteSchema: JSONSchema7 = { $id: v4(), type: 'object',