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:
2026-08-30 16:45:18 -03:00
parent fc4f5db13a
commit ed4ae4421e
17 changed files with 862 additions and 6 deletions

View File

@@ -91,6 +91,23 @@ RUN sed -i \
&& grep -q '<param name="rtp-start-port" value="16384"/>' /etc/freeswitch/autoload_configs/switch.conf.xml \
&& grep -q '<param name="rtp-end-port" value="16584"/>' /etc/freeswitch/autoload_configs/switch.conf.xml
# Rotas de entrada por DID (PHASE 56, docs/INBOUND_ROUTES.md) — achado
# real: nenhuma chamada que chega pelo profile "external" carrega
# `b2bcall_tenant_id` (só REGISTER de ramal e discagem de saída setam essa
# variable), então uma chamada de entrada não tem como saber de qual
# tenant é. O contexto "public" vanilla (pra onde o profile "external"
# aponta por padrão) é um ARQUIVO ESTÁTICO (`dialplan/public.xml`) — a
# config estática sempre ganha da consulta ao mod_xml_curl, então esse
# contexto NUNCA seria dinâmico enquanto se chamar "public". Renomeado
# pra "inbound" (sem arquivo estático nenhum): toda chamada de entrada
# passa a cair no mod_xml_curl (b2bcall-fs-config), que resolve o dono do
# DID e injeta o tenant antes de encaminhar (packages/telephony
# buildInboundRouteXml). `dialplan/public.xml` fica no lugar, sem efeito
# — não precisa remover, só não é mais alcançado por nenhum profile.
RUN sed -i 's#<param name="context" value="public"/>#<param name="context" value="inbound"/>#' \
/etc/freeswitch/sip_profiles/external.xml \
&& grep -q '<param name="context" value="inbound"/>' /etc/freeswitch/sip_profiles/external.xml
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh