From 60e9f6838e7f63e69876a1738d5f606b629c8d23 Mon Sep 17 00:00:00 2001 From: Matheus Date: Fri, 28 Aug 2026 06:47:28 -0300 Subject: [PATCH] feat: add Event Socket integration (b2bcall-fs-events) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .dockerignore | 9 ++ TODO.md | 17 +- apps/freeswitch-events/Dockerfile | 23 +++ apps/freeswitch-events/package.json | 22 +++ apps/freeswitch-events/src/main.ts | 112 +++++++++++++ apps/freeswitch-events/tsconfig.json | 9 ++ docker-compose.yml | 18 +++ docs/EVENT_SOCKET.md | 74 +++++++++ infrastructure/freeswitch/Dockerfile | 2 + infrastructure/freeswitch/entrypoint.sh | 2 +- .../overrides/autoload_configs/acl.conf.xml | 14 ++ .../autoload_configs/event_socket.conf.xml | 16 ++ packages/shared/package.json | 1 + packages/shared/src/index.ts | 1 + packages/shared/src/logger.ts | 28 ++++ packages/telephony/package.json | 16 ++ packages/telephony/src/freeswitch-provider.ts | 147 ++++++++++++++++++ packages/telephony/src/index.ts | 3 + packages/telephony/src/normalize-event.ts | 126 +++++++++++++++ packages/telephony/src/types.ts | 72 +++++++++ packages/telephony/tsconfig.json | 8 + pnpm-lock.yaml | 57 +++++-- 22 files changed, 762 insertions(+), 15 deletions(-) create mode 100644 .dockerignore create mode 100644 apps/freeswitch-events/Dockerfile create mode 100644 apps/freeswitch-events/package.json create mode 100644 apps/freeswitch-events/src/main.ts create mode 100644 apps/freeswitch-events/tsconfig.json create mode 100644 docs/EVENT_SOCKET.md create mode 100644 infrastructure/freeswitch/overrides/autoload_configs/acl.conf.xml create mode 100644 infrastructure/freeswitch/overrides/autoload_configs/event_socket.conf.xml create mode 100644 packages/shared/src/logger.ts create mode 100644 packages/telephony/package.json create mode 100644 packages/telephony/src/freeswitch-provider.ts create mode 100644 packages/telephony/src/index.ts create mode 100644 packages/telephony/src/normalize-event.ts create mode 100644 packages/telephony/src/types.ts create mode 100644 packages/telephony/tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..beff765 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +**/node_modules +**/dist +.git +.env +.env.* +!.env.example +FIRST_LOGIN.txt +*.log diff --git a/TODO.md b/TODO.md index af8e4b4..f217e4d 100644 --- a/TODO.md +++ b/TODO.md @@ -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) --- diff --git a/apps/freeswitch-events/Dockerfile b/apps/freeswitch-events/Dockerfile new file mode 100644 index 0000000..92f4da5 --- /dev/null +++ b/apps/freeswitch-events/Dockerfile @@ -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"] diff --git a/apps/freeswitch-events/package.json b/apps/freeswitch-events/package.json new file mode 100644 index 0000000..0896444 --- /dev/null +++ b/apps/freeswitch-events/package.json @@ -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" + } +} diff --git a/apps/freeswitch-events/src/main.ts b/apps/freeswitch-events/src/main.ts new file mode 100644 index 0000000..cb18a1a --- /dev/null +++ b/apps/freeswitch-events/src/main.ts @@ -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); +}); diff --git a/apps/freeswitch-events/tsconfig.json b/apps/freeswitch-events/tsconfig.json new file mode 100644 index 0000000..3fbff51 --- /dev/null +++ b/apps/freeswitch-events/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/docker-compose.yml b/docker-compose.yml index ec9636c..aebde38 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/EVENT_SOCKET.md b/docs/EVENT_SOCKET.md new file mode 100644 index 0000000..ef8ad67 --- /dev/null +++ b/docs/EVENT_SOCKET.md @@ -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). diff --git a/infrastructure/freeswitch/Dockerfile b/infrastructure/freeswitch/Dockerfile index 3b7ee11..c798614 100644 --- a/infrastructure/freeswitch/Dockerfile +++ b/infrastructure/freeswitch/Dockerfile @@ -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 diff --git a/infrastructure/freeswitch/entrypoint.sh b/infrastructure/freeswitch/entrypoint.sh index e22f9e3..f2f7dbd 100644 --- a/infrastructure/freeswitch/entrypoint.sh +++ b/infrastructure/freeswitch/entrypoint.sh @@ -7,7 +7,7 @@ set -eu : "${ESL_PASSWORD:?ESL_PASSWORD precisa estar definido no ambiente do container}" -sed -i "s///" \ +sed -i "s/__ESL_PASSWORD__/${ESL_PASSWORD}/" \ /etc/freeswitch/autoload_configs/event_socket.conf.xml exec "$@" diff --git a/infrastructure/freeswitch/overrides/autoload_configs/acl.conf.xml b/infrastructure/freeswitch/overrides/autoload_configs/acl.conf.xml new file mode 100644 index 0000000..3f2c7b7 --- /dev/null +++ b/infrastructure/freeswitch/overrides/autoload_configs/acl.conf.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/infrastructure/freeswitch/overrides/autoload_configs/event_socket.conf.xml b/infrastructure/freeswitch/overrides/autoload_configs/event_socket.conf.xml new file mode 100644 index 0000000..8e35ad5 --- /dev/null +++ b/infrastructure/freeswitch/overrides/autoload_configs/event_socket.conf.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/packages/shared/package.json b/packages/shared/package.json index 6de5525..850290c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -11,6 +11,7 @@ "@b2bcall/types": "workspace:*" }, "devDependencies": { + "@types/node": "^22.20.1", "typescript": "^5.7.0" } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 15b4454..1bdd6ec 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1 +1,2 @@ export * from "@b2bcall/types"; +export * from "./logger"; diff --git a/packages/shared/src/logger.ts b/packages/shared/src/logger.ts new file mode 100644 index 0000000..1b6bc70 --- /dev/null +++ b/packages/shared/src/logger.ts @@ -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): void; + info(msg: string, context?: Record): void; + warn(msg: string, context?: Record): void; + error(msg: string, context?: Record): void; +} + +export function createLogger(service: string): Logger { + const write = (level: string, msg: string, context: Record = {}) => { + 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), + }; +} diff --git a/packages/telephony/package.json b/packages/telephony/package.json new file mode 100644 index 0000000..4741acc --- /dev/null +++ b/packages/telephony/package.json @@ -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" + } +} diff --git a/packages/telephony/src/freeswitch-provider.ts b/packages/telephony/src/freeswitch-provider.ts new file mode 100644 index 0000000..a0ca4ae --- /dev/null +++ b/packages/telephony/src/freeswitch-provider.ts @@ -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 { + 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 { + 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 = { + ...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 { + await this.call().api(`uuid_kill ${uuid}${cause ? ` ${cause}` : ""}`); + } + + async transfer(uuid: string, destination: string, dialplan?: string, context?: string): Promise { + await this.call().api( + `uuid_transfer ${uuid} ${destination}${dialplan ? ` ${dialplan}` : ""}${context ? ` ${context}` : ""}`, + ); + } + + async bridge(uuidA: string, uuidB: string): Promise { + await this.call().api(`uuid_bridge ${uuidA} ${uuidB}`); + } + + getChannels(): Promise { + return this.apiJson("show channels as json"); + } + + getCalls(): Promise { + return this.apiJson("show calls as json"); + } + + getRegistrations(): Promise { + return this.apiJson("show registrations as json"); + } + + getGateways(): Promise { + return this.apiJson("show gateways as json"); + } + + async getQueues(): Promise { + const res = await this.call().api("callcenter_config queue list"); + return res.body; + } + + async setAgentStatus(agentId: string, status: string): Promise { + await this.call().api(`callcenter_config agent set status '${agentId}' '${status}'`); + } + + async setAgentContact(agentId: string, contact: string): Promise { + await this.call().api(`callcenter_config agent set contact '${agentId}' '${contact}'`); + } + + async addAgentToQueue(queueName: string, agentId: string): Promise { + await this.call().api(`callcenter_config queue add member ${queueName} ${agentId}`); + } + + async removeAgentFromQueue(queueName: string, agentId: string): Promise { + await this.call().api(`callcenter_config queue del member ${queueName} ${agentId}`); + } + + async reloadXml(): Promise { + await this.call().api("reloadxml"); + } +} diff --git a/packages/telephony/src/index.ts b/packages/telephony/src/index.ts new file mode 100644 index 0000000..a393e04 --- /dev/null +++ b/packages/telephony/src/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./normalize-event"; +export * from "./freeswitch-provider"; diff --git a/packages/telephony/src/normalize-event.ts b/packages/telephony/src/normalize-event.ts new file mode 100644 index 0000000..0987a56 --- /dev/null +++ b/packages/telephony/src/normalize-event.ts @@ -0,0 +1,126 @@ +import type { NormalizedEvent, NormalizedEventType } from "./types"; + +type RawHeaders = Record; + +function channelVar(headers: RawHeaders, name: string): string | undefined { + return headers[`variable_${name}`]; +} + +function baseFields(headers: RawHeaders, extra: Record = {}) { + 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): 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): 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; + } +} diff --git a/packages/telephony/src/types.ts b/packages/telephony/src/types.ts new file mode 100644 index 0000000..c9ab2d4 --- /dev/null +++ b/packages/telephony/src/types.ts @@ -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; + transfer(uuid: string, destination: string, dialplan?: string, context?: string): Promise; + bridge(uuidA: string, uuidB: string): Promise; + + getChannels(): Promise; + getCalls(): Promise; + + getRegistrations(): Promise; + getGateways(): Promise; + + getQueues(): Promise; + + setAgentStatus(agentId: string, status: string): Promise; + setAgentContact(agentId: string, contact: string): Promise; + + addAgentToQueue(queueName: string, agentId: string): Promise; + removeAgentFromQueue(queueName: string, agentId: string): Promise; + + reloadXml(): Promise; +} + +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; + 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; +} diff --git a/packages/telephony/tsconfig.json b/packages/telephony/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/telephony/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33d61f0..cd42e71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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