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:
@@ -108,6 +108,20 @@ FreeSWITCH (`agent list`).
|
||||
- Quota de agentes (`max_agents`) — depende de Plans/Entitlements.
|
||||
- `PauseReason.maxDuration` existe no modelo mas não é aplicado
|
||||
automaticamente ainda (ninguém força o fim da pausa ao expirar).
|
||||
- **Achado ao testar a fase Realtime Monitoring, sistêmico (não é só
|
||||
Agent)**: `@@unique([tenantId, userId])` em `Agent` não exclui
|
||||
`deletedAt` — apagar um agente (soft delete) e tentar criar outro pro
|
||||
mesmo usuário no mesmo tenant falha com `Unique constraint failed`,
|
||||
porque a linha apagada continua ocupando o slot único indefinidamente.
|
||||
Confirmado ao vivo durante o teste desta fase. O mesmo padrão
|
||||
(`@@unique` combinado com soft delete, sem excluir `deletedAt`) existe
|
||||
em pelo menos mais 4 models: `Extension` (`tenantId, number`), `Trunk`
|
||||
(`tenantId, name`), `Queue` (`tenantId, name`), `PauseReason`
|
||||
(`tenantId, code`) — todos vão ter o mesmo problema (não dá pra reusar
|
||||
um número/nome/código depois de apagar). Precisa de um índice único
|
||||
parcial (`WHERE deleted_at IS NULL`) em cada um — não corrigido nesta
|
||||
fase (é uma migration própria tocando 5 tabelas, fora do escopo de
|
||||
Realtime Monitoring); fica registrado aqui pra não se perder.
|
||||
|
||||
## Correção: eventos CUSTOM (`callcenter::info`) nunca chegavam
|
||||
|
||||
|
||||
129
docs/REALTIME.md
Normal file
129
docs/REALTIME.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Monitoramento em tempo real (WebSocket)
|
||||
|
||||
Agente.md secao 54-55 (filas/ramais) e 161 (WebSocket multi-tenant). Esta
|
||||
fase entrega a infraestrutura de push em tempo real — o consumo visual
|
||||
("Monitoramento → Filas/Ramais", cards coloridos) fica pra fase Frontend.
|
||||
|
||||
## Requisito central: tenant-scoped no servidor
|
||||
|
||||
Secao 161: "Não transmitir tudo e filtrar somente no browser." O gateway
|
||||
(`apps/api/src/realtime/realtime.gateway.ts`) só tem **um** ponto de
|
||||
emissão, `broadcastToTenant()`, e ele sempre usa `server.to(room)` — nunca
|
||||
`server.emit()` global. Cada socket só entra na room do próprio tenant
|
||||
(`tenant:<tenantId>`) na conexão, nunca escolhe a room ele mesmo.
|
||||
|
||||
## Autenticação na conexão
|
||||
|
||||
Socket.io não usa headers HTTP como uma request REST — o client manda o
|
||||
access token em `socket.handshake.auth.token` (não em `Authorization`).
|
||||
`RealtimeGateway.handleConnection`:
|
||||
|
||||
1. Rejeita (`disconnect(true)`) se não tiver token.
|
||||
2. Valida o JWT (`verifyAccessToken`, mesmo helper usado no `JwtAuthGuard`).
|
||||
3. Exige `tenantId` no token (mesmo princípio do `PermissionGuard`: nunca
|
||||
um tenant vindo do client, só do JWT já emitido por `/auth/select-tenant`).
|
||||
4. Exige a permission `monitoring.view` (`userHasPermission`, já existia
|
||||
desde a fase RBAC — só não tinha nenhum consumidor ainda).
|
||||
5. Só então entra na room do tenant.
|
||||
|
||||
Testado: token ausente/inválido → desconectado na hora, nenhum evento
|
||||
vaza. Token válido → entra na room certa, recebe eventos do próprio tenant.
|
||||
|
||||
## Origem dos eventos
|
||||
|
||||
Um único canal Redis, `b2bcall:events` (o mesmo que já existia desde a fase
|
||||
Event Socket) — o `RealtimeRedisBridge` (`apps/api`) assina esse canal com
|
||||
uma conexão dedicada (pub/sub exige conexão própria, não pode compartilhar
|
||||
com a usada pra comandos) e reencaminha pra `broadcastToTenant()` quando o
|
||||
evento já tem `tenantId` resolvido; sem tenant resolvido, o evento é
|
||||
descartado (nenhuma room saberia receber).
|
||||
|
||||
Dois produtores publicam nesse canal:
|
||||
|
||||
1. **`b2bcall-fs-events`** (eventos do FreeSWITCH, normalizados — ver
|
||||
`packages/telephony/src/normalize-event.ts`). A maioria desses eventos
|
||||
(`callcenter::info`, `sofia::gateway_state`) não carrega
|
||||
`b2bcall_tenant_id` como channel variable (só existe a partir do
|
||||
Predictive Engine) — o tenant é resolvido pelo id do agente/fila embutido
|
||||
no nome FreeSWITCH (`<uuid>@dominio`), com fan-out sobre os tenants
|
||||
ativos (mesmo padrão de `trunk-status.ts`), cacheado por id em
|
||||
`tenant-resolve.ts` (agente/fila nunca troca de tenant, cache nunca fica
|
||||
desatualizado). Isso só passou a funcionar depois da correção do bug de
|
||||
subscrição de eventos CUSTOM — ver docs/AGENTS.md e docs/EVENT_SOCKET.md.
|
||||
2. **`apps/api`** (mudanças no nosso próprio `Agent.state`, via
|
||||
`realtime-publish.helper.ts`, chamado de dentro de
|
||||
`agents-me.controller.ts` em login/logout/pause/resume). Aqui o
|
||||
`tenantId` já vem direto do JWT da requisição HTTP — sem fan-out.
|
||||
|
||||
## Tipos de evento emitidos
|
||||
|
||||
- `AGENT_STATE_CHANGED`: nosso enum próprio (`OFFLINE`/`AVAILABLE`/
|
||||
`PAUSED`/...), só muda via login/logout/pause/resume.
|
||||
- `AGENT_STATUS_CHANGED`: `CC-Agent-State` bruto do mod_callcenter
|
||||
(`Waiting`/`Receiving`/...) — vocabulário diferente do de cima, não dá
|
||||
pra misturar (mesmo agente pode estar "AVAILABLE" no nosso enum e
|
||||
"Receiving" no mod_callcenter simultaneamente, description de momentos
|
||||
diferentes do ciclo de uma chamada).
|
||||
- `QUEUE_MEMBER_COUNT`: contagem ao vivo de chamadas esperando por fila
|
||||
(`CC-Count`) — a peça central da secao 54 ("Chamadas esperando").
|
||||
- `QUEUE_MEMBER_LEFT`: uma chamada saiu da fila, com `cause`/`cancelReason`
|
||||
e timestamps de entrada/saída — atendida vs. abandonada, base pro cálculo
|
||||
futuro de Service Level/Abandon Rate.
|
||||
- `AGENT_OFFERED_CALL` / `AGENT_BRIDGE_FAILED`: uma chamada foi ofertada a
|
||||
um agente / falhou ao bridgear (ex.: `USER_NOT_REGISTERED`).
|
||||
- `CALL_CREATED`/`CALL_ANSWERED`/`CALL_ENDED`: já existiam desde a fase
|
||||
Event Socket, mas só carregam `tenantId` quando o `b2bcall_tenant_id`
|
||||
channel variable existir (chamadas puramente sintéticas de teste, como as
|
||||
usadas pra verificar esta fase, não têm — não chegam no WebSocket).
|
||||
|
||||
## Verificado ponta a ponta
|
||||
|
||||
Client de teste com `socket.io-client`, autenticado com o JWT de um tenant
|
||||
de teste, na room `tenant:<id>`:
|
||||
|
||||
```
|
||||
login do agente → AGENT_STATE_CHANGED {state: "AVAILABLE"}
|
||||
pause → AGENT_STATE_CHANGED {state: "PAUSED"}
|
||||
resume → AGENT_STATE_CHANGED {state: "AVAILABLE"}
|
||||
logout → AGENT_STATE_CHANGED {state: "OFFLINE"}
|
||||
|
||||
originate null/dummy &callcenter(fila@dominio), com agente logado numa fila:
|
||||
→ QUEUE_MEMBER_COUNT {count: 3}
|
||||
→ AGENT_STATUS_CHANGED {state: "Receiving"}
|
||||
→ AGENT_OFFERED_CALL
|
||||
→ AGENT_BRIDGE_FAILED {hangupCause: "USER_NOT_REGISTERED"} (ramal sem SIP real registrado)
|
||||
→ QUEUE_MEMBER_LEFT {cause: "Cancel", cancelReason: "TIMEOUT"}
|
||||
→ QUEUE_MEMBER_COUNT {count: 2}
|
||||
|
||||
token ausente/inválido → socket desconectado na hora, nenhum evento recebido.
|
||||
```
|
||||
|
||||
Todos chegaram só na room do tenant certo, com o `tenantId` batendo.
|
||||
|
||||
## O que falta
|
||||
|
||||
- **Ramais/extensões (secao 55 completa)**: as cores dependem de "ocupado"
|
||||
(busy — CHANNEL_ANSWER/HANGUP tied a um ramal específico) e "offline"
|
||||
(registro SIP — `sofia::register`/`unregister`, cujo mecanismo de entrega
|
||||
agora funciona, mas nunca foi exercitado com um client SIP real nesta
|
||||
sessão). Além disso, resolver **qual ramal** um evento de canal pertence
|
||||
exige extrair o número de discagem dos headers (`Caller-Destination-Number`/
|
||||
`Channel-Name`), não implementado ainda — e o mesmo limite de domínio
|
||||
compartilhado entre tenants já documentado em `docs/EXTENSIONS.md` afeta
|
||||
esse mapeamento. Não implementado; fica pra quando houver um client SIP de
|
||||
verdade pra testar.
|
||||
- **Persistir `AGENT_STATUS_CHANGED`/`QUEUE_MEMBER_COUNT` em `Agent.state`
|
||||
ou numa tabela de snapshot de fila**: hoje só passam pelo WebSocket, não
|
||||
gravam nada — suficiente pro requisito "tempo real", mas sem histórico
|
||||
consultável fora da tabela `agent_state_events` (que só reflete
|
||||
login/logout/pause/resume, não os estados derivados de chamada).
|
||||
- TME/TMA/Service Level/Abandon Rate (secao 54): dependem de CDR
|
||||
(fase futura, depois do Predictive Engine na ordem do agente.md secao
|
||||
232) — `QUEUE_MEMBER_LEFT` já traz os dados brutos (`cause`,
|
||||
`cancelReason`, timestamps) que vão alimentar esse cálculo quando CDR
|
||||
existir.
|
||||
- Dashboard Tenant/Platform (secao 162-163) — fase Frontend.
|
||||
- Reconexão/reconciliação de estado ao reconectar o WebSocket (perder um
|
||||
evento por queda de rede momentânea não é recuperável hoje — o client
|
||||
precisa buscar o snapshot atual via REST depois de reconectar; não existe
|
||||
endpoint de snapshot ainda).
|
||||
Reference in New Issue
Block a user