From 7763afacc00bbbbe5c454c3f4fb78bb1eb0826d0 Mon Sep 17 00:00:00 2001 From: Matheus Date: Sun, 30 Aug 2026 17:00:16 -0300 Subject: [PATCH] =?UTF-8?q?feat(dialplan):=20IVR=20=E2=80=94=20play=5Fand?= =?UTF-8?q?=5Fget=5Fdigits=20+=20branching=20por=20d=C3=ADgito,=20testado?= =?UTF-8?q?=20com=20DTMF=20real?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8 --- TODO.md | 32 ++++++++-- .../dialplan/dialplan-versions.controller.ts | 8 +-- .../dto/create-dialplan-extension.dto.ts | 9 +-- .../is-allowed-condition-field.validator.ts | 29 +++++++++ docs/INBOUND_ROUTES.md | 62 +++++++++++++++---- packages/telephony/src/dialplan-xml.ts | 24 ++++++- 6 files changed, 135 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/dialplan/dto/is-allowed-condition-field.validator.ts diff --git a/TODO.md b/TODO.md index 6d20a98..4789c0e 100644 --- a/TODO.md +++ b/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 `loopback` sem SDP real, não um bug da resolução; ver docs/INBOUND_ROUTES.md pro isolamento completo) -- [ ] IVR em si (menu com `play_and_get_digits` + branching por dígito - coletado) ainda não construído — é a próxima fase. Precisa - widening de `ALLOWED_CONDITION_FIELDS` (hoje um enum fixo) pra - aceitar `${variavel}` com a MESMA proteção anti-RCE já aplicada em - `data` (secao 180), já que `field` também é expandido pelo - FreeSWITCH em tempo de chamada. Ver "O que falta" em +- [x] **IVR (menu com `play_and_get_digits` + branching por dígito)**: + `play_and_get_digits` adicionado ao allowlist de applications; + `ALLOWED_CONDITION_FIELDS` ganhou um segundo modo aceitando + `${variavel}` simples (regex sem parênteses — nunca vira chamada de + API, mesmo risco zero de `${destination_number}` já é hoje) +- [x] **achado real construindo o primeiro menu**: FreeSWITCH resolve + TODAS as `` de um contexto ANTES de executar qualquer + `` — uma variable setada por `play_and_get_digits` NUNCA + afeta o casamento de OUTRA `` 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 +- [ ] 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 --- diff --git a/apps/api/src/dialplan/dialplan-versions.controller.ts b/apps/api/src/dialplan/dialplan-versions.controller.ts index 30d78ee..abd8881 100644 --- a/apps/api/src/dialplan/dialplan-versions.controller.ts +++ b/apps/api/src/dialplan/dialplan-versions.controller.ts @@ -11,11 +11,7 @@ import { import { XMLValidator } from "fast-xml-parser"; import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; -import { - buildDialplanXml, - type AllowedConditionField, - type AllowedDialplanApplication, -} from "@b2bcall/telephony"; +import { buildDialplanXml, type AllowedDialplanApplication } from "@b2bcall/telephony"; import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; import { PermissionGuard } from "../common/guards/permission.guard"; import { RequirePermission } from "../common/decorators/require-permission.decorator"; @@ -47,7 +43,7 @@ export class DialplanVersionsController { context, extensions.map((e) => ({ name: e.name, - conditionField: e.conditionField as AllowedConditionField, + conditionField: e.conditionField, conditionExpr: e.conditionExpr, actions: e.actions as unknown as { application: AllowedDialplanApplication; data?: string }[], antiActions: e.antiActions as unknown as diff --git a/apps/api/src/dialplan/dto/create-dialplan-extension.dto.ts b/apps/api/src/dialplan/dto/create-dialplan-extension.dto.ts index 045b4ca..8442638 100644 --- a/apps/api/src/dialplan/dto/create-dialplan-extension.dto.ts +++ b/apps/api/src/dialplan/dto/create-dialplan-extension.dto.ts @@ -4,15 +4,14 @@ import { ArrayMinSize, IsArray, IsBoolean, - IsIn, IsInt, IsOptional, IsString, MaxLength, ValidateNested, } from "class-validator"; -import { ALLOWED_CONDITION_FIELDS, type AllowedConditionField } from "@b2bcall/telephony"; import { ActionDto } from "./action.dto"; +import { IsAllowedConditionField } from "./is-allowed-condition-field.validator"; export class CreateDialplanExtensionDto { @IsOptional() @@ -24,8 +23,10 @@ export class CreateDialplanExtensionDto { @MaxLength(120) name!: string; - @IsIn(ALLOWED_CONDITION_FIELDS) - conditionField!: AllowedConditionField; + @IsString() + @MaxLength(80) + @IsAllowedConditionField() + conditionField!: string; @IsString() @MaxLength(255) diff --git a/apps/api/src/dialplan/dto/is-allowed-condition-field.validator.ts b/apps/api/src/dialplan/dto/is-allowed-condition-field.validator.ts new file mode 100644 index 0000000..f636e3a --- /dev/null +++ b/apps/api/src/dialplan/dto/is-allowed-condition-field.validator.ts @@ -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}"; + }, + }, + }); + }; +} diff --git a/docs/INBOUND_ROUTES.md b/docs/INBOUND_ROUTES.md index 39cf1f6..183dec4 100644 --- a/docs/INBOUND_ROUTES.md +++ b/docs/INBOUND_ROUTES.md @@ -80,20 +80,58 @@ 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 `` de um contexto (decide quais `` +batem) ANTES de executar qualquer `` — variables setadas por uma +action (como o dígito que `play_and_get_digits` guarda) NUNCA afetam o +casamento de outras `` 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 `` +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 +"`. `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 -- IVR (menu com `play_and_get_digits` + branching por dígito) — a rota de - entrada já pode apontar `destinationContext` pra um contexto de IVR - dedicado, mas o "menu" em si (dialplan `play_and_get_digits` + - condição sobre o dígito coletado) ainda não foi construído. Pontos em - aberto: `ALLOWED_CONDITION_FIELDS` hoje é um enum fixo (nunca aceita - `${variavel}` arbitrária) — vai precisar de um allowlist mais amplo com - a MESMA proteção contra RCE já aplicada em `data` (`IsSafeDialplanData`), - 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). +- Tela de frontend pra autoria de IVR — hoje o menu é construído à mão + no editor genérico de dialplan (`Telefonia > Dialplan`, escolhendo um + `context` novo), não tem uma UI dedicada de "menu com opções" ainda. + Backend já suporta tudo que uma UI assim precisaria gerar. +- Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID → + ramal); não tem seletor de "fila" ou "IVR" como destino ainda — hoje é + texto livre pro `destinationContext`/`destinationNumber`. - Perda das proteções de toll-fraud do `public.xml` vanilla (unroll de loop de chamada, etc.) — não replicadas no contexto `inbound` novo. Aceitável pra esta fase (sem trunks reais ainda), mas revisar antes de diff --git a/packages/telephony/src/dialplan-xml.ts b/packages/telephony/src/dialplan-xml.ts index 13387bd..7447e7b 100644 --- a/packages/telephony/src/dialplan-xml.ts +++ b/packages/telephony/src/dialplan-xml.ts @@ -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[];