feat(dialplan): IVR — play_and_get_digits + branching por dígito, testado com DTMF real
Continuação de "montar o IVR e as rotas de entrada": com a fundação de
roteamento por DID já funcionando, faltava o menu em si.
`play_and_get_digits` adicionado ao allowlist de applications (só coleta
dígitos numa variable, nunca executa nada — mesmo risco zero dos outros
já permitidos). `ALLOWED_CONDITION_FIELDS` ganhou um segundo modo:
`${variavel}` simples além dos 6 campos fixos, com a mesma garantia
anti-RCE de `data` (o regex exige identificador puro, sem parênteses,
então nunca vira `${funcname(...)}`).
Achado real construindo o primeiro menu de teste: FreeSWITCH resolve
TODAS as condições de um contexto antes de executar qualquer ação — uma
variable setada por `play_and_get_digits` nunca afeta o casamento de
OUTRA extension na mesma passada. Descoberto porque o primeiro desenho
(2 extensions separadas, uma coletando o dígito, outra checando
`${ivr_choice}`) nunca bateu num teste real com DTMF — o regex era
avaliado com a variable ainda vazia. A forma que funciona: um `transfer`
explícito, na MESMA extension, usando o dígito coletado como novo
destination_number — isso dispara uma consulta de dialplan nova (as
variables já setadas persistem), e as extensions seguintes casam por
destination_number normal.
Testado ponta a ponta com DTMF de verdade: como linphonec não tem um
comando de enviar DTMF interativo, usei `fs_cli uuid_recv_dtmf` (API do
FreeSWITCH que simula o dígito chegando como input do chamador, mesmo
caminho que RFC2833 usaria). Softphone externo ligou pro DID, o IVR
atendeu, tocou o prompt, esperou o dígito: "1" bridged com um ramal real
(atendeu, áudio PCMU bidirecional confirmado); "2" foi pra uma resposta
alternativa (tom diferente) — confirma que a ramificação distingue de
verdade, não só cai sempre na primeira opção.
Detalhes completos em docs/INBOUND_ROUTES.md. Falta só a UI dedicada de
autoria de menu (hoje construído à mão no editor genérico de dialplan) —
backend já suporta tudo que ela precisaria gerar.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
@@ -31,6 +31,10 @@ export const ALLOWED_DIALPLAN_APPLICATIONS = [
|
||||
// grupo como texto (ou `${user_data(...)}`, já auditado acima), nunca
|
||||
// um comando; mesmo padrão de risco zero dos outros já permitidos.
|
||||
"pickup",
|
||||
// IVR (PHASE 56) — coleta N dígitos numa variable (`min max tries
|
||||
// timeout terminators file invalid_file var_name regexp
|
||||
// digit_timeout`), nunca executa nada; mesmo padrão de risco zero.
|
||||
"play_and_get_digits",
|
||||
] as const;
|
||||
|
||||
export type AllowedDialplanApplication = (typeof ALLOWED_DIALPLAN_APPLICATIONS)[number];
|
||||
@@ -85,6 +89,21 @@ export const ALLOWED_CONDITION_FIELDS = [
|
||||
|
||||
export type AllowedConditionField = (typeof ALLOWED_CONDITION_FIELDS)[number];
|
||||
|
||||
const VARIABLE_CONDITION_FIELD_PATTERN = /^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/;
|
||||
|
||||
/**
|
||||
* PHASE 56 (IVR): além dos campos fixos acima, aceita um `${variavel}`
|
||||
* simples (ex.: `${ivr_choice}`, o dígito que `play_and_get_digits`
|
||||
* guardou) — é assim que o dialplan ramifica por dígito coletado. O
|
||||
* regex exige identificador puro, sem parênteses — nunca bate com
|
||||
* `${funcname(...)}` (chamada de API), então o mesmo risco de RCE já
|
||||
* fechado pra `data` (secao 180) não se aplica aqui: não tem função pra
|
||||
* chamar, só interpolação de valor, igual `${destination_number}` já é.
|
||||
*/
|
||||
export function isAllowedConditionField(field: string): boolean {
|
||||
return (ALLOWED_CONDITION_FIELDS as readonly string[]).includes(field) || VARIABLE_CONDITION_FIELD_PATTERN.test(field);
|
||||
}
|
||||
|
||||
export interface DialplanAction {
|
||||
application: AllowedDialplanApplication;
|
||||
data?: string;
|
||||
@@ -92,7 +111,10 @@ export interface DialplanAction {
|
||||
|
||||
export interface DialplanExtensionInput {
|
||||
name: string;
|
||||
conditionField: AllowedConditionField;
|
||||
// string, não AllowedConditionField: PHASE 56 (IVR) também aceita
|
||||
// `${variavel}` — validado na entrada da API via isAllowedConditionField,
|
||||
// não pelo tipo (ver comentário acima da função).
|
||||
conditionField: string;
|
||||
conditionExpr: string;
|
||||
actions: DialplanAction[];
|
||||
antiActions?: DialplanAction[];
|
||||
|
||||
Reference in New Issue
Block a user