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

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).