Files
B2BCall-dialer/docs/INBOUND_ROUTES.md
Matheus ed4ae4421e feat(telefonia): rotas de entrada por DID — fundação real pro IVR
Pedido do usuário: "pode iniciar a montar o IVR e as rotas de entrada".
Investigando antes de escrever qualquer XML de IVR, achei que NENHUMA
chamada de entrada por tronco tinha como funcionar hoje, IVR ou não:
nenhuma carregava `b2bcall_tenant_id` (só REGISTER de ramal e discagem de
saída setam essa variable), e mesmo corrigindo isso, o profile "external"
apontava pro contexto "public" vanilla — um arquivo ESTÁTICO, que sempre
ganha de uma consulta ao mod_xml_curl, então nunca seria dinâmico
enquanto se chamasse "public".

Perguntei ao usuário a granularidade certa (por DID ou por tronco) antes
de desenhar o schema — escolheu por DID, mais flexível (um tronco pode
carregar vários números com destinos diferentes). `InboundRoute` nova
(RLS real, FORCE ROW LEVEL SECURITY): `didNumber` único GLOBAL entre
tenants (mesma exceção já aceita em Tenant.telephonyDomain) — é a ÚNICA
forma de descobrir de qual tenant é uma chamada de entrada ANTES de
identificar o tenant. Resolvido por fan-out sobre tenants ativos, nunca
uma query sem contexto de RLS.

Dockerfile repontou o profile external pra context="inbound" (sem
arquivo estático, cai no mod_xml_curl de verdade). O XML gerado pra esse
contexto injeta b2bcall_tenant_id + domain_name (achado real: sem setar
domain_name explicitamente, o bridge da "Discagem interna" resolvia pro
domínio GLOBAL default, nunca pro do tenant) e transfere pro dialplan
real do tenant — reaproveita 100% do que já existe, inclusive pickup de
grupo (PHASE 53).

CRUD completo (InboundRoutesController, permissions novas no seed) + tela
"Telefonia > Rotas de Entrada" no frontend.

Testado com uma chamada REAL: um softphone registrado como ramal normal,
outro discando direto pro profile external (porta 5080, sem registrar —
exatamente como um provedor de tronco manda) um DID cadastrado. `show
channels` confirma: tenant certo, contexto certo, domínio certo no
bridge, codec PCMU negociado nos dois lados, ramal tocou e atendeu de
verdade. Detalhes completos, inclusive uma tentativa de teste que falhou
por limitação do canal `loopback` (não um bug), em docs/INBOUND_ROUTES.md.

O IVR em si (menu com play_and_get_digits) fica pra próxima fase — esta
é a fundação sem a qual nada de chamada de entrada funcionava.

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

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

O que falta

  • IVR (menu com play_and_get_digits + branching por dígito) — a rota de entrada já pode apontar destinationContext pra um contexto de IVR dedicado, mas o "menu" em si (dialplan play_and_get_digits + condição sobre o dígito coletado) ainda não foi construído. Pontos em aberto: ALLOWED_CONDITION_FIELDS hoje é um enum fixo (nunca aceita ${variavel} arbitrária) — vai precisar de um allowlist mais amplo com a MESMA proteção contra RCE já aplicada em data (IsSafeDialplanData), já que field também é expandido pelo FreeSWITCH em tempo de chamada.
  • Tela de frontend cobre só CRUD simples (DID → ramal); não tem seletor de "fila" ou "IVR" como destino ainda — hoje é só texto livre pro destinationContext/destinationNumber (o operador escolhe o contexto e número certos manualmente).
  • 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.