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
This commit is contained in:
100
docs/INBOUND_ROUTES.md
Normal file
100
docs/INBOUND_ROUTES.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user