Files
B2BCall-dialer/docs/INBOUND_ROUTES.md
Matheus 7763afacc0 feat(dialplan): IVR — play_and_get_digits + branching por dígito, testado com DTMF real
Continuação de "montar o IVR e as rotas de entrada": com a fundação de
roteamento por DID já funcionando, faltava o menu em si.

`play_and_get_digits` adicionado ao allowlist de applications (só coleta
dígitos numa variable, nunca executa nada — mesmo risco zero dos outros
já permitidos). `ALLOWED_CONDITION_FIELDS` ganhou um segundo modo:
`${variavel}` simples além dos 6 campos fixos, com a mesma garantia
anti-RCE de `data` (o regex exige identificador puro, sem parênteses,
então nunca vira `${funcname(...)}`).

Achado real construindo o primeiro menu de teste: FreeSWITCH resolve
TODAS as condições de um contexto antes de executar qualquer ação — uma
variable setada por `play_and_get_digits` nunca afeta o casamento de
OUTRA extension na mesma passada. Descoberto porque o primeiro desenho
(2 extensions separadas, uma coletando o dígito, outra checando
`${ivr_choice}`) nunca bateu num teste real com DTMF — o regex era
avaliado com a variable ainda vazia. A forma que funciona: um `transfer`
explícito, na MESMA extension, usando o dígito coletado como novo
destination_number — isso dispara uma consulta de dialplan nova (as
variables já setadas persistem), e as extensions seguintes casam por
destination_number normal.

Testado ponta a ponta com DTMF de verdade: como linphonec não tem um
comando de enviar DTMF interativo, usei `fs_cli uuid_recv_dtmf` (API do
FreeSWITCH que simula o dígito chegando como input do chamador, mesmo
caminho que RFC2833 usaria). Softphone externo ligou pro DID, o IVR
atendeu, tocou o prompt, esperou o dígito: "1" bridged com um ramal real
(atendeu, áudio PCMU bidirecional confirmado); "2" foi pra uma resposta
alternativa (tom diferente) — confirma que a ramificação distingue de
verdade, não só cai sempre na primeira opção.

Detalhes completos em docs/INBOUND_ROUTES.md. Falta só a UI dedicada de
autoria de menu (hoje construído à mão no editor genérico de dialplan) —
backend já suporta tudo que ela precisaria gerar.

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

7.7 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".

O que falta

  • Tela de frontend pra autoria de IVR — hoje o menu é construído à mão no editor genérico de dialplan (Telefonia > Dialplan, escolhendo um context novo), não tem uma UI dedicada de "menu com opções" ainda. Backend já suporta tudo que uma UI assim precisaria gerar.
  • Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID → ramal); não tem seletor de "fila" ou "IVR" como destino ainda — hoje é texto livre pro destinationContext/destinationNumber.
  • 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.