Pedido do usuário: "adiciona upload de áudio pro prompt do IVR" — até aqui o prompt era só texto livre (na prática, sempre um tom padrão, nunca voz de verdade). `POST /ivr-menus/:id/prompt` (multipart via @fastify/multipart — primeiro upload de arquivo binário desta API) só aceita WAV (cabeçalho RIFF/WAVE validado antes de gravar; esta implantação do FreeSWITCH não tem mod_shout, então MP3 nunca funcionaria de qualquer forma). Gravado num bind mount NOVO (./data/ivr-prompts no host ↔ /ivr-prompts no container freeswitch) — mesma convenção já usada 3x neste projeto pra arquivo que o FreeSWITCH precisa enxergar de verdade (gateways externos, filas do callcenter, spool de gravação), mas na direção contrária: apps/api (host) escreve o que o usuário sobe, o FreeSWITCH lê ao vivo durante play_and_get_digits. 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. GET /ivr-menus/:id/prompt (autenticado) serve o preview — mesmo princípio do player de gravações, nunca uma URL direta pro storage. Achado real corrigido antes de commitar: minha primeira versão do delete de menu deixava o .wav órfão no disco — agora deletar o menu ou trocar/remover o prompt sempre limpa o arquivo, confirmado com um teste real de upload+delete. Tela "Telefonia > IVR" ganhou upload/troca/remoção de áudio por menu + player de preview. 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): 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. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
11 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.
O que falta
- Sem TTS (texto→voz) — só upload de arquivo WAV já gravado.
- Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem
destino "fila" — só ramal, dentro do contexto
default. - Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID →
ramal); não tem seletor dedicado de "IVR" como destino ainda (o
operador digita o contexto/
ivr_entrymanualmente, mostrados na própria tela de IVR pra copiar). - 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.