Files
B2BCall-dialer/packages/telephony/src/freeswitch-provider.ts
Matheus 97ef8a6ba8 feat: edição de rotas de entrada, fix de 2 bugs reais no ESL, diagnóstico de NAT/áudio e softphone WebRTC (PHASE 65/66)
Três achados reportados pelo usuário numa mensagem só: (1) Rotas de Entrada
não tinha edição depois de criada — implementada no mesmo padrão de Filas;
(2) telas de Platform > Infraestrutura sempre davam "Timeout no ESL" nesta
VM — não era limitação permanente como o comentário antigo dizia, e sim
ESL_HOST=freeswitch (nome DNS que só existe dentro da rede do Docker) mais
um segundo bug independente (`show gateways as json` não é comando válido
nesta versão do FreeSWITCH); (3) ramal externo registrava mas sem áudio —
diagnosticado com contadores de pacote do iptables: a VM está atrás de um
roteador sem port-forward pra faixa de RTP, achado de infraestrutura de
rede, não bug de código.

Também integra o softphone WebRTC (handphone.js/OpenSIPS, já em produção):
código-fonte encontrado em git.falehandix.com.br/Handix/handphone-2.0,
patch mínimo pra aceitar o endereço do proxy em runtime (era build-time),
nova config global (Platform > Infraestrutura > Softphone WebRTC) e widget
na topbar do tenant que pega usuário/domínio/senha do ramal vinculado ao
agente logado.

Adiciona docs/QA_SETUP.md — runbook completo pra subir o ambiente do zero
numa máquina nova (Docker, migrations, seed, systemd), e completa o
.env.example que estava faltando a maioria das variáveis reais.

Testado ponta a ponta com Playwright: edição de rota (criar/editar/F5),
as 3 telas de Infraestrutura com dado real, e um tenant/ramal/agente de
teste criados na hora confirmando que o script do softphone recebe as
credenciais certas.

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

207 lines
7.3 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { FreeSwitchClient, type FreeSwitchResponse } from "esl";
import type { OriginateParams, TelephonyProvider } from "./types";
export interface FreeSwitchProviderOptions {
host: string;
port: number;
password: string;
logger?: { debug: (msg: string, data?: unknown) => void; info: (msg: string, data?: unknown) => void; error: (msg: string, data?: unknown) => void };
}
/**
* Implementação FreeSWITCH da TelephonyProvider (agente.md secao 25), sobre
* `esl` (client ESL "inbound"). A biblioteca já cuida de reconexão com
* backoff (secao 195) — expomos os eventos 'connect'/'reconnecting'/'end'
* pra quem precisar (ex.: b2bcall-fs-events, pra resubscrever eventos a cada
* reconexão).
*
* Métodos verificados manualmente contra o FreeSWITCH rodando nesta fase:
* originate, killCall, getChannels, getCalls, getGateways, getRegistrations,
* reloadXml. Os métodos de fila/agente (setAgentStatus, addAgentToQueue...)
* seguem a sintaxe documentada do mod_callcenter mas ainda não foram
* testados contra uma fila real — não existe nenhuma ainda (fase Queues).
*/
export class FreeSwitchTelephonyProvider implements TelephonyProvider {
private readonly client: FreeSwitchClient;
private current: FreeSwitchResponse | undefined;
constructor(options: FreeSwitchProviderOptions) {
this.client = new FreeSwitchClient({
host: options.host,
port: options.port,
password: options.password,
logger: options.logger,
});
this.client.on("connect", (call) => {
this.current = call;
});
this.client.on("end", () => {
this.current = undefined;
});
}
/** Client ESL bruto — usado por b2bcall-fs-events pra assinar eventos. */
get eslClient(): FreeSwitchClient {
return this.client;
}
connect(): void {
this.client.connect();
}
async disconnect(): Promise<void> {
await this.client.end();
}
/**
* Espera a primeira conexão ficar pronta (ou já estar pronta), com
* timeout. Útil pra evitar corrida entre "acabei de chamar connect()" e
* "já quero mandar um comando" logo no boot do serviço.
*/
async waitUntilConnected(timeoutMs = 5000): Promise<boolean> {
if (this.current) return true;
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), timeoutMs);
this.client.once("connect", () => {
clearTimeout(timer);
resolve(true);
});
});
}
private call(): FreeSwitchResponse {
if (!this.current) {
throw new Error("FreeSWITCH ESL nao conectado");
}
return this.current;
}
private async apiJson(command: string): Promise<unknown> {
const res = await this.call().api(command);
try {
return JSON.parse(res.body);
} catch {
return res.body;
}
}
async originate(params: OriginateParams): Promise<{ uuid: string }> {
const uuid = params.channelVariables?.origination_uuid ?? randomUUID();
const vars: Record<string, string> = {
...params.channelVariables,
origination_uuid: uuid,
ignore_early_media: "true",
};
if (params.callerIdName) vars.origination_caller_id_name = params.callerIdName;
if (params.callerIdNumber) vars.origination_caller_id_number = params.callerIdNumber;
if (params.timeoutSeconds) vars.originate_timeout = String(params.timeoutSeconds);
const varString = Object.entries(vars)
.map(([key, value]) => `${key}='${value}'`)
.join(",");
const app = `&${params.application}(${params.applicationArgs ?? ""})`;
await this.call().bgapi(`originate {${varString}}${params.destination} ${app}`);
return { uuid };
}
async killCall(uuid: string, cause?: string): Promise<void> {
await this.call().api(`uuid_kill ${uuid}${cause ? ` ${cause}` : ""}`);
}
async transfer(uuid: string, destination: string, dialplan?: string, context?: string): Promise<void> {
await this.call().api(
`uuid_transfer ${uuid} ${destination}${dialplan ? ` ${dialplan}` : ""}${context ? ` ${context}` : ""}`,
);
}
async bridge(uuidA: string, uuidB: string): Promise<void> {
await this.call().api(`uuid_bridge ${uuidA} ${uuidB}`);
}
getChannels(): Promise<unknown> {
return this.apiJson("show channels as json");
}
getCalls(): Promise<unknown> {
return this.apiJson("show calls as json");
}
getRegistrations(): Promise<unknown> {
return this.apiJson("show registrations as json");
}
/**
* Achado real testando a tela "Infraestrutura > Nodes" (secao
* "Achados na sessão de PHASE 65" em docs/FREESWITCH.md): `show
* gateways as json` NÃO é um sub-comando válido de `show` nesta
* versão do FreeSWITCH (`-USAGE: codec|endpoint|application|...`) —
* o próprio pacote `esl` trata qualquer reply começando com `-` como
* erro e rejeita a promise (com `.message` vazio, por isso o erro
* aparecia como `""` na tela). Gateways aparecem como linhas
* `type=gateway` dentro de `sofia status` (já usado por
* `runApi("sofia status")` no endpoint de profiles) — não tem
* variante JSON, mas o texto cru já é suficiente pra esta tela.
*/
getGateways(): Promise<unknown> {
return this.runApi("sofia status");
}
async getQueues(): Promise<unknown> {
const res = await this.call().api("callcenter_config queue list");
return res.body;
}
async setAgentStatus(agentId: string, status: string): Promise<void> {
await this.call().api(`callcenter_config agent set status '${agentId}' '${status}'`);
}
async setAgentContact(agentId: string, contact: string): Promise<void> {
await this.call().api(`callcenter_config agent set contact '${agentId}' '${contact}'`);
}
/**
* `callcenter_config` NÃO tem "queue add member"/"queue del member" —
* comando inexistente, confirmado com `help callcenter_config` contra o
* FreeSWITCH real (fase Agents/Tiers). O jeito certo de associar um
* agente a uma fila é `tier add`/`tier del`.
*/
async addAgentToQueue(queueName: string, agentId: string, level = 1, position = 1): Promise<void> {
await this.call().api(`callcenter_config tier add ${queueName} ${agentId} ${level} ${position}`);
}
async removeAgentFromQueue(queueName: string, agentId: string): Promise<void> {
await this.call().api(`callcenter_config tier del ${queueName} ${agentId}`);
}
/**
* `callcenter_config agent add` — precisa existir antes de
* setAgentStatus/setAgentContact/addAgentToQueue funcionarem pra um
* agente novo. `type` normalmente é "callback" (disca pro `contact`
* quando uma chamada é oferecida).
*/
async addAgent(agentId: string, type: "callback" | "uuid-standby" = "callback"): Promise<void> {
await this.call().api(`callcenter_config agent add '${agentId}' '${type}'`);
}
async removeAgent(agentId: string): Promise<void> {
await this.call().api(`callcenter_config agent del '${agentId}'`);
}
async reloadXml(): Promise<void> {
await this.call().api("reloadxml");
}
/**
* Escape hatch pra comandos `api` que não têm método dedicado na
* interface TelephonyProvider (ex.: `sofia profile external rescan`).
* Não faz parte da interface abstrata de proposito — usar com moderação,
* preferir os métodos tipados quando existirem.
*/
async runApi(command: string): Promise<string> {
const res = await this.call().api(command);
return res.body;
}
}