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:
147
packages/telephony/src/freeswitch-provider.ts
Normal file
147
packages/telephony/src/freeswitch-provider.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user