Mesmo gap que Rotas de Entrada já tinha resolvido na PHASE 62: uma opção dentro de um menu de IVR só sabia apontar pra ramal (bridge fixo). Novo IvrMenuOption.destinationType (EXTENSION/QUEUE/IVR) decide a action de cada branch em buildIvrDialplanExtensions, mesmo padrão de buildInboundRouteXml — QUEUE vira answer+callcenter, IVR vira transfer pro IVR_ENTRY_DESTINATION do menu alvo (efetivamente um sub-menu). Sem CALL_GROUP aqui de propósito: diferente de InboundRoute (resolvido a cada chamada), o dialplan de um IVR é compilado uma vez ao salvar — "quem está no grupo agora" ficaria desatualizado até a próxima edição. "Outro IVR" cria a primeira forma de um menu apontar pra outro (uma InboundRoute nunca é ela mesma um menu, nunca formava ciclo antes). assertNoIvrCycle monta o grafo com todos os menus do tenant antes de compilar e rejeita qualquer save que criaria um ciclo, em create e update. Os dois editores de IVR (form clássico e o editor visual de nós) ganharam o mesmo par de dropdowns "Tipo"/"Destino" já usado em Rotas de Entrada. Testado ponta a ponta com Playwright, tenant/fila/2 menus de IVR reais criados na hora: menu com opção Fila e menu com opção Outro IVR apontando pro primeiro, os dois persistindo certo depois de reload completo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
307 lines
17 KiB
Markdown
307 lines
17 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.
|
|
|
|
### Posição dos nós persiste entre recargas (PHASE 61)
|
|
|
|
`IvrMenu.entryPositionX/Y` + `IvrMenuOption.positionX/Y` (nullable,
|
|
puramente de apresentação — nunca entram no dialplan compilado). Salvos
|
|
no banco (nunca `localStorage`, mesma convenção do resto do app: estado
|
|
compartilhado entre quem quer que edite o tenant, não por navegador) a
|
|
cada "Salvar alterações" — o editor lê a posição de verdade do estado de
|
|
nós do `@xyflow/react` (reflete arrastos feitos na sessão), não do
|
|
estado de conteúdo das opções. Sem posição salva (menu novo, opção
|
|
recém-adicionada), cai num layout automático em coluna. Testado ponta a
|
|
ponta: `PATCH` com coordenadas específicas, `GET` de volta confirma os
|
|
mesmos valores, e a página carregada de novo (SSR) já embute essas
|
|
coordenadas nos props iniciais do componente.
|
|
|
|
## Dropdown de destino: ramal, IVR, fila ou grupo (PHASE 62)
|
|
|
|
Achado real reportado pelo usuário: o destino de uma rota de entrada era
|
|
um campo de texto livre — sem dropdown, e (descoberto construindo o
|
|
dropdown) fila/grupo nunca tinham sido implementados de verdade como
|
|
destino possível, só ramal/IVR funcionavam.
|
|
|
|
`InboundRoute.destinationType` (novo enum: `EXTENSION`/`IVR`/`QUEUE`/
|
|
`CALL_GROUP`) decide como `buildInboundRouteXml` interpreta o destino:
|
|
|
|
- **EXTENSION/IVR**: comportamento inalterado — `transfer` pro contexto
|
|
do tenant, reaproveitando o dialplan já testado (PHASE 56/58).
|
|
- **QUEUE**: `destinationNumber` guarda o `Queue.id`. A resolução de
|
|
entrada emite `answer` + `callcenter data="<queueId>@<domain>"`
|
|
direto — nunca passa pelo dialplan "default" nem precisa de nenhuma
|
|
regra nova lá. `callcenter` entrou no allowlist de applications
|
|
(`packages/telephony/src/dialplan-xml.ts`) com o mesmo risco zero de
|
|
`pickup` — só recebe `<queueId>@<domain>` como texto.
|
|
- **CALL_GROUP**: `destinationNumber` guarda o valor de
|
|
`Extension.callGroup`. A resolução de entrada consulta AGORA (nunca um
|
|
snapshot salvo na criação da rota) todos os ramais do tenant com esse
|
|
`callGroup` e emite `bridge` com uma leg por ramal
|
|
(`user/A@domain,user/B@domain,...`) — toca todos ao mesmo tempo, quem
|
|
atender primeiro cancela os outros (ring group de verdade). Trocar
|
|
quem está no grupo depois de criar a rota já vale na PRÓXIMA chamada,
|
|
sem precisar re-salvar nada — a query roda a cada chamada de entrada,
|
|
não uma vez só.
|
|
|
|
Testado ponta a ponta com chamadas reais nos 2 mecanismos novos: fila —
|
|
softphone externo discou o DID, `show channels` confirmou a chamada
|
|
dentro da application `callcenter` com o nome certo da fila; grupo — 2
|
|
ramais reais registrados no mesmo `callGroup`, a chamada tocou nos DOIS
|
|
ao mesmo tempo (mesmo `call_uuid`, ambas as legs `RINGING`), atender em
|
|
um cancelou o outro automaticamente.
|
|
|
|
Tela "Rotas de Entrada": "Tipo de destino" (Ramal/IVR/Fila/Grupo de
|
|
ramais) + um segundo dropdown listando as opções reais do tenant pra
|
|
cada tipo (ramais cadastrados, menus de IVR, filas, ou os valores
|
|
distintos de `callGroup` já usados em algum ramal).
|
|
|
|
## Dropdown de destino numa opção de IVR: ramal, fila ou outro IVR (PHASE 67)
|
|
|
|
Mesmo gap da seção acima, um nível abaixo: até aqui, uma OPÇÃO dentro de
|
|
um menu de IVR só sabia apontar pra ramal (`bridge` fixo). Rota de
|
|
entrada já suportava fila/grupo pra ENTRAR num menu, mas depois de
|
|
entrar, cada dígito só discava um ramal.
|
|
|
|
`IvrMenuOption.destinationType` (novo enum próprio,
|
|
`IvrOptionDestinationType`: `EXTENSION`/`QUEUE`/`IVR`) decide a action de
|
|
cada branch em `buildIvrDialplanExtensions` (packages/telephony), mesmo
|
|
padrão do `InboundRoute` acima:
|
|
|
|
- **EXTENSION**: inalterado — `bridge` direto pro ramal.
|
|
- **QUEUE**: `answer` + `callcenter data="<queueId>@<domain>"`.
|
|
- **IVR**: `transfer` pro `IVR_ENTRY_DESTINATION` do contexto do MENU
|
|
ALVO — reaproveita a mesma entrada que uma `InboundRoute` usa pra
|
|
entrar num IVR, então um menu vira efetivamente um sub-menu de outro.
|
|
|
|
**Sem `CALL_GROUP` aqui, de propósito** (diferente de `InboundRoute`,
|
|
que tem os 4 tipos): o dialplan de um IVR é compilado UMA VEZ, ao salvar
|
|
o menu (`compileAndActivateIvrDialplan`) — não resolvido a cada chamada
|
|
como a resolução de `InboundRoute`. Um destino `CALL_GROUP` ficaria
|
|
"quem estava no grupo no momento em que o menu foi salvo por último",
|
|
uma defasagem silenciosa que ninguém pediu — melhor não oferecer do que
|
|
oferecer errado.
|
|
|
|
**Ciclos**: "Outro IVR" cria a primeira forma de um menu apontar pra
|
|
outro (uma `InboundRoute` nunca é ela mesma um menu, então nunca podia
|
|
formar ciclo). `assertNoIvrCycle` (`ivr-menus.controller.ts`) monta o
|
|
grafo com todos os menus do tenant antes de compilar e rejeita (409)
|
|
qualquer save que criaria um ciclo (A→B→A, ou até A apontando pra si
|
|
mesmo) — checado tanto em `create` quanto `update`.
|
|
|
|
Os dois editores de IVR (form clássico e o editor visual de nós, PHASE
|
|
60/61) ganharam o mesmo par de dropdowns "Tipo"/"Destino" já usado em
|
|
Rotas de Entrada. Testado ponta a ponta: menu A com opção tipo Fila,
|
|
menu B com opção tipo Outro IVR apontando pro menu A — os dois
|
|
persistiram certo depois de um reload completo da página.
|
|
|
|
## O que falta
|
|
|
|
- Sem TTS (texto→voz) — só upload de arquivo WAV já gravado.
|
|
- Grupo de ramais só é alcançável por Rota de Entrada — não existe (e
|
|
não foi pedido) um jeito de discar um grupo de dentro do próprio
|
|
dialplan "default" via feature code.
|
|
- 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.
|