Commit Graph

2 Commits

Author SHA1 Message Date
7763afacc0 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
2026-08-30 17:00:16 -03:00
0720a0efe3 feat: implement Dialplan with structured editor and versioning
- dialplan_extensions table (tenant-scoped, RLS): structured editor per
  agente.md secao 43 -- context, condition field/expr, actions/anti-actions
  (JSON), continue, order, enabled. One condition per extension (deliberate
  simplification vs raw FreeSWITCH's multi-condition extensions).
- dialplan_versions table (tenant-scoped, RLS): generate/validate/version/
  activate flow (secao 44). Reactivating an older version IS the rollback
  mechanism -- no separate endpoint needed.
- apps/api/src/dialplan: extensions CRUD + versions/generate (builds XML,
  validates well-formedness with fast-xml-parser, saves as DRAFT) +
  versions/:id/activate (atomically flips ACTIVE, supersedes the previous
  one). Reused freeswitch.view/.configure permissions rather than inventing
  new ones not in the agente.md permission list.
- packages/telephony: buildDialplanXml() plus ALLOWED_DIALPLAN_APPLICATIONS,
  an explicit allowlist (answer/bridge/playback/hangup/set/export/... --
  deliberately no system/exec/socket) guarding against a tenant configuring
  a dialplan action that runs arbitrary commands on the FreeSWITCH host
  (agente.md secao 180)
- b2bcall-fs-config resolves dialplan dynamically per call (unlike Trunks'
  file+rescan approach -- dialplan is fetched fresh via mod_xml_curl on
  every call anyway) by tenant id from the variable_b2bcall_tenant_id
  channel variable already injected at directory resolution, then serving
  whichever DialplanVersion is ACTIVE for that context
- verified end-to-end: created a rule for destination_number 7000, generated
  and activated v1, originated a call that actually routed through the
  dialplan (not bypassing it via &app()) -- CALL_CREATED -> CALL_ANSWERED ->
  CALL_ENDED with the correct tenantId throughout. Created and activated a
  v2, then rolled back to v1 by reactivating it; status transitions
  (ACTIVE/SUPERSEDED) all confirmed via the API.

CRITICAL FINDING, fixed in this same phase: deliberately testing that the
application allowlist rejects 'system' got back 201 instead of 400 --
NestJS's ValidationPipe had been silently inert across all of apps/api's
@Body() DTOs since the API was first created. Root cause: running via
 (esbuild) instead of a real  build -- esbuild doesn't always
resolve cross-file parameter types for design:paramtypes metadata, and Nest
skips validation without any error when it can't determine the DTO class.
Fixed by always building with tsc before running (tsc && tsx dist/main.js
-- still via tsx because internal workspace packages aren't built to JS
yet). Re-verified with two deliberate bad-input tests post-fix, both
correctly rejected with 400. A stray malicious test row (dialplan action
'system') created while the bug was live was deleted; it was never baked
into an activated version, so nothing could have executed it.
See docs/VALIDATION_PIPE_BUG.md for the full writeup.

docs/DIALPLAN.md, docs/VALIDATION_PIPE_BUG.md, docs/EXTENSIONS.md updated
2026-08-28 08:41:45 -03:00