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

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
**/node_modules
**/dist
.git
.env
.env.*
!.env.example
FIRST_LOGIN.txt
*.log

17
TODO.md
View File

@@ -27,6 +27,21 @@
teste 1000-1019, senhas fracas) — substituir por mod_xml_curl na fase
Extensions/Trunks/Dialplan
## PHASE 06 — Event Socket (agente.md secao 21-25, 195)
- [x] `packages/telephony`: interface `TelephonyProvider` + `FreeSwitchTelephonyProvider`
(sobre a lib `esl`, reconexão com backoff já embutida na lib)
- [x] `normalizeEslEvent()`: eventos ESL crus → vocabulário interno (secao 24)
- [x] `apps/freeswitch-events` (b2bcall-fs-events): conexão ESL permanente,
resubscreve a cada reconexão, publica eventos normalizados no canal Redis
`b2bcall:events`
- [x] Achado: FreeSWITCH 1.11 aplica ACL implícita (só loopback) sem
`apply-inbound-acl` — bloqueava conexão de outro container mesmo com
senha certa. Corrigido com ACL própria cobrindo loopback + rede Docker.
- [x] Testado ponta a ponta com chamada loopback local: CALL_CREATED →
CALL_ANSWERED → CALL_ENDED corretos no Redis
- [ ] Reconciliação pós-reconexão (calls/agents/queues/registrations/gateways)
— não é possível ainda, sem essas tabelas persistidas
## PHASE 02 — SaaS Core
- [x] Monorepo Node.js/TypeScript (pnpm workspaces, tsconfig base)
- [x] Node 22 LTS + pnpm instalados no host
@@ -58,7 +73,7 @@
— testado ponta a ponta com curl (login, refresh rotation, logout, RBAC, 401/403/429)
- [ ] Password reset por e-mail — depende de SMTP configurado
## PHASE 06+ — ver `agente.md` seções 21 em diante (Event Socket, XML Curl, Telefonia,
## PHASE 07+ — ver `agente.md` seções 26 em diante (XML Curl, Extensions, Trunks, Dialplan,
Call Center, Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)
---

View File

@@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1.7
#
# Build a partir da raiz do monorepo (context: .), só com os pacotes que
# este serviço realmente usa. Roda via `tsx` direto (sem etapa de `tsc build`
# nem dist/): os pacotes internos (@b2bcall/shared, @b2bcall/telephony) ainda
# não tem pipeline de build próprio — ver docs/FREESWITCH_EVENTS.md.
FROM node:22-slim
RUN corepack enable && corepack prepare pnpm@11.24.0 --activate
WORKDIR /repo
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
COPY packages/types packages/types
COPY packages/shared packages/shared
COPY packages/telephony packages/telephony
COPY apps/freeswitch-events apps/freeswitch-events
RUN pnpm install --frozen-lockfile --filter @b2bcall/freeswitch-events...
WORKDIR /repo/apps/freeswitch-events
CMD ["pnpm", "exec", "tsx", "src/main.ts"]

View File

@@ -0,0 +1,22 @@
{
"name": "@b2bcall/freeswitch-events",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/main.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@b2bcall/shared": "workspace:*",
"@b2bcall/telephony": "workspace:*",
"esl": "11.2.1",
"ioredis": "^6.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.23.12",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,112 @@
import Redis from "ioredis";
import type { FreeSwitchEventData } from "esl";
import { FreeSwitchTelephonyProvider, normalizeEslEvent } from "@b2bcall/telephony";
import { createLogger } from "@b2bcall/shared";
const logger = createLogger("b2bcall-fs-events");
const REDIS_CHANNEL = "b2bcall:events";
// Eventos consumidos (agente.md secao 23). HEARTBEAT só é logado, nunca
// normalizado/publicado — não representa uma chamada.
const SUBSCRIBED_EVENTS = [
"HEARTBEAT",
"CHANNEL_CREATE",
"CHANNEL_ORIGINATE",
"CHANNEL_PROGRESS",
"CHANNEL_PROGRESS_MEDIA",
"CHANNEL_ANSWER",
"CHANNEL_BRIDGE",
"CHANNEL_UNBRIDGE",
"CHANNEL_HANGUP",
"CHANNEL_HANGUP_COMPLETE",
"CHANNEL_DESTROY",
"CHANNEL_STATE",
"CHANNEL_CALLSTATE",
"BACKGROUND_JOB",
"CUSTOM",
] as const;
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} nao definido no ambiente`);
}
return value;
}
async function main() {
const redis = new Redis(requireEnv("REDIS_URL"));
redis.on("error", (err) => logger.error("erro na conexao com Redis", { error: String(err) }));
const provider = new FreeSwitchTelephonyProvider({
host: requireEnv("ESL_HOST"),
port: Number(process.env.ESL_PORT ?? 8021),
password: requireEnv("ESL_PASSWORD"),
logger: {
debug: () => {},
info: (msg) => logger.debug(msg),
error: (msg, data) => logger.error(msg, { detail: data }),
},
});
const client = provider.eslClient;
client.on("connect", async (call) => {
logger.info("conectado ao FreeSWITCH via ESL");
// Re-executado a cada reconexao (secao 195: "resubscribe" apos reconectar).
await call.event_json(...SUBSCRIBED_EVENTS);
call.on("HEARTBEAT", () => logger.debug("heartbeat"));
for (const eventName of SUBSCRIBED_EVENTS) {
if (eventName === "HEARTBEAT") continue;
call.on(eventName, (raw) => handleEvent(eventName, raw));
}
});
client.on("reconnecting", (retryMs) => {
logger.warn("reconectando ao FreeSWITCH apos perda de conexao", { retryMs });
});
client.on("error", (err) => {
logger.error("erro no client ESL", { error: String(err) });
});
function handleEvent(eventName: string, raw: FreeSwitchEventData) {
// Para eventos JSON (event_json), os campos reais do evento FreeSWITCH
// (Event-Name, Unique-ID, Event-Subclass, variable_*, ...) vem em
// `raw.body`; `raw.headers` são só os headers do protocolo ESL.
const normalized = normalizeEslEvent(eventName, raw.body);
if (!normalized) {
logger.debug("evento sem mapeamento normalizado", { eventName });
return;
}
redis.publish(REDIS_CHANNEL, JSON.stringify(normalized)).catch((err) => {
logger.error("falha ao publicar evento normalizado no Redis", { error: String(err) });
});
logger.info(`evento: ${normalized.type}`, {
callUuid: normalized.callUuid,
tenantId: normalized.tenantId,
});
}
provider.connect();
const shutdown = async () => {
logger.info("encerrando b2bcall-fs-events");
await provider.disconnect();
redis.disconnect();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
main().catch((err) => {
logger.error("falha fatal ao iniciar b2bcall-fs-events", { error: String(err) });
process.exit(1);
});

View File

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

View File

@@ -51,6 +51,24 @@ services:
retries: 10
start_period: 20s
fs-events:
build:
context: .
dockerfile: apps/freeswitch-events/Dockerfile
container_name: b2bcall-fs-events
restart: unless-stopped
depends_on:
- freeswitch
- redis
environment:
ESL_HOST: freeswitch
ESL_PORT: "8021"
ESL_PASSWORD: ${ESL_PASSWORD}
# Hostnames internos do compose (freeswitch/redis), diferente do
# REDIS_URL do .env que aponta pra localhost (uso pelo apps/api, que
# ainda roda no host) — ver docs/NETWORK_ARCHITECTURE.md.
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
secrets:
freeswitch_pat:
environment: FREESWITCH_PAT

74
docs/EVENT_SOCKET.md Normal file
View File

@@ -0,0 +1,74 @@
# Event Socket
`b2bcall-fs-events` (`apps/freeswitch-events`) mantém a conexão ESL permanente
com o FreeSWITCH (agente.md secao 21) — nenhum outro serviço deve rodar
`fs_cli` via shell pra ações operacionais.
## Biblioteca
Usa [`esl`](https://www.npmjs.com/package/esl) (v11, mantida ativamente,
TypeScript nativo, zero dependências de `libesl`). A classe `FreeSwitchClient`
já resolve reconexão com backoff sozinha (agente.md secao 195) — só precisamos
reagir a `connect`/`reconnecting`/`error` e resubscrever a cada `connect`
(a lib entrega um objeto de chamada novo a cada reconexão).
## `packages/telephony`
- `TelephonyProvider` (interface, agente.md secao 25) + `FreeSwitchTelephonyProvider`
(implementação sobre `esl`). Métodos testados manualmente contra o
FreeSWITCH rodando: `originate`, `killCall`, `getChannels`, `getCalls`,
`getGateways`, `getRegistrations`, `reloadXml`. Os métodos de fila/agente
(`setAgentStatus`, `addAgentToQueue`, ...) seguem a sintaxe documentada do
`mod_callcenter` mas não foram exercitados contra uma fila real ainda —
não existe nenhuma (fase Queues).
- `normalizeEslEvent()`: traduz eventos ESL crus pro vocabulário interno
(agente.md secao 24). Mapeamento de `callcenter::info``AGENT_STATUS_CHANGED`
é best-effort (nomes de campo inferidos da documentação, não testados —
revisar na fase Queues/Agents).
## Achados durante os testes
1. **ACL implícita do Event Socket**: sem `apply-inbound-acl` explícito, o
FreeSWITCH 1.11 rejeita ("Access Denied, go away.") qualquer conexão que
não seja loopback — mesmo com a senha certa. Descoberto porque
`b2bcall-fs-events` (outro container) não conseguia conectar. Corrigido
criando uma ACL própria (`b2bcall_internal`, em
`overrides/autoload_configs/acl.conf.xml`) cobrindo loopback + a rede
interna do Docker Compose (`172.16.0.0/12`, nunca `0.0.0.0/0`).
2. Nessa mesma correção, um erro de digitação inicial (usar só `localnet.auto`,
que cobre a rede Docker mas **não** loopback) quebrou até o `fs_cli` local
— corrigido combinando as duas faixas na mesma ACL.
3. O logger JSON de `packages/shared` quebrava (`TypeError: Do not know how
to serialize a BigInt`) porque a lib `esl` usa `bigint` nos campos de
estatística de erro. Corrigido com um `replacer` no `JSON.stringify`.
## Eventos consumidos e publicados
Lista completa em `apps/freeswitch-events/src/main.ts`
(`SUBSCRIBED_EVENTS`), cobrindo a secao 23 do `agente.md`. `HEARTBEAT` só é
logado em debug, nunca normalizado. Eventos normalizados são publicados em
JSON no canal Redis `b2bcall:events` (pub/sub simples — vira a base pra
WebSocket multi-tenant na fase Realtime Monitoring, que ainda não existe).
## Verificado ponta a ponta
Sem SIP real disponível ainda, a verificação usou uma chamada loopback local:
```bash
docker exec b2bcall-freeswitch fs_cli -p "$ESL_PASSWORD" -x "originate null/_test_ &park()"
# ... uuid_kill pra encerrar
```
Resultado observado no canal Redis: `CALL_CREATED` → `CALL_ANSWERED` →
`CALL_ENDED` (com `hangupCause`), todos com o `callUuid` correto.
## Limitações desta fase
- Reconciliação pós-reconexão (secao 195: "reconcile calls, agents, queues,
registrations, gateways") não é possível ainda — não existem tabelas de
`calls`/`agents`/`queues` persistidas pra reconciliar contra. Só a
resubscrição de eventos está implementada. Revisitar quando essas tabelas
existirem.
- `b2bcall-fs-events` roda via `tsx` direto (sem etapa de build/`dist`) —
simples mas ~99MB de RAM em runtime (razoável no orçamento atual, mas vale
revisar se muitos workers assim rodarem juntos mais pra frente).

View File

@@ -43,6 +43,8 @@ RUN --mount=type=secret,id=freeswitch_pat,required=true \
# vanilla padrão por enquanto — serão substituídos por mod_xml_curl na fase
# "Extensions/Trunks/Dialplan" (ver docs/FREESWITCH.md).
COPY overrides/autoload_configs/modules.conf.xml /etc/freeswitch/autoload_configs/modules.conf.xml
COPY overrides/autoload_configs/event_socket.conf.xml /etc/freeswitch/autoload_configs/event_socket.conf.xml
COPY overrides/autoload_configs/acl.conf.xml /etc/freeswitch/autoload_configs/acl.conf.xml
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

View File

@@ -7,7 +7,7 @@ set -eu
: "${ESL_PASSWORD:?ESL_PASSWORD precisa estar definido no ambiente do container}"
sed -i "s/<param name=\"password\" value=\"ClueCon\"\/>/<param name=\"password\" value=\"${ESL_PASSWORD}\"\/>/" \
sed -i "s/__ESL_PASSWORD__/${ESL_PASSWORD}/" \
/etc/freeswitch/autoload_configs/event_socket.conf.xml
exec "$@"

View File

@@ -0,0 +1,14 @@
<configuration name="acl.conf" description="Network Lists">
<network-lists>
<!-- ACL propria pro Event Socket: loopback (fs_cli local) + rede interna
do Docker Compose (outros containers, ex.: b2bcall-fs-events).
172.16.0.0/12 cobre o range padrao que o Docker aloca pras redes
bridge de projeto (confirmado: b2bcall_default = 172.18.0.0/16).
Nunca 0.0.0.0/0 — nao e' pra ser alcancavel de fora do host. -->
<list name="b2bcall_internal" default="deny">
<node type="allow" cidr="127.0.0.0/8"/>
<node type="allow" cidr="::1/128"/>
<node type="allow" cidr="172.16.0.0/12"/>
</list>
</network-lists>
</configuration>

View File

@@ -0,0 +1,16 @@
<configuration name="event_socket.conf" description="Socket Client">
<settings>
<param name="nat-map" value="false"/>
<param name="listen-ip" value="::"/>
<param name="listen-port" value="8021"/>
<!-- Substituido em runtime pelo entrypoint.sh (nunca fica secret real na
imagem) -->
<param name="password" value="__ESL_PASSWORD__"/>
<!-- Sem isto, o FreeSWITCH 1.11 aplica um default implicito de
loopback-only e rejeita ("Access Denied, go away.") qualquer
conexao vinda de outro container Docker, mesmo com senha correta.
localnet.auto e' construida automaticamente no boot a partir da
subnet local detectada (cobre a rede do docker-compose). -->
<param name="apply-inbound-acl" value="b2bcall_internal"/>
</settings>
</configuration>

View File

@@ -11,6 +11,7 @@
"@b2bcall/types": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.20.1",
"typescript": "^5.7.0"
}
}

View File

@@ -1 +1,2 @@
export * from "@b2bcall/types";
export * from "./logger";

View File

@@ -0,0 +1,28 @@
/**
* Logging JSON estruturado (agente.md secao 189). Sempre inclui `service` e
* `ts`; nunca logar secrets — quem chama é responsável por isso.
*/
export interface Logger {
debug(msg: string, context?: Record<string, unknown>): void;
info(msg: string, context?: Record<string, unknown>): void;
warn(msg: string, context?: Record<string, unknown>): void;
error(msg: string, context?: Record<string, unknown>): void;
}
export function createLogger(service: string): Logger {
const write = (level: string, msg: string, context: Record<string, unknown> = {}) => {
process.stdout.write(
`${JSON.stringify(
{ level, service, msg, ts: new Date().toISOString(), ...context },
(_key, value) => (typeof value === "bigint" ? value.toString() : value),
)}\n`,
);
};
return {
debug: (msg, context) => write("debug", msg, context),
info: (msg, context) => write("info", msg, context),
warn: (msg, context) => write("warn", msg, context),
error: (msg, context) => write("error", msg, context),
};
}

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"]
}

57
pnpm-lock.yaml generated
View File

@@ -70,6 +70,31 @@ importers:
specifier: ^5.7.0
version: 5.9.3
apps/freeswitch-events:
dependencies:
'@b2bcall/shared':
specifier: workspace:*
version: link:../../packages/shared
'@b2bcall/telephony':
specifier: workspace:*
version: link:../../packages/telephony
esl:
specifier: 11.2.1
version: 11.2.1
ioredis:
specifier: ^6.0.0
version: 6.0.0
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.20.1
tsx:
specifier: ^4.23.12
version: 4.23.12
typescript:
specifier: ^5.7.0
version: 5.9.3
packages/auth:
dependencies:
'@b2bcall/database':
@@ -116,6 +141,19 @@ importers:
'@b2bcall/types':
specifier: workspace:*
version: link:../types
devDependencies:
'@types/node':
specifier: ^22.20.1
version: 22.20.1
typescript:
specifier: ^5.7.0
version: 5.9.3
packages/telephony:
dependencies:
esl:
specifier: 11.2.1
version: 11.2.1
devDependencies:
typescript:
specifier: ^5.7.0
@@ -682,9 +720,6 @@ packages:
'@types/node@22.20.1':
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
'@types/node@26.4.0':
resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==}
'@types/pg@8.23.1':
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
@@ -897,6 +932,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
esl@11.2.1:
resolution: {integrity: sha512-H1qQHYbSgZ61yzzPh29HPFCoZC63ZkJ9t4YJJPlKmkqmNPUbeVCzM5dW6GzPkbm+6KIyejW2sXGegj8cqfzhhw==}
exsolve@1.1.1:
resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==}
@@ -1339,9 +1377,6 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
valibot@1.4.2:
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
peerDependencies:
@@ -1834,13 +1869,9 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@26.4.0':
dependencies:
undici-types: 8.3.0
'@types/pg@8.23.1':
dependencies:
'@types/node': 26.4.0
'@types/node': 22.20.1
pg-protocol: 1.16.0
pg-types: 2.2.0
@@ -2102,6 +2133,8 @@ snapshots:
'@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2
esl@11.2.1: {}
exsolve@1.1.1: {}
fast-check@3.23.2:
@@ -2515,8 +2548,6 @@ snapshots:
undici-types@6.21.0: {}
undici-types@8.3.0: {}
valibot@1.4.2(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3