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:
32
TODO.md
32
TODO.md
@@ -2063,13 +2063,33 @@ mostrar quantos ramais estao online e o status de cada ramal criado")
|
|||||||
`INCOMPATIBLE_DESTINATION` — isolado como limitação do canal
|
`INCOMPATIBLE_DESTINATION` — isolado como limitação do canal
|
||||||
`loopback` sem SDP real, não um bug da resolução; ver
|
`loopback` sem SDP real, não um bug da resolução; ver
|
||||||
docs/INBOUND_ROUTES.md pro isolamento completo)
|
docs/INBOUND_ROUTES.md pro isolamento completo)
|
||||||
- [ ] IVR em si (menu com `play_and_get_digits` + branching por dígito
|
- [x] **IVR (menu com `play_and_get_digits` + branching por dígito)**:
|
||||||
coletado) ainda não construído — é a próxima fase. Precisa
|
`play_and_get_digits` adicionado ao allowlist de applications;
|
||||||
widening de `ALLOWED_CONDITION_FIELDS` (hoje um enum fixo) pra
|
`ALLOWED_CONDITION_FIELDS` ganhou um segundo modo aceitando
|
||||||
aceitar `${variavel}` com a MESMA proteção anti-RCE já aplicada em
|
`${variavel}` simples (regex sem parênteses — nunca vira chamada de
|
||||||
`data` (secao 180), já que `field` também é expandido pelo
|
API, mesmo risco zero de `${destination_number}` já é hoje)
|
||||||
FreeSWITCH em tempo de chamada. Ver "O que falta" em
|
- [x] **achado real construindo o primeiro menu**: FreeSWITCH resolve
|
||||||
|
TODAS as `<condition>` de um contexto ANTES de executar qualquer
|
||||||
|
`<action>` — uma variable setada por `play_and_get_digits` NUNCA
|
||||||
|
afeta o casamento de OUTRA `<extension>` na mesma passada (testado
|
||||||
|
e confirmado com DTMF de verdade: 2 extensions separadas nunca
|
||||||
|
bateram, regex avaliado com a variable ainda vazia). A forma que
|
||||||
|
funciona: dentro da MESMA extension, um `transfer` explícito usando
|
||||||
|
o dígito coletado como novo `destination_number` — dispara uma
|
||||||
|
consulta de dialplan NOVA (variables já setadas persistem), e as
|
||||||
|
extensions seguintes casam por `destination_number` normal
|
||||||
|
- [x] Testado ponta a ponta com DTMF de verdade (`fs_cli uuid_recv_dtmf`
|
||||||
|
— `linphonec` não tem comando de enviar DTMF interativo, usada a
|
||||||
|
API do FreeSWITCH que simula o dígito chegando como input do
|
||||||
|
chamador): softphone externo liga pro DID → IVR atende, toca
|
||||||
|
prompt, espera dígito → dígito `1` bridged com ramal real
|
||||||
|
(atendeu, áudio PCMU bidirecional); dígito `2` foi pra resposta
|
||||||
|
alternativa (tom diferente) — confirma ramificação de verdade, não
|
||||||
|
só "sempre cai na primeira opção". Detalhes completos em
|
||||||
docs/INBOUND_ROUTES.md
|
docs/INBOUND_ROUTES.md
|
||||||
|
- [ ] Tela de frontend dedicada de autoria de IVR (menu com opções) —
|
||||||
|
hoje é construído à mão no editor genérico de dialplan; backend já
|
||||||
|
suporta tudo que uma UI assim precisaria gerar
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import {
|
|||||||
import { XMLValidator } from "fast-xml-parser";
|
import { XMLValidator } from "fast-xml-parser";
|
||||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
import {
|
import { buildDialplanXml, type AllowedDialplanApplication } from "@b2bcall/telephony";
|
||||||
buildDialplanXml,
|
|
||||||
type AllowedConditionField,
|
|
||||||
type AllowedDialplanApplication,
|
|
||||||
} from "@b2bcall/telephony";
|
|
||||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
@@ -47,7 +43,7 @@ export class DialplanVersionsController {
|
|||||||
context,
|
context,
|
||||||
extensions.map((e) => ({
|
extensions.map((e) => ({
|
||||||
name: e.name,
|
name: e.name,
|
||||||
conditionField: e.conditionField as AllowedConditionField,
|
conditionField: e.conditionField,
|
||||||
conditionExpr: e.conditionExpr,
|
conditionExpr: e.conditionExpr,
|
||||||
actions: e.actions as unknown as { application: AllowedDialplanApplication; data?: string }[],
|
actions: e.actions as unknown as { application: AllowedDialplanApplication; data?: string }[],
|
||||||
antiActions: e.antiActions as unknown as
|
antiActions: e.antiActions as unknown as
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ import {
|
|||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { ALLOWED_CONDITION_FIELDS, type AllowedConditionField } from "@b2bcall/telephony";
|
|
||||||
import { ActionDto } from "./action.dto";
|
import { ActionDto } from "./action.dto";
|
||||||
|
import { IsAllowedConditionField } from "./is-allowed-condition-field.validator";
|
||||||
|
|
||||||
export class CreateDialplanExtensionDto {
|
export class CreateDialplanExtensionDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -24,8 +23,10 @@ export class CreateDialplanExtensionDto {
|
|||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@IsIn(ALLOWED_CONDITION_FIELDS)
|
@IsString()
|
||||||
conditionField!: AllowedConditionField;
|
@MaxLength(80)
|
||||||
|
@IsAllowedConditionField()
|
||||||
|
conditionField!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { registerDecorator, type ValidationOptions } from "class-validator";
|
||||||
|
import { isAllowedConditionField } from "@b2bcall/telephony";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHASE 56 (IVR): `conditionField` era um `@IsIn` fechado (6 valores
|
||||||
|
* fixos). Continua aceitando esses, mas agora também `${variavel}`
|
||||||
|
* simples (ex.: `${ivr_choice}`) — necessário pra ramificar um menu de
|
||||||
|
* IVR pelo dígito que `play_and_get_digits` coletou. Delegado pra
|
||||||
|
* `isAllowedConditionField` (packages/telephony) pra manter as duas
|
||||||
|
* pontas (o que a API aceita e o que o XML de fato usa) sincronizadas.
|
||||||
|
*/
|
||||||
|
export function IsAllowedConditionField(validationOptions?: ValidationOptions) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
name: "isAllowedConditionField",
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
validator: {
|
||||||
|
validate(value: unknown) {
|
||||||
|
return typeof value === "string" && isAllowedConditionField(value);
|
||||||
|
},
|
||||||
|
defaultMessage() {
|
||||||
|
return "conditionField deve ser um dos campos fixos ou uma variável simples como ${ivr_choice}";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -80,20 +80,58 @@ confirmado testando a mesma resolução com destino simples
|
|||||||
`answer`+`playback` via loopback (sucesso) e depois com uma chamada SIP
|
`answer`+`playback` via loopback (sucesso) e depois com uma chamada SIP
|
||||||
de verdade ponta a ponta (sucesso completo, áudio incluído).)
|
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".
|
||||||
|
|
||||||
## O que falta
|
## O que falta
|
||||||
|
|
||||||
- IVR (menu com `play_and_get_digits` + branching por dígito) — a rota de
|
- Tela de frontend pra autoria de IVR — hoje o menu é construído à mão
|
||||||
entrada já pode apontar `destinationContext` pra um contexto de IVR
|
no editor genérico de dialplan (`Telefonia > Dialplan`, escolhendo um
|
||||||
dedicado, mas o "menu" em si (dialplan `play_and_get_digits` +
|
`context` novo), não tem uma UI dedicada de "menu com opções" ainda.
|
||||||
condição sobre o dígito coletado) ainda não foi construído. Pontos em
|
Backend já suporta tudo que uma UI assim precisaria gerar.
|
||||||
aberto: `ALLOWED_CONDITION_FIELDS` hoje é um enum fixo (nunca aceita
|
- Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID →
|
||||||
`${variavel}` arbitrária) — vai precisar de um allowlist mais amplo com
|
ramal); não tem seletor de "fila" ou "IVR" como destino ainda — hoje é
|
||||||
a MESMA proteção contra RCE já aplicada em `data` (`IsSafeDialplanData`),
|
texto livre pro `destinationContext`/`destinationNumber`.
|
||||||
já que `field` também é expandido pelo FreeSWITCH em tempo de chamada.
|
|
||||||
- Tela de frontend cobre só CRUD simples (DID → ramal); não tem seletor
|
|
||||||
de "fila" ou "IVR" como destino ainda — hoje é só texto livre pro
|
|
||||||
`destinationContext`/`destinationNumber` (o operador escolhe o contexto
|
|
||||||
e número certos manualmente).
|
|
||||||
- Perda das proteções de toll-fraud do `public.xml` vanilla (unroll de
|
- Perda das proteções de toll-fraud do `public.xml` vanilla (unroll de
|
||||||
loop de chamada, etc.) — não replicadas no contexto `inbound` novo.
|
loop de chamada, etc.) — não replicadas no contexto `inbound` novo.
|
||||||
Aceitável pra esta fase (sem trunks reais ainda), mas revisar antes de
|
Aceitável pra esta fase (sem trunks reais ainda), mas revisar antes de
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export const ALLOWED_DIALPLAN_APPLICATIONS = [
|
|||||||
// grupo como texto (ou `${user_data(...)}`, já auditado acima), nunca
|
// grupo como texto (ou `${user_data(...)}`, já auditado acima), nunca
|
||||||
// um comando; mesmo padrão de risco zero dos outros já permitidos.
|
// um comando; mesmo padrão de risco zero dos outros já permitidos.
|
||||||
"pickup",
|
"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;
|
] as const;
|
||||||
|
|
||||||
export type AllowedDialplanApplication = (typeof ALLOWED_DIALPLAN_APPLICATIONS)[number];
|
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];
|
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 {
|
export interface DialplanAction {
|
||||||
application: AllowedDialplanApplication;
|
application: AllowedDialplanApplication;
|
||||||
data?: string;
|
data?: string;
|
||||||
@@ -92,7 +111,10 @@ export interface DialplanAction {
|
|||||||
|
|
||||||
export interface DialplanExtensionInput {
|
export interface DialplanExtensionInput {
|
||||||
name: string;
|
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;
|
conditionExpr: string;
|
||||||
actions: DialplanAction[];
|
actions: DialplanAction[];
|
||||||
antiActions?: DialplanAction[];
|
antiActions?: DialplanAction[];
|
||||||
|
|||||||
Reference in New Issue
Block a user