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
130 lines
6.8 KiB
Markdown
130 lines
6.8 KiB
Markdown
# 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).
|