chore(ops): cleanly purge old clube67 folder references
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,43 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,87 +0,0 @@
|
||||
### 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.
|
||||
@@ -1,167 +0,0 @@
|
||||
# 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 30–60s 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.
|
||||
@@ -1,998 +0,0 @@
|
||||
# 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 82–159) |
|
||||
| **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 93–103)
|
||||
|
||||
```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 22–58)
|
||||
|
||||
```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 105–114)
|
||||
|
||||
```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 61–99)
|
||||
|
||||
```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 116–125)
|
||||
|
||||
```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 101–154)
|
||||
|
||||
```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 127–137)
|
||||
|
||||
```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 156–199)
|
||||
|
||||
```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 139–148)
|
||||
|
||||
```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 201–236)
|
||||
|
||||
```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 150–159)
|
||||
|
||||
```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 238–289)
|
||||
|
||||
```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`;
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user