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