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,16 @@
{
"name": "@b2bcall/telephony",
"version": "0.0.1",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"esl": "11.2.1"
},
"devDependencies": {
"typescript": "^5.7.0"
}
}

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");
}
}

View File

@@ -0,0 +1,3 @@
export * from "./types";
export * from "./normalize-event";
export * from "./freeswitch-provider";

View File

@@ -0,0 +1,126 @@
import type { NormalizedEvent, NormalizedEventType } from "./types";
type RawHeaders = Record<string, string | undefined>;
function channelVar(headers: RawHeaders, name: string): string | undefined {
return headers[`variable_${name}`];
}
function baseFields(headers: RawHeaders, extra: Record<string, unknown> = {}) {
return {
callUuid: headers["Unique-ID"],
tenantId: channelVar(headers, "b2bcall_tenant_id"),
b2bcallCallId: channelVar(headers, "b2bcall_call_id"),
b2bcallCampaignId: channelVar(headers, "b2bcall_campaign_id"),
b2bcallLeadId: channelVar(headers, "b2bcall_lead_id"),
data: { ...extra },
};
}
/**
* Traduz um evento ESL cru (headers + body) para o vocabulário interno do
* B2BCall (agente.md secao 24). Retorna `null` quando o evento não tem
* mapeamento definido ainda — o chamador decide se loga em debug ou ignora.
*
* CUSTOM/callcenter::info: os nomes exatos de campo (`CC-Action`,
* `CC-Agent-Status`, ...) foram inferidos da documentação do mod_callcenter,
* não testados contra uma fila real ainda (isso só será possível na fase
* Queues/Agents). Revisar então.
*/
export function normalizeEslEvent(
eventName: string | undefined,
headers: RawHeaders,
): NormalizedEvent | null {
const occurredAt = new Date().toISOString();
const emit = (type: NormalizedEventType, extra?: Record<string, unknown>): NormalizedEvent => ({
type,
occurredAt,
...baseFields(headers, extra),
});
switch (eventName) {
case "CHANNEL_CREATE":
return emit("CALL_CREATED");
case "CHANNEL_PROGRESS":
case "CHANNEL_PROGRESS_MEDIA":
return emit("CALL_RINGING");
case "CHANNEL_ANSWER":
return emit("CALL_ANSWERED");
case "CHANNEL_BRIDGE":
return emit("CALL_BRIDGED", { otherLegUuid: headers["Other-Leg-Unique-ID"] });
case "CHANNEL_UNBRIDGE":
return emit("CALL_UNBRIDGED", { otherLegUuid: headers["Other-Leg-Unique-ID"] });
case "CHANNEL_HANGUP_COMPLETE":
return emit("CALL_ENDED", { hangupCause: headers["Hangup-Cause"] });
case "BACKGROUND_JOB":
return emit("BACKGROUND_JOB_COMPLETED", {
jobUuid: headers["Job-UUID"],
commandReply: headers["Job-Command"],
});
case "CUSTOM":
return normalizeCustomEvent(headers, occurredAt);
default:
return null;
}
}
function normalizeCustomEvent(headers: RawHeaders, occurredAt: string): NormalizedEvent | null {
const subclass = headers["Event-Subclass"];
const emit = (type: NormalizedEventType, extra?: Record<string, unknown>): NormalizedEvent => ({
type,
occurredAt,
...baseFields(headers, extra),
});
switch (subclass) {
case "sofia::register":
return emit("EXTENSION_REGISTERED", {
user: headers["from-user"],
host: headers["from-host"],
contact: headers["contact"],
networkIp: headers["network-ip"],
});
case "sofia::unregister":
case "sofia::expire":
return emit("EXTENSION_UNREGISTERED", {
user: headers["from-user"],
host: headers["from-host"],
});
case "sofia::gateway_state": {
const state = headers["State"];
const gateway = headers["Gateway"];
if (state === "UP" || state === "REGED") {
return emit("GATEWAY_UP", { gateway, state });
}
if (state === "DOWN" || state === "FAILED" || state === "FAIL_WAIT") {
return emit("GATEWAY_DOWN", { gateway, state });
}
return null;
}
case "callcenter::info": {
if (headers["CC-Action"] === "agent-state-change") {
return emit("AGENT_STATUS_CHANGED", {
queue: headers["CC-Queue"],
agent: headers["CC-Agent"],
status: headers["CC-Agent-Status"],
});
}
return null;
}
default:
return null;
}
}

View File

@@ -0,0 +1,72 @@
/**
* Interface de telefonia (agente.md secao 25). Implementação:
* FreeSwitchTelephonyProvider. Nunca reimplementar em Node o que o
* FreeSWITCH já faz (secao 16) — esta interface é uma fina camada sobre
* comandos ESL, não um motor de telefonia paralelo.
*/
export interface TelephonyProvider {
originate(params: OriginateParams): Promise<{ uuid: string }>;
killCall(uuid: string, cause?: string): Promise<void>;
transfer(uuid: string, destination: string, dialplan?: string, context?: string): Promise<void>;
bridge(uuidA: string, uuidB: string): Promise<void>;
getChannels(): Promise<unknown>;
getCalls(): Promise<unknown>;
getRegistrations(): Promise<unknown>;
getGateways(): Promise<unknown>;
getQueues(): Promise<unknown>;
setAgentStatus(agentId: string, status: string): Promise<void>;
setAgentContact(agentId: string, contact: string): Promise<void>;
addAgentToQueue(queueName: string, agentId: string): Promise<void>;
removeAgentFromQueue(queueName: string, agentId: string): Promise<void>;
reloadXml(): Promise<void>;
}
export interface OriginateParams {
/** Ex.: "sofia/gateway/my-trunk/5511999999999" ou "loopback/1000" */
destination: string;
/** Aplicação a executar quando a chamada for atendida, ex.: "park", "echo" */
application: string;
applicationArgs?: string;
channelVariables?: Record<string, string>;
callerIdName?: string;
callerIdNumber?: string;
timeoutSeconds?: number;
}
/**
* Eventos internos normalizados (agente.md secao 24). O resto do domínio
* nunca deve depender de headers ESL crus — só desses tipos.
*/
export type NormalizedEventType =
| "CALL_CREATED"
| "CALL_RINGING"
| "CALL_ANSWERED"
| "CALL_BRIDGED"
| "CALL_UNBRIDGED"
| "CALL_ENDED"
| "EXTENSION_REGISTERED"
| "EXTENSION_UNREGISTERED"
| "AGENT_STATUS_CHANGED"
| "GATEWAY_UP"
| "GATEWAY_DOWN"
| "BACKGROUND_JOB_COMPLETED";
export interface NormalizedEvent {
type: NormalizedEventType;
occurredAt: string;
/** UUID do channel/call quando aplicável. */
callUuid?: string;
/** Channel variables b2bcall_* quando presentes (secao 81) — ainda não
* populadas nesta fase (só existirão a partir do Predictive Engine). */
tenantId?: string;
b2bcallCallId?: string;
b2bcallCampaignId?: string;
b2bcallLeadId?: string;
data: Record<string, unknown>;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}