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
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:
- Rejeita (
disconnect(true)) se não tiver token. - Valida o JWT (
verifyAccessToken, mesmo helper usado noJwtAuthGuard). - Exige
tenantIdno token (mesmo princípio doPermissionGuard: nunca um tenant vindo do client, só do JWT já emitido por/auth/select-tenant). - Exige a permission
monitoring.view(userHasPermission, já existia desde a fase RBAC — só não tinha nenhum consumidor ainda). - 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:
b2bcall-fs-events(eventos do FreeSWITCH, normalizados — verpackages/telephony/src/normalize-event.ts). A maioria desses eventos (callcenter::info,sofia::gateway_state) não carregab2bcall_tenant_idcomo 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 detrunk-status.ts), cacheado por id emtenant-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.apps/api(mudanças no nosso próprioAgent.state, viarealtime-publish.helper.ts, chamado de dentro deagents-me.controller.tsem login/logout/pause/resume). Aqui otenantIdjá 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-Statebruto 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, comcause/cancelReasone 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ó carregamtenantIdquando ob2bcall_tenant_idchannel 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 emdocs/EXTENSIONS.mdafeta esse mapeamento. Não implementado; fica pra quando houver um client SIP de verdade pra testar. - Persistir
AGENT_STATUS_CHANGED/QUEUE_MEMBER_COUNTemAgent.stateou 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 tabelaagent_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_LEFTjá 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).