Files
B2BCall-dialer/docs/INBOUND_ROUTES.md
Matheus 432cd55adb feat(ivr): dropdown de destino (fila/outro IVR) numa opção de menu, com bloqueio de ciclo (PHASE 67)
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
2026-08-31 09:41:22 -03:00

17 KiB

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_numbertransfer 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_dtmflinphonec 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.