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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}";
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user