# 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=`, `set domain_name=` (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 XML `. 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 `` de um contexto (decide quais `` batem) ANTES de executar qualquer `` — variables setadas por uma action (como o dígito que `play_and_get_digits` guarda) NUNCA afetam o casamento de outras `` 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 `` 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 "`. `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//.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. ## 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`. - 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.