Files
B2BCall-dialer/docs/INBOUND_ROUTES.md
Matheus 0148b926a0 feat(ivr): editor visual do menu (canvas de nós, sem Node-RED)
Pedido do usuário: "ajusta o IVR para fazer o fluxo de maneira visual
usando nodeRED". Perguntei antes de construir: integrar Node-RED de
verdade significa rodar uma plataforma externa completa (motor de
execução próprio, nós "function" = execução de código arbitrário — o
mesmo tipo de risco corrigido no dialplan nesta sessão — e sem
multi-tenancy nativa), um projeto de vários dias com decisões de
arquitetura antes de começar a construir. O usuário escolheu a
alternativa: um editor visual próprio, sem dependência externa.

`@xyflow/react` (sucessor mantido do reactflow) — canvas de nós/setas
dentro da própria tela "Telefonia > IVR": 1 nó "Entrada" fixo conectado
a 1 nó por opção (dígito + select de ramal + descrição, editável direto
no nó, arrastável pro canvas). "Adicionar opção"/"Salvar alterações"
chamam o mesmo PATCH /ivr-menus/:id que já existia — nenhuma mudança de
backend necessária, só uma forma nova de editar o mesmo dado (o modelo
IvrMenu/IvrMenuOption continua sendo a fonte da verdade).

Testado ponta a ponta pela tela de verdade, não só leitura de código:
criado um menu real, enviado um prompt de VOZ real (WAV sintetizado com
espeak-ng dizendo uma saudação de verdade, não um tom sintético como nos
testes anteriores desta fase) e completada uma chamada real — a saudação
de ~6s tocou até o fim, o dígito foi capturado, o ramal certo atendeu
com áudio de verdade. Depois, uma segunda opção foi adicionada via PATCH
(a mesma chamada que o botão "Salvar" do editor visual faz) — confirmado
no banco que a versão 2 do dialplan compilou as duas opções
corretamente, superando a versão 1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 18:21:26 -03:00

220 lines
12 KiB
Markdown

# Rotas de entrada (PHASE 56)
Pedido do usuário: "pode iniciar a montar o IVR e as rotas de entrada". A
investigação achou que **nenhuma chamada de tronco tinha como funcionar
hoje**, IVR ou não — esse documento cobre a fundação (rota por DID →
tenant); o IVR em si (menu com `play_and_get_digits`) é a próxima fase,
construída em cima disso.
## O achado real
Toda chamada originada de um ramal registrado ou de discagem de saída
carrega o channel variable `b2bcall_tenant_id` (setado por
`buildDirectoryUserXml`/originate do discador). **Uma chamada de ENTRADA
por tronco nunca carregava isso** — não existia nenhum mecanismo pra
identificar de qual tenant uma chamada de entrada é, então
`resolveDialplanXml` (`apps/freeswitch-config`) sempre devolvia
"not found" pra ela.
Pior: mesmo se essa variable existisse, o profile `external` do
FreeSWITCH (onde troncos recebem chamada) aponta pro contexto `public`
por padrão — um **arquivo estático** (`dialplan/public.xml`, vanilla).
Config estática sempre ganha de uma consulta ao `mod_xml_curl`, então
mesmo trocando a aplicação, esse contexto nunca seria dinâmico enquanto
se chamasse "public".
## O mecanismo
1. **`InboundRoute`** (nova tabela, tenant-scoped, RLS real) — cada linha
é `didNumber` (o número que o provedor manda no INVITE) → `tenantId` +
`destinationContext`/`destinationNumber` (pra onde a chamada cai
dentro do dialplan do TENANT DONO). `didNumber` é `@unique` **global**
— mesma exceção já aceita em `Tenant.telephonyDomain` (PHASE 50): é a
ÚNICA forma de descobrir de qual tenant é uma chamada de entrada
ANTES de saber o tenant.
2. **Dockerfile**: o profile `external` foi repontado de `context="public"`
pra `context="inbound"` — um contexto sintético, sem nenhum arquivo
estático, então toda chamada de entrada cai obrigatoriamente no
`mod_xml_curl` (`b2bcall-fs-config`, binding já genérico pra qualquer
contexto do section `dialplan`). `dialplan/public.xml` continua no
disco, só não é mais alcançado por nenhum profile.
3. **`apps/freeswitch-config`**: quando `resolveDialplanXml` recebe uma
requisição SEM `variable_b2bcall_tenant_id` e com `Caller-Context ==
"inbound"`, lê `Caller-Destination-Number` (o DID discado) e faz
fan-out sobre os tenants ativos (`withTenantContext` por tenant, nunca
uma query sem contexto — `InboundRoute` tem `FORCE ROW LEVEL
SECURITY` de verdade) até achar quem é dono do DID.
4. **`buildInboundRouteXml`** (`packages/telephony`) gera um XML de
dialplan mínimo pro contexto `inbound`: `set b2bcall_tenant_id=<id>`,
`set domain_name=<domínio do tenant>` (achado real testando com
chamada de verdade — sem isso o `bridge
data="user/${destination_number}@${domain_name}"` da regra "Discagem
interna" resolvia pro domínio GLOBAL default, nunca pro do tenant,
porque a leg de entrada não é um ramal registrado e nada preenche essa
variable sozinho), e `transfer <destinationNumber> XML
<destinationContext>`. O `transfer` dispara uma nova consulta de
dialplan — agora já com `b2bcall_tenant_id` presente — reaproveitando
100% do dialplan normal do tenant (a mesma regra "Discagem interna" já
testada pro pickup de grupo, PHASE 53).
## Testado ponta a ponta com uma chamada real
Nenhum tronco PSTN real disponível neste laboratório — simulado com dois
softphones reais (`linphone-cli`) em containers Docker na mesma rede: um
registrado como ramal normal (`8001@acme.b2bcall.net`), outro discando
DIRETO pro profile `external` (porta 5080, sem registrar — exatamente
como um provedor de tronco manda uma chamada) um número (DID) cadastrado
numa `InboundRoute` apontando pra `8001`. Confirmado via `show channels`:
a chamada de entrada resolveu o tenant certo, transferiu pro contexto
`default` do tenant, bridged com o `user/8001@acme.b2bcall.net` (domínio
correto, não o global) e ficou `ACTIVE` com codec PCMU negociado nos dois
lados — ramal tocou, atendeu, áudio bidirecional confirmado.
(Tentativa inicial usando `originate loopback/.../inbound &park()` como
simulação deu `INCOMPATIBLE_DESTINATION`/488 — isolado como limitação do
canal `loopback` (sem SDP/codec real negociado), não um bug da resolução:
confirmado testando a mesma resolução com destino simples
`answer`+`playback` via loopback (sucesso) e depois com uma chamada SIP
de verdade ponta a ponta (sucesso completo, áudio incluído).)
## IVR (PHASE 56, construído em cima da fundação acima)
`play_and_get_digits` adicionado ao `ALLOWED_DIALPLAN_APPLICATIONS` — só
coleta dígitos numa variable, nunca executa nada, mesmo risco zero dos
outros já permitidos. `ALLOWED_CONDITION_FIELDS` ganhou um segundo modo:
além dos 6 campos fixos, aceita um `${variavel}` simples (regex exige
identificador puro, sem parênteses — nunca bate com `${funcname(...)}`,
então o mesmo risco de RCE já fechado pra `data` não se aplica aqui).
**Achado real construindo o primeiro menu de teste**: FreeSWITCH resolve
TODAS as `<condition>` de um contexto (decide quais `<extension>`
batem) ANTES de executar qualquer `<action>` — variables setadas por uma
action (como o dígito que `play_and_get_digits` guarda) NUNCA afetam o
casamento de outras `<extension>` no MESMO contexto/mesma passada
("Uma condição por extension" já era uma simplificação deliberada desta
implementação, mas mesmo o FreeSWITCH puro com múltiplas `<condition>`
por extension só re-avalia a PRÓXIMA condição da MESMA extension, não
outras extensions). Confirmado testando com DTMF de verdade: duas
extensions separadas (`IVR entrada` fazendo `play_and_get_digits`, `IVR
opcao 1`/`opcao 2` checando `${ivr_choice}`) NUNCA bateu — o regex era
avaliado com a variable ainda vazia.
**A forma que funciona**: dentro da MESMA extension que colheu o dígito,
um `transfer` explícito usando o valor coletado como novo
`destination_number``transfer data="${ivr_choice} XML
<mesmo-contexto>"`. `transfer` dispara uma consulta de dialplan
COMPLETAMENTE NOVA (nova passada de parse, variables já setadas
persistem), então as extensions seguintes podem casar por
`destination_number` normal (`^1$`, `^2$`, ...) — nem precisa do
widening de `${variavel}` pra ISSO especificamente (mas o widening
continua útil/correto de se ter, por exemplo pra decisões dentro de uma
única extension com múltiplas conditions no futuro).
Testado ponta a ponta com DTMF de verdade (`fs_cli uuid_recv_dtmf`
`linphonec` não tem comando de enviar DTMF interativo, então a
verificação usa a API do FreeSWITCH que simula o dígito chegando como
input do chamador, o mesmo caminho que RFC2833/inband usaria): softphone
externo liga pro DID → IVR atende, toca prompt, espera dígito →
dígito `1` → bridge com ramal real registrado (atendeu, áudio PCMU
bidirecional confirmado); dígito `2` → resposta alternativa (tom
diferente + desliga) — confirma que a ramificação distingue de verdade,
não só "sempre cai na primeira opção".
## Tela de autoria de IVR (PHASE 58)
`Telefonia > IVR` (`apps/frontend/.../telefonia/ivr`) — cria um `IvrMenu`
(nome + contexto derivado do nome) com opções (dígito → ramal + rótulo
opcional), sem precisar tocar no editor genérico de dialplan. Por baixo,
`IvrMenusController` (`POST/PATCH/DELETE /ivr-menus`) compila o menu +
opções em `buildIvrDialplanExtensions` (`packages/telephony/src/ivr-xml.ts`)
e já gera + ativa uma nova versão do dialplan do contexto — o mesmo fluxo
generate+activate manual, automático. `IVR_ENTRY_DESTINATION`
("ivr_entry") é o valor fixo que toda `InboundRoute` precisa usar como
`destinationNumber` pra entrar nesse menu (`destinationContext =
IvrMenu.context`).
`greeting` (o prompt tocado ao entrar) passa pela MESMA proteção
anti-RCE de `data` de dialplan (`IsSafeDialplanData`) — é texto livre do
tenant, nunca pode virar `${system(...)}`. Sem pipeline de upload/TTS de
áudio ainda: null usa um tom padrão; texto livre vira o argumento `file`
de `play_and_get_digits` (aceita qualquer caminho/URL que o FreeSWITCH
já resolva, incluindo `tone_stream://`).
Testado ponta a ponta criando um menu de verdade via API (não só
lendo XML manualmente escrito): softphone externo discou o DID, o menu
recém-criado atendeu, tocou o prompt, colheu o dígito com DTMF real
(`uuid_recv_dtmf`), e bridged com o ramal certo — confirmando que o
compilador produz XML funcionalmente idêntico ao testado manualmente na
PHASE 56.
## Upload de áudio pro prompt (PHASE 59)
`POST /ivr-menus/:id/prompt` (multipart, campo `file`) — só aceita WAV
(cabeçalho RIFF/WAVE validado antes de gravar; sem `mod_shout` nesta
implantação, MP3 nunca funcionaria mesmo). Gravado num bind mount NOVO
compartilhado com o container do FreeSWITCH (`./data/ivr-prompts` no
host ↔ `/ivr-prompts` no container) — mesmo raciocínio já usado pras
gravações de chamada (`./data/recordings-spool`), mas na direção
contrária: aqui é `apps/api` (host) que ESCREVE e o FreeSWITCH que LÊ.
Um fetch em rede (S3/HTTP) durante uma chamada ativa foi descartado de
propósito — latência/confiabilidade desnecessárias pra um prompt de
poucos segundos, e esta é a MESMA convenção já usada 3x neste projeto
pra arquivos que o FreeSWITCH precisa enxergar (gateways externos, filas
do callcenter, spool de gravação).
`greeting` passa a guardar o path como o FreeSWITCH enxerga
(`/ivr-prompts/<tenantId>/<menuId>.wav`), nunca o path do host.
`GET /ivr-menus/:id/prompt` (autenticado, `ivr.view`) serve o preview —
mesmo princípio do player de gravações, nunca uma URL direta pro
storage/disco. Deletar o menu ou trocar/remover o prompt sempre limpa o
arquivo do disco (nunca deixa órfão).
Testado ponta a ponta com um WAV real de 44.1kHz/mono (não o formato
"nativo" de telefonia, de propósito — pra confirmar que funciona com o
que uma pessoa qualquer gravaria/exportaria): softphone externo discou
o DID, `play_and_get_digits` abriu e tocou o arquivo até o fim duas
vezes (`mod_sndfile` resample automático, sem erro no log), colheu o
dígito real e bridged corretamente com o ramal de destino.
## Editor visual (PHASE 60)
`apps/frontend/.../telefonia/ivr/ivr-flow-editor.tsx` — canvas de nós e
setas (`@xyflow/react`) em cima do MESMO modelo de sempre (entrada +
opções por dígito), sem nenhuma dependência de execução externa. Um nó
"Entrada" fixo conectado a um nó por opção (dígito + select de ramal +
descrição, editável direto no nó); "Adicionar opção"/"Salvar alterações"
chamam o mesmo `PATCH /ivr-menus/:id` que já existia. Avaliado (e
descartado por enquanto, ver AskUserQuestion desta sessão) integrar
Node-RED de verdade: seria uma plataforma externa completa (motor de
execução próprio, nós "function" = RCE, sem multi-tenancy nativa) —
esforço de vários dias e superfície de risco nova, não um ajuste na tela
atual. Um editor visual próprio, sobre o backend já existente, entrega o
mesmo valor imediato (ver estrutura do menu como grafo, editar visualmente)
sem essas duas desvantagens.
Testado ponta a ponta pela tela de verdade: menu criado, prompt de voz
real enviado (WAV sintetizado com `espeak-ng`, não um tom sintético),
uma segunda opção adicionada via `PATCH` (mesma chamada que o botão
"Salvar" do editor visual faz) — confirmado no banco que a versão 2 do
dialplan compilou as duas opções corretamente, superando a versão 1.
## O que falta
- Sem TTS (texto→voz) — só upload de arquivo WAV já gravado.
- Editor visual não persiste posição manual dos nós entre recargas
(layout recalculado a cada carga da página — arrastar só ajuda
durante a mesma sessão de edição).
- Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem
destino "fila" — só ramal, dentro do contexto `default`.
- Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID →
ramal); não tem seletor dedicado de "IVR" como destino ainda (o
operador digita o contexto/`ivr_entry` manualmente, mostrados na
própria tela de IVR pra copiar).
- Perda das proteções de toll-fraud do `public.xml` vanilla (unroll de
loop de chamada, etc.) — não replicadas no contexto `inbound` novo.
Aceitável pra esta fase (sem trunks reais ainda), mas revisar antes de
conectar um provedor PSTN de verdade.