Mesmo gap que Rotas de Entrada já tinha resolvido na PHASE 62: uma opção dentro de um menu de IVR só sabia apontar pra ramal (bridge fixo). Novo IvrMenuOption.destinationType (EXTENSION/QUEUE/IVR) decide a action de cada branch em buildIvrDialplanExtensions, mesmo padrão de buildInboundRouteXml — QUEUE vira answer+callcenter, IVR vira transfer pro IVR_ENTRY_DESTINATION do menu alvo (efetivamente um sub-menu). Sem CALL_GROUP aqui de propósito: diferente de InboundRoute (resolvido a cada chamada), o dialplan de um IVR é compilado uma vez ao salvar — "quem está no grupo agora" ficaria desatualizado até a próxima edição. "Outro IVR" cria a primeira forma de um menu apontar pra outro (uma InboundRoute nunca é ela mesma um menu, nunca formava ciclo antes). assertNoIvrCycle monta o grafo com todos os menus do tenant antes de compilar e rejeita qualquer save que criaria um ciclo, em create e update. Os dois editores de IVR (form clássico e o editor visual de nós) ganharam o mesmo par de dropdowns "Tipo"/"Destino" já usado em Rotas de Entrada. Testado ponta a ponta com Playwright, tenant/fila/2 menus de IVR reais criados na hora: menu com opção Fila e menu com opção Outro IVR apontando pro primeiro, os dois persistindo certo depois de reload completo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
17 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
-
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é@uniqueglobal — mesma exceção já aceita emTenant.telephonyDomain(PHASE 50): é a ÚNICA forma de descobrir de qual tenant é uma chamada de entrada ANTES de saber o tenant. -
Dockerfile: o profile
externalfoi repontado decontext="public"pracontext="inbound"— um contexto sintético, sem nenhum arquivo estático, então toda chamada de entrada cai obrigatoriamente nomod_xml_curl(b2bcall-fs-config, binding já genérico pra qualquer contexto do sectiondialplan).dialplan/public.xmlcontinua no disco, só não é mais alcançado por nenhum profile. -
apps/freeswitch-config: quandoresolveDialplanXmlrecebe uma requisição SEMvariable_b2bcall_tenant_ide comCaller-Context == "inbound", lêCaller-Destination-Number(o DID discado) e faz fan-out sobre os tenants ativos (withTenantContextpor tenant, nunca uma query sem contexto —InboundRoutetemFORCE ROW LEVEL SECURITYde verdade) até achar quem é dono do DID. -
buildInboundRouteXml(packages/telephony) gera um XML de dialplan mínimo pro contextoinbound:set b2bcall_tenant_id=<id>,set domain_name=<domínio do tenant>(achado real testando com chamada de verdade — sem isso obridge 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), etransfer <destinationNumber> XML <destinationContext>. Otransferdispara uma nova consulta de dialplan — agora já comb2bcall_tenant_idpresente — 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 <condition> de um contexto (decide quais <extension>
batem) ANTES de executar qualquer <action> — variables setadas por uma
action (como o dígito que play_and_get_digits guarda) NUNCA afetam o
casamento de outras <extension> 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 <condition>
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 <mesmo-contexto>". 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/<tenantId>/<menuId>.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.
Editor visual (PHASE 60)
apps/frontend/.../telefonia/ivr/ivr-flow-editor.tsx — canvas de nós e
setas (@xyflow/react) em cima do MESMO modelo de sempre (entrada +
opções por dígito), sem nenhuma dependência de execução externa. Um nó
"Entrada" fixo conectado a um nó por opção (dígito + select de ramal +
descrição, editável direto no nó); "Adicionar opção"/"Salvar alterações"
chamam o mesmo PATCH /ivr-menus/:id que já existia. Avaliado (e
descartado por enquanto, ver AskUserQuestion desta sessão) integrar
Node-RED de verdade: seria uma plataforma externa completa (motor de
execução próprio, nós "function" = RCE, sem multi-tenancy nativa) —
esforço de vários dias e superfície de risco nova, não um ajuste na tela
atual. Um editor visual próprio, sobre o backend já existente, entrega o
mesmo valor imediato (ver estrutura do menu como grafo, editar visualmente)
sem essas duas desvantagens.
Testado ponta a ponta pela tela de verdade: menu criado, prompt de voz
real enviado (WAV sintetizado com espeak-ng, não um tom sintético),
uma segunda opção adicionada via PATCH (mesma chamada que o botão
"Salvar" do editor visual faz) — confirmado no banco que a versão 2 do
dialplan compilou as duas opções corretamente, superando a versão 1.
Posição dos nós persiste entre recargas (PHASE 61)
IvrMenu.entryPositionX/Y + IvrMenuOption.positionX/Y (nullable,
puramente de apresentação — nunca entram no dialplan compilado). Salvos
no banco (nunca localStorage, mesma convenção do resto do app: estado
compartilhado entre quem quer que edite o tenant, não por navegador) a
cada "Salvar alterações" — o editor lê a posição de verdade do estado de
nós do @xyflow/react (reflete arrastos feitos na sessão), não do
estado de conteúdo das opções. Sem posição salva (menu novo, opção
recém-adicionada), cai num layout automático em coluna. Testado ponta a
ponta: PATCH com coordenadas específicas, GET de volta confirma os
mesmos valores, e a página carregada de novo (SSR) já embute essas
coordenadas nos props iniciais do componente.
Dropdown de destino: ramal, IVR, fila ou grupo (PHASE 62)
Achado real reportado pelo usuário: o destino de uma rota de entrada era um campo de texto livre — sem dropdown, e (descoberto construindo o dropdown) fila/grupo nunca tinham sido implementados de verdade como destino possível, só ramal/IVR funcionavam.
InboundRoute.destinationType (novo enum: EXTENSION/IVR/QUEUE/
CALL_GROUP) decide como buildInboundRouteXml interpreta o destino:
- EXTENSION/IVR: comportamento inalterado —
transferpro contexto do tenant, reaproveitando o dialplan já testado (PHASE 56/58). - QUEUE:
destinationNumberguarda oQueue.id. A resolução de entrada emiteanswer+callcenter data="<queueId>@<domain>"direto — nunca passa pelo dialplan "default" nem precisa de nenhuma regra nova lá.callcenterentrou no allowlist de applications (packages/telephony/src/dialplan-xml.ts) com o mesmo risco zero depickup— só recebe<queueId>@<domain>como texto. - CALL_GROUP:
destinationNumberguarda o valor deExtension.callGroup. A resolução de entrada consulta AGORA (nunca um snapshot salvo na criação da rota) todos os ramais do tenant com essecallGroupe emitebridgecom uma leg por ramal (user/A@domain,user/B@domain,...) — toca todos ao mesmo tempo, quem atender primeiro cancela os outros (ring group de verdade). Trocar quem está no grupo depois de criar a rota já vale na PRÓXIMA chamada, sem precisar re-salvar nada — a query roda a cada chamada de entrada, não uma vez só.
Testado ponta a ponta com chamadas reais nos 2 mecanismos novos: fila —
softphone externo discou o DID, show channels confirmou a chamada
dentro da application callcenter com o nome certo da fila; grupo — 2
ramais reais registrados no mesmo callGroup, a chamada tocou nos DOIS
ao mesmo tempo (mesmo call_uuid, ambas as legs RINGING), atender em
um cancelou o outro automaticamente.
Tela "Rotas de Entrada": "Tipo de destino" (Ramal/IVR/Fila/Grupo de
ramais) + um segundo dropdown listando as opções reais do tenant pra
cada tipo (ramais cadastrados, menus de IVR, filas, ou os valores
distintos de callGroup já usados em algum ramal).
Dropdown de destino numa opção de IVR: ramal, fila ou outro IVR (PHASE 67)
Mesmo gap da seção acima, um nível abaixo: até aqui, uma OPÇÃO dentro de
um menu de IVR só sabia apontar pra ramal (bridge fixo). Rota de
entrada já suportava fila/grupo pra ENTRAR num menu, mas depois de
entrar, cada dígito só discava um ramal.
IvrMenuOption.destinationType (novo enum próprio,
IvrOptionDestinationType: EXTENSION/QUEUE/IVR) decide a action de
cada branch em buildIvrDialplanExtensions (packages/telephony), mesmo
padrão do InboundRoute acima:
- EXTENSION: inalterado —
bridgedireto pro ramal. - QUEUE:
answer+callcenter data="<queueId>@<domain>". - IVR:
transferproIVR_ENTRY_DESTINATIONdo contexto do MENU ALVO — reaproveita a mesma entrada que umaInboundRouteusa pra entrar num IVR, então um menu vira efetivamente um sub-menu de outro.
Sem CALL_GROUP aqui, de propósito (diferente de InboundRoute,
que tem os 4 tipos): o dialplan de um IVR é compilado UMA VEZ, ao salvar
o menu (compileAndActivateIvrDialplan) — não resolvido a cada chamada
como a resolução de InboundRoute. Um destino CALL_GROUP ficaria
"quem estava no grupo no momento em que o menu foi salvo por último",
uma defasagem silenciosa que ninguém pediu — melhor não oferecer do que
oferecer errado.
Ciclos: "Outro IVR" cria a primeira forma de um menu apontar pra
outro (uma InboundRoute nunca é ela mesma um menu, então nunca podia
formar ciclo). assertNoIvrCycle (ivr-menus.controller.ts) monta o
grafo com todos os menus do tenant antes de compilar e rejeita (409)
qualquer save que criaria um ciclo (A→B→A, ou até A apontando pra si
mesmo) — checado tanto em create quanto update.
Os dois editores de IVR (form clássico e o editor visual de nós, PHASE 60/61) ganharam o mesmo par de dropdowns "Tipo"/"Destino" já usado em Rotas de Entrada. Testado ponta a ponta: menu A com opção tipo Fila, menu B com opção tipo Outro IVR apontando pro menu A — os dois persistiram certo depois de um reload completo da página.
O que falta
- Sem TTS (texto→voz) — só upload de arquivo WAV já gravado.
- Grupo de ramais só é alcançável por Rota de Entrada — não existe (e não foi pedido) um jeito de discar um grupo de dentro do próprio dialplan "default" via feature code.
- Perda das proteções de toll-fraud do
public.xmlvanilla (unroll de loop de chamada, etc.) — não replicadas no contextoinboundnovo. Aceitável pra esta fase (sem trunks reais ainda), mas revisar antes de conectar um provedor PSTN de verdade.