Files
B2BCall-dialer/docs/INBOUND_ROUTES.md
Matheus 738bfa4f36 feat(rotas-entrada): dropdown de destino real (ramal/IVR/fila/grupo)
Pedido do usuário: "em vez de o campo de destino ser aberto tem que ter
um dropdown listando todos os ramais do tennant e tb todas ivr e filas
e grupos de ramais do tennant" — até aqui o destino era texto livre.

Construir o dropdown revelou que fila e grupo NUNCA tinham sido
implementados de verdade como destino possível — só ramal/IVR
funcionavam. Oferecer as duas opções sem o mecanismo por trás seria
mostrar um dropdown mentiroso, então:

InboundRoute.destinationType (novo enum EXTENSION/IVR/QUEUE/CALL_GROUP)
decide como buildInboundRouteXml interpreta o destino:
- EXTENSION/IVR: inalterado, o mesmo transfer já testado (PHASE 56/58).
- QUEUE: destinationNumber guarda o Queue.id; a resolução de entrada
  emite `answer` + `callcenter data="<queueId>@<domain>"` direto, sem
  tocar no dialplan "default". `callcenter` entrou no allowlist de
  applications com o mesmo risco zero de `pickup`.
- CALL_GROUP: destinationNumber guarda o Extension.callGroup; a
  resolução consulta AGORA (nunca um snapshot salvo) todos os ramais
  com esse callGroup e emite um `bridge` multi-leg — toca todos ao
  mesmo tempo, quem atender primeiro cancela os outros. Trocar quem
  está no grupo depois de criar a rota já vale na próxima chamada.

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 no mesmo callGroup, a chamada tocou nos DOIS ao mesmo
tempo (mesmo call_uuid, ambas RINGING), atender em um cancelou o outro
automaticamente — ring group de verdade.

Tela: "Tipo de destino" + um segundo dropdown com as opções reais do
tenant pra cada tipo (ramais, menus de IVR, filas, ou os valores
distintos de callGroup já usados em algum ramal). Testado com
Playwright: os 4 tipos aparecem, e trocar o tipo atualiza as opções do
segundo dropdown corretamente.

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

15 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).

O que falta

  • Sem TTS (texto→voz) — só upload de arquivo WAV já gravado.
  • Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem destino "fila" — só ramal, dentro do contexto default. Rota de entrada já suporta fila/grupo, mas um MENU de IVR ainda só bridge pra ramal.
  • 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.