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:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
FIRST_LOGIN.txt
|
||||
*.log
|
||||
17
TODO.md
17
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)
|
||||
|
||||
---
|
||||
|
||||
23
apps/freeswitch-events/Dockerfile
Normal file
23
apps/freeswitch-events/Dockerfile
Normal 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"]
|
||||
22
apps/freeswitch-events/package.json
Normal file
22
apps/freeswitch-events/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
112
apps/freeswitch-events/src/main.ts
Normal file
112
apps/freeswitch-events/src/main.ts
Normal 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);
|
||||
});
|
||||
9
apps/freeswitch-events/tsconfig.json
Normal file
9
apps/freeswitch-events/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -51,6 +51,24 @@ services:
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
fs-events:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/freeswitch-events/Dockerfile
|
||||
container_name: b2bcall-fs-events
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- freeswitch
|
||||
- redis
|
||||
environment:
|
||||
ESL_HOST: freeswitch
|
||||
ESL_PORT: "8021"
|
||||
ESL_PASSWORD: ${ESL_PASSWORD}
|
||||
# Hostnames internos do compose (freeswitch/redis), diferente do
|
||||
# REDIS_URL do .env que aponta pra localhost (uso pelo apps/api, que
|
||||
# ainda roda no host) — ver docs/NETWORK_ARCHITECTURE.md.
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||
|
||||
secrets:
|
||||
freeswitch_pat:
|
||||
environment: FREESWITCH_PAT
|
||||
|
||||
74
docs/EVENT_SOCKET.md
Normal file
74
docs/EVENT_SOCKET.md
Normal 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).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ set -eu
|
||||
|
||||
: "${ESL_PASSWORD:?ESL_PASSWORD precisa estar definido no ambiente do container}"
|
||||
|
||||
sed -i "s/<param name=\"password\" value=\"ClueCon\"\/>/<param name=\"password\" value=\"${ESL_PASSWORD}\"\/>/" \
|
||||
sed -i "s/__ESL_PASSWORD__/${ESL_PASSWORD}/" \
|
||||
/etc/freeswitch/autoload_configs/event_socket.conf.xml
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -11,6 +11,7 @@
|
||||
"@b2bcall/types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.1",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "@b2bcall/types";
|
||||
export * from "./logger";
|
||||
|
||||
28
packages/shared/src/logger.ts
Normal file
28
packages/shared/src/logger.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
16
packages/telephony/package.json
Normal file
16
packages/telephony/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@b2bcall/telephony",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"esl": "11.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
147
packages/telephony/src/freeswitch-provider.ts
Normal file
147
packages/telephony/src/freeswitch-provider.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { FreeSwitchClient, type FreeSwitchResponse } from "esl";
|
||||
import type { OriginateParams, TelephonyProvider } from "./types";
|
||||
|
||||
export interface FreeSwitchProviderOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
password: string;
|
||||
logger?: { debug: (msg: string, data?: unknown) => void; info: (msg: string, data?: unknown) => void; error: (msg: string, data?: unknown) => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementação FreeSWITCH da TelephonyProvider (agente.md secao 25), sobre
|
||||
* `esl` (client ESL "inbound"). A biblioteca já cuida de reconexão com
|
||||
* backoff (secao 195) — expomos os eventos 'connect'/'reconnecting'/'end'
|
||||
* pra quem precisar (ex.: b2bcall-fs-events, pra resubscrever eventos a cada
|
||||
* reconexão).
|
||||
*
|
||||
* Métodos verificados manualmente contra o FreeSWITCH rodando nesta fase:
|
||||
* originate, killCall, getChannels, getCalls, getGateways, getRegistrations,
|
||||
* reloadXml. Os métodos de fila/agente (setAgentStatus, addAgentToQueue...)
|
||||
* seguem a sintaxe documentada do mod_callcenter mas ainda não foram
|
||||
* testados contra uma fila real — não existe nenhuma ainda (fase Queues).
|
||||
*/
|
||||
export class FreeSwitchTelephonyProvider implements TelephonyProvider {
|
||||
private readonly client: FreeSwitchClient;
|
||||
private current: FreeSwitchResponse | undefined;
|
||||
|
||||
constructor(options: FreeSwitchProviderOptions) {
|
||||
this.client = new FreeSwitchClient({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
password: options.password,
|
||||
logger: options.logger,
|
||||
});
|
||||
this.client.on("connect", (call) => {
|
||||
this.current = call;
|
||||
});
|
||||
this.client.on("end", () => {
|
||||
this.current = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/** Client ESL bruto — usado por b2bcall-fs-events pra assinar eventos. */
|
||||
get eslClient(): FreeSwitchClient {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
this.client.connect();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await this.client.end();
|
||||
}
|
||||
|
||||
private call(): FreeSwitchResponse {
|
||||
if (!this.current) {
|
||||
throw new Error("FreeSWITCH ESL nao conectado");
|
||||
}
|
||||
return this.current;
|
||||
}
|
||||
|
||||
private async apiJson(command: string): Promise<unknown> {
|
||||
const res = await this.call().api(command);
|
||||
try {
|
||||
return JSON.parse(res.body);
|
||||
} catch {
|
||||
return res.body;
|
||||
}
|
||||
}
|
||||
|
||||
async originate(params: OriginateParams): Promise<{ uuid: string }> {
|
||||
const uuid = params.channelVariables?.origination_uuid ?? randomUUID();
|
||||
const vars: Record<string, string> = {
|
||||
...params.channelVariables,
|
||||
origination_uuid: uuid,
|
||||
ignore_early_media: "true",
|
||||
};
|
||||
if (params.callerIdName) vars.origination_caller_id_name = params.callerIdName;
|
||||
if (params.callerIdNumber) vars.origination_caller_id_number = params.callerIdNumber;
|
||||
if (params.timeoutSeconds) vars.originate_timeout = String(params.timeoutSeconds);
|
||||
|
||||
const varString = Object.entries(vars)
|
||||
.map(([key, value]) => `${key}='${value}'`)
|
||||
.join(",");
|
||||
const app = `&${params.application}(${params.applicationArgs ?? ""})`;
|
||||
|
||||
await this.call().bgapi(`originate {${varString}}${params.destination} ${app}`);
|
||||
return { uuid };
|
||||
}
|
||||
|
||||
async killCall(uuid: string, cause?: string): Promise<void> {
|
||||
await this.call().api(`uuid_kill ${uuid}${cause ? ` ${cause}` : ""}`);
|
||||
}
|
||||
|
||||
async transfer(uuid: string, destination: string, dialplan?: string, context?: string): Promise<void> {
|
||||
await this.call().api(
|
||||
`uuid_transfer ${uuid} ${destination}${dialplan ? ` ${dialplan}` : ""}${context ? ` ${context}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
async bridge(uuidA: string, uuidB: string): Promise<void> {
|
||||
await this.call().api(`uuid_bridge ${uuidA} ${uuidB}`);
|
||||
}
|
||||
|
||||
getChannels(): Promise<unknown> {
|
||||
return this.apiJson("show channels as json");
|
||||
}
|
||||
|
||||
getCalls(): Promise<unknown> {
|
||||
return this.apiJson("show calls as json");
|
||||
}
|
||||
|
||||
getRegistrations(): Promise<unknown> {
|
||||
return this.apiJson("show registrations as json");
|
||||
}
|
||||
|
||||
getGateways(): Promise<unknown> {
|
||||
return this.apiJson("show gateways as json");
|
||||
}
|
||||
|
||||
async getQueues(): Promise<unknown> {
|
||||
const res = await this.call().api("callcenter_config queue list");
|
||||
return res.body;
|
||||
}
|
||||
|
||||
async setAgentStatus(agentId: string, status: string): Promise<void> {
|
||||
await this.call().api(`callcenter_config agent set status '${agentId}' '${status}'`);
|
||||
}
|
||||
|
||||
async setAgentContact(agentId: string, contact: string): Promise<void> {
|
||||
await this.call().api(`callcenter_config agent set contact '${agentId}' '${contact}'`);
|
||||
}
|
||||
|
||||
async addAgentToQueue(queueName: string, agentId: string): Promise<void> {
|
||||
await this.call().api(`callcenter_config queue add member ${queueName} ${agentId}`);
|
||||
}
|
||||
|
||||
async removeAgentFromQueue(queueName: string, agentId: string): Promise<void> {
|
||||
await this.call().api(`callcenter_config queue del member ${queueName} ${agentId}`);
|
||||
}
|
||||
|
||||
async reloadXml(): Promise<void> {
|
||||
await this.call().api("reloadxml");
|
||||
}
|
||||
}
|
||||
3
packages/telephony/src/index.ts
Normal file
3
packages/telephony/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./normalize-event";
|
||||
export * from "./freeswitch-provider";
|
||||
126
packages/telephony/src/normalize-event.ts
Normal file
126
packages/telephony/src/normalize-event.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { NormalizedEvent, NormalizedEventType } from "./types";
|
||||
|
||||
type RawHeaders = Record<string, string | undefined>;
|
||||
|
||||
function channelVar(headers: RawHeaders, name: string): string | undefined {
|
||||
return headers[`variable_${name}`];
|
||||
}
|
||||
|
||||
function baseFields(headers: RawHeaders, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
callUuid: headers["Unique-ID"],
|
||||
tenantId: channelVar(headers, "b2bcall_tenant_id"),
|
||||
b2bcallCallId: channelVar(headers, "b2bcall_call_id"),
|
||||
b2bcallCampaignId: channelVar(headers, "b2bcall_campaign_id"),
|
||||
b2bcallLeadId: channelVar(headers, "b2bcall_lead_id"),
|
||||
data: { ...extra },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Traduz um evento ESL cru (headers + body) para o vocabulário interno do
|
||||
* B2BCall (agente.md secao 24). Retorna `null` quando o evento não tem
|
||||
* mapeamento definido ainda — o chamador decide se loga em debug ou ignora.
|
||||
*
|
||||
* CUSTOM/callcenter::info: os nomes exatos de campo (`CC-Action`,
|
||||
* `CC-Agent-Status`, ...) foram inferidos da documentação do mod_callcenter,
|
||||
* não testados contra uma fila real ainda (isso só será possível na fase
|
||||
* Queues/Agents). Revisar então.
|
||||
*/
|
||||
export function normalizeEslEvent(
|
||||
eventName: string | undefined,
|
||||
headers: RawHeaders,
|
||||
): NormalizedEvent | null {
|
||||
const occurredAt = new Date().toISOString();
|
||||
|
||||
const emit = (type: NormalizedEventType, extra?: Record<string, unknown>): NormalizedEvent => ({
|
||||
type,
|
||||
occurredAt,
|
||||
...baseFields(headers, extra),
|
||||
});
|
||||
|
||||
switch (eventName) {
|
||||
case "CHANNEL_CREATE":
|
||||
return emit("CALL_CREATED");
|
||||
|
||||
case "CHANNEL_PROGRESS":
|
||||
case "CHANNEL_PROGRESS_MEDIA":
|
||||
return emit("CALL_RINGING");
|
||||
|
||||
case "CHANNEL_ANSWER":
|
||||
return emit("CALL_ANSWERED");
|
||||
|
||||
case "CHANNEL_BRIDGE":
|
||||
return emit("CALL_BRIDGED", { otherLegUuid: headers["Other-Leg-Unique-ID"] });
|
||||
|
||||
case "CHANNEL_UNBRIDGE":
|
||||
return emit("CALL_UNBRIDGED", { otherLegUuid: headers["Other-Leg-Unique-ID"] });
|
||||
|
||||
case "CHANNEL_HANGUP_COMPLETE":
|
||||
return emit("CALL_ENDED", { hangupCause: headers["Hangup-Cause"] });
|
||||
|
||||
case "BACKGROUND_JOB":
|
||||
return emit("BACKGROUND_JOB_COMPLETED", {
|
||||
jobUuid: headers["Job-UUID"],
|
||||
commandReply: headers["Job-Command"],
|
||||
});
|
||||
|
||||
case "CUSTOM":
|
||||
return normalizeCustomEvent(headers, occurredAt);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCustomEvent(headers: RawHeaders, occurredAt: string): NormalizedEvent | null {
|
||||
const subclass = headers["Event-Subclass"];
|
||||
const emit = (type: NormalizedEventType, extra?: Record<string, unknown>): NormalizedEvent => ({
|
||||
type,
|
||||
occurredAt,
|
||||
...baseFields(headers, extra),
|
||||
});
|
||||
|
||||
switch (subclass) {
|
||||
case "sofia::register":
|
||||
return emit("EXTENSION_REGISTERED", {
|
||||
user: headers["from-user"],
|
||||
host: headers["from-host"],
|
||||
contact: headers["contact"],
|
||||
networkIp: headers["network-ip"],
|
||||
});
|
||||
|
||||
case "sofia::unregister":
|
||||
case "sofia::expire":
|
||||
return emit("EXTENSION_UNREGISTERED", {
|
||||
user: headers["from-user"],
|
||||
host: headers["from-host"],
|
||||
});
|
||||
|
||||
case "sofia::gateway_state": {
|
||||
const state = headers["State"];
|
||||
const gateway = headers["Gateway"];
|
||||
if (state === "UP" || state === "REGED") {
|
||||
return emit("GATEWAY_UP", { gateway, state });
|
||||
}
|
||||
if (state === "DOWN" || state === "FAILED" || state === "FAIL_WAIT") {
|
||||
return emit("GATEWAY_DOWN", { gateway, state });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case "callcenter::info": {
|
||||
if (headers["CC-Action"] === "agent-state-change") {
|
||||
return emit("AGENT_STATUS_CHANGED", {
|
||||
queue: headers["CC-Queue"],
|
||||
agent: headers["CC-Agent"],
|
||||
status: headers["CC-Agent-Status"],
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
72
packages/telephony/src/types.ts
Normal file
72
packages/telephony/src/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Interface de telefonia (agente.md secao 25). Implementação:
|
||||
* FreeSwitchTelephonyProvider. Nunca reimplementar em Node o que o
|
||||
* FreeSWITCH já faz (secao 16) — esta interface é uma fina camada sobre
|
||||
* comandos ESL, não um motor de telefonia paralelo.
|
||||
*/
|
||||
export interface TelephonyProvider {
|
||||
originate(params: OriginateParams): Promise<{ uuid: string }>;
|
||||
killCall(uuid: string, cause?: string): Promise<void>;
|
||||
transfer(uuid: string, destination: string, dialplan?: string, context?: string): Promise<void>;
|
||||
bridge(uuidA: string, uuidB: string): Promise<void>;
|
||||
|
||||
getChannels(): Promise<unknown>;
|
||||
getCalls(): Promise<unknown>;
|
||||
|
||||
getRegistrations(): Promise<unknown>;
|
||||
getGateways(): Promise<unknown>;
|
||||
|
||||
getQueues(): Promise<unknown>;
|
||||
|
||||
setAgentStatus(agentId: string, status: string): Promise<void>;
|
||||
setAgentContact(agentId: string, contact: string): Promise<void>;
|
||||
|
||||
addAgentToQueue(queueName: string, agentId: string): Promise<void>;
|
||||
removeAgentFromQueue(queueName: string, agentId: string): Promise<void>;
|
||||
|
||||
reloadXml(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface OriginateParams {
|
||||
/** Ex.: "sofia/gateway/my-trunk/5511999999999" ou "loopback/1000" */
|
||||
destination: string;
|
||||
/** Aplicação a executar quando a chamada for atendida, ex.: "park", "echo" */
|
||||
application: string;
|
||||
applicationArgs?: string;
|
||||
channelVariables?: Record<string, string>;
|
||||
callerIdName?: string;
|
||||
callerIdNumber?: string;
|
||||
timeoutSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eventos internos normalizados (agente.md secao 24). O resto do domínio
|
||||
* nunca deve depender de headers ESL crus — só desses tipos.
|
||||
*/
|
||||
export type NormalizedEventType =
|
||||
| "CALL_CREATED"
|
||||
| "CALL_RINGING"
|
||||
| "CALL_ANSWERED"
|
||||
| "CALL_BRIDGED"
|
||||
| "CALL_UNBRIDGED"
|
||||
| "CALL_ENDED"
|
||||
| "EXTENSION_REGISTERED"
|
||||
| "EXTENSION_UNREGISTERED"
|
||||
| "AGENT_STATUS_CHANGED"
|
||||
| "GATEWAY_UP"
|
||||
| "GATEWAY_DOWN"
|
||||
| "BACKGROUND_JOB_COMPLETED";
|
||||
|
||||
export interface NormalizedEvent {
|
||||
type: NormalizedEventType;
|
||||
occurredAt: string;
|
||||
/** UUID do channel/call quando aplicável. */
|
||||
callUuid?: string;
|
||||
/** Channel variables b2bcall_* quando presentes (secao 81) — ainda não
|
||||
* populadas nesta fase (só existirão a partir do Predictive Engine). */
|
||||
tenantId?: string;
|
||||
b2bcallCallId?: string;
|
||||
b2bcallCampaignId?: string;
|
||||
b2bcallLeadId?: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
8
packages/telephony/tsconfig.json
Normal file
8
packages/telephony/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
57
pnpm-lock.yaml
generated
57
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user