feat(realtime): monitoramento em tempo real via WebSocket multi-tenant

Fecha agente.md secao 54-55 (infraestrutura) e 161 (WebSocket multi-tenant).
Entrega o pipeline de push em tempo real completo — o consumo visual
("Monitoramento -> Filas/Ramais") fica pra fase Frontend.

Requisito central da secao 161 ("nao transmitir tudo e filtrar so no
browser"): RealtimeGateway tem um unico ponto de emissao,
broadcastToTenant(), sempre server.to(`tenant:<id>`), nunca broadcast
global. Cada socket entra na room do proprio tenant no handshake, nunca
escolhe a room.

Autenticacao na conexao (handshake.auth.token, nao Authorization header):
valida o JWT (mesmo verifyAccessToken do JwtAuthGuard), exige tenantId no
token e a permission monitoring.view (ja existia desde RBAC, sem
consumidor ate agora) — mesmo principio de nunca confiar em tenant_id do
client, so do JWT ja emitido por /auth/select-tenant.

Origem dos eventos: canal Redis unico b2bcall:events (o mesmo desde Event
Socket). Dois produtores: b2bcall-fs-events (eventos do FreeSWITCH,
resolvendo tenantId por fan-out quando nao ha channel variable, ver
tenant-resolve.ts) e apps/api (mudancas no nosso Agent.state via
agents-me.controller, tenantId direto do JWT, sem fan-out).

Bug real achado e corrigido ao construir esta fase: nenhum evento CUSTOM do
ESL (sofia::register, sofia::gateway_state, callcenter::info) jamais
chegava em b2bcall-fs-events nesta sessao inteira. Causa: event_json(...)
mandava "CUSTOM" como ultimo token do comando `event json`, sem subclass
depois — mod_event_socket exige os subclasses logo depois do token CUSTOM
no mesmo comando pra serem entregues. Corrigido separando PLAIN_EVENTS
(viram listener .on()) de CUSTOM_SUBCLASSES (so compoem o comando de
assinatura). Resolve as lacunas ja documentadas em docs/TRUNKS.md e
docs/AGENTS.md. De quebra, corrigido um bug de nome de campo
(CC-Agent-Status, que nao existe -> CC-Agent-State) e um segundo bug real
em trunk-sync.ts (rescan nunca descarregava gateway removido -> agora roda
`killgw` antes do rescan).

Novos tipos normalizados a partir de callcenter::info, com nomes de campo
confirmados contra uma fila real: AGENT_OFFERED_CALL, AGENT_BRIDGE_FAILED,
QUEUE_MEMBER_COUNT (chamadas esperando, secao 54), QUEUE_MEMBER_LEFT (com
cause/cancelReason e timestamps — base pra Service Level/Abandon Rate
quando CDR existir).

Verificado ponta a ponta com um client socket.io real: login/pause/resume/
logout emitindo AGENT_STATE_CHANGED; chamada de teste numa fila com agente
logado emitindo QUEUE_MEMBER_COUNT/LEFT, AGENT_OFFERED_CALL,
AGENT_BRIDGE_FAILED, AGENT_STATUS_CHANGED (CC-Agent-State correto); token
ausente/invalido desconectado na hora, sem vazar nenhum evento.

Achado sistemico durante o teste (documentado, nao corrigido nesta fase):
@@unique combinado com soft delete, sem excluir deletedAt, em
Agent/Extension/Trunk/Queue/PauseReason — nao da pra reusar numero/nome/
codigo depois de apagar. Precisa de indice unico parcial em cada um, fora
do escopo desta fase.

typecheck do workspace inteiro limpo. ~144MB de memoria total nos
containers (fs-events 44MB, fs-config 45MB, freeswitch 26MB, postgres
21MB, redis 8MB).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
2026-08-28 11:55:52 -03:00
parent b940ce3e63
commit f051fe3162
15 changed files with 661 additions and 12 deletions

48
TODO.md
View File

@@ -228,14 +228,52 @@
resume→Available, logout→Logged Out — todos confirmados batendo
entre `Agent.state` (banco) e `callcenter_config agent list`
(FreeSWITCH)
- [ ] Estados derivados de chamada (RINGING/IN_CALL/WRAP_UP/RESERVED) —
dependem de `callcenter::info` (CUSTOM event), ainda não provado
funcionando (mesma lacuna do `sofia::gateway_state`, ver docs/TRUNKS.md)
- [x] Estados derivados de chamada (RINGING/IN_CALL/WRAP_UP/RESERVED) —
mecanismo de entrega do `callcenter::info` corrigido e confirmado
(ver PHASE 13); persistir em `Agent.state` ainda não implementado
- [ ] `PauseReason.maxDuration` não é aplicado automaticamente
- [ ] Quota de agentes — depende de Plans/Entitlements
- [ ] Achado sistêmico: `@@unique` combinado com soft delete (sem excluir
`deletedAt`) em `Agent`/`Extension`/`Trunk`/`Queue`/`PauseReason` —
não dá pra reusar número/nome/código depois de apagar. Precisa de
índice único parcial em cada um, migration própria (ver docs/AGENTS.md)
## PHASE 13+ver `agente.md` seções 54 em diante (Monitoramento em tempo real,
Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)
## PHASE 13 — Realtime Monitoring / WebSocket multi-tenant (agente.md secao 54-55, 161)
- [x] **Bug real, achado nesta fase**: nenhum evento CUSTOM do ESL
(`sofia::register`, `sofia::gateway_state`, `callcenter::info`) jamais
chegava em `b2bcall-fs-events` — `event_json(...)` mandava `"CUSTOM"`
como último token do comando `event json`, sem subclass depois
(mod_event_socket exige os subclasses logo depois do token CUSTOM no
mesmo comando). Corrigido separando `PLAIN_EVENTS`/`CUSTOM_SUBCLASSES`.
Resolve as lacunas documentadas em PHASE 09/12 e docs/TRUNKS.md/AGENTS.md.
- [x] Corrigido de quebra: `CC-Agent-Status` (não existe) → `CC-Agent-State`
(campo real); novos tipos normalizados `AGENT_OFFERED_CALL`,
`AGENT_BRIDGE_FAILED`, `QUEUE_MEMBER_COUNT`, `QUEUE_MEMBER_LEFT`
- [x] Corrigido de quebra: `sofia profile external rescan` nunca descarregava
um gateway cujo arquivo foi apagado (fantasma na memória do Sofia) —
`trunk-sync.ts` agora roda `killgw <nome>` pra cada gateway removido
- [x] `tenant-resolve.ts` (fs-events): resolve tenantId por fan-out
(agente/fila não carregam `b2bcall_tenant_id` — só existe a partir do
Predictive Engine), cacheado por id
- [x] `apps/api`: `RealtimeGateway` (socket.io sobre o Fastify HTTP server),
auth via JWT no handshake (`monitoring.view`), uma room por tenant
(`tenant:<id>`) — nunca broadcast global, sempre `server.to(room)`
(secao 161: tenant-scoped no servidor, nunca filtrar só no browser)
- [x] `RealtimeRedisBridge`: assina `b2bcall:events` (canal único, mesmo
usado desde Event Socket), reencaminha pro tenant certo
- [x] `AGENT_STATE_CHANGED` publicado direto de `agents-me.controller.ts`
(login/logout/pause/resume) — tenantId já vem do JWT, sem fan-out
- [x] Testado ponta a ponta: login/pause/resume/logout via WS, chamada de
teste numa fila real (QUEUE_MEMBER_COUNT/LEFT, AGENT_OFFERED_CALL,
AGENT_BRIDGE_FAILED, AGENT_STATUS_CHANGED com CC-Agent-State correto),
token inválido desconectado na hora
- [ ] Ramais/extensões (secao 55 completa: busy/registro) — precisa de SIP
real pra testar, e extrair ramal dos headers de canal (não feito)
- [ ] TME/TMA/Service Level/Abandon Rate — dependem de CDR (fase futura)
- [ ] Snapshot/reconciliação ao reconectar o WebSocket
## PHASE 14+ — ver `agente.md` seções 56 em diante (Predictive Dialer,
Recordings, AI, Billing, Frontend, Reports, Security, Tests)
---