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 teste 1000-1019, senhas fracas) — substituir por mod_xml_curl na fase
Extensions/Trunks/Dialplan 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 ## PHASE 02 — SaaS Core
- [x] Monorepo Node.js/TypeScript (pnpm workspaces, tsconfig base) - [x] Monorepo Node.js/TypeScript (pnpm workspaces, tsconfig base)
- [x] Node 22 LTS + pnpm instalados no host - [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) — testado ponta a ponta com curl (login, refresh rotation, logout, RBAC, 401/403/429)
- [ ] Password reset por e-mail — depende de SMTP configurado - [ ] 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) 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 retries: 10
start_period: 20s 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: secrets:
freeswitch_pat: freeswitch_pat:
environment: 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 # vanilla padrão por enquanto — serão substituídos por mod_xml_curl na fase
# "Extensions/Trunks/Dialplan" (ver docs/FREESWITCH.md). # "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/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 COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /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}" : "${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 /etc/freeswitch/autoload_configs/event_socket.conf.xml
exec "$@" 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:*" "@b2bcall/types": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.20.1",
"typescript": "^5.7.0" "typescript": "^5.7.0"
} }
} }

View File

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