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:
16
packages/telephony/package.json
Normal file
16
packages/telephony/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
3
packages/telephony/src/index.ts
Normal file
3
packages/telephony/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./normalize-event";
|
||||
export * from "./freeswitch-provider";
|
||||
126
packages/telephony/src/normalize-event.ts
Normal file
126
packages/telephony/src/normalize-event.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
72
packages/telephony/src/types.ts
Normal file
72
packages/telephony/src/types.ts
Normal 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>;
|
||||
}
|
||||
8
packages/telephony/tsconfig.json
Normal file
8
packages/telephony/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user