Files
B2BCall-dialer/docs/AGENTS.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

153 lines
7.9 KiB
Markdown

# Agentes, Tiers e Pausas
Agente.md secao 45-49 e 52. Completa o Call Center: depois desta fase, um
usuário autenticado consegue logar como agente, entrar numa fila real, se
pausar e voltar — tudo refletido de verdade no FreeSWITCH.
## Modelo
- `agents` (tenant-scoped, RLS): liga `User` (login) → `Extension` (ramal
SIP) → identidade de agente (`User`/`Agent`/`Extension` separados,
agente.md secao 45). `state` espelha o estado real (secao 46), nunca
escrito direto pela API — só via login/logout/pause/resume.
- `tiers`: Queue↔Agent (secao 52) — level/position espelham 1:1 o
mod_callcenter.
- `agent_sessions`: uma linha por ciclo login→logout.
- `agent_state_events`: histórico de transições de estado (auditoria).
- `pause_reasons`/`agent_pause_events` (secao 48).
## Mecanismo: 100% dinâmico, sem arquivo
Confirmado na fase anterior (Queues) via `help callcenter_config`: agentes e
tiers não têm representação em XML — só comandos ESL diretos
(`agent add/del/set status/set contact`, `tier add/del`). Isso muda o padrão
de sincronização: em vez de "regenerar todos os arquivos" (Trunks/Queues),
`apps/api` publica uma mensagem **por ação, com payload** (`{tenantId,
agentId, action}` em `b2bcall:agents:sync`; `{tenantId, queueId, agentId,
level, position, action}` em `b2bcall:tiers:sync`) — `b2bcall-fs-config`
recebe e aplica o comando correspondente.
## Achados reais confirmados manualmente antes de codar
Rodei cada comando contra o FreeSWITCH real antes de escrever qualquer
lógica de sync (mesmo método usado nas fases anteriores):
- `agent add` em cima de um agente que já existe: **erro**
("Agent already exist!") — não é idempotente. `tier add` duplicado: mesmo
erro ("Tier already exist!"). Ambos capturados e ignorados no sync
(`.catch(() => undefined)`), já que essa condição é esperada em qualquer
resync.
- `tier del`/`agent del` em algo que não existe: **não dá erro** (+OK) —
seguro chamar sem checar existência antes.
- `agent set status` só aceita 3 valores exatos: `Available`, `On Break`,
`Logged Out` — qualquer outro string dá `-ERR Invalid Agent Status!`
(testado deliberadamente). Os estados derivados de chamada do nosso enum
(RESERVED/RINGING/IN_CALL/WRAP_UP) não têm status próprio no
mod_callcenter — mapeiam pra "Available" (só o `state`, campo separado,
muda sozinho conforme a chamada progride — não escrevemos isso).
- **Achado de corrida real**, visto ao testar o fluxo completo: como um
agente só passa a existir no FreeSWITCH no login (`agent add` roda ali,
não na criação do registro `Agent`), atribuir um tier (`POST
/queues/:id/agents`) **antes** do primeiro login do agente falha
silenciosamente do lado do FreeSWITCH (`-ERR Agent not found!`, logado
mas não propagado como erro HTTP). Por isso o login **sempre
re-sincroniza todos os tiers do agente** depois de garantir que ele
existe — a auto-correção aconteceu exatamente assim no teste real.
## Fluxo de login (agente.md secao 47)
```
POST /agents/me/login
→ valida usuário (JWT) e ramal (Agent.extensionId precisa existir)
→ cria agent_session
→ agent add (idempotente via catch) + set contact (dial-string do ramal)
→ set status Available
→ re-sincroniza todos os tiers do agente
→ Agent.state = AVAILABLE
```
`POST /agents/me/logout|pause|resume` seguem o mesmo padrão — sempre sobre
o agente do **próprio usuário autenticado** (nunca um `agentId` arbitrário
do client, mesmo princípio de nunca confiar em tenant_id/ids sensíveis vindo
do frontend sem checar contra o JWT).
## Verificado ponta a ponta
```
POST /extensions {"number":"2000",...}
POST /agents {userId, extensionId, name}
POST /queues {"name":"Suporte"}
POST /queues/:queueId/agents {agentId} → tier add falha (agente ainda nao existe)
POST /agents/me/login → agent add + contact + status Available
→ tier re-sync (dessa vez funciona)
callcenter_config agent list
→ status=Available, contact={ignore_early_media=true}user/2000@b2bcall.local ✓
callcenter_config tier list
→ queue|agent com state=Ready ✓
POST /agents/me/pause {pauseReasonId} → status=On Break ✓
POST /agents/me/resume → status=Available ✓
POST /agents/me/logout → status=Logged Out ✓
```
Todos os 4 estados confirmados batendo entre o banco (`Agent.state`) e o
FreeSWITCH (`agent list`).
## O que falta
- Estados derivados de chamada (RINGING, IN_CALL, WRAP_UP, RESERVED) — o
mecanismo de entrega do `callcenter::info` foi corrigido e confirmado
funcionando (ver seção abaixo), mas gravar esses estados de volta em
`Agent.state` ainda não foi implementado — hoje o campo só muda via
login/logout/pause/resume. O estado real do mod_callcenter (`CC-Agent-State`:
`Waiting`/`Receiving`/...) já está disponível em tempo real via WebSocket
(docs/REALTIME.md); persistir isso em `Agent.state` fica pra quando o
Predictive Engine/CDR precisarem consultar esse histórico.
- Tela do agente (secao 49) — fase Frontend.
- 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
Achado ao testar esta fase ponta a ponta com uma chamada real de teste
(`originate null/dummy &callcenter(fila@dominio)`): nenhum evento CUSTOM
aparecia em `b2bcall-fs-events`, apesar do agente ser ofertado a chamada de
verdade (`agent list` mostrava `last_offered_call` mudando). Causa raiz:
`event_json(...SUBSCRIBED_EVENTS)` mandava `"CUSTOM"` como último argumento
do comando `event json`, sem nenhum subclass depois — o mod_event_socket do
FreeSWITCH exige que os subclasses (`callcenter::info`, `sofia::register`,
`sofia::gateway_state`, ...) venham logo depois do token `CUSTOM` no mesmo
comando, senão zero eventos CUSTOM são entregues (de qualquer subclass).
Afetava também `sofia::gateway_state` (ver correção equivalente em
docs/TRUNKS.md).
Corrigido separando `PLAIN_EVENTS` (nomes normais, cada um vira um listener
`.on()`) de `CUSTOM_SUBCLASSES` (só compõem o comando `event json`, nunca
viram listener — o client ESL sempre emite `"CUSTOM"` como nome de evento,
com o subclass real no header `Event-Subclass`).
De quebra, corrigido também o nome de campo errado em
`normalizeCustomEvent`: o código lia `CC-Agent-Status`, que não existe — o
campo real é `CC-Agent-State`. Os `CC-Action` reais observados no teste
(úteis pro monitoramento de filas, agente.md secao 54): `agent-offering`,
`agent-state-change`, `bridge-agent-fail`, `member-queue-end` (com
`CC-Cause`/`CC-Cancel-Reason` e timestamps de entrada/saída — abandono vs.
atendida), `members-count` (contagem ao vivo de chamadas esperando por
fila). Ver `packages/telephony/src/normalize-event.ts`.