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:
@@ -118,6 +118,50 @@ function actionsXml(tag: "action" | "anti-action", actions: DialplanAction[] | u
|
||||
* múltiplas <condition> por extension); cobre o editor estruturado descrito
|
||||
* na especificação sem a complexidade de encadeamento arbitrário.
|
||||
*/
|
||||
/**
|
||||
* Contexto sintético "inbound" (PHASE 56, docs/INBOUND_ROUTES.md) — o
|
||||
* profile "external" do FreeSWITCH aponta pra este contexto (renomeado do
|
||||
* "public" vanilla no Dockerfile), que NÃO tem arquivo estático, então
|
||||
* toda chamada de entrada por tronco passa por aqui via mod_xml_curl.
|
||||
* Diferente do dialplan do tenant (`buildDialplanXml`, versionado,
|
||||
* editável), este XML é gerado direto pela resolução de
|
||||
* `InboundRoute.didNumber` — nunca guardado em `dialplan_versions`. Faz só
|
||||
* duas coisas: injeta `b2bcall_tenant_id` (nenhuma chamada de entrada
|
||||
* carrega isso até aqui) e transfere pro destino real do tenant — o
|
||||
* `transfer` dispara uma nova resolução de dialplan (agora já com a
|
||||
* variable presente), reaproveitando 100% do dialplan normal do tenant
|
||||
* (ex.: a regra "Discagem interna" já testada pro pickup de grupo).
|
||||
*
|
||||
* `domain_name` TAMBÉM precisa ser setado explicitamente — achado real
|
||||
* testando com uma chamada de verdade: sem isso, o `bridge
|
||||
* data="user/${"${destination_number}"}@${"${domain_name}"}"` da
|
||||
* "Discagem interna" resolve `${"${domain_name}"}` pro domínio GLOBAL
|
||||
* default (`$${domain}` do vars.xml, ex.: "b2bcall.local"), nunca pro
|
||||
* domínio de verdade do tenant — a leg de entrada não é um ramal
|
||||
* registrado, então nada preenche essa variable sozinho.
|
||||
*/
|
||||
export function buildInboundRouteXml(params: {
|
||||
tenantId: string;
|
||||
domain: string;
|
||||
destinationNumber: string;
|
||||
destinationContext: string;
|
||||
}): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="freeswitch/xml">
|
||||
<section name="dialplan">
|
||||
<context name="inbound">
|
||||
<extension name="inbound-route">
|
||||
<condition>
|
||||
<action application="set" data="b2bcall_tenant_id=${xmlEscape(params.tenantId)}"/>
|
||||
<action application="set" data="domain_name=${xmlEscape(params.domain)}"/>
|
||||
<action application="transfer" data="${xmlEscape(params.destinationNumber)} XML ${xmlEscape(params.destinationContext)}"/>
|
||||
</condition>
|
||||
</extension>
|
||||
</context>
|
||||
</section>
|
||||
</document>`;
|
||||
}
|
||||
|
||||
export function buildDialplanXml(context: string, extensions: DialplanExtensionInput[]): string {
|
||||
const sorted = [...extensions].sort((a, b) => a.order - b.order);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user