Appearance
WhatsApp Flows declarativos
WhatsApp Flows (Meta) são formulários interativos multi-tela que rodam dentro do aplicativo WhatsApp. São fundamentalmente diferentes dos flows internos do engine:
| Flows internos | WhatsApp Flows | |
|---|---|---|
| O que são | Máquina de estados conversacional | Formulários/telas interativas da Meta |
| Estado | SessionState no Redis, persistente | Stateless — cada submit é independente |
| Execução | flow-ai-engine | flow-ai-whatsapp-flow-engine |
| Trigger | Qualquer mensagem do usuário | Botão interativo enviado pelo flow interno |
| Resposta | Mensagens de texto/mídia no chat | Dados do formulário enviados via webhook |
Ciclo de vida: UNPUBLISHED → DRAFT → PUBLISHED
┌──────────────────────────────────────────────────────────────────┐
│ UNPUBLISHED │
│ flow criado no banco, sem metaFlowId ainda │
└──────────────────────┬───────────────────────────────────────────┘
│ POST /:id/meta/draft
↓
┌──────────────────────────────────────────────────────────────────┐
│ DRAFT │
│ metaFlowId atribuído pela Meta │
│ flow.json validado pela Meta (validationErrors) │
│ Webhook URI configurado em endpointUri │
└──────────────────────┬───────────────────────────────────────────┘
│ POST /:id/meta/publish
↓
┌──────────────────────────────────────────────────────────────────┐
│ PUBLISHED │
│ Disponível para uso em mensagens interativas │
│ lastPublishedAt atualizado │
└──────────────────────────────────────────────────────────────────┘
↕ POST /:id/meta/sync
(consulta Meta a qualquer momento)Draft (POST /:id/meta/draft)
Três chamadas à Meta Cloud API em sequência:
POST /{waba-id}/flows→ cria o flow na Meta, recebemetaFlowId(ou atualiza nome/categorias/endpoint_uri se já existe)POST /{metaFlowId}/assets→ faz upload doflowDefinitionJson(Meta Flow Definition v6.0)- Meta valida o JSON e retorna
validation_errors[]— salvo emvalidationErrorsno banco
Após o draft, o cache Redis wa:flow:{metaFlowId} é sincronizado com o endpointConfig.
endpointMode — quem processa o webhook
O endpoint_uri registrado na Meta depende do campo endpointMode do flow (precedência em buildEndpointUri):
endpointMode | endpoint_uri registrado | Quem processa |
|---|---|---|
declarative (padrão) | {META_FLOW_PUBLIC_URL}/public/whatsapp-flows/{metaFlowId}/webhook | engine declarativa dentro do flow-ai-meta-api |
external | endpointUrl do próprio flow | endpoint HTTP externo (fora da plataforma) |
Nota:
META_FLOW_ENDPOINT_URLé um override global que aponta para uma URL externa e tem precedência sobre o webhook do meta-api mesmo em mododeclarative. Quando definido, a Meta posta na URL externa e a engine declarativa do meta-api não roda. Ordem embuildEndpointUri:external+endpointUrl>META_FLOW_ENDPOINT_URL> webhook do meta-api.
Modo external exige endpointUrl preenchido; modo declarative exige que o servidor tenha META_FLOW_ENDPOINT_URL ou META_FLOW_PUBLIC_URL configurado (caso contrário saveDraft lança 500). O campo flowAction do flow (navigate estático ou data_exchange dinâmico via webhook) e dataApiVersion (padrão 3.0) também vivem no WhatsAppFlow.
Publish (POST /:id/meta/publish)
Exige metaFlowId já existente. Uma única chamada à Meta:
POST /{metaFlowId}/publishMeta ativa o flow. publishState atualizado para "PUBLISHED", lastPublishedAt gravado. Log de publicação criado em whatsapp_flow_publish_logs.
Sync (POST /:id/meta/sync)
Consulta o estado atual na Meta (sem alterar nada do lado da Meta) e reflete no banco:
GET /{metaFlowId}?fields=id,name,status,validation_errors,endpoint_uri,categoriesAtualiza publishState, validationErrors e lastSyncedAt; e, quando a Meta os devolve, também endpointUri e categories. Mapeamento de status Meta → local:
| Status Meta | publishState local |
|---|---|
DRAFT | "DRAFT" |
PUBLISHED | "PUBLISHED" |
DEPRECATED | "DEPRECATED" |
BLOCKED | "BLOCKED" |
| (qualquer outro / ausente) | "UNPUBLISHED" |
O estado "ERROR" não vem do sync — é gravado apenas quando o draft ou o publish falham (validation errors da Meta ou exceção durante a chamada).
endpointConfig — o engine declarativo
O campo endpointConfig do WhatsAppFlow é um JSON que instrui o flow-ai-whatsapp-flow-engine sobre como processar cada webhook do formulário sem código customizado. É uma micro-linguagem declarativa com seis tipos de passo: validate, http, branch, set, map e tool.
typescript
type FlowEndpointConfig = {
enabled: boolean
baseUrl?: string
defaultHeaders?: Record<string, string>
env?: Record<string, string | number | boolean> // variáveis interpoláveis
actions: Record<string, FlowEndpointAction>
// └─ chave: "init" | "{screen}.{action}" | "{action}"
__layout__?: Record<string, { x: number; y: number }> // posições no builder — engine ignora
deferredJobs?: FlowEndpointDeferredJob[] // jobs pós-request — engine ignora (ver "Jobs deferidos")
}
type FlowEndpointAction = {
description?: string
steps: FlowEndpointStep[] // validate | http | branch | set | map | tool
response?: FlowEndpointResponseConfig // resposta padrão após todos os steps
error?: FlowEndpointErrorConfig
}Nota:
__layout__edeferredJobssó existem para UI/pós-processamento. O engine (flow-ai-whatsapp-flow-engine) os ignora — quem lêdeferredJobsé oflow-ai-core(ver a seção "Jobs deferidos").
Passo validate
typescript
{
type: "validate",
rules: [
{ field: "email", type: "required" },
{ field: "cpf", type: "cpf" }, // valida CPF brasileiro
{ field: "birthday", type: "min_age", value: 18 }, // value = anos
{ field: "phone", type: "regex", value: "^\\d{10,11}$" } // value = pattern
]
}FlowValidateRule tem apenas os campos field, type (required/cpf/min_age/regex), value? e message?. Para min_age o value é o número de anos; para regex o value é o pattern (não há campo pattern).
Se qualquer regra falhar, o engine retorna error_messages imediatamente — sem executar passos subsequentes. O formulário exibe os erros ao usuário.
Passo http
typescript
{
type: "http",
method: "POST",
url: "{{env.API_BASE}}/submit",
headers: { "Authorization": "Bearer {{env.API_TOKEN}}" },
body: { "data": "{{data}}", "user": "{{data.name}}" },
resultKey: "submitResult", // armazena resposta no contexto
conditions: [
{
// se submitResult.status === "error" → transiciona para tela de erro
field: "submitResult.status",
operator: "equals",
value: "error",
nextScreen: "error_screen"
}
]
}{{data}} é o payload completo do formulário. {{data.campo}} acessa campos individuais. {{env.X}} acessa variáveis declaradas em config.env. {{resultKey.campo}} acessa respostas de passos HTTP anteriores.
conditions — como uma condição casa
Cada FlowEndpointCondition é avaliada em ordem; na primeira que casa, o engine decide a saída. O shape não tem action/screen — usa três campos mutuamente exclusivos (precedência de cima para baixo):
| Campo | Efeito quando a condição casa |
|---|---|
error: { field, message } | Permanece na tela atual e devolve error_messages: { [field]: message } — erro inline no campo (mesma semântica do validate). Tem precedência sobre os demais. |
nextScreen: string | Transiciona para essa tela na resposta. |
stopExecution: true | Encerra a action e monta a resposta padrão. |
Operadores suportados (FlowEndpointConditionOperator): exists, equals, not_equals, truthy, falsy, contains, non_empty, empty.
O error inline atende o padrão "consultar API externa e, se inválido, mostrar o erro no próprio campo" (ex.: validar CPF numa base externa) sem trocar de tela.
Passo branch
Ramifica sem chamar HTTP — avalia conditions contra o contexto e pode nextScreen/stopExecution a partir do input do usuário (ex.: escolher a próxima tela conforme um search_type).
typescript
{ type: "branch", conditions: [ /* FlowEndpointCondition[] */ ] }Passo set
Escreve/compõe campos escalares em data via interpolação (replica o "adicionar valores ao form" que um endpoint externo faria). Quando o template é exatamente um único token {{caminho}}, resolve para o valor cru (array/objeto/número), não string.
typescript
{
type: "set",
values: { full_address: "{{addr.logradouro}}, {{data.numberAddress}}" }
}Passo map
Mapeia um array do contexto para um campo de data, renomeando os campos de cada item via {{item.x}}.
typescript
{
type: "map",
from: "addr.condominios", // array no contexto
to: "cond_items", // campo destino em data
item: { id: "{{item.id}}", title: "{{item.condominio}}" }
}Passo tool
Invoca uma Tool/Ferramenta publicada (reuso DRY de uma integração já cadastrada — ex.: consulta externa, validação de CPF). No flow-ai-meta-api o toolExecutor injetado (executeToolViaCore) delega para o endpoint interno do flow-ai-core (POST /internal/tools/execute, timeout de 8s) — o meta-api permanece "burro", sem executeTool nem banco. A saída (string; JSON é parseado) vira ctx[resultKey].
typescript
{
type: "tool",
publishedToolId: "tool_abc",
inputMappings: { cpf: "{{data.cpf}}" },
resultKey: "lookup",
conditions: [ /* opcional */ ]
}Nota: só tools "puras" (sem dependência de contato/sessão) fazem sentido aqui — a execução usa uma sessão stub. Sem
FLOW_AI_CORE_INTERNAL_URLconfigurado no meta-api, o passotoolfalha de forma tratável.
Resolução de action
O engine resolve a action key combinando screen + action do webhook:
"{screen}.{action}" → procura exatamente essa chave em config.actions
"{action}" → fallback se a chave composta não existirA action que a Meta envia ao abrir o formulário (antes de o usuário preencher qualquer campo) costuma ser usada para carregar dados iniciais — ex.: preencher dropdowns com dados de uma API. O engine não trata nenhuma chave de forma especial: o lookup em config.actions é uma comparação de string case-sensitive, então a chave configurada deve casar exatamente com o action recebido da Meta. O único curto-circuito fora do engine é o ping (health check), tratado direto na rota.
Pipeline do webhook
Cada submissão do formulário chega como webhook cifrado no flow-ai-meta-api:
Meta → POST /public/whatsapp-flows/{metaFlowId}/webhook
(body: { encrypted_aes_key, encrypted_flow_data, initial_vector })
1. Resolve wa:flow:{metaFlowId} → endpointConfig, whatsAppFlowId, phoneNumberId
└─ ausente → 404 (metaFlowId desconhecido)
2. Validação HMAC-SHA256
├─ Carrega wa:webhook:{phoneNumberId} → appSecret (ausente → 403)
├─ Header: x-hub-signature-256
└─ Comparação timing-safe com o appSecret (inválido → 401)
3. Carrega wa:encryption:{phoneNumberId} → RSA private key + passphrase
└─ ausente → 421 (chaves ainda não subiram)
4. Decifra a requisição (RSA-OAEP-SHA256 + AES-128-GCM)
├─ RSA-OAEP → decifra encrypted_aes_key → aes_key (16 bytes)
└─ AES-128-GCM → decifra encrypted_flow_data → payload JSON
5. Ping health check (curto-circuito)
└─ action === "ping" → { data: { status: "active" } }
6. Engine declarativo
└─ FlowEndpointEngine.execute(request, endpointConfig)
├─ Resolve action key (screen.action ou action)
├─ Roda passos validate → erros retornam imediatamente
├─ Roda passos http → interpolação de placeholders + chamada HTTP
└─ Monta resposta final
7. Cifra a resposta (AES-128-GCM)
├─ IV flipado bit a bit (requisito da Meta: ~byte & 0xff)
└─ Resposta cifrada retornada como text/plain base64
8. Loga em stream:wa-flow-logs
└─ WhatsAppFlowWebhookLogEvent → consumer persiste em whatsapp_flow_webhook_logsA dupla criptografia (RSA outer + AES inner) é um protocolo imposto pela Meta para garantir que só o detentor da chave privada possa processar o payload — mesmo que a HTTPS seja comprometida.
Cache Redis
Chave: wa:flow:{metaFlowId}
Sem TTL (invalidado manualmente no CRUD)typescript
type WhatsAppFlowCacheConfig = {
metaFlowId: string
whatsAppFlowId: string // ID interno no banco
routerId: string
phoneNumberId: string
endpointConfig: FlowEndpointConfig
}O cache é populado pelo flow-ai-core ao salvar draft ou ao publicar, e deletado quando o endpointConfig é removido ou o flow é deletado. O flow-ai-meta-api nunca toca o banco — lê tudo do Redis.
O warm-up no boot do flow-ai-core também popula os caches de todos os WhatsApp Flows que já têm metaFlowId (qualquer publishState), respeitando a mesma regra do syncWhatsAppFlowCache — escreve quando há endpointConfig, remove caso contrário:
typescript
await makeWhatsAppFlowService().warmupCache() // wa:flow:* para todo flow com metaFlowIdLogs
Logs de publicação (whatsapp_flow_publish_logs)
Registra cada tentativa de draft ou publish:
typescript
{
whatsAppFlowId: string
metaFlowId?: string
status: "success" | "error"
flowDefinitionJson: Json // o que foi enviado à Meta
validationErrors?: Json // erros de validação retornados
errorMessage?: string
publishedBy?: string // userId
createdAt: DateTime
}Logs de webhook (whatsapp_flow_webhook_logs)
Registra cada execução do engine declarativo:
typescript
{
whatsAppFlowId: string
action: string // ex: "data_exchange", "INIT"
screen?: string // tela ativa no formulário
requestData?: Json // dados do formulário (decifrados)
responseData?: Json // resposta enviada de volta
durationMs?: number // tempo total de execução
success: boolean
errorMessage?: string
flowToken?: string // token da sessão do Flow — correlaciona eventos da mesma jornada
createdAt: DateTime
}Os logs de webhook são escritos via stream (stream:wa-flow-logs) pelo flow-ai-meta-api e persistidos pelo consumer core-wa-flow-logs no flow-ai-core. Isso evita que a latência de escrita no banco aumente o tempo de resposta do webhook — a Meta tem timeout de 10s para respostas de webhook.
Nota: o mesmo
stream:wa-flow-logstem dois grupos de consumo noflow-ai-core:core-wa-flow-logs(persiste os logs, acima) ecore-wa-flow-deferred(agenda jobs deferidos, abaixo). São independentes — cada grupo lê o stream por conta própria.
Jobs deferidos
Um job deferido executa uma Tool publicada fora do request do webhook, com retry/backoff — para casos em que o dado só existe minutos depois (ex.: a O.S. do IXC, criada de forma assíncrona pelo sistema externo).
A configuração vive em endpointConfig.deferredJobs[] (FlowEndpointDeferredJob):
typescript
type FlowEndpointDeferredJob = {
id: string // estável dentro do flow (ex: "ixc-os")
screen: string // request.screen que dispara o job
requireKeys?: string[] // chaves que precisam estar não-vazias no responseData
dedupeKey?: string // campo do responseData usado para deduplicar
publishedToolId: string
inputMappings: Record<string, string> // templates {{data.x}} sobre o responseData
retry?: { maxAttempts?: number; baseDelaySec?: number }
}Scheduler (core-wa-flow-deferred, whatsapp-flow-deferred.scheduler.ts) — lê stream:wa-flow-logs e, para cada evento com success === true eaction === "data_exchange", procura deferredJobs cujo screen casa e cujas requireKeys estão não-vazias no responseData. Cria um WhatsAppFlowDeferredJob com os inputs resolvidos. O jobKey ({whatsAppFlowId}:{job.id}:{dedupe|entryId}) é único — redelivery do stream ou submits repetidos não duplicam o job (violação P2002 é ignorada). O endpointConfig é lido do banco com cache em memória (TTL 30s).
Worker (whatsapp-flow-deferred.worker.ts) — polling a cada 5s; faz claim transacional (updateMany com guarda de status, seguro com múltiplas réplicas) e executa a tool in-process (executeTool, sessão pura, timeout de 35s):
| Resultado | Ação |
|---|---|
retorno com erro: string (ou exceção) | retryável — backoff linear baseDelaySec * attempts |
esgotou maxAttempts (padrão 10) | status = failed, grava lastError |
| sucesso | status = completed, grava result |
O modelo WhatsAppFlowDeferredJob (status: pending/in_progress/completed/failed; attempts, maxAttempts, baseDelaySec, nextAttemptAt, lastError, result) é persistido em whatsapp_flow_deferred_jobs.
Preview / simulação
Duas rotas do flow-ai-core ajudam a validar sem passar pela Meta: GET /:id/payload-preview (retorna flowLaunchPayload + flowDefinitionJson sanitizado) e POST /:id/simulate (roda o engine declarativo sem cripto, com o payload em claro — mesma semântica do webhook do meta-api).
Arquivos relevantes
| Arquivo | Papel |
|---|---|
packages/flow-ai-database/prisma/schema.prisma | Modelos WhatsAppFlow, WhatsAppFlowPublishLog, WhatsAppFlowWebhookLog, WhatsAppFlowDeferredJob |
packages/flow-ai-types/src/whatsapp.ts | FlowEndpointConfig, FlowEndpointAction, FlowEndpointStep, FlowEndpointCondition, FlowEndpointDeferredJob, tipos de webhook (reexportados via src/index.ts) |
packages/flow-ai-whatsapp-flow-engine/src/engine.ts | Engine declarativo — resolve actions, roda passos (validate/http/branch/set/map/tool), interpola |
packages/flow-ai-whatsapp-flow-engine/src/conditions.ts | matchesCondition — avalia FlowEndpointCondition |
packages/flow-ai-whatsapp-flow-engine/src/validations.ts | runValidations — regras required/cpf/min_age/regex |
packages/flow-ai-whatsapp-flow-engine/src/crypto.ts | RSA-OAEP-SHA256 + AES-128-GCM decrypt/encrypt |
packages/flow-ai-redis/src/cache.ts | getWaFlow, setWaFlow, deleteWaFlow, getWaEncryption |
services/flow-ai-core/src/services/whatsapp-flow-meta.service.ts | Draft, publish, sync com a Meta Cloud API; buildEndpointUri (declarative vs external) |
services/flow-ai-core/src/services/whatsapp-flow-cache.ts | syncWhatsAppFlowCache() |
services/flow-ai-core/src/http/routes/whatsapp-flow.routes.ts | Endpoints REST + logs paginados + /simulate + /payload-preview |
services/flow-ai-meta-api/src/http/routes/meta-flows.ts | Handler do webhook — decrypt → engine → encrypt → log; executeToolViaCore |
services/flow-ai-core/src/consumers/whatsapp-flow-log.consumer.ts | Persiste logs de webhook do stream:wa-flow-logs (grupo core-wa-flow-logs) |
services/flow-ai-core/src/consumers/whatsapp-flow-deferred.scheduler.ts | Agenda WhatsAppFlowDeferredJob a partir do stream:wa-flow-logs (grupo core-wa-flow-deferred) |
services/flow-ai-core/src/consumers/whatsapp-flow-deferred.worker.ts | Executa jobs deferidos com retry/backoff |