chore(ops): restore all source files in newwhats.clube67.com
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)

This commit is contained in:
VPS 4 Deploy Agent
2026-05-18 03:28:29 +02:00
parent 52f73753e7
commit 2f8c04a0a7
755 changed files with 466910 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
# Diagnóstico da Área de Mensagens
## Faltando no Frontend
| Item | Arquivo | Função |
| :--- | :--- | :--- |
| Zustand + Immer | `store/chatStore.ts` | Store central — gerencia chats, mensagens, optimistic UI, socket listeners. É o cérebro do estado |
| Zustand | `store/instanceStore.ts` | Gerencia instâncias WhatsApp, QR codes, reconexão |
| Socket.IO Client | `services/socketService.ts` | Camada de comunicação real-time (rooms: tenant, instance, chat) |
| Axios | `services/chatApiService.ts` | Client HTTP com refresh automático de token, todas as APIs (chats, messages, instances, media, auth) |
| useChatFilter | `hooks/inbox/useChatFilter.ts` | Filtros da lista (busca, favoritos, arquivados, scopes) |
| useInstanceSelector | `hooks/inbox/useInstanceSelector.ts` | Seleção e troca de instância ativa |
| useMessageInput | `hooks/inbox/useMessageInput.ts` | Lógica do input de envio |
| Inbox Page | `pages/inbox/index.tsx` | Orquestrador — conecta todos os hooks e componentes |
## Faltando no Backend
| Item | Arquivo | Função |
| :--- | :--- | :--- |
| Express + CORS + Helmet | `server.ts` | Server HTTP, rotas, middleware de auth |
| Socket.IO Server | `infra/socket/socketServer.ts` | Rooms (tenant/instance/chat), JWT auth no handshake |
| WhatsAppConnectionManager | `connection/WhatsAppConnectionManager.ts` | Core — gerencia conexão Baileys, reconexão com backoff exponencial, watchdog, sync pós-reconexão, emissão de baileys:log |
| Chat Routes | `modules/chats/chat.routes.ts` | REST API de chats — listagem, mensagens paginadas, archive, pin, mark read, search |
| Avatar Proxy | `whatsapp.send.routes.ts` | Proxy de foto de perfil via Baileys com cache em DB |
| Prisma Schema | `prisma/schema.prisma` | Modelos: User, Instance, Chat, Message, Contact, Protocol, AiBot, Broadcast |
| PostgreSQL | via Prisma | Banco principal |
| DragonflyDB | `infra/cache/dragonfly.ts` | Cache Redis-compatible (status de instâncias, QR codes) |
## Conceitos Arquiteturais Ausentes
- **Optimistic UI:** `sendMessage` injeta mensagem no estado antes da resposta do server.
- **Modelo de rooms Socket.IO:** `tenant:{userId}` (auto-join) → `instance:{id}``chat:{id}`.
- **Fluxo de mensagem completo:** Baileys event → `MessageHandler` → Prisma → Socket.IO emit → chatStore listener → useMemo bridge → `MessageArea``MessageBubble`.
- **Sync histórico:** `handleHistory()` salva em batch com `createMany` + skipDuplicates.
- **Status de mensagem:** `messages.update` do Baileys → DB update → `message:status` socket event.
- **JWT auth:** access token + refresh token com renovação automática no interceptor Axios.
## Correções Relevantes
- **Prisma ORM:** Não é "provavelmente", é certeza. Está no `package.json` e o `schema` está definido.
- **Queries:** `message.repository.ts` utiliza queries Prisma (ORM), e não SQL direto.
- **Reactions:** Reactions das mensagens ainda não estão implementadas no backend (são stub).
- **Baileys:** Dependência direta é `@whiskeysockets/baileys`, não um "WebSockets / Baileys" genérico.
@@ -0,0 +1,87 @@
### Role: Arquiteto de Software SaaS Sênior & Especialista em Sistemas de Agentes (Temporal.io)
### Task: Arquitetar um SaaS de Mensageria Omnichannel e Atendimento Corporativo com Orquestração Durável
**Contexto & Ambiente de Trabalho:**
O sistema legado (backend antigo) localizado em `whatsapp.local` deve ser estritamente ignorado.
O nosso workspace atual está em `/home/deploy/projetos/newwhats.local`.
- **Backend:** Deve ser construído COMPLETAMENTE DO ZERO.
- **Frontend:** O layout visual (Tailwind/Componentes) já está APROVADO na pasta `frontend`. Você deve LIMPAR a lógica antiga (estados, chamadas de API soltas) e plugar a nova inteligência do zero usando Zustand, Sockets e integração com o novo backend, respeitando a interface existente.
O sistema é um CRM robusto, White-Label e genérico, projetado para alta demanda corporativa.
---
### 1. Padrões de Código, Infraestrutura e Qualidade (NÍVEL PRODUÇÃO)
- **Zero Dados Mockados:** É ESTRITAMENTE PROIBIDO o uso de dados mockados. Todo componente React e serviço Node.js deve consumir dados reais do PostgreSQL/DragonflyDB via Zustand/Sockets ou chamadas de API reais.
- **Zero Gambiarras (No Hacky Solutions):** O código não deve ser fragmentado. Use injeção de dependência, Repository Pattern para acesso ao banco, operações atômicas (como `upsert` no Prisma) e separe claramente as responsabilidades.
- **Uso Estrito da Documentação Anexa (Baileys):** O LLM DEVE basear a implementação da biblioteca do WhatsApp na documentação anexa (`baileys_api_docs.md`).
- **Infraestrutura Local (Bare-Metal):** Desenvolvimento inicial sem Docker. Conexões com PostgreSQL, DragonflyDB, NATS e Temporal.io parametrizadas via variáveis de ambiente (`.env`).
- **Tratamento de Mídia:** O download de buffers da Baileys deve ser assíncrono e salvo corretamente (sem travar o event loop) antes da entrega ao frontend.
### 2. Regras de Interatividade e Workflow de Execução
- **Lista de Tarefas (Checklist):** Antes de gerar qualquer linha de código ou explicação técnica, você (LLM) DEVE iniciar sua resposta com um Checklist visível contendo todos os entregáveis da fase atual. À medida que for entregando cada bloco, você deve atualizar o checklist marcando o item como concluído (ex: `[x] Item concluído`, `[ ] Item pendente`).
- **Instrução de Parada (PAUSA PARA QR CODE):** O primeiro passo prático é estabelecer a sessão. Quando você terminar de gerar os artefatos da Fase 1 (onde o QR Code é emitido), **VOCÊ DEVE PARAR IMEDIATAMENTE DE GERAR A RESPOSTA.**
- **Mensagem Exigida:** Pare e exiba exatamente esta mensagem: *"⏳ **Aguardando escaneamento:** A estrutura de conexão do WhatsApp foi gerada. Por favor, configure o `.env`, rode este módulo inicial, escaneie o QR Code com seu aparelho e me avise aqui digitando 'Escaneado!' para prosseguirmos com Temporal, NATS e React."*
- **Atenção:** Não gere entregáveis da Fase 2 até que eu confirme o escaneamento.
---
### 3. Stack Tecnológica Definitiva
- **Backend Core:** Node.js (TypeScript) com arquitetura modular.
- **Banco de Dados Relacional:** **PostgreSQL** (Prisma ORM).
- **Cache & Sessão Rápida:** **DragonflyDB**.
- **Transporte de Mensagens:** **NATS JetStream**.
- **Orquestração de Agentes:** **Temporal.io**.
- **Frontend:** React, Vite, **Tailwind CSS**, Zustand, Immer.
- **WhatsApp Engine:** Baileys (v6+ Multi-device).
- **IA & Voz:** OpenAI (GPT-4o/Whisper) & Gemini (1.5 Flash para Micro-prompts).
---
### 4. RBAC, Estrutura Organizacional & Regras de Negócio
- **Hierarquia e Tenants:** Tabela principal DEVE ser `users` (plural). Hierarquia: `admin` (Dono do SaaS) e `user` (Tenant/Cliente).
- **Gestão Corporativa (Dentro do Tenant):** Gerenciamento dinâmico de **Setores** e **Cargos**.
- **Operação de Atendimento:** Agrupamento de funcionários em **Equipes de Atendimento**. Todo novo atendimento deve gerar um **Número de Protocolo** rastreável.
- **Sincronização de Contatos:** Regra de ouro para o upsert: priorizar o nome salvo na agenda (`name`), usar o nome verificado (`verifiedName`) ou o pushname (`notify`) como fallback. Chave única no Prisma para impedir duplicações (`@@unique([tenantId, instanceId, jid])`).
---
### 5. O Cérebro IA Multi-Agente & Orquestração (Temporal.io)
A IA não deve consumir o histórico completo de chat para economizar tokens. Usaremos **State Machine (Stateful Memory)** via Temporal.io:
**A. Cadeia de Decisão por Micro-Prompts (Low Token Consumption)**
- **Agente Receptor:** Identifica a intenção via Webhook/Baileys.
- **Agente de Dados:** Consulta o PostgreSQL/CRM via Activities do Temporal para verificar disponibilidade e contexto (`reply_to_id`).
- **Agente Analista:** Usa LLMs leves (Gemini Flash) forçados a retornar apenas 1 token (ex: 1 para "Escalar", 2 para "Resolver").
- **Stateful Memory (Cérebro):** O Temporal mantém um resumo ativo de 2 frases e o status da negociação. Mensagens que marcam (`reply`) enviam apenas o par [Mensagem Marcada + Pergunta Atual].
**B. Lógicas Críticas de Negócio (Workflows)**
- **Conflito de 1 Hora (Pessoa A vs B):** Se A pede o horário de B (pendente), o Temporal marca como `WAITING_B` e executa um `await workflow.sleep('1 hour')`. Sem resposta de B, a agenda é liberada automaticamente para A via NATS.
- **Gestão de Reputação:** Contatos possuem um `score_reputacao` (0-100) e uma `flag_restricao`.
- **Agente de Conformidade:** Faltas sucessivas reduzem o score. Se ficar RESTRITO, o workflow bloqueia agendamentos automáticos.
---
### 6. UX/UI da Inbox: INTEGRAÇÃO COM LAYOUT APROVADO
Como o layout (Clone do WA Web em Tailwind) já está aprovado, sua tarefa no frontend é **plugar a inteligência**:
- **Sidebar (Esquerda):** Conectar a lista scrollável ao Zustand para renderizar dinamicamente: Foto, Nome, Horário, Snippet (com ícones de Check ✓✓), Badge de `unread_count` e Número do Protocolo.
- **Área Principal (Direita):**
- **Empty State:** Manter o layout, mas tornar o **Banner SaaS** dinâmico (ex: ler dias restantes do backend).
- **Chat Ativo:** Plugar o map de mensagens do Zustand para renderizar balões (`fromMe`). Ligar o Footer Fixo (input) aos eventos de emissão do Socket.IO.
- **Proxy:** Consumir a rota `/api/inbox/avatar/:jid` para as fotos de perfil.
---
### 7. Entregáveis Solicitados (Fase 1 - Imediato)
*(Lembre-se da Regra 2: Crie o Checklist primeiro e marque os itens ao entregar)*
1. **Infra e Arquitetura:**
- Estrutura de Diretórios lógica para `/home/deploy/projetos/newwhats.local/backend`.
- Exemplo de arquivo `.env`.
2. **Schema do Prisma Atualizado:** Estrutura relacional normalizada: `users`, `sectors`, `teams`, `protocols` (ID, Status, Resumo, Deadline), `contacts` (com `score_reputacao` e `flag_restricao`), `instances`, `chats`, `messages` (com `reply_to_id`), `plans`, `api_keys`.
3. **Módulo Baileys + DragonflyDB:** O código profissional inicial do backend necessário para emitir o QR Code real e validar a conexão no terminal. **(Consulte o arquivo `baileys_api_docs.md` em anexo)**.
4. *(Pare a geração e exiba a mensagem de pausa da Regra 2).*
### 8. Entregáveis Solicitados (Fase 2 - Pós QR Code)
1. Códigos do Temporal.io: Workflow implementando a regra do "Conflito de 1 Hora" e a "Gestão de Reputação".
2. Implementação do Upsert de Contatos e WhatsApp Warmer no backend.
3. ChatStore (Zustand) com Immer e os Snippets de como conectar essa store aos componentes React existentes do layout aprovado.
@@ -0,0 +1,167 @@
# Proposta — Monitor de sistema (disco, memória, CPU)
## Contexto
Quando o servidor cai ou trava, precisamos de visibilidade sobre o estado de
**disco, memória e CPU** nos minutos anteriores à falha — caso contrário o
diagnóstico vira adivinhação. O monitor precisa:
1. Sobreviver a crash/hang do backend Node (senão não vale nada pós-mortem)
2. Ter pouca sobrecarga (não queremos o próprio monitor derrubar o servidor)
3. Ser simples de consultar depois do incidente
---
## Opções
### 🅰 Post-mortem via log rotativo (mínimo viável)
Script bash standalone + systemd timer coletando snapshot a cada 3060s em
`/var/log/newwhats-health.log` (rotativo por `logrotate`).
**Campos coletados:**
- Uso de disco (`df -h /`)
- Memória (`free -m` — total, usado, livre, cache, swap)
- CPU / load average (`uptime`, `top -bn1 | head`)
- Processos do newwhats (PID, %CPU, %MEM, comando)
- Rede (`ss -s` — conexões TCP ativas)
- Uptime (`uptime -p`)
**Entregáveis:**
- `/opt/newwhats/scripts/collect-health.sh` — script bash idempotente
- `/etc/systemd/system/newwhats-health.service` — unit oneshot
- `/etc/systemd/system/newwhats-health.timer` — timer disparando a cada 30s
- `/etc/logrotate.d/newwhats-health` — rotação diária, retenção configurável
**Vantagem crítica:** roda fora do backend. Sobrevive a crash, hang, OOM
killer, e até a `kill -9` do processo Node. Quando o servidor cair, basta
`tail /var/log/newwhats-health.log` e aparece exatamente o estado nos
últimos minutos.
**Desvantagem:** sem dashboard — só via terminal.
---
### 🅱 Post-mortem + endpoint em tempo real
**Tudo do A, mais:**
Endpoint `/api/admin/system-health` no backend que retorna JSON com leitura
ao vivo:
```ts
{
uptime: number, // os.uptime()
loadAvg: [n, n, n], // os.loadavg()
memory: {
total: number, // os.totalmem()
free: number, // os.freemem()
usedPct: number,
processRss: number, // process.memoryUsage().rss
processHeap: number, // process.memoryUsage().heapUsed
},
disk: {
total: number, // fs.statfs('/')
free: number,
usedPct: number,
},
cpu: {
cores: number,
model: string,
},
node: {
version: string,
pid: number,
},
}
```
Protegido por `adminMiddleware`.
**Vantagem:** permite montar dashboard no frontend depois sem re-arquitetar.
Útil também pra curl/monitoring externo (UptimeRobot, etc).
**Desvantagem:** endpoint depende do backend estar vivo. Se o processo
travou, só o log bash salva. Por isso sempre acompanha o A, nunca substitui.
---
### 🅲 A + B + dashboard frontend + alertas
**Tudo do B, mais:**
- Página admin no frontend (`/admin/system`) com gráficos das últimas 24h
- Polling do endpoint a cada 5s
- Histórico persistido em tabela Postgres (`system_health_samples`) ou
stream no Dragonfly (mais leve, TTL de 24h automático)
- Emit de `system:alert` via socket.io quando threshold é ultrapassado:
- Memória > 90%
- Disco > 85%
- Load > `numCores * 2`
- Toast no frontend mostrando o alerta
**Desvantagens:**
- Adiciona chart lib no frontend (recharts, apex, chart.js — ~50KB gzipped)
- Alertas via socket.io não chegam se o backend estiver morto — falsa
sensação de segurança
- Alertas em tempo real geralmente são feitos com ferramenta externa
(Grafana/Prometheus/Netdata) — reinventar dentro do app é trabalho sem
ROI claro
---
## Recomendação: A+B
**Por quê:**
1. **A cobre o caso real** que você descreveu ("servidor caiu ou travou").
Post-mortem via log é o que **sempre** funciona, mesmo no pior cenário.
2. **B dá um hook** pra construir dashboard depois sem refatorar — o
endpoint é pequeno e vive debaixo do `adminMiddleware`.
3. **C adiciona complexidade** que pode ser feita em sprint futura se você
decidir que o ganho vale. Começar sem gráficos é mais barato e mais
rápido de entregar.
4. **Alertas em tempo real são complementares, não substitutos:** se você
quiser notificações robustas, a resposta é integrar com Prometheus /
Grafana / Netdata — que foram feitos pra isso — não reescrever a roda
dentro do app.
---
## Decisões que precisam de resposta antes de aplicar
| # | Decisão | Opções | Default sugerido |
|---|---------|--------|------------------|
| 1 | Intervalo de coleta | 30s / 60s | **30s** (granularidade importa pra detectar spikes) |
| 2 | Retenção do log | 7 dias / 30 dias | **7 dias** (rotação diária, arquivo ~10MB/dia) |
| 3 | Notificações imediatas | email / webhook / nada | **nada** (começar simples) |
| 4 | Path do log | `/var/log/newwhats-health.log` (precisa root na criação) / `/home/deploy/projetos/newwhats.local/logs/health.log` (sem sudo) | **`/var/log/newwhats-health.log`** (padrão systemd, integra com logrotate global) |
---
## Escopo explicitamente fora
- ❌ Monitoramento distribuído (vários hosts)
- ❌ Alertas por SMS/WhatsApp/Slack
- ❌ Dashboard com gráficos históricos (fica na Opção C)
- ❌ Integração com Prometheus/Grafana/Netdata (trabalho separado, fora do
scope do app)
- ❌ APM / tracing de requests (é outra categoria de ferramenta)
- ❌ Monitor de health do Postgres / Dragonfly (checar separadamente se
houver sintomas — hoje não é gargalo)
---
## Estimativa de entrega (referência, não commitment)
**Opção A isolada:** script bash + 2 units systemd + logrotate config →
3 arquivos pequenos, baixa superfície.
**Opção A+B:** adiciona 1 endpoint no backend (~40 linhas TypeScript) e
uma chamada em `server.ts` pra montar a rota. Sem mudança no frontend.
**Opção C:** soma uma página React com gráficos + migration + lógica de
sample + lógica de alerta + toast handler. Bem mais trabalho.
@@ -0,0 +1,998 @@
# Tipos de Disparo — baileys_interactive
> Documentação dos 6 tipos de mensagem disponíveis no `<select id="dispatchType">`.
> Cada seção lista os arquivos envolvidos e os trechos de código **comentados** linha a linha.
---
## Mapa de Arquivos
| Camada | Arquivo | Papel |
|--------|---------|-------|
| **Frontend — HTML** | `public/index.html` | Formulários de cada tipo de disparo (linhas 82159) |
| **Frontend — JS** | `public/app.js` | Switch de formulários, builders de payload, loop de envio |
| **Backend — Rotas** | `src/routes/messages.ts` | Endpoints POST de cada tipo (`/v1/messages/send_*`) |
| **Backend — Tipos** | `src/types/whatsapp.ts` | Interfaces TypeScript para os payloads do socket |
| **Backend — Config** | `src/config.ts` | Limites de botões, cards, opções de poll etc. |
| **Backend — Helpers** | `src/utils/helpers.ts` | `toJid()` (formata número → JID) e `isConnected()` |
| **Backend — Socket** | `src/services/whatsapp.ts` | Gerencia instâncias Baileys (criar, conectar, desconectar) |
---
## Como o Fluxo Funciona (visão geral)
```
[Usuário seleciona tipo no <select>]
app.js: evento 'change' esconde todos os <div id="formXxx">
e exibe apenas o div do tipo escolhido
[Usuário preenche campos e clica "Enviar"]
app.js: getXxxPayload() monta { url, body }
app.js: loop por destinatários → fetch POST para o endpoint
messages.ts: valida instância + conectividade
whatsapp.ts: ctx.sock.sendMessage(jid, payload_baileys)
[Mensagem entregue via InfiniteAPI/Baileys]
```
---
## 1. Menu (texto numerado)
**Valor no select:** `menu`
**Endpoint:** `POST /v1/messages/send_menu`
**Descrição:** Monta uma mensagem de texto simples com opções numeradas em negrito. O usuário responde digitando o número.
### Frontend — `public/index.html` (linhas 93103)
```html
<!-- Formulário visível quando tipo = "menu" -->
<div id="formMenu" class="dispatch-form">
<!-- Título opcional em negrito no topo da mensagem -->
<div class="form-row">
<label>Título</label>
<input type="text" id="menuTitle" placeholder="Menu de Opções">
</div>
<!-- Texto introdutório antes das opções -->
<div class="form-row">
<label>Texto</label>
<textarea id="menuText" rows="2" placeholder="Escolha uma opção:"></textarea>
</div>
<!-- Lista dinâmica de opções — cada item vira uma linha "1. Opção" -->
<div class="form-row">
<label>Opções</label>
<div id="menuOptionsList" class="dynamic-list"></div>
<!-- data-for="menuOptions" → app.js chama addMenuOption() ao clicar -->
<button type="button" class="btn btn-small btn-ghost add-item" data-for="menuOptions">
+ Adicionar opção
</button>
</div>
<!-- Rodapé em itálico no final da mensagem -->
<div class="form-row">
<label>Rodapé</label>
<input type="text" id="menuFooter" placeholder="Responda com número">
</div>
</div>
```
### Frontend — `public/app.js`
```js
// --- Adiciona uma linha de input para nova opção (chamado via data-for="menuOptions") ---
function addMenuOption() {
// addRow(containerId, htmlInterna):
// cria um div.item-row com o input + botão "Remover" automático
addRow('menuOptionsList', '<input type="text" placeholder="Texto da opção" data-field="opt">');
}
// --- Monta o payload para envio ao backend ---
function getMenuPayload() {
const options = [];
// Coleta o valor de cada input data-field="opt" não vazio
document.querySelectorAll('#menuOptionsList .item-row input[data-field="opt"]').forEach((inp) => {
const v = inp.value.trim();
if (v) options.push(v);
});
return {
url: '/v1/messages/send_menu', // endpoint do backend
body: {
instance: document.getElementById('dispatchInstance').value, // instância WhatsApp
to: document.getElementById('dispatchTo').value.trim(), // número(s) destino
title: document.getElementById('menuTitle').value.trim() || 'Menu',
text: document.getElementById('menuText').value.trim() || 'Escolha uma opção:',
options: options.length ? options : ['Opção 1'], // fallback se vazio
footer: document.getElementById('menuFooter').value.trim() || undefined,
},
};
}
```
### Backend — `src/routes/messages.ts` (linhas 2258)
```ts
// POST /v1/messages/send_menu
router.post('/send_menu', async (req: Request, res: Response) => {
try {
// Desestrutura o body: instance (default 'main'), número destino, título, texto, opções e rodapé
const { instance = 'main', to, title, text, options, footer } = req.body;
// Valida presença obrigatória: destino e ao menos 1 opção
if (!to || !Array.isArray(options) || options.length === 0) {
return res.status(400).json({ ok: false, error: 'missing to/options' });
}
// Busca e valida a instância Baileys (deve existir e estar conectada)
const ctx = validateInstance(instance, res);
if (!ctx) return;
// Converte número para JID WhatsApp: "5511999..." → "5511999...@s.whatsapp.net"
const jid = toJid(to);
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
// Monta o texto numerado:
// *Título*\n\nTexto\n\n*1.* Opção 1\n*2.* Opção 2\n\n_Rodapé_
let menuText = '';
if (title) menuText += `*${title}*\n\n`; // título em negrito
if (text) menuText += `${text}\n\n`; // texto introdutório
options.forEach((opt, idx) => {
// Suporta opção como string simples ou objeto { text }
const label = typeof opt === 'string' ? opt : opt.text ?? `Opção ${idx + 1}`;
menuText += `*${idx + 1}.* ${label}\n`; // "1. Opção" em negrito
});
if (footer) menuText += `\n_${footer}_`; // rodapé em itálico
// Envia mensagem de texto simples (sem recurso especial do Baileys)
await ctx.sock.sendMessage(jid, { text: menuText.trim() });
return res.json({ ok: true, hint: 'User should reply with the option number (1, 2, 3...)' });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: message });
}
});
```
**Payload de exemplo (JSON enviado pelo frontend):**
```json
{
"instance": "main",
"to": "5511999999999",
"title": "Menu de Opções",
"text": "Escolha uma opção:",
"options": ["Produto A", "Produto B", "Falar com atendente"],
"footer": "Responda com o número"
}
```
---
## 2. Botões quick reply
**Valor no select:** `buttons`
**Endpoint:** `POST /v1/messages/send_buttons_helpers`
**Descrição:** Mensagem com até **3 botões clicáveis** de resposta rápida (`nativeButtons` do Baileys). O usuário toca no botão sem digitar nada.
### Frontend — `public/index.html` (linhas 105114)
```html
<!-- Formulário visível quando tipo = "buttons" -->
<div id="formButtons" class="dispatch-form hidden">
<!-- Corpo da mensagem -->
<div class="form-row">
<label>Texto</label>
<textarea id="buttonsText" rows="2" placeholder="Como posso ajudar?"></textarea>
</div>
<!-- Texto secundário abaixo da mensagem principal -->
<div class="form-row">
<label>Rodapé</label>
<input type="text" id="buttonsFooter" placeholder="Atendimento 24h">
</div>
<!-- Até 3 botões: cada linha tem ID (identificador interno) + Texto visível -->
<div class="form-row">
<label>Botões (máx 3)</label>
<div id="buttonsList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="buttons">
+ Adicionar botão
</button>
</div>
</div>
```
### Frontend — `public/app.js`
```js
// Adiciona linha com 2 inputs: ID do botão e texto visível
function addButtonRow() {
addRow(
'buttonsList',
'<input type="text" placeholder="ID do botão" data-field="id">' +
'<input type="text" placeholder="Texto do botão" data-field="text">'
);
}
function getButtonsPayload() {
const buttons = [];
// Coleta pares (id, text) de cada linha — ignora linhas incompletas
document.querySelectorAll('#buttonsList .item-row').forEach((row) => {
const id = row.querySelector('[data-field="id"]')?.value?.trim();
const text = row.querySelector('[data-field="text"]')?.value?.trim();
if (id && text) buttons.push({ id, text });
});
return {
url: '/v1/messages/send_buttons_helpers',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
text: document.getElementById('buttonsText').value.trim() || 'Escolha:',
footer: document.getElementById('buttonsFooter').value.trim() || undefined,
// Limita a 3 botões no frontend; o backend também aplica o limit de config
buttons: buttons.length ? buttons.slice(0, 3) : [{ id: 'btn1', text: 'Opção 1' }],
},
};
}
```
### Backend — `src/routes/messages.ts` (linhas 6199)
```ts
// POST /v1/messages/send_buttons_helpers
router.post('/send_buttons_helpers', async (req: Request, res: Response) => {
try {
const { instance = 'main', to, text, buttons, footer } = req.body;
if (!to || !text || !Array.isArray(buttons) || buttons.length === 0) {
return res.status(400).json({ ok: false, error: 'missing to/text/buttons' });
}
// config.limits.maxButtons = 3 (definido em src/config.ts)
const limited = buttons.slice(0, config.limits.maxButtons);
const ctx = validateInstance(instance, res);
if (!ctx) return;
const jid = toJid(to);
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
// Converte array de { id, text } para o formato nativeButtons do InfiniteAPI/Baileys
const nativeButtons = limited.map((btn, idx) => ({
type: 'reply' as const, // tipo "reply" = botão de resposta rápida
id: btn.id ?? `btn_${idx}`, // ID retornado quando o usuário clicar
text: btn.text ?? `Botão ${idx + 1}`,
}));
// sendMessage com nativeButtons ativa o recurso de quick reply no WhatsApp
const result = await ctx.sock.sendMessage(jid, {
nativeButtons,
text: String(text),
footer: footer ? String(footer) : undefined,
});
return res.json({ ok: true, format: 'nativeButtons', messageId: result?.key?.id });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: message });
}
});
```
**Payload de exemplo:**
```json
{
"instance": "main",
"to": "5511999999999",
"text": "Como posso ajudar?",
"footer": "Atendimento 24h",
"buttons": [
{ "id": "btn_sim", "text": "✅ Sim" },
{ "id": "btn_nao", "text": "❌ Não" },
{ "id": "btn_ajuda", "text": "🆘 Ajuda" }
]
}
```
---
## 3. Botões CTA (URL / Copiar / Ligar)
**Valor no select:** `interactive`
**Endpoint:** `POST /v1/messages/send_interactive_helpers`
**Descrição:** Botões de ação que abrem URLs, copiam código ou ligam para um número. Usa também `nativeButtons` mas com tipos `url`, `copy` e `call`.
### Frontend — `public/index.html` (linhas 116125)
```html
<!-- Formulário visível quando tipo = "interactive" -->
<div id="formInteractive" class="dispatch-form hidden">
<div class="form-row">
<label>Texto</label>
<textarea id="interactiveText" rows="3" placeholder="Mensagem com CTAs"></textarea>
</div>
<div class="form-row">
<label>Rodapé</label>
<input type="text" id="interactiveFooter">
</div>
<!-- Cada botão CTA tem: tipo (url/copy/call) + texto visível + valor extra -->
<div class="form-row">
<label>Botões CTA</label>
<div id="interactiveList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="interactive">
+ Adicionar botão
</button>
</div>
</div>
```
### Frontend — `public/app.js`
```js
// Cada linha CTA tem: select de tipo + texto + valor extra (url/código/fone)
function addInteractiveRow() {
addRow(
'interactiveList',
`<select data-field="type">
<option value="url">URL</option>
<option value="copy">Copiar</option>
<option value="call">Ligar</option>
</select>
<input type="text" placeholder="Texto do botão" data-field="text">
<input type="text" placeholder="URL / Código / Telefone" data-field="extra">`
);
}
function getInteractivePayload() {
const buttons = [];
document.querySelectorAll('#interactiveList .item-row').forEach((row) => {
const type = row.querySelector('[data-field="type"]')?.value || 'url';
const text = row.querySelector('[data-field="text"]')?.value?.trim();
const extra = row.querySelector('[data-field="extra"]')?.value?.trim();
if (!text || !extra) return; // ignora linhas incompletas
const btn = { type, text };
// O campo "extra" tem significado diferente conforme o tipo:
if (type === 'url') btn.url = extra; // link a abrir
if (type === 'copy') btn.copyCode = extra; // código a copiar (ex: cupom)
if (type === 'call') btn.phoneNumber = extra; // número a ligar
buttons.push(btn);
});
return {
url: '/v1/messages/send_interactive_helpers',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
text: document.getElementById('interactiveText').value.trim() || 'Confira:',
footer: document.getElementById('interactiveFooter').value.trim() || undefined,
buttons,
},
};
}
```
### Backend — `src/routes/messages.ts` (linhas 101154)
```ts
// POST /v1/messages/send_interactive_helpers
router.post('/send_interactive_helpers', async (req: Request, res: Response) => {
try {
const { instance = 'main', to, text, buttons, footer } = req.body;
if (!to || !text || !Array.isArray(buttons) || buttons.length === 0) {
return res.status(400).json({ ok: false, error: 'missing to/text/buttons' });
}
const ctx = validateInstance(instance, res);
if (!ctx) return;
const jid = toJid(to);
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
// Converte cada botão para o formato nativo do InfiniteAPI:
const nativeButtons = buttons.slice(0, config.limits.maxButtons).map((btn, idx) => {
const type = (btn.type ?? 'reply').toLowerCase();
if (type === 'url' || btn.url) {
// Botão que abre uma URL no navegador
return { type: 'url' as const, text: btn.text ?? 'Abrir', url: btn.url! };
}
if (type === 'copy' || btn.copyCode || btn.copyText) {
// Botão que copia um código/texto para a área de transferência
return { type: 'copy' as const, text: btn.text ?? 'Copiar', copyText: btn.copyCode ?? btn.copyText ?? '' };
}
if (type === 'call' || btn.phoneNumber) {
// Botão que inicia uma chamada telefônica
return { type: 'call' as const, text: btn.text ?? 'Ligar', phoneNumber: btn.phoneNumber! };
}
// Fallback: reply simples
return { type: 'reply' as const, id: `btn_${idx}`, text: btn.text ?? 'Botão' };
});
const result = await ctx.sock.sendMessage(jid, {
nativeButtons,
text: String(text),
footer: footer ? String(footer) : undefined,
});
return res.json({ ok: true, format: 'nativeButtons', messageId: result?.key?.id });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: message });
}
});
```
**Payload de exemplo:**
```json
{
"instance": "main",
"to": "5511999999999",
"text": "Confira nossas opções:",
"buttons": [
{ "type": "url", "text": "🌐 Ver site", "url": "https://exemplo.com" },
{ "type": "copy", "text": "📋 Copiar cupom","copyCode": "DESC10" },
{ "type": "call", "text": "📞 Ligar", "phoneNumber": "5511999999999" }
]
}
```
---
## 4. Lista dropdown
**Valor no select:** `list`
**Endpoint:** `POST /v1/messages/send_list_helpers`
**Descrição:** Mensagem com botão que abre uma lista em dropdown. Suporta seções e itens com ID, título e descrição. Usa `nativeList` do InfiniteAPI.
### Frontend — `public/index.html` (linhas 127137)
```html
<!-- Formulário visível quando tipo = "list" -->
<div id="formList" class="dispatch-form hidden">
<div class="form-row">
<label>Texto</label>
<textarea id="listText" rows="2" placeholder="Escolha uma categoria:"></textarea>
</div>
<!-- Texto que aparece no botão que abre a lista -->
<div class="form-row">
<label>Texto do botão</label>
<input type="text" id="listButtonText" value="Ver Cardápio" placeholder="Ver opções">
</div>
<div class="form-row">
<label>Rodapé</label>
<input type="text" id="listFooter" placeholder="Delivery grátis acima de R$ 50">
</div>
<!-- Seções: cada seção tem um título e N itens (id + título + descrição) -->
<div class="form-row">
<label>Seções</label>
<div id="listSectionsList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="listSections">
+ Adicionar seção
</button>
</div>
</div>
```
### Frontend — `public/app.js`
```js
// Cria um bloco "seção" com título + lista de itens aninhados
function addListSection() {
const container = document.getElementById('listSectionsList');
const block = document.createElement('div');
block.className = 'block-section'; // classe usada pelo backend para buscar seções
block.innerHTML = `
<div class="block-title">Seção</div>
<input type="text" class="section-title" placeholder="Título da seção">
<div class="sub-list section-rows"></div>
<button type="button" class="btn btn-small btn-ghost add-row-in-section">+ Adicionar item</button>
<button type="button" class="btn btn-small btn-danger btn-remove-block">Remover seção</button>
`;
// Botão "Adicionar item" dentro da seção cria um item com id / título / descrição
block.querySelector('.add-row-in-section').addEventListener('click', () => {
const row = document.createElement('div');
row.className = 'item-row';
row.innerHTML = `
<input type="text" placeholder="ID" data-field="id">
<input type="text" placeholder="Título" data-field="title">
<input type="text" placeholder="Descrição" data-field="desc">
<button type="button" class="btn btn-small btn-ghost btn-remove">Remover</button>
`;
row.querySelector('.btn-remove').onclick = () => row.remove();
block.querySelector('.section-rows').appendChild(row);
});
block.querySelector('.btn-remove-block').onclick = () => block.remove();
container.appendChild(block);
}
function getListPayload() {
const sections = [];
// Itera cada bloco-seção e coleta título + seus itens
document.querySelectorAll('#listSectionsList .block-section').forEach((block) => {
const title = block.querySelector('.section-title')?.value?.trim() || 'Seção';
const rows = [];
block.querySelectorAll('.section-rows .item-row').forEach((row) => {
const id = row.querySelector('[data-field="id"]')?.value?.trim();
const titleR= row.querySelector('[data-field="title"]')?.value?.trim();
const desc = row.querySelector('[data-field="desc"]')?.value?.trim();
if (id && titleR) rows.push({ id, title: titleR, description: desc || '' });
});
if (rows.length) sections.push({ title, rows });
});
return {
url: '/v1/messages/send_list_helpers',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
text: document.getElementById('listText').value.trim() || 'Escolha:',
buttonText: document.getElementById('listButtonText').value.trim() || 'Ver opções',
footer: document.getElementById('listFooter').value.trim() || undefined,
// Fallback: 1 seção com 1 item se o usuário não preencheu nada
sections: sections.length ? sections : [
{ title: 'Opções', rows: [{ id: 'opt1', title: 'Opção 1', description: '' }] }
],
},
};
}
```
### Backend — `src/routes/messages.ts` (linhas 156199)
```ts
// POST /v1/messages/send_list_helpers
router.post('/send_list_helpers', async (req: Request, res: Response) => {
try {
const { instance = 'main', to, text, footer, buttonText, sections } = req.body;
if (!to || !text || !buttonText || !Array.isArray(sections) || sections.length === 0) {
return res.status(400).json({ ok: false, error: 'missing to/text/buttonText/sections' });
}
const ctx = validateInstance(instance, res);
if (!ctx) return;
const jid = toJid(to);
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
// nativeList é o formato do InfiniteAPI para listas dropdown
const result = await ctx.sock.sendMessage(jid, {
nativeList: {
buttonText: String(buttonText), // texto do botão que abre o dropdown
sections: sections.map((s) => ({
title: s.title ?? 'Opções',
rows: (s.rows ?? []).map((row, idx) => ({
id: row.id ?? `row_${idx}`, // identificador retornado ao clicar
title: row.title ?? 'Item',
description: row.description ?? '', // subtítulo opcional
})),
})),
},
text: String(text),
footer: footer ? String(footer) : undefined,
});
return res.json({ ok: true, format: 'nativeList', messageId: result?.key?.id });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: message });
}
});
```
**Payload de exemplo:**
```json
{
"instance": "main",
"to": "5511999999999",
"text": "Escolha uma categoria:",
"buttonText": "Ver Cardápio",
"footer": "Delivery grátis acima de R$ 50",
"sections": [
{
"title": "🍕 Pizzas",
"rows": [
{ "id": "pizza_marg", "title": "Margherita", "description": "Molho, mozzarella, manjericão" },
{ "id": "pizza_cal", "title": "Calabresa", "description": "Molho, calabresa, cebola" }
]
},
{
"title": "🥤 Bebidas",
"rows": [
{ "id": "beb_refri", "title": "Refrigerante", "description": "Lata 350ml" }
]
}
]
}
```
---
## 5. Enquete (Poll)
**Valor no select:** `poll`
**Endpoint:** `POST /v1/messages/send_poll`
**Descrição:** Cria uma enquete nativa do WhatsApp. O usuário vota sem precisar digitar. Suporta múltipla escolha via `selectableCount`.
### Frontend — `public/index.html` (linhas 139148)
```html
<!-- Formulário visível quando tipo = "poll" -->
<div id="formPoll" class="dispatch-form hidden">
<!-- Pergunta da enquete (título exibido pelo WhatsApp) -->
<div class="form-row">
<label>Pergunta</label>
<input type="text" id="pollName" placeholder="Qual sua linguagem favorita?">
</div>
<!-- Quantas opções o usuário pode selecionar (1 = escolha única) -->
<div class="form-row">
<label>Quantas pode escolher</label>
<input type="number" id="pollSelectable" value="1" min="1">
</div>
<!-- Lista de opções de resposta (mín. 2, máx. 12 — limite do WhatsApp) -->
<div class="form-row">
<label>Opções</label>
<div id="pollOptionsList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="pollOptions">
+ Adicionar opção
</button>
</div>
</div>
```
### Frontend — `public/app.js`
```js
// Cada opção de poll é um input de texto simples
function addPollOption() {
addRow('pollOptionsList', '<input type="text" placeholder="Opção" data-field="opt">');
}
function getPollPayload() {
const options = [];
document.querySelectorAll('#pollOptionsList .item-row input[data-field="opt"]').forEach((inp) => {
const v = inp.value.trim();
if (v) options.push(v);
});
return {
url: '/v1/messages/send_poll',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
name: document.getElementById('pollName').value.trim() || 'Enquete',
// Mínimo 2 opções; fallback se o usuário não preencheu
options: options.length >= 2 ? options : ['Sim', 'Não'],
selectableCount: parseInt(document.getElementById('pollSelectable').value, 10) || 1,
},
};
}
```
### Backend — `src/routes/messages.ts` (linhas 201236)
```ts
// POST /v1/messages/send_poll
router.post('/send_poll', async (req: Request, res: Response) => {
try {
const { instance = 'main', to, name, options, selectableCount } = req.body;
// Enquete exige ao menos 2 opções
if (!to || !name || !Array.isArray(options) || options.length < 2) {
return res.status(400).json({ ok: false, error: 'missing to/name/options (min 2)' });
}
// Limita a 12 opções (config.limits.maxPollOptions) e garante strings
const opts = options.slice(0, config.limits.maxPollOptions).map((o) =>
typeof o === 'string' ? o : String(o)
);
const ctx = validateInstance(instance, res);
if (!ctx) return;
const jid = toJid(to);
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
// Formato poll do InfiniteAPI/Baileys
const result = await ctx.sock.sendMessage(jid, {
poll: {
name: String(name), // pergunta exibida
values: opts, // array de strings com as opções
selectableCount: Math.min(
Math.max(1, selectableCount ?? 1), // mínimo 1
opts.length // máximo = total de opções
),
},
});
return res.json({ ok: true, messageId: result?.key?.id });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: message });
}
});
```
**Payload de exemplo:**
```json
{
"instance": "main",
"to": "5511999999999",
"name": "Qual seu framework favorito?",
"options": ["React", "Vue", "Angular", "Svelte"],
"selectableCount": 1
}
```
---
## 6. Carrossel
**Valor no select:** `carousel`
**Endpoint:** `POST /v1/messages/send_carousel_helpers`
**Descrição:** Mensagem com cards horizontais deslizáveis (`nativeCarousel`). Cada card pode ter título, corpo, imagem e botões próprios.
### Frontend — `public/index.html` (linhas 150159)
```html
<!-- Formulário visível quando tipo = "carousel" -->
<div id="formCarousel" class="dispatch-form hidden">
<!-- Texto opcional acima dos cards -->
<div class="form-row">
<label>Texto (acima dos cards)</label>
<input type="text" id="carouselText" placeholder="Ofertas especiais">
</div>
<div class="form-row">
<label>Rodapé</label>
<input type="text" id="carouselFooter">
</div>
<!-- Cada card é um bloco com título, descrição, imagem e botões próprios -->
<div class="form-row">
<label>Cards</label>
<div id="carouselCardsList" class="dynamic-list"></div>
<button type="button" class="btn btn-small btn-ghost add-item" data-for="carouselCards">
+ Adicionar card
</button>
</div>
</div>
```
### Frontend — `public/app.js`
```js
// Cria um bloco-card com campos próprios (title, body, footer, imageUrl) + botões internos
function addCarouselCard() {
const container = document.getElementById('carouselCardsList');
const block = document.createElement('div');
block.className = 'block-section';
block.innerHTML = `
<div class="block-title">Card</div>
<div class="form-row"><input type="text" placeholder="Título" data-field="title"></div>
<div class="form-row"><input type="text" placeholder="Corpo/descrição" data-field="body"></div>
<div class="form-row"><input type="text" placeholder="Rodapé" data-field="footer"></div>
<div class="form-row"><input type="text" placeholder="URL da imagem" data-field="imageUrl"></div>
<div class="sub-list card-buttons"></div>
<button type="button" class="btn btn-small btn-ghost add-card-btn">+ Botão no card</button>
<button type="button" class="btn btn-small btn-danger btn-remove-block">Remover card</button>
`;
// Botões dentro do card (quick reply específico do card)
block.querySelector('.add-card-btn').addEventListener('click', () => {
const row = document.createElement('div');
row.className = 'item-row';
row.innerHTML = `
<input type="text" placeholder="ID" data-field="id">
<input type="text" placeholder="Texto" data-field="text">
<button type="button" class="btn btn-small btn-ghost btn-remove">Remover</button>
`;
row.querySelector('.btn-remove').onclick = () => row.remove();
block.querySelector('.card-buttons').appendChild(row);
});
block.querySelector('.btn-remove-block').onclick = () => block.remove();
container.appendChild(block);
}
function getCarouselPayload() {
const cards = [];
document.querySelectorAll('#carouselCardsList .block-section').forEach((block) => {
const title = block.querySelector('[data-field="title"]')?.value?.trim();
const body = block.querySelector('[data-field="body"]')?.value?.trim();
const footer = block.querySelector('[data-field="footer"]')?.value?.trim();
const imageUrl = block.querySelector('[data-field="imageUrl"]')?.value?.trim();
const buttons = [];
// Coleta botões internos do card
block.querySelectorAll('.card-buttons .item-row').forEach((row) => {
const id = row.querySelector('[data-field="id"]')?.value?.trim();
const text = row.querySelector('[data-field="text"]')?.value?.trim();
if (id && text) buttons.push({ id, text });
});
cards.push({
title: title || '',
body: body || '',
footer: footer || undefined,
imageUrl: imageUrl || undefined,
// Fallback: ao menos 1 botão por card (exigência do InfiniteAPI)
buttons: buttons.length ? buttons : [{ id: 'btn1', text: 'Ver' }],
});
});
return {
url: '/v1/messages/send_carousel_helpers',
body: {
instance: document.getElementById('dispatchInstance').value,
to: document.getElementById('dispatchTo').value.trim(),
text: document.getElementById('carouselText').value.trim() || undefined,
footer: document.getElementById('carouselFooter').value.trim() || undefined,
cards: cards.length ? cards : [
{ title: 'Card', body: '', buttons: [{ id: 'b1', text: 'Botão' }] }
],
},
};
}
```
### Backend — `src/routes/messages.ts` (linhas 238289)
```ts
// POST /v1/messages/send_carousel_helpers
router.post('/send_carousel_helpers', async (req: Request, res: Response) => {
try {
const { instance = 'main', to, text, footer, cards } = req.body;
if (!to || !Array.isArray(cards) || cards.length === 0) {
return res.status(400).json({ ok: false, error: 'missing to/cards' });
}
// Limita a 10 cards (config.limits.maxCarouselCards)
const limited = cards.slice(0, config.limits.maxCarouselCards);
const ctx = validateInstance(instance, res);
if (!ctx) return;
const jid = toJid(to);
if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' });
// Formata cada card para o schema do InfiniteAPI/Baileys
const formattedCards = limited.map((card, idx) => ({
title: card.title ?? `Card ${idx + 1}`,
body: card.body ?? '',
footer: card.footer, // opcional
// Se imageUrl for fornecida, passa como { url } para o Baileys
image: card.imageUrl ? { url: card.imageUrl } : undefined,
buttons: (card.buttons ?? []).map((btn, bIdx) => ({
type: 'reply' as const,
id: btn.id ?? `card${idx}_btn${bIdx}`,
text: btn.text ?? 'Botão',
})),
}));
// nativeCarousel = array de cards deslizáveis
const result = await ctx.sock.sendMessage(jid, {
nativeCarousel: { cards: formattedCards },
text: text ? String(text) : undefined,
footer: footer ? String(footer) : undefined,
});
return res.json({ ok: true, format: 'nativeCarousel', messageId: result?.key?.id });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: message });
}
});
```
**Payload de exemplo:**
```json
{
"instance": "main",
"to": "5511999999999",
"text": "🔥 Ofertas especiais de hoje:",
"cards": [
{
"title": "Pizza Margherita",
"body": "Molho, mozzarella, manjericão",
"footer": "R$ 49,90",
"imageUrl": "https://exemplo.com/pizza.jpg",
"buttons": [{ "id": "pedir_pizza", "text": "🍕 Pedir agora" }]
},
{
"title": "Combo Bebida",
"body": "Refrigerante 2L + suco",
"footer": "R$ 19,90",
"buttons": [{ "id": "pedir_combo", "text": "🥤 Adicionar" }]
}
]
}
```
---
## Arquivo de Limites — `src/config.ts`
```ts
export const config = {
port: parseInt(process.env.PORT ?? '8787', 10),
apiKey: process.env.API_KEY ?? 'ACFH4RFOTME4RU50R4FKGNW34LDFG8DSQ',
authFolder: process.env.AUTH_FOLDER ?? 'auth',
limits: {
maxButtons: 3, // botões quick reply / CTA (types 2 e 3)
maxCarouselCards: 10, // cards no carrossel (type 6)
maxListSections: 10, // seções na lista dropdown (type 4)
maxListRowsPerSection: 10,// itens por seção na lista (type 4)
maxPollOptions: 12, // opções de enquete (type 5)
},
} as const;
```
---
## Tipos TypeScript envolvidos — `src/types/whatsapp.ts`
```ts
// Tipos dos botões nativos (usados em "buttons" e "interactive")
interface NativeButtonReply { type: 'reply'; id: string; text: string } // quick reply
interface NativeButtonUrl { type: 'url'; text: string; url: string } // abre link
interface NativeButtonCopy { type: 'copy'; text: string; copyText: string } // copia texto
interface NativeButtonCall { type: 'call'; text: string; phoneNumber: string } // disca
// MessageContent: tudo que pode ser passado para sock.sendMessage()
interface MessageContent {
text?: string;
footer?: string;
nativeButtons?: NativeButton[]; // tipos 2 e 3
nativeList?: { // tipo 4
buttonText: string;
sections: NativeListSection[];
};
nativeCarousel?: { cards: NativeCarouselCard[] }; // tipo 6
poll?: { // tipo 5
name: string;
values: string[];
selectableCount?: number;
};
}
```
---
## Helper `toJid` — `src/utils/helpers.ts`
```ts
// Converte número (+55 35 9882-8503 ou 553598828503) para JID do WhatsApp
export function toJid(phone: string | null | undefined): string | null {
if (!phone || typeof phone !== 'string') return null;
const cleaned = phone.replace(/\D/g, ''); // remove tudo que não é dígito
if (cleaned.length < 10) return null;
if (phone.includes('@')) return phone; // já é JID, retorna como está
return `${cleaned}@s.whatsapp.net`;
}
```