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
207 lines
7.3 KiB
TypeScript
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;
|
|
}
|
|
}
|