feat(ivr): tela de autoria de menu de IVR no frontend

Pedido do usuário: "constrói a tela de IVR no frontend". Até aqui um
menu de IVR só existia se alguém escrevesse as regras à mão no editor
genérico de dialplan (o que eu fiz manualmente pra testar na PHASE 56) —
sem UI nenhuma pra isso.

`IvrMenu`/`IvrMenuOption` (RLS real): nome + contexto (derivado do nome)
+ opções (dígito → ramal + rótulo opcional). Nenhuma tabela nova pro
dialplan em si — `IvrMenusController` compila o menu inteiro em
`DialplanExtension`/`DialplanVersion` do contexto do menu
(`buildIvrDialplanExtensions`, packages/telephony) usando as MESMAS 2
formas de `<extension>` já testadas com DTMF real na PHASE 56 (entrada
com `play_and_get_digits` + `transfer` usando o dígito coletado como
novo destination_number, uma extension por dígito) — e já gera + ativa
a versão nova automaticamente, o mesmo generate+activate manual que o
editor de dialplan faz, só que embutido no create/update do menu.

`greeting` (o prompt do menu) é texto livre do tenant, então passa pela
MESMA proteção anti-RCE já aplicada em `data` de dialplan
(`IsSafeDialplanData`) — nunca pode virar `${system(...)}`.

Tela "Telefonia > IVR": lista de menus com as opções de cada um, criação
com nome/contexto (auto-gerado do nome, editável) + linhas dinâmicas de
opção (dígito + select de ramal já cadastrado + rótulo), remoção com
confirmação de 2 cliques. Mostra o contexto/destino fixo (`ivr_entry`)
que uma Rota de Entrada precisa usar pra apontar pro menu.

Testado ponta a ponta criando um menu DE VERDADE pela tela/API (não só
lendo o XML manualmente escrito antes): softphone externo discou um DID
apontado pro menu recém-criado, atendeu, tocou o prompt, colheu o dígito
com DTMF real (`uuid_recv_dtmf`) e bridged com o ramal certo — confirma
que o compilador produz XML funcionalmente idêntico ao testado
manualmente. Falta pipeline de upload/TTS de áudio, sub-menus e destino
"fila" — ver docs/INBOUND_ROUTES.md.

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 17:24:07 -03:00
parent 83eb2c5aea
commit 36e85c1abf
16 changed files with 1000 additions and 6 deletions

View File

@@ -5,4 +5,5 @@ export * from "./directory-xml";
export * from "./gateway-xml";
export * from "./dialplan-xml";
export * from "./default-dialplan";
export * from "./ivr-xml";
export * from "./queue-xml";

View File

@@ -0,0 +1,74 @@
import type { DialplanExtensionInput } from "./dialplan-xml";
/**
* destination_number fixo usado pra entrar num menu de IVR (PHASE 58) —
* toda `InboundRoute` que aponta pra um `IvrMenu` deve usar exatamente
* este valor como `destinationNumber`, com `destinationContext` igual ao
* `IvrMenu.context`.
*/
export const IVR_ENTRY_DESTINATION = "ivr_entry";
const DEFAULT_GREETING = "tone_stream://%(500,0,800)";
const INVALID_TONE = "tone_stream://%(500,0,400)";
/** Digits válidos num menu de IVR — os mesmos que um telefone real manda. */
export const ALLOWED_IVR_DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"] as const;
export type AllowedIvrDigit = (typeof ALLOWED_IVR_DIGITS)[number];
function escapeRegexLiteral(digit: string): string {
// Só "*" precisa de escape entre os digits permitidos (regex especial);
// os demais (0-9, #) já são literais seguros.
return digit === "*" ? "\\*" : digit;
}
export interface IvrMenuOptionInput {
digit: string;
destinationNumber: string;
destinationContext: string;
}
/**
* Compila um `IvrMenu` + suas opções nas mesmas 2 formas de `<extension>`
* já testadas ponta a ponta com DTMF real (PHASE 56): uma entrada que
* atende, toca o prompt, coleta 1 dígito com `play_and_get_digits` e
* `transfer`e pro PRÓPRIO contexto usando o dígito coletado como novo
* `destination_number` — achado real, documentado em
* docs/INBOUND_ROUTES.md: FreeSWITCH resolve todas as condições de um
* contexto ANTES de executar qualquer ação, então uma variable setada
* por uma extension nunca é enxergada por OUTRA extension na mesma
* passada; só um `transfer` (nova consulta de dialplan) resolve isso —
* e uma extension por dígito, casando por `destination_number` normal.
*/
export function buildIvrDialplanExtensions(
menu: { context: string; greeting?: string | null },
options: IvrMenuOptionInput[],
): DialplanExtensionInput[] {
const greetingFile = menu.greeting?.trim() || DEFAULT_GREETING;
const entry: DialplanExtensionInput = {
name: "IVR entrada",
conditionField: "destination_number",
conditionExpr: `^${IVR_ENTRY_DESTINATION}$`,
continueOnFalse: false,
order: 1,
actions: [
{ application: "answer" },
{
application: "play_and_get_digits",
data: `1 1 3 5000 # ${greetingFile} ${INVALID_TONE} ivr_choice \\d+ 3000`,
},
{ application: "transfer", data: `\${ivr_choice} XML ${menu.context}` },
],
};
const branches: DialplanExtensionInput[] = options.map((opt, i) => ({
name: `IVR opção ${opt.digit}`,
conditionField: "destination_number",
conditionExpr: `^${escapeRegexLiteral(opt.digit)}$`,
continueOnFalse: false,
order: 10 + i,
actions: [{ application: "bridge", data: `user/${opt.destinationNumber}@\${domain_name}` }],
}));
return [entry, ...branches];
}