Files
B2BCall-dialer/docs/REALTIME.md
Matheus f051fe3162 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
2026-08-28 11:55:52 -03:00

6.8 KiB

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