feat: add Event Socket integration (b2bcall-fs-events)

- packages/telephony: TelephonyProvider interface (agente.md secao 25) and
  FreeSwitchTelephonyProvider implementation over the 'esl' library
  (actively maintained, TypeScript-native, built-in reconnect-with-backoff
  satisfying secao 195); normalizeEslEvent() translates raw ESL events into
  the internal vocabulary (secao 24)
- apps/freeswitch-events (b2bcall-fs-events): permanent ESL connection,
  resubscribes on every reconnect, publishes normalized events to the
  'b2bcall:events' Redis pub/sub channel; containerized (Dockerfile +
  docker-compose service) since its whole job is reaching the freeswitch
  container by internal hostname
- packages/shared: reusable createLogger() (structured JSON per secao 189),
  fixed a BigInt serialization crash surfaced by the esl library's error
  stats
- found and fixed a real FreeSWITCH 1.11 default: without an explicit
  apply-inbound-acl, mod_event_socket silently rejects any non-loopback
  connection ('Access Denied, go away.') even with the correct password —
  added a dedicated ACL (loopback + the Docker Compose network range, never
  0.0.0.0/0) in infrastructure/freeswitch/overrides/autoload_configs/
- verified end-to-end with a local loopback test call: CALL_CREATED ->
  CALL_ANSWERED -> CALL_ENDED observed on the Redis channel with the
  correct callUuid and hangup cause
- docs/EVENT_SOCKET.md
This commit is contained in:
2026-08-28 06:47:28 -03:00
parent b3b0aaacb3
commit 60e9f6838e
22 changed files with 762 additions and 15 deletions

View File

@@ -0,0 +1,147 @@
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();
}
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");
}
getGateways(): Promise<unknown> {
return this.apiJson("show gateways as json");
}
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}'`);
}
async addAgentToQueue(queueName: string, agentId: string): Promise<void> {
await this.call().api(`callcenter_config queue add member ${queueName} ${agentId}`);
}
async removeAgentFromQueue(queueName: string, agentId: string): Promise<void> {
await this.call().api(`callcenter_config queue del member ${queueName} ${agentId}`);
}
async reloadXml(): Promise<void> {
await this.call().api("reloadxml");
}
}