diff --git a/.github/workflows/manager-v2.yml b/.github/workflows/manager-v2.yml new file mode 100644 index 00000000..4a1f4beb --- /dev/null +++ b/.github/workflows/manager-v2.yml @@ -0,0 +1,70 @@ +name: Manager V2 + +on: + pull_request: + paths: + - manager-v2/** + - pkg/routes/** + - Dockerfile + - .github/workflows/manager-v2.yml + push: + branches: + - feat/manager-v2-foundation + paths: + - manager-v2/** + - pkg/routes/** + - Dockerfile + - .github/workflows/manager-v2.yml + +permissions: + contents: read + +jobs: + build-manager-v2: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22.12" + cache: npm + cache-dependency-path: manager-v2/package.json + + - name: Install dependencies + working-directory: manager-v2 + run: npm install --no-audit --no-fund + + - name: Typecheck + working-directory: manager-v2 + run: npm run typecheck + + - name: Validate API catalog coverage + working-directory: manager-v2 + run: npm run check:catalog + + - name: Build frontend + working-directory: manager-v2 + run: npm run build + + - name: Validate embedded route + run: | + test -f manager-v2/dist/index.html + grep -q registerManagerV2Routes pkg/routes/routes.go + grep -q /manager-v2/assets pkg/routes/manager_v2.go + grep -q manager-v2-build Dockerfile + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Test Manager V2 routes + run: go test ./pkg/routes + + - name: Build server + run: go build -o /tmp/evolution-go-manager-v2 ./cmd/evolution-go diff --git a/.github/workflows/voip-integration.yml b/.github/workflows/voip-integration.yml new file mode 100644 index 00000000..cc01bebf --- /dev/null +++ b/.github/workflows/voip-integration.yml @@ -0,0 +1,57 @@ +name: VoIP integration + +on: + push: + branches: + - dev/astracalls-integration + - feat/manager-call-panel + pull_request: + paths: + - "cmd/evolution-go/main.go" + - "pkg/call/**" + - "pkg/routes/routes.go" + - "manager/dist/**" + - "go.mod" + - "go.sum" + - ".github/workflows/voip-integration.yml" + +permissions: + contents: read + +jobs: + test-call-module: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Validate Manager call panel + run: | + node --check manager/dist/assets/call-manager.js + grep -q '/assets/call-manager.css' manager/dist/index.html + grep -q '/assets/call-manager.js' manager/dist/index.html + grep -q 'evolution-call-pcm' manager/dist/assets/call-manager.js + grep -q '/call/status' manager/dist/assets/call-manager.js + + - name: Download dependencies + run: go mod download + + - name: Test default call build + run: go test -race ./pkg/call/... + + - name: Test experimental Pion relay build + run: go test -race -tags=voip_pion ./pkg/call/... + + - name: Build default server + run: go build -o /tmp/evolution-go-default ./cmd/evolution-go + + - name: Build experimental Pion server + run: go build -tags=voip_pion -o /tmp/evolution-go-pion ./cmd/evolution-go diff --git a/Dockerfile b/Dockerfile index 462ed49d..e2b5b4b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,11 @@ +FROM node:22-alpine AS manager-v2-build + +WORKDIR /manager-v2 +COPY manager-v2/package.json ./ +RUN npm install --no-audit --no-fund +COPY manager-v2/ ./ +RUN npm run typecheck && npm run build + FROM golang:1.25.0-alpine AS build RUN apk update && apk add --no-cache git build-base libjpeg-turbo-dev libwebp-dev @@ -15,7 +23,10 @@ RUN go mod download COPY . . ARG VERSION=dev -RUN CGO_ENABLED=1 go build -ldflags "-X main.version=${VERSION}" -o server ./cmd/evolution-go +# Mantém a imagem padrão sem tags. Para habilitar chamadas com Pion, use: +# docker build --build-arg GO_BUILD_TAGS=voip_pion ... +ARG GO_BUILD_TAGS="" +RUN CGO_ENABLED=1 go build -tags "${GO_BUILD_TAGS}" -ldflags "-X main.version=${VERSION}" -o server ./cmd/evolution-go FROM alpine:3.19.1 AS final @@ -26,6 +37,7 @@ WORKDIR /app COPY --from=build /build/server . COPY --from=build /build/manager/dist ./manager/dist +COPY --from=manager-v2-build /manager-v2/dist ./manager-v2/dist COPY --from=build /build/VERSION ./VERSION ENV TZ=America/Sao_Paulo diff --git a/cmd/evolution-go/main.go b/cmd/evolution-go/main.go index 5234583f..efcc97fe 100644 --- a/cmd/evolution-go/main.go +++ b/cmd/evolution-go/main.go @@ -22,6 +22,7 @@ import ( _ "modernc.org/sqlite" call_handler "github.com/evolution-foundation/evolution-go/pkg/call/handler" + call_lifecycle "github.com/evolution-foundation/evolution-go/pkg/call/lifecycle" call_service "github.com/evolution-foundation/evolution-go/pkg/call/service" chat_handler "github.com/evolution-foundation/evolution-go/pkg/chat/handler" chat_service "github.com/evolution-foundation/evolution-go/pkg/chat/service" @@ -179,6 +180,9 @@ func setupRouter(db *gorm.DB, authDB *sql.DB, sqliteDB *sql.DB, config *config.C natsProducer, loggerWrapper, ) + callCoordinator := call_lifecycle.NewCoordinator() + whatsmeowService.SetClientLifecycle(callCoordinator) + instanceService := instance_service.NewInstanceService( instanceRepository, killChannel, @@ -192,7 +196,7 @@ func setupRouter(db *gorm.DB, authDB *sql.DB, sqliteDB *sql.DB, config *config.C messageService := message_service.NewMessageService(clientPointer, messageRepository, whatsmeowService, loggerWrapper) chatService := chat_service.NewChatService(clientPointer, whatsmeowService, loggerWrapper) groupService := group_service.NewGroupService(clientPointer, whatsmeowService, loggerWrapper) - callService := call_service.NewCallService(clientPointer, whatsmeowService, loggerWrapper) + callService := call_service.NewCallService(clientPointer, whatsmeowService, loggerWrapper, callCoordinator) communityService := community_service.NewCommunityService(clientPointer, whatsmeowService, loggerWrapper) labelService := label_service.NewLabelService(clientPointer, whatsmeowService, labelRepository, loggerWrapper) newsletterService := newsletter_service.NewNewsletterService(clientPointer, whatsmeowService, loggerWrapper) diff --git a/docs/examples/call-webrtc-pcm.html b/docs/examples/call-webrtc-pcm.html new file mode 100644 index 00000000..65c6ecac --- /dev/null +++ b/docs/examples/call-webrtc-pcm.html @@ -0,0 +1,335 @@ + + + + + + Evolution Go — chamada WebRTC PCM + + + +

Chamada WebRTC PCM

+

Exemplo experimental. Use HTTPS ou localhost para liberar o microfone. A chamada do WhatsApp precisa estar no estado active e o servidor deve ser compilado com -tags=voip_pion.

+ +
+ + + +
+ + + +
+
+ +

Estado

+

+
+
+
+
diff --git a/docs/examples/call-webrtc-public-deploy.md b/docs/examples/call-webrtc-public-deploy.md
new file mode 100644
index 00000000..9b18b537
--- /dev/null
+++ b/docs/examples/call-webrtc-public-deploy.md
@@ -0,0 +1,140 @@
+# Implantação pública da ponte WebRTC de chamadas
+
+Este guia publica a ponte navegador ⇄ Evolution em uma única porta UDP/TCP, sem depender de STUN ou TURN externo.
+
+> A mídia do navegador só existe na build `voip_pion`. A chamada WhatsApp também precisa chegar ao estado `active` antes da criação da sessão WebRTC.
+
+## Variáveis obrigatórias
+
+Configure o IPv4 anunciado e a porta de mídia:
+
+```env
+CALL_WEBRTC_PUBLIC_IP=203.0.113.10
+CALL_WEBRTC_MEDIA_PORT=50000
+```
+
+Em uma VPS cujo IPv4 público está diretamente associado à interface, também é possível usar:
+
+```env
+CALL_WEBRTC_PUBLIC_IP=auto
+CALL_WEBRTC_MEDIA_PORT=50000
+```
+
+`auto` detecta o IPv4 escolhido pela rota padrão. Em servidores atrás de NAT, balanceador ou encaminhamento de porta, use explicitamente o endereço externo.
+
+As duas variáveis devem ser definidas juntas. Configuração parcial, endereço inválido, porta inválida ou falha de bind impedem a criação da sessão WebRTC.
+
+## Compilação direta
+
+```bash
+go build -tags=voip_pion -o evolution-go ./cmd/evolution-go
+
+CALL_WEBRTC_PUBLIC_IP=203.0.113.10 \
+CALL_WEBRTC_MEDIA_PORT=50000 \
+./evolution-go
+```
+
+## Imagem Docker
+
+O Dockerfile mantém a build padrão quando nenhum argumento é informado. Para incluir o Pion:
+
+```bash
+docker build \
+  --build-arg GO_BUILD_TAGS=voip_pion \
+  -t evolution-go:voip-pion .
+```
+
+### Rede host
+
+É a opção mais simples em uma VPS Linux:
+
+```bash
+docker run --rm \
+  --network host \
+  -e CALL_WEBRTC_PUBLIC_IP=203.0.113.10 \
+  -e CALL_WEBRTC_MEDIA_PORT=50000 \
+  evolution-go:voip-pion
+```
+
+### Encaminhamento explícito
+
+Quando a rede host não estiver disponível, publique a mesma porta nos dois protocolos:
+
+```bash
+docker run --rm \
+  -p 8080:8080/tcp \
+  -p 50000:50000/udp \
+  -p 50000:50000/tcp \
+  -e CALL_WEBRTC_PUBLIC_IP=203.0.113.10 \
+  -e CALL_WEBRTC_MEDIA_PORT=50000 \
+  evolution-go:voip-pion
+```
+
+Exemplo equivalente em Compose:
+
+```yaml
+services:
+  evolution:
+    build:
+      context: .
+      args:
+        GO_BUILD_TAGS: voip_pion
+    environment:
+      CALL_WEBRTC_PUBLIC_IP: 203.0.113.10
+      CALL_WEBRTC_MEDIA_PORT: "50000"
+    ports:
+      - "8080:8080/tcp"
+      - "50000:50000/udp"
+      - "50000:50000/tcp"
+```
+
+## Firewall e proxy
+
+Libere no firewall ou security group:
+
+```text
+UDP 50000 entrada
+TCP 50000 entrada
+```
+
+Traefik, Nginx ou Caddy podem publicar a API e a página em HTTPS, mas não devem intermediar a porta ICE. O tráfego WebRTC chega diretamente ao Evolution:
+
+```text
+navegador ── UDP 50000 ──► Evolution
+          └─ TCP 50000 ──► Evolution, quando UDP falha
+```
+
+O HTTPS continua obrigatório para que navegadores remotos liberem `getUserMedia`.
+
+## Comportamento do runtime
+
+Quando as variáveis estão configuradas, o processo cria uma única API Pion compartilhada e:
+
+- anuncia o IPv4 configurado por NAT 1:1;
+- usa `ICEUDPMux` na porta fixa;
+- usa `ICETCPMux` passivo na mesma porta;
+- compartilha os muxes entre todas as sessões e chamadas;
+- mantém os limites de quatro sessões por chamada e oito frames por fila;
+- não entrega chaves WhatsApp ou SRTP ao navegador.
+
+Sem as variáveis, a ponte continua usando candidatos host e portas efêmeras para desenvolvimento local.
+
+## Quando TURN ainda é necessário
+
+Esta estratégia cobre VPSs e servidores com uma porta pública encaminhável. TURN ainda pode ser necessário quando:
+
+- o servidor está atrás de CGNAT sem encaminhamento;
+- a rede do navegador bloqueia UDP e também ICE-TCP nessa porta;
+- somente tráfego por 443 através de um relay é permitido;
+- a implantação exige compatibilidade máxima em redes corporativas restritas.
+
+## Checklist de validação
+
+1. Confirme que a imagem foi compilada com `GO_BUILD_TAGS=voip_pion`.
+2. Confirme que UDP e TCP estão liberados na porta configurada.
+3. Inicie o Evolution com as duas variáveis.
+4. Verifique o log `browser WebRTC fixed ICE endpoint enabled`.
+5. Deixe a chamada WhatsApp chegar a `active`.
+6. Abra `docs/examples/call-webrtc-pcm.html` por HTTPS.
+7. Crie a sessão e confirme candidatos com o IPv4 e a porta pública no SDP answer.
+8. Teste microfone e reprodução a partir de outra rede.
diff --git a/docs/examples/manager-call-panel.md b/docs/examples/manager-call-panel.md
new file mode 100644
index 00000000..dd4809bb
--- /dev/null
+++ b/docs/examples/manager-call-panel.md
@@ -0,0 +1,74 @@
+# Painel de chamadas no `/manager`
+
+O Manager carrega um módulo de chamadas independente do bundle React existente. Um botão de telefone aparece no canto inferior direito de `/manager` e usa as rotas autenticadas de chamadas da instância.
+
+## Requisitos
+
+- servidor compilado com `-tags=voip_pion`;
+- instância WhatsApp conectada;
+- página do Manager em HTTPS ou `localhost` para acesso ao microfone;
+- para redes diferentes, `CALL_WEBRTC_PUBLIC_IP` e `CALL_WEBRTC_MEDIA_PORT` configurados e a porta liberada em UDP e TCP.
+
+## Uso
+
+1. Abra `https://SEU_DOMINIO/manager`.
+2. Clique no botão de telefone.
+3. Confirme a URL da API. Quando o Manager e a API usam o mesmo domínio, o valor padrão já é correto.
+4. Informe a API key da instância.
+5. Clique em **Salvar e consultar**.
+6. Digite o número completo com DDI e clique em **Ligar**.
+7. Quando a chamada ficar `active`, o painel tenta conectar o áudio automaticamente. Também é possível usar **Conectar áudio** manualmente.
+
+O painel permite:
+
+- iniciar chamadas de voz;
+- acompanhar `ringing`, `connecting`, `active`, `ended` e `failed`;
+- atender ou recusar chamadas recebidas;
+- conectar microfone e alto-falante pelo DataChannel PCM;
+- silenciar o microfone;
+- encerrar a chamada;
+- consultar contadores de frames enviados, recebidos e descartados;
+- visualizar diagnóstico local.
+
+## Armazenamento da chave
+
+Por padrão, a configuração fica em `sessionStorage` e desaparece quando a sessão do navegador é encerrada. A opção **Salvar chave neste navegador** usa `localStorage` para manter a API key entre acessos.
+
+Não habilite a persistência em computadores compartilhados. A chave nunca é colocada na URL ou no conteúdo do log do painel.
+
+## Implementação
+
+O Manager versionado contém apenas o bundle compilado original. Por isso, o módulo foi integrado como assets isolados:
+
+```text
+manager/dist/assets/call-manager.js
+manager/dist/assets/call-manager.css
+```
+
+E carregado por:
+
+```text
+manager/dist/index.html
+```
+
+O módulo usa o mesmo protocolo da página de teste independente:
+
+```text
+DataChannel: evolution-call-pcm
+Protocol: evcall.pcm.v1
+PCM: float32 little-endian, mono, 16 kHz
+Frame nominal: 960 amostras / 60 ms
+```
+
+## Diagnóstico
+
+Se o painel mostrar `501`, a imagem foi compilada sem `voip_pion`.
+
+Se a chamada fica ativa, mas o canal de áudio não abre:
+
+- confirme o log `browser WebRTC fixed ICE endpoint enabled`;
+- libere `CALL_WEBRTC_MEDIA_PORT` em UDP e TCP;
+- confira os candidatos em `chrome://webrtc-internals`;
+- confirme que o Manager está sendo acessado por HTTPS.
+
+O painel fecha tracks do microfone, AudioContext, DataChannel e PeerConnection ao desconectar o áudio. O backend também remove a sessão WebRTC quando a chamada termina, a instância desconecta ou o cliente WhatsApp é substituído.
diff --git a/docs/wiki/guias-api/api-calls-experimental.md b/docs/wiki/guias-api/api-calls-experimental.md
new file mode 100644
index 00000000..f5d3e65c
--- /dev/null
+++ b/docs/wiki/guias-api/api-calls-experimental.md
@@ -0,0 +1,356 @@
+# API de chamadas — integração experimental
+
+Esta branch adiciona a integração experimental WaCalls/AstraCalls ao Evolution Go.
+
+> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. A variante `voip_pion` negocia DataChannels com os relays do WhatsApp, processa RTP/SRTP autenticado, usa codec MLow com jitter/PLC e oferece uma ponte WebRTC PCM para microfone e reprodução no navegador. A conexão com relays reais ainda precisa de validação ponta a ponta. Não use em produção.
+
+Todas as rotas usam a autenticação normal da instância do Evolution.
+
+## Consultar o runtime
+
+```http
+GET /call/status
+apikey: INSTANCE_TOKEN
+```
+
+Os monitores são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, handlers, DataChannels, sessões RTP/SRTP, jitter buffers, codecs, sessões WebRTC e material privado são removidos antes que o novo cliente seja registrado.
+
+Exemplo de resposta:
+
+```json
+{
+  "instanceId": "INSTANCE_ID",
+  "connected": true,
+  "calls": []
+}
+```
+
+Chaves de chamada, JIDs internos de dispositivos, chaves SRTP, tokens de relay, pacotes enfileirados e buffers PCM nunca fazem parte dessa resposta.
+
+## Iniciar uma chamada
+
+```http
+POST /call/start
+Content-Type: application/json
+apikey: INSTANCE_TOKEN
+```
+
+```json
+{
+  "number": "5511999999999",
+  "video": false
+}
+```
+
+A oferta é enviada como uma consulta do protocolo. Quando o ACK contém relays estruturados, a chave gerada, os participantes e os candidatos são copiados para o registro privado da chamada.
+
+A resposta HTTP `201` contém somente o estado público:
+
+```json
+{
+  "id": "32_CHARACTER_CALL_ID",
+  "peer": "5511999999999:DEVICE@s.whatsapp.net",
+  "direction": "outgoing",
+  "state": "ringing",
+  "video": false,
+  "createdAt": "2026-07-31T23:00:00Z",
+  "updatedAt": "2026-07-31T23:00:00Z"
+}
+```
+
+## Aceitar uma chamada recebida
+
+```http
+POST /call/{callId}/accept
+apikey: INSTANCE_TOKEN
+```
+
+O runtime descriptografa a chave recebida usando a sessão Signal já autenticada, envia `preaccept` automaticamente e mantém o material somente na memória privada. O endpoint envia a stanza `accept` e retorna a chamada no estado `connecting`.
+
+`CallAccept` não marca a chamada como `active` sozinho. Na variante Pion, `active` é publicado somente depois que o relay abre e as sessões RTP/SRTP, jitter e MLow são criadas com sucesso.
+
+## Encerrar ou rejeitar
+
+```http
+DELETE /call/{callId}
+apikey: INSTANCE_TOKEN
+```
+
+A rota envia `terminate`, muda o estado público para `ended` e remove relays, contextos criptográficos, codec, jitter, buffers PCM e sessões WebRTC ligadas à chamada.
+
+A rota de rejeição existente foi preservada:
+
+```http
+POST /call/reject
+Content-Type: application/json
+apikey: INSTANCE_TOKEN
+```
+
+```json
+{
+  "callCreator": "5511999999999@s.whatsapp.net",
+  "callId": "CALL_ID"
+}
+```
+
+## Estados rastreados
+
+O snapshot público usa `ringing`, `connecting`, `active`, `ended` e `failed`.
+
+Internamente, a negociação usa uma máquina estrita com `initiating`, `ringing`, `incoming_ringing`, `connecting`, `active`, `on_hold` e `ended`. Transições inválidas são rejeitadas sem alterar o estado.
+
+## Relay e transporte Pion
+
+O módulo reconhece candidatos com atributos diretos e respostas estruturadas `te2`. Os candidatos são ordenados pelo menor RTT e associados ao material privado pelo `callId`.
+
+A implementação Pion experimental inclui:
+
+- PeerConnection e DataChannel `wa-web-call` por relay;
+- transformação do SDP para credenciais e fingerprint do WhatsApp;
+- registro STUN com subscriptions de SSRC;
+- allocation, retries e keepalive;
+- broadcast e recebimento de frames;
+- timeout, fechamento e limpeza de buffers;
+- SSRC determinístico por `callId` e JID de dispositivo.
+
+## RTP e SRTP
+
+O caminho de pacotes inclui:
+
+- RTP versão 2 com CSRC, extensões e padding validados;
+- payload type `120`;
+- derivação HKDF-SHA256 por dispositivo;
+- AES-CTR e HMAC-SHA1 truncado;
+- autenticação verificada antes da descriptografia;
+- rollover counter na transição `65535 → 0`;
+- janela antirreplay de 64 pacotes;
+- pacotes autenticados fora de ordem;
+- rejeição de reutilização do índice de envio;
+- sessão independente por `callId`;
+- limpeza sincronizada durante término, rejeição, logout ou reconexão.
+
+## Jitter buffer e perda de pacotes
+
+Cada chamada possui um jitter buffer antes do decoder MLow. A configuração padrão atual é fixa:
+
+- frames de 60 ms;
+- atraso inicial de dois pacotes, aproximadamente 120 ms;
+- limite de 64 pacotes enfileirados;
+- até cinco frames consecutivos de concealment por lacuna.
+
+O buffer usa sequência estendida para rollover, aceita pacotes fora de ordem antes do prazo, contabiliza duplicatas/atrasados/overflow e chama `Decode(nil)` somente quando um pacote futuro confirma a lacuna. Ele não fabrica áudio ao final do fluxo.
+
+## Codec MLow e PCM
+
+O codec MLow em Go puro foi portado da revisão MIT fixa `edeb31f0427aba896639db503153b777a405eccf` do WaCalls. Não há dependência de CGO ou `libopus`.
+
+O pipeline:
+
+- aceita PCM mono `float32` em 16 kHz;
+- acumula frames de 960 amostras/60 ms;
+- sanitiza `NaN`, infinito e amplitudes fora de `[-1, 1]`;
+- codifica MLow e envia por RTP/SRTP;
+- reordena e aplica PLC antes do decode recebido;
+- entrega PCM por callback interno;
+- serializa encoder/decoder por chamada;
+- espera envio e playout antes do teardown.
+
+## Ponte WebRTC do navegador
+
+A ponte do navegador usa WebRTC para fornecer DTLS/SCTP, mas transmite PCM em um DataChannel em vez de uma media track. Isso evita uma segunda pilha Opus no servidor e reutiliza diretamente o pipeline MLow existente.
+
+Ela só está disponível na build `voip_pion`. A build padrão responde `501 Not Implemented`.
+
+### Criar sessão
+
+A chamada deve estar em `active`.
+
+```http
+POST /call/{callId}/webrtc
+Content-Type: application/json
+apikey: INSTANCE_TOKEN
+```
+
+```json
+{
+  "offer": {
+    "type": "offer",
+    "sdp": "v=0\r\n..."
+  }
+}
+```
+
+O navegador deve criar previamente um DataChannel com:
+
+```text
+label: evolution-call-pcm
+protocol: evcall.pcm.v1
+ordered: true
+```
+
+Resposta `201`:
+
+```json
+{
+  "sessionId": "UUID",
+  "answer": {
+    "type": "answer",
+    "sdp": "v=0\r\n..."
+  },
+  "audio": {
+    "dataChannel": "evolution-call-pcm",
+    "protocol": "evcall.pcm.v1",
+    "format": "f32le",
+    "sampleRate": 16000,
+    "channels": 1,
+    "frameSamples": 960
+  }
+}
+```
+
+A API espera uma oferta completa com os candidatos ICE já coletados. Não há endpoint de trickle ICE nesta etapa.
+
+### Listar e fechar sessões
+
+```http
+GET /call/{callId}/webrtc
+apikey: INSTANCE_TOKEN
+```
+
+A resposta contém estado, frames de entrada/saída e descartes por sessão.
+
+```http
+DELETE /call/{callId}/webrtc/{sessionId}
+apikey: INSTANCE_TOKEN
+```
+
+Há limite de quatro sessões por chamada. Todas são fechadas automaticamente quando a chamada termina, é rejeitada, a instância desconecta ou o cliente WhatsApp é substituído.
+
+### Framing PCM `EVPC` versão 1
+
+Cada mensagem binária possui:
+
+| Offset | Tamanho | Campo |
+|---:|---:|---|
+| 0 | 4 | magic ASCII `EVPC` |
+| 4 | 1 | versão `1` |
+| 5 | 1 | tipo `1` para PCM |
+| 6 | 2 | flags, atualmente zero, little-endian |
+| 8 | 4 | sample rate `16000`, little-endian |
+| 12 | 4 | número de amostras, little-endian |
+| 16 | variável | amostras `float32` little-endian |
+
+O servidor aceita no máximo 3840 amostras por mensagem. O frame nominal contém 960 amostras. Filas internas possuem oito frames e o envio é descartado quando o buffer SCTP ultrapassa 512 KiB.
+
+### Exemplo pronto
+
+Abra o arquivo:
+
+```text
+docs/examples/call-webrtc-pcm.html
+```
+
+Ele implementa:
+
+- troca SDP autenticada;
+- `getUserMedia` com cancelamento de eco, redução de ruído e ganho automático;
+- resampling da taxa do `AudioContext` para 16 kHz;
+- envio em frames de 960 amostras;
+- resampling de 16 kHz para a taxa do dispositivo;
+- reprodução por `AudioWorklet`;
+- mute, backpressure e encerramento da sessão.
+
+O navegador exige HTTPS ou `localhost` para liberar o microfone.
+
+### Redes diferentes sem TURN
+
+Sem configuração adicional, o Pion mantém candidatos host e portas efêmeras, adequado para desenvolvimento local. Para publicar a ponte diretamente na internet, configure as duas variáveis antes de iniciar o processo:
+
+```env
+CALL_WEBRTC_PUBLIC_IP=203.0.113.10
+CALL_WEBRTC_MEDIA_PORT=50000
+```
+
+Também é aceito:
+
+```env
+CALL_WEBRTC_PUBLIC_IP=auto
+CALL_WEBRTC_MEDIA_PORT=50000
+```
+
+`auto` consulta o endereço IPv4 escolhido pela rota padrão. Ele é apropriado quando o Evolution roda diretamente em uma VPS com o IP público na interface. Em máquinas atrás de NAT, informe explicitamente o endereço externo encaminhado.
+
+Quando as variáveis estão presentes, o runtime:
+
+- anuncia `CALL_WEBRTC_PUBLIC_IP` como candidato ICE host por NAT 1:1;
+- abre `0.0.0.0:CALL_WEBRTC_MEDIA_PORT` em UDP;
+- abre a mesma porta em TCP para fallback ICE-TCP passivo;
+- usa um `ICEUDPMux` e um `ICETCPMux` compartilhados por todas as sessões;
+- mantém a troca SDP completa, sem trickle ICE;
+- não depende de um servidor STUN/TURN externo.
+
+As duas variáveis são obrigatórias em conjunto. Endereço, porta ou bind inválidos fazem a criação da sessão WebRTC falhar explicitamente; o sistema não troca silenciosamente para portas efêmeras.
+
+No firewall ou security group, libere a mesma porta nos dois protocolos:
+
+```text
+UDP 50000 entrada
+TCP 50000 entrada
+```
+
+Em Docker, use rede host ou encaminhe a porta fixa diretamente:
+
+```yaml
+ports:
+  - "50000:50000/udp"
+  - "50000:50000/tcp"
+```
+
+Traefik, Nginx e outros proxies HTTP continuam responsáveis apenas por HTTPS/API. A mídia ICE chega diretamente à porta UDP/TCP configurada.
+
+TURN ainda pode ser necessário em redes corporativas que bloqueiem tanto UDP quanto ICE-TCP para portas externas, ou quando o servidor não possui qualquer porta publicamente encaminhável.
+
+## Build experimental
+
+```bash
+go build ./cmd/evolution-go
+go build -tags=voip_pion ./cmd/evolution-go
+```
+
+Exemplo de execução pública:
+
+```bash
+CALL_WEBRTC_PUBLIC_IP=203.0.113.10 \
+CALL_WEBRTC_MEDIA_PORT=50000 \
+./evolution-go
+```
+
+O workflow testa permanentemente as duas variantes:
+
+```bash
+go test -race ./pkg/call/...
+go test -race -tags=voip_pion ./pkg/call/...
+```
+
+## Segurança e limites
+
+- todas as rotas SDP são autenticadas pela instância;
+- a chamada deve estar `active` antes de criar a sessão;
+- ofertas SDP acima de 256 KiB são rejeitadas;
+- mensagens de texto, labels/protocolos incorretos e frames PCM inválidos são descartados;
+- filas e buffer SCTP são limitados;
+- payloads e PCM temporários são sobrescritos antes do descarte;
+- chaves de chamada e SRTP nunca são entregues ao navegador;
+- nenhuma rota HTTP recebe áudio bruto;
+- a porta de mídia aceita tráfego ICE público e deve ser protegida por firewall contra origens e volumes abusivos.
+
+## Limitações atuais
+
+- falta validar uma chamada real ponta a ponta com relay WhatsApp;
+- o exemplo realiza resampling linear, ainda sem filtro de alta qualidade;
+- a publicação de mídia fixa atualmente aceita somente IPv4;
+- não há TURN integrado para redes que bloqueiem UDP e ICE-TCP;
+- jitter do WhatsApp ainda é estático, sem ajuste adaptativo pela rede;
+- sessões e chaves ficam apenas em memória;
+- publicação normalizada de estados nos produtores de eventos continua pendente;
+- API e framing permanecem experimentais enquanto o PR estiver em rascunho.
\ No newline at end of file
diff --git a/go.mod b/go.mod
index c1c97f6a..c4c4936f 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
 	github.com/minio/minio-go/v7 v7.0.80
 	github.com/nats-io/nats.go v1.39.0
 	github.com/patrickmn/go-cache v2.1.0+incompatible
+	github.com/pion/webrtc/v4 v4.2.15
 	github.com/rabbitmq/amqp091-go v1.10.0
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/swaggo/files v1.0.1
@@ -76,6 +77,21 @@ require (
 	github.com/ncruces/go-strftime v0.1.9 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.3 // indirect
 	github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
+	github.com/pion/datachannel v1.6.0 // indirect
+	github.com/pion/dtls/v3 v3.1.4 // indirect
+	github.com/pion/ice/v4 v4.2.7 // indirect
+	github.com/pion/interceptor v0.1.45 // indirect
+	github.com/pion/logging v0.2.4 // indirect
+	github.com/pion/mdns/v2 v2.1.0 // indirect
+	github.com/pion/randutil v0.1.0 // indirect
+	github.com/pion/rtcp v1.2.16 // indirect
+	github.com/pion/rtp v1.10.2 // indirect
+	github.com/pion/sctp v1.10.0 // indirect
+	github.com/pion/sdp/v3 v3.0.18 // indirect
+	github.com/pion/srtp/v3 v3.0.11 // indirect
+	github.com/pion/stun/v3 v3.1.5 // indirect
+	github.com/pion/transport/v4 v4.0.2 // indirect
+	github.com/pion/turn/v5 v5.0.9 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/rogpeppe/go-internal v1.12.0 // indirect
 	github.com/rs/xid v1.6.0 // indirect
@@ -83,6 +99,7 @@ require (
 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
 	github.com/ugorji/go/codec v1.2.12 // indirect
 	github.com/vektah/gqlparser/v2 v2.5.27 // indirect
+	github.com/wlynxg/anet v0.0.5 // indirect
 	go.mau.fi/libsignal v0.2.2 // indirect
 	go.mau.fi/util v0.9.10 // indirect
 	golang.org/x/arch v0.10.0 // indirect
@@ -90,6 +107,7 @@ require (
 	golang.org/x/sync v0.21.0 // indirect
 	golang.org/x/sys v0.46.0 // indirect
 	golang.org/x/text v0.38.0 // indirect
+	golang.org/x/time v0.14.0 // indirect
 	golang.org/x/tools v0.46.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
diff --git a/go.sum b/go.sum
index c8e3eefc..0061db56 100644
--- a/go.sum
+++ b/go.sum
@@ -136,6 +136,40 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH
 github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
 github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
 github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
+github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
+github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
+github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
+github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
+github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao=
+github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY=
+github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo=
+github.com/pion/interceptor v0.1.45/go.mod h1:gNDYM/uFKcLe/B3gS2/7+aw6z+RDiMy2qKTnF1LO31w=
+github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
+github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
+github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
+github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
+github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
+github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
+github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
+github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
+github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
+github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
+github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg=
+github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
+github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
+github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
+github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ=
+github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
+github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8=
+github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
+github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
+github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
+github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
+github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
+github.com/pion/turn/v5 v5.0.9 h1:zNeBfRyzGn7MPyUTvmvxeltLEjlFdSLPT1tlakoaOXM=
+github.com/pion/turn/v5 v5.0.9/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E=
+github.com/pion/webrtc/v4 v4.2.15 h1:Ir/MauNFCfg+kgyBYPQLiGdVWFlzEcLxqtuzAkYkky0=
+github.com/pion/webrtc/v4 v4.2.15/go.mod h1:CPTcyLfIzC4scOkQ4UY4pj6WvbUGhcNLIpK28cP5h6M=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
@@ -176,6 +210,8 @@ github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTd
 github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
 github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI=
 github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
+github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
+github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 go.mau.fi/libsignal v0.2.2 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU=
 go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0=
@@ -227,6 +263,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
 golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
diff --git a/manager-v2/.keep b/manager-v2/.keep
new file mode 100644
index 00000000..dfb88c57
--- /dev/null
+++ b/manager-v2/.keep
@@ -0,0 +1 @@
+foundation
\ No newline at end of file
diff --git a/manager-v2/README.md b/manager-v2/README.md
new file mode 100644
index 00000000..705b2e54
--- /dev/null
+++ b/manager-v2/README.md
@@ -0,0 +1,147 @@
+# Evolution GO API Test Manager
+
+Interface de desenvolvimento do Evolution GO, escrita do zero em React e TypeScript.
+
+O Manager V2 não é uma caixa de entrada nem um sistema de atendimento. Ele existe para:
+
+- criar ou configurar o acesso a uma instância;
+- conectar e salvar a sessão do WhatsApp;
+- gerar QR Code ou código de pareamento;
+- testar as funções públicas da API;
+- inspecionar respostas e erros;
+- reproduzir chamadas com cURL;
+- validar telefonia WebRTC no navegador.
+
+## Princípios
+
+- nenhuma dependência do bundle compilado do Manager legado;
+- nenhuma cópia de componentes do AstraCalls/AGPL;
+- `/manager` permanece disponível como fallback;
+- `/manager-v2` é uma ferramenta técnica de conexão e testes;
+- nenhuma API nova é inventada apenas para alimentar a interface;
+- cada rota nova registrada no backend deve entrar no catálogo do API Lab.
+
+## Áreas
+
+### Instância
+
+O perfil de conexão armazena:
+
+- URL do Evolution GO;
+- ID da instância;
+- API key da instância;
+- API key global opcional;
+- preferência para salvar permanentemente ou apenas durante a sessão do navegador.
+
+A tela possui ações rápidas para:
+
+- consultar status;
+- conectar;
+- gerar QR Code;
+- reconectar;
+- desconectar;
+- fazer logout.
+
+O API Lab também contém as rotas administrativas para criar, listar, consultar, excluir e configurar proxy de instâncias.
+
+### API Lab
+
+O catálogo cobre as rotas registradas atualmente no roteador Go, incluindo:
+
+- servidor e instâncias;
+- texto, link, mídia, figurinha, localização e contato;
+- botões reply, copy, URL, call e PIX;
+- listas e carrosséis;
+- enquetes e status;
+- usuários, privacidade, perfil e bloqueios;
+- ações de mensagens e chats;
+- grupos e comunidades;
+- labels;
+- newsletters;
+- chamadas e sessões WebRTC.
+
+Cada operação fornece:
+
+- exemplo inicial de payload;
+- método HTTP editável;
+- caminho editável;
+- autenticação por chave da instância, global ou sem chave;
+- corpo JSON, multipart ou sem corpo;
+- upload de arquivo com nome de campo editável;
+- status HTTP e duração;
+- resposta completa;
+- cURL equivalente;
+- histórico dos últimos testes da sessão.
+
+A operação **Requisição personalizada** permite testar rotas novas ou variações sem esperar uma tela específica.
+
+### Chamadas
+
+A central de voz é um teste especializado para recursos que não podem ser validados apenas com JSON:
+
+- início, aceite, recusa e encerramento;
+- acompanhamento de `/call/status`;
+- criação da sessão WebRTC;
+- captura e reprodução PCM por AudioWorklet;
+- mute;
+- contadores de frames enviados, recebidos e descartados;
+- diagnóstico local de WebRTC, relay e SRTP.
+
+HTTPS é necessário para acesso ao microfone fora de `localhost`.
+
+## Cobertura automática das rotas
+
+O comando abaixo compara `pkg/routes/routes.go` com o catálogo do frontend:
+
+```bash
+npm run check:catalog
+```
+
+O CI falha quando uma rota registrada no Evolution GO não possui entrada no API Lab. Rotas de interface, favicon e Swagger são ignoradas.
+
+## Desenvolvimento
+
+```bash
+cd manager-v2
+npm install
+npm run check:catalog
+npm run typecheck
+npm run dev
+```
+
+O Vite inicia em:
+
+```text
+http://localhost:5173/manager-v2/
+```
+
+## Build
+
+```bash
+npm run check:catalog
+npm run typecheck
+npm run build
+```
+
+O resultado é criado em `manager-v2/dist`.
+
+## Docker e publicação
+
+O Dockerfile compila o frontend em um estágio Node separado e copia o resultado para a imagem final. O servidor Go publica:
+
+```text
+/manager     Manager legado
+/manager-v2  API Test Manager
+```
+
+Para compilar com telefonia Pion:
+
+```bash
+docker build --build-arg GO_BUILD_TAGS=voip_pion -t evolution-go:manager-v2 .
+```
+
+Depois do deploy:
+
+```text
+https://SEU_DOMINIO/manager-v2
+```
diff --git a/manager-v2/index.html b/manager-v2/index.html
new file mode 100644
index 00000000..5f9a5ebf
--- /dev/null
+++ b/manager-v2/index.html
@@ -0,0 +1,14 @@
+
+
+  
+    
+    
+    
+    
+    Evolution GO Manager V2
+  
+  
+    
+ + + diff --git a/manager-v2/package.json b/manager-v2/package.json new file mode 100644 index 00000000..e75023e7 --- /dev/null +++ b/manager-v2/package.json @@ -0,0 +1,23 @@ +{ + "name": "evolution-go-manager-v2", + "private": true, + "version": "0.2.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc -b && vite build", + "typecheck": "tsc -b --pretty false", + "check:catalog": "node scripts/check-api-catalog.mjs", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.9.2", + "vite": "^8.1.5" + } +} diff --git a/manager-v2/scripts/check-api-catalog.mjs b/manager-v2/scripts/check-api-catalog.mjs new file mode 100644 index 00000000..3520bffb --- /dev/null +++ b/manager-v2/scripts/check-api-catalog.mjs @@ -0,0 +1,44 @@ +import { readFileSync } from "node:fs"; + +const routesSource = readFileSync(new URL("../../pkg/routes/routes.go", import.meta.url), "utf8"); +const catalogSource = readFileSync(new URL("../src/api-catalog.ts", import.meta.url), "utf8"); + +const ignored = new Set([ + "/favicon.ico", + "/manager", + "/manager/*any", +]); + +const routePaths = new Set(); +let currentGroup = ""; +for (const line of routesSource.split(/\r?\n/)) { + const groupMatch = line.match(/routes\s*(?::=|=)\s*eng\.Group\("([^"]+)"\)/); + if (groupMatch) { + currentGroup = groupMatch[1]; + continue; + } + + const groupedRoute = line.match(/routes\.(?:GET|POST|PUT|PATCH|DELETE)\("([^"]+)"/); + if (groupedRoute) { + routePaths.add(`${currentGroup}${groupedRoute[1]}`); + continue; + } + + const directRoute = line.match(/eng\.(?:GET|POST|PUT|PATCH|DELETE)\("([^"]+)"/); + if (directRoute && !directRoute[1].startsWith("/swagger") && !ignored.has(directRoute[1])) { + routePaths.add(directRoute[1]); + } +} + +const catalogPaths = new Set( + Array.from(catalogSource.matchAll(/"(\/[^"\s]+)"/g), (match) => match[1]), +); + +const missing = Array.from(routePaths).filter((path) => !catalogPaths.has(path)).sort(); +if (missing.length > 0) { + console.error("API Lab is missing registered routes:"); + missing.forEach((path) => console.error(`- ${path}`)); + process.exit(1); +} + +console.log(`API catalog covers ${routePaths.size} registered routes.`); diff --git a/manager-v2/src/api-catalog.ts b/manager-v2/src/api-catalog.ts new file mode 100644 index 00000000..679a86fc --- /dev/null +++ b/manager-v2/src/api-catalog.ts @@ -0,0 +1,152 @@ +import type { ApiAuthMode } from "./api"; + +export type BodyMode = "none" | "json" | "multipart"; + +export type ApiOperation = { + id: string; + category: string; + title: string; + method: string; + path: string; + auth: ApiAuthMode; + bodyMode: BodyMode; + sample?: unknown; + description: string; + fileField?: string; +}; + +const quoted = { messageId: "", participant: "" }; +const commonSend = { delay: 0, mentionAll: false, mentionedJid: [], formatJid: true, quoted }; + +function op( + id: string, + category: string, + title: string, + method: string, + path: string, + description: string, + options: Partial> = {}, +): ApiOperation { + return { + id, + category, + title, + method, + path, + description, + auth: options.auth ?? "instance", + bodyMode: options.bodyMode ?? (["GET", "DELETE"].includes(method) ? "none" : "json"), + sample: options.sample, + fileField: options.fileField, + }; +} + +export const API_OPERATIONS: ApiOperation[] = [ + op("custom", "Personalizado", "Requisição personalizada", "POST", "/send/text", "Edite método, caminho, autenticação e corpo para testar qualquer rota.", { sample: { number: "5562999999999", text: "Teste personalizado" } }), + op("server-ok", "Servidor", "Verificar servidor", "GET", "/server/ok", "Confirma que o servidor HTTP está respondendo.", { auth: "none" }), + + op("instance-create", "Instância", "Criar instância", "POST", "/instance/create", "Cria uma instância e define sua API key.", { auth: "admin", sample: { instanceId: "minha-instancia", name: "Minha instância", token: "CHAVE_DA_INSTANCIA", proxy: null, advancedSettings: null } }), + op("instance-all", "Instância", "Listar instâncias", "GET", "/instance/all", "Lista todas as instâncias.", { auth: "admin" }), + op("instance-info", "Instância", "Detalhes da instância", "GET", "/instance/info/:instanceId", "Consulta uma instância pelo ID.", { auth: "admin" }), + op("instance-delete", "Instância", "Excluir instância", "DELETE", "/instance/delete/:instanceId", "Exclui permanentemente uma instância.", { auth: "admin" }), + op("instance-proxy-set", "Instância", "Configurar proxy", "POST", "/instance/proxy/:instanceId", "Configura proxy da instância.", { auth: "admin", sample: { protocol: "http", host: "127.0.0.1", port: "8080", username: "usuario", password: "senha" } }), + op("instance-proxy-delete", "Instância", "Remover proxy", "DELETE", "/instance/proxy/:instanceId", "Remove a configuração de proxy.", { auth: "admin" }), + op("instance-force", "Instância", "Forçar reconexão", "POST", "/instance/forcereconnect/:instanceId", "Força a atualização e reconexão administrativa.", { auth: "admin", sample: { number: "5562999999999" } }), + op("instance-logs", "Instância", "Logs da instância", "GET", "/instance/logs/:instanceId", "Consulta logs; parâmetros de data, nível e limite podem ser adicionados na query string.", { auth: "admin" }), + op("instance-connect", "Instância", "Conectar", "POST", "/instance/connect", "Inicia a conexão da sessão.", { sample: { webhookUrl: "", subscribe: [], immediate: false, phone: "", rabbitmqEnable: "", webSocketEnable: "", natsEnable: "" } }), + op("instance-status", "Instância", "Status", "GET", "/instance/status", "Consulta estado conectado/logado."), + op("instance-qr", "Instância", "QR Code", "GET", "/instance/qr", "Obtém QR Code, código ou etapa de passkey."), + op("instance-pair", "Instância", "Parear por telefone", "POST", "/instance/pair", "Solicita código de pareamento.", { sample: { phone: "5562999999999", subscribe: [] } }), + op("instance-disconnect", "Instância", "Desconectar", "POST", "/instance/disconnect", "Desconecta sem apagar a sessão.", { sample: {} }), + op("instance-reconnect", "Instância", "Reconectar", "POST", "/instance/reconnect", "Reinicia a conexão preservando credenciais.", { sample: {} }), + op("instance-logout", "Instância", "Logout", "DELETE", "/instance/logout", "Desvincula o aparelho e remove a sessão."), + op("instance-advanced-get", "Instância", "Ler configurações avançadas", "GET", "/instance/:instanceId/advanced-settings", "Consulta configurações avançadas."), + op("instance-advanced-put", "Instância", "Atualizar configurações avançadas", "PUT", "/instance/:instanceId/advanced-settings", "Atualiza configurações avançadas com JSON livre.", { sample: {} }), + + op("send-text", "Envio", "Texto comum", "POST", "/send/text", "Envia texto com menções, resposta, atraso e encaminhamento.", { sample: { number: "5562999999999", text: "Mensagem de teste", forwardingScore: 0, ...commonSend } }), + op("send-link", "Envio", "Link com prévia", "POST", "/send/link", "Envia link e gera metadados de prévia.", { sample: { number: "5562999999999", text: "Confira https://evolution-api.com", title: "", url: "", description: "", imgUrl: "", ...commonSend } }), + op("send-media-file", "Envio", "Mídia por arquivo", "POST", "/send/media", "Upload multipart de imagem, vídeo, áudio ou documento.", { bodyMode: "multipart", fileField: "file", sample: { number: "5562999999999", type: "image", caption: "Teste de mídia", filename: "arquivo.jpg", delay: 0, mentionAll: false } }), + op("send-media-url", "Envio", "Mídia por URL/base64", "POST", "/send/media", "Envia mídia por URL pública ou base64.", { sample: { number: "5562999999999", url: "https://picsum.photos/800/600", type: "image", caption: "Imagem de teste", filename: "imagem.jpg", forwardingScore: 0, ...commonSend } }), + op("send-poll", "Envio", "Enquete", "POST", "/send/poll", "Envia enquete com duas ou mais opções.", { sample: { number: "5562999999999", question: "Qual opção você prefere?", maxAnswer: 1, options: ["Opção A", "Opção B"], ...commonSend } }), + op("send-sticker", "Envio", "Figurinha", "POST", "/send/sticker", "Baixa uma imagem pública e envia como WebP.", { sample: { number: "5562999999999", sticker: "https://picsum.photos/512/512", ...commonSend } }), + op("send-location", "Envio", "Localização", "POST", "/send/location", "Envia latitude, longitude, nome e endereço.", { sample: { number: "5562999999999", name: "Local de teste", latitude: -16.6869, longitude: -49.2648, address: "Goiânia - GO", ...commonSend } }), + op("send-contact", "Envio", "Contato vCard", "POST", "/send/contact", "Envia um contato em formato vCard.", { sample: { number: "5562999999999", vcard: { fullName: "Contato Teste", phone: "5562888888888", organization: "Evolution GO" }, ...commonSend } }), + op("send-button", "Envio", "Botões", "POST", "/send/button", "Testa reply, copy, URL, call e PIX.", { sample: { number: "5562999999999", title: "Oferta especial", description: "Escolha uma opção", footer: "Evolution GO", buttons: [{ type: "reply", displayText: "Quero saber mais", id: "btn_info" }, { type: "reply", displayText: "Agora não", id: "btn_no" }], imageUrl: "", videoUrl: "", ...commonSend } }), + op("send-list", "Envio", "Lista interativa", "POST", "/send/list", "Envia uma lista de seleção única.", { sample: { number: "5562999999999", title: "Nossos planos", description: "Escolha uma opção", buttonText: "Abrir menu", footerText: "Evolution GO", sections: [{ title: "Planos", rows: [{ title: "Plano básico", description: "R$ 29,90/mês", rowId: "plan_basic" }] }], ...commonSend } }), + op("send-carousel", "Envio", "Carrossel", "POST", "/send/carousel", "Envia cartões interativos com mídia e botões.", { sample: { number: "5562999999999", body: "Confira nossas novidades", footer: "Evolution GO", delay: 0, formatJid: true, quoted, cards: [{ header: { title: "Oferta do dia", subtitle: "Somente hoje", imageUrl: "https://picsum.photos/seed/evolution/600/400", videoUrl: "" }, body: { text: "Card de demonstração" }, footer: "Por tempo limitado", buttons: [{ type: "REPLY", displayText: "Tenho interesse", id: "card_interest", copyCode: "" }] }] } }), + op("send-status-text", "Envio", "Status de texto", "POST", "/send/status/text", "Publica texto no status.", { sample: { text: "Status enviado pelo Evolution GO", id: "" } }), + op("send-status-media-file", "Envio", "Status com arquivo", "POST", "/send/status/media", "Publica imagem ou vídeo por multipart.", { bodyMode: "multipart", fileField: "file", sample: { type: "image", caption: "Status de teste", id: "" } }), + op("send-status-media-url", "Envio", "Status por URL", "POST", "/send/status/media", "Publica imagem ou vídeo por URL.", { sample: { type: "image", url: "https://picsum.photos/1080/1920", caption: "Status de teste", id: "" } }), + + op("user-info", "Usuário", "Informações do usuário", "POST", "/user/info", "Consulta status, dispositivos, LID e nome verificado.", { sample: { number: ["5562999999999"] } }), + op("user-check", "Usuário", "Verificar números", "POST", "/user/check", "Verifica se números estão no WhatsApp.", { sample: { number: ["5562999999999"], formatJid: false } }), + op("user-avatar", "Usuário", "Avatar", "POST", "/user/avatar", "Consulta foto de perfil.", { sample: { number: "5562999999999", preview: true } }), + op("user-contacts", "Usuário", "Contatos", "GET", "/user/contacts", "Lista contatos sincronizados."), + op("user-privacy-get", "Usuário", "Ler privacidade", "GET", "/user/privacy", "Consulta as configurações de privacidade."), + op("user-privacy-set", "Usuário", "Atualizar privacidade", "POST", "/user/privacy", "Atualiza todas as opções de privacidade.", { sample: { groupAdd: "all", lastSeen: "all", status: "all", profile: "all", readReceipts: "all", callAdd: "all", online: "all" } }), + op("user-block", "Usuário", "Bloquear", "POST", "/user/block", "Bloqueia um contato.", { sample: { number: "5562999999999" } }), + op("user-unblock", "Usuário", "Desbloquear", "POST", "/user/unblock", "Desbloqueia um contato.", { sample: { number: "5562999999999" } }), + op("user-blocklist", "Usuário", "Lista de bloqueio", "GET", "/user/blocklist", "Lista contatos bloqueados."), + op("user-profile-picture", "Usuário", "Foto do perfil", "POST", "/user/profilePicture", "Atualiza foto por URL ou base64 conforme o backend.", { sample: { image: "BASE64_OU_URL" } }), + op("user-profile-name", "Usuário", "Nome do perfil", "POST", "/user/profileName", "Atualiza o nome do perfil.", { sample: { name: "Evolution GO" } }), + op("user-profile-status", "Usuário", "Recado do perfil", "POST", "/user/profileStatus", "Atualiza o recado do perfil.", { sample: { status: "Disponível" } }), + + op("message-react", "Mensagem", "Reagir", "POST", "/message/react", "Adiciona ou remove reação.", { sample: { number: "5562999999999", messageId: "ID_DA_MENSAGEM", emoji: "👍" } }), + op("message-presence", "Mensagem", "Presença no chat", "POST", "/message/presence", "Envia composing, paused ou recording.", { sample: { number: "5562999999999", presence: "composing", delay: 1000 } }), + op("message-read", "Mensagem", "Marcar como lida", "POST", "/message/markread", "Marca mensagem como lida.", { sample: { number: "5562999999999", messageId: "ID_DA_MENSAGEM" } }), + op("message-played", "Mensagem", "Marcar como reproduzida", "POST", "/message/markplayed", "Marca mídia de áudio como reproduzida.", { sample: { number: "5562999999999", messageId: "ID_DA_MENSAGEM" } }), + op("message-download", "Mensagem", "Baixar mídia", "POST", "/message/downloadmedia", "Baixa mídia usando os metadados da mensagem.", { sample: {} }), + op("message-status", "Mensagem", "Status da mensagem", "POST", "/message/status", "Consulta status por ID.", { sample: { messageId: "ID_DA_MENSAGEM" } }), + op("message-delete", "Mensagem", "Apagar para todos", "POST", "/message/delete", "Apaga uma mensagem enviada.", { sample: { number: "5562999999999", messageId: "ID_DA_MENSAGEM" } }), + op("message-edit", "Mensagem", "Editar", "POST", "/message/edit", "Edita texto enviado.", { sample: { number: "5562999999999", messageId: "ID_DA_MENSAGEM", text: "Texto editado" } }), + + op("chat-pin", "Chat", "Fixar", "POST", "/chat/pin", "Fixa uma conversa.", { sample: { number: "5562999999999" } }), + op("chat-unpin", "Chat", "Desafixar", "POST", "/chat/unpin", "Remove conversa dos fixados.", { sample: { number: "5562999999999" } }), + op("chat-archive", "Chat", "Arquivar", "POST", "/chat/archive", "Arquiva uma conversa.", { sample: { number: "5562999999999" } }), + op("chat-unarchive", "Chat", "Desarquivar", "POST", "/chat/unarchive", "Desarquiva uma conversa.", { sample: { number: "5562999999999" } }), + op("chat-mute", "Chat", "Silenciar", "POST", "/chat/mute", "Silencia uma conversa.", { sample: { number: "5562999999999", duration: 86400 } }), + op("chat-unmute", "Chat", "Remover silêncio", "POST", "/chat/unmute", "Remove o silêncio.", { sample: { number: "5562999999999" } }), + op("chat-history", "Chat", "Sincronizar histórico", "POST", "/chat/history-sync", "Solicita history sync.", { sample: {} }), + + op("group-list", "Grupo", "Listar grupos", "GET", "/group/list", "Lista grupos conhecidos."), + op("group-info", "Grupo", "Informações", "POST", "/group/info", "Consulta metadados do grupo.", { sample: { number: "120363000000000000@g.us" } }), + op("group-invite", "Grupo", "Link de convite", "POST", "/group/invitelink", "Obtém link de convite.", { sample: { number: "120363000000000000@g.us" } }), + op("group-photo", "Grupo", "Foto", "POST", "/group/photo", "Atualiza foto do grupo.", { sample: { number: "120363000000000000@g.us", image: "BASE64_OU_URL" } }), + op("group-name", "Grupo", "Nome", "POST", "/group/name", "Altera nome do grupo.", { sample: { number: "120363000000000000@g.us", name: "Novo nome" } }), + op("group-description", "Grupo", "Descrição", "POST", "/group/description", "Altera descrição do grupo.", { sample: { number: "120363000000000000@g.us", description: "Descrição de teste" } }), + op("group-create", "Grupo", "Criar grupo", "POST", "/group/create", "Cria grupo com participantes.", { sample: { name: "Grupo de teste", participants: ["5562999999999"] } }), + op("group-participant", "Grupo", "Participantes", "POST", "/group/participant", "Adiciona, remove, promove ou rebaixa.", { sample: { number: "120363000000000000@g.us", participants: ["5562999999999"], action: "add" } }), + op("group-myall", "Grupo", "Meus grupos", "GET", "/group/myall", "Consulta grupos da conta."), + op("group-join", "Grupo", "Entrar por convite", "POST", "/group/join", "Entra por URL ou código.", { sample: { code: "CODIGO_DO_CONVITE" } }), + op("group-leave", "Grupo", "Sair", "POST", "/group/leave", "Sai de um grupo.", { sample: { number: "120363000000000000@g.us" } }), + op("group-settings", "Grupo", "Configurações", "POST", "/group/settings", "Atualiza configurações do grupo.", { sample: { number: "120363000000000000@g.us", action: "announcement" } }), + + op("call-status", "Chamadas", "Status", "GET", "/call/status", "Lista chamadas da instância."), + op("call-start", "Chamadas", "Iniciar", "POST", "/call/start", "Inicia chamada de voz ou vídeo.", { sample: { number: "5562999999999", video: false } }), + op("call-accept", "Chamadas", "Aceitar", "POST", "/call/:callId/accept", "Aceita chamada recebida.", { sample: {} }), + op("call-webrtc-create", "Chamadas", "Criar WebRTC", "POST", "/call/:callId/webrtc", "Cria sessão WebRTC a partir de uma oferta SDP.", { sample: { offer: { type: "offer", sdp: "COLE_O_SDP" } } }), + op("call-webrtc-list", "Chamadas", "Listar WebRTC", "GET", "/call/:callId/webrtc", "Lista sessões WebRTC."), + op("call-webrtc-close", "Chamadas", "Fechar WebRTC", "DELETE", "/call/:callId/webrtc/:sessionId", "Fecha uma sessão WebRTC."), + op("call-terminate", "Chamadas", "Encerrar", "DELETE", "/call/:callId", "Encerra uma chamada."), + op("call-reject", "Chamadas", "Recusar", "POST", "/call/reject", "Recusa chamada recebida.", { sample: { number: "5562999999999", callCreator: "5562999999999@s.whatsapp.net", callId: "CALL_ID" } }), + + op("community-create", "Comunidade", "Criar", "POST", "/community/create", "Cria comunidade.", { sample: { name: "Comunidade de teste", description: "Criada pelo API Lab" } }), + op("community-add", "Comunidade", "Adicionar grupo", "POST", "/community/add", "Adiciona grupo à comunidade.", { sample: { number: "120363000000000000@g.us", communityId: "120363000000000001@g.us" } }), + op("community-remove", "Comunidade", "Remover grupo", "POST", "/community/remove", "Remove grupo da comunidade.", { sample: { number: "120363000000000000@g.us", communityId: "120363000000000001@g.us" } }), + + op("label-chat", "Labels", "Aplicar no chat", "POST", "/label/chat", "Aplica label à conversa.", { sample: { number: "5562999999999", labelId: "LABEL_ID" } }), + op("label-message", "Labels", "Aplicar na mensagem", "POST", "/label/message", "Aplica label à mensagem.", { sample: { messageId: "ID_DA_MENSAGEM", labelId: "LABEL_ID" } }), + op("label-edit", "Labels", "Criar ou editar", "POST", "/label/edit", "Cria ou edita label.", { sample: { id: "", name: "Cliente", color: 1, predefinedId: "" } }), + op("label-list", "Labels", "Listar", "GET", "/label/list", "Lista labels."), + op("unlabel-chat", "Labels", "Remover do chat", "POST", "/unlabel/chat", "Remove label da conversa.", { sample: { number: "5562999999999", labelId: "LABEL_ID" } }), + op("unlabel-message", "Labels", "Remover da mensagem", "POST", "/unlabel/message", "Remove label da mensagem.", { sample: { messageId: "ID_DA_MENSAGEM", labelId: "LABEL_ID" } }), + + op("newsletter-create", "Newsletter", "Criar canal", "POST", "/newsletter/create", "Cria newsletter/canal.", { sample: { name: "Canal de teste", description: "Criado pelo Evolution GO" } }), + op("newsletter-list", "Newsletter", "Listar canais", "GET", "/newsletter/list", "Lista canais."), + op("newsletter-info", "Newsletter", "Informações", "POST", "/newsletter/info", "Consulta canal.", { sample: { newsletterId: "120363000000000000@newsletter" } }), + op("newsletter-link", "Newsletter", "Link", "POST", "/newsletter/link", "Obtém link do canal.", { sample: { newsletterId: "120363000000000000@newsletter" } }), + op("newsletter-subscribe", "Newsletter", "Inscrever-se", "POST", "/newsletter/subscribe", "Inscreve a conta no canal.", { sample: { newsletterId: "120363000000000000@newsletter" } }), + op("newsletter-messages", "Newsletter", "Mensagens", "POST", "/newsletter/messages", "Consulta mensagens recentes.", { sample: { newsletterId: "120363000000000000@newsletter", count: 20 } }), + + op("poll-results", "Enquetes", "Resultados", "GET", "/polls/:pollMessageId/results", "Consulta votos de uma enquete."), +]; diff --git a/manager-v2/src/api-lab-layout.css b/manager-v2/src/api-lab-layout.css new file mode 100644 index 00000000..843546f6 --- /dev/null +++ b/manager-v2/src/api-lab-layout.css @@ -0,0 +1,4 @@ +.api-route-row.editable { grid-template-columns: 90px minmax(0, 1fr) 180px 130px; } +.multipart-file-row { display: grid; grid-template-columns: minmax(150px, .4fr) minmax(0, 1fr); gap: 9px; } +@media (max-width: 900px) { .api-route-row.editable { grid-template-columns: 90px minmax(0, 1fr); } } +@media (max-width: 620px) { .api-route-row.editable, .multipart-file-row { grid-template-columns: 1fr; } } diff --git a/manager-v2/src/api-lab.tsx b/manager-v2/src/api-lab.tsx new file mode 100644 index 00000000..0a63b4de --- /dev/null +++ b/manager-v2/src/api-lab.tsx @@ -0,0 +1,295 @@ +import { useMemo, useState } from "react"; +import { API_OPERATIONS, type ApiOperation, type BodyMode } from "./api-catalog"; +import type { ApiAuthMode, ApiExecutionResult, EvolutionApi, EvolutionConnection } from "./api"; +import { GuidedRequestEditor, supportsGuidedRequest, validateRequestDraft } from "./guided-request"; +import { RequestPresetPanel, type RequestPresetDraft } from "./request-presets"; + +function replaceInstanceId(path: string, connection: EvolutionConnection): string { + return path.replaceAll(":instanceId", connection.instanceId || "INSTANCE_ID"); +} + +function stringifySample(sample: unknown): string { + return sample === undefined ? "" : JSON.stringify(sample, null, 2); +} + +function appendFormValue(form: FormData, key: string, value: unknown): void { + if (value === undefined || value === null) return; + if (typeof value === "object") form.set(key, JSON.stringify(value)); + else form.set(key, String(value)); +} + +function responseText(result: ApiExecutionResult | null): string { + if (!result) return "Execute uma operação para visualizar a resposta."; + if (typeof result.data === "string") return result.data; + return JSON.stringify(result.data, null, 2) ?? String(result.data); +} + +function buildCurl( + connection: EvolutionConnection, + operationValue: Pick, + method: string, + bodyMode: BodyMode, + path: string, + body: string, +): string { + const keyLabel = operationValue.auth === "admin" ? "SUA_CHAVE_GLOBAL" : operationValue.auth === "none" ? "" : "SUA_CHAVE_DA_INSTANCIA"; + const parts = [`curl -X ${method} '${connection.baseUrl}${path}'`]; + if (keyLabel) parts.push(`-H 'apikey: ${keyLabel}'`); + if (bodyMode === "json" && body.trim()) { + parts.push("-H 'Content-Type: application/json'"); + parts.push(`--data '${body.replaceAll("'", "'\\''")}'`); + } + if (bodyMode === "multipart") { + try { + const parsed = JSON.parse(body || "{}") as Record; + Object.entries(parsed).forEach(([key, value]) => parts.push(`-F '${key}=${typeof value === "object" ? JSON.stringify(value) : String(value)}'`)); + } catch { + // Keep cURL visible while the JSON editor is incomplete. + } + parts.push(`-F '${operationValue.fileField || "file"}=@/caminho/arquivo'`); + } + return parts.join(" \\\n "); +} + +export function ApiLab({ api, connection }: { api: EvolutionApi | null; connection: EvolutionConnection }) { + const initial = API_OPERATIONS.find((item) => item.id === "send-text") ?? API_OPERATIONS[0]; + const [query, setQuery] = useState(""); + const [category, setCategory] = useState("Todos"); + const [selectedId, setSelectedId] = useState(initial.id); + const [method, setMethod] = useState(initial.method); + const [path, setPath] = useState(() => replaceInstanceId(initial.path, connection)); + const [bodyMode, setBodyMode] = useState(initial.bodyMode); + const [body, setBody] = useState(() => stringifySample(initial.sample)); + const [auth, setAuth] = useState(initial.auth); + const [file, setFile] = useState(null); + const [fileField, setFileField] = useState(initial.fileField || "file"); + const [editorMode, setEditorMode] = useState<"guided" | "json">(supportsGuidedRequest(initial.id) ? "guided" : "json"); + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + const [result, setResult] = useState(null); + const [history, setHistory] = useState>([]); + + const selected = API_OPERATIONS.find((item) => item.id === selectedId) ?? initial; + const guidedAvailable = supportsGuidedRequest(selected.id); + const validation = useMemo(() => validateRequestDraft(selected.id, bodyMode, body, file), [body, bodyMode, file, selected.id]); + const categories = useMemo(() => ["Todos", ...Array.from(new Set(API_OPERATIONS.map((item) => item.category)))], []); + const visible = useMemo(() => { + const normalized = query.trim().toLocaleLowerCase("pt-BR"); + return API_OPERATIONS.filter((item) => { + if (category !== "Todos" && item.category !== category) return false; + if (!normalized) return true; + return `${item.title} ${item.path} ${item.description}`.toLocaleLowerCase("pt-BR").includes(normalized); + }); + }, [category, query]); + + const choose = (item: ApiOperation) => { + setSelectedId(item.id); + setMethod(item.method); + setPath(replaceInstanceId(item.path, connection)); + setBodyMode(item.bodyMode); + setBody(stringifySample(item.sample)); + setAuth(item.auth); + setFile(null); + setFileField(item.fileField || "file"); + setEditorMode(supportsGuidedRequest(item.id) ? "guided" : "json"); + setError(""); + setResult(null); + }; + + const applyPreset = (preset: RequestPresetDraft) => { + const operation = API_OPERATIONS.find((item) => item.id === preset.operationId); + if (!operation) { + setError(`A operação ${preset.operationId} não existe mais no catálogo.`); + return; + } + setSelectedId(operation.id); + setMethod(preset.method); + setPath(preset.path); + setBodyMode(preset.bodyMode); + setBody(preset.body); + setAuth(preset.auth); + setFile(null); + setFileField(preset.fileField || operation.fileField || "file"); + setEditorMode(supportsGuidedRequest(operation.id) ? "guided" : "json"); + setError(""); + setResult(null); + }; + + const resetPayload = () => { + setBody(stringifySample(selected.sample)); + setFile(null); + setFileField(selected.fileField || "file"); + setError(""); + }; + + const formatPayload = () => { + try { + setBody(JSON.stringify(JSON.parse(body || "{}") as unknown, null, 2)); + setError(""); + } catch (cause) { + setError(cause instanceof Error ? `JSON inválido: ${cause.message}` : "JSON inválido"); + } + }; + + const run = async () => { + if (!api || running) return; + if (validation.errors.length) { + setError(validation.errors.join(" ")); + return; + } + setRunning(true); + setError(""); + try { + let requestBody: BodyInit | null | undefined; + if (bodyMode === "json" && body.trim()) { + requestBody = JSON.stringify(JSON.parse(body) as unknown); + } else if (bodyMode === "multipart") { + const form = new FormData(); + const values = JSON.parse(body || "{}") as Record; + Object.entries(values).forEach(([key, value]) => appendFormValue(form, key, value)); + if (file) form.set(fileField || "file", file, file.name); + requestBody = form; + } + const response = await api.execute({ method, path, auth, body: requestBody }); + setResult(response); + setHistory((current) => [{ id: Date.now(), title: selected.title, status: response.status, duration: response.durationMs }, ...current].slice(0, 20)); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Falha ao executar a requisição"); + } finally { + setRunning(false); + } + }; + + const curl = buildCurl(connection, { auth, fileField }, method, bodyMode, path, body); + const renderedResponse = responseText(result); + const presetDraft: RequestPresetDraft = { + operationId: selected.id, + operationTitle: selected.title, + method, + path, + auth, + bodyMode, + body, + fileField, + }; + + return ( +
+ + +
+
+
+
{selected.category}

{selected.title}

+ {method} +
+

{selected.description}

+
+ + setPath(event.target.value)} aria-label="Caminho da API" /> + + +
+ + {bodyMode !== "none" && ( + <> +
+ {guidedAvailable && } + + {guidedAvailable ? "Os dois modos usam o mesmo payload." : "Esta operação usa o editor JSON livre."} +
+ + {guidedAvailable && editorMode === "guided" ? ( + + ) : ( +