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 { 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 { 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 { 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 = { ...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 { await this.call().api(`uuid_kill ${uuid}${cause ? ` ${cause}` : ""}`); } async transfer(uuid: string, destination: string, dialplan?: string, context?: string): Promise { await this.call().api( `uuid_transfer ${uuid} ${destination}${dialplan ? ` ${dialplan}` : ""}${context ? ` ${context}` : ""}`, ); } async bridge(uuidA: string, uuidB: string): Promise { await this.call().api(`uuid_bridge ${uuidA} ${uuidB}`); } getChannels(): Promise { return this.apiJson("show channels as json"); } getCalls(): Promise { return this.apiJson("show calls as json"); } getRegistrations(): Promise { 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 { return this.runApi("sofia status"); } async getQueues(): Promise { const res = await this.call().api("callcenter_config queue list"); return res.body; } async setAgentStatus(agentId: string, status: string): Promise { await this.call().api(`callcenter_config agent set status '${agentId}' '${status}'`); } async setAgentContact(agentId: string, contact: string): Promise { 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 { await this.call().api(`callcenter_config tier add ${queueName} ${agentId} ${level} ${position}`); } async removeAgentFromQueue(queueName: string, agentId: string): Promise { 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 { await this.call().api(`callcenter_config agent add '${agentId}' '${type}'`); } async removeAgent(agentId: string): Promise { await this.call().api(`callcenter_config agent del '${agentId}'`); } async reloadXml(): Promise { 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 { const res = await this.call().api(command); return res.body; } }