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
97 lines
3.8 KiB
TypeScript
97 lines
3.8 KiB
TypeScript
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;
|
|
destinationType: "EXTENSION" | "QUEUE" | "IVR";
|
|
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.
|
|
*
|
|
* PHASE 67 (dropdown de destino real: ramal/fila/outro IVR) — cada branch
|
|
* decide a action pelo `destinationType`, mesmo padrão de
|
|
* `buildInboundRouteXml`: EXTENSION continua `bridge` direto pro ramal;
|
|
* QUEUE vira `answer` + `callcenter`; IVR vira `transfer` pro
|
|
* `IVR_ENTRY_DESTINATION` do contexto alvo (reaproveita a MESMA entrada
|
|
* que qualquer chamada de fora usa pra entrar naquele menu). Sem
|
|
* CALL_GROUP aqui — motivo no comentário do schema (`IvrMenuOption`).
|
|
*/
|
|
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) => {
|
|
let actions: DialplanExtensionInput["actions"];
|
|
if (opt.destinationType === "QUEUE") {
|
|
actions = [
|
|
{ application: "answer" },
|
|
{ application: "callcenter", data: `${opt.destinationNumber}@\${domain_name}` },
|
|
];
|
|
} else if (opt.destinationType === "IVR") {
|
|
actions = [{ application: "transfer", data: `${IVR_ENTRY_DESTINATION} XML ${opt.destinationContext}` }];
|
|
} else {
|
|
actions = [{ application: "bridge", data: `user/${opt.destinationNumber}@\${domain_name}` }];
|
|
}
|
|
return {
|
|
name: `IVR opção ${opt.digit}`,
|
|
conditionField: "destination_number",
|
|
conditionExpr: `^${escapeRegexLiteral(opt.digit)}$`,
|
|
continueOnFalse: false,
|
|
order: 10 + i,
|
|
actions,
|
|
};
|
|
});
|
|
|
|
return [entry, ...branches];
|
|
}
|