Files
B2BCall-dialer/docs/AGENTS.md
Matheus b940ce3e63 fix(events): eventos CUSTOM do ESL nunca eram entregues
Achado ao iniciar a fase de Realtime Monitoring: nenhum evento CUSTOM
(sofia::register, sofia::gateway_state, callcenter::info) jamais chegou em
b2bcall-fs-events nesta sessao, apesar do estado real do FreeSWITCH mudar de
verdade (confirmado originando uma chamada de teste pra dentro de uma fila
real com agente logado). Isso deixava dois gaps documentados como "nao
verificado" em docs/TRUNKS.md e docs/AGENTS.md.

Causa raiz: `event_json(...SUBSCRIBED_EVENTS)` mandava "CUSTOM" como ultimo
token do comando `event json`, sem nenhum subclass depois. O
mod_event_socket do FreeSWITCH exige que os subclasses (callcenter::info,
sofia::register, ...) venham imediatamente depois do token CUSTOM no mesmo
comando — sem isso, zero eventos CUSTOM sao entregues, de qualquer
subclass.

Corrigido separando PLAIN_EVENTS (nomes normais, cada um vira um listener
.on()) de CUSTOM_SUBCLASSES (so compoe o comando de subscricao — o client
ESL sempre emite "CUSTOM" como nome de evento, com o subclass real no
header Event-Subclass). Corrigido tambem um bug de nome de campo:
normalizeCustomEvent lia CC-Agent-Status, que nao existe; o campo real e
CC-Agent-State.

Com o pipeline corrigido, chegam eventos ricos de callcenter::info nunca
antes observados: agent-offering, bridge-agent-fail, members-count (fila em
tempo real) e member-queue-end (com CC-Cause/CC-Cancel-Reason e timestamps
de entrada/saida — atendida vs. abandonada). Adicionados como novos tipos
normalizados: AGENT_OFFERED_CALL, AGENT_BRIDGE_FAILED, QUEUE_MEMBER_COUNT,
QUEUE_MEMBER_LEFT.

De quebra, achado e corrigido um segundo bug real ao reverificar Trunks com
o pipeline de eventos funcionando: `sofia profile external rescan` nunca
descarregava um gateway cujo arquivo .xml foi apagado (fica fantasma na
memoria do Sofia indefinidamente). trunk-sync.ts agora roda `sofia profile
external killgw <nome>` pra cada gateway removido, antes do rescan.

Reverificado ponta a ponta pra ambos os bugs:
- Trunk com register:true apontando pra host inexistente: Trunk.status no
  banco passa de UNKNOWN pra FAILED sozinho, via evento, sem polling.
- Trunk apagado via API: gateway some imediatamente de `sofia status
  gateway`, sem esperar reinicio de profile.
- Chamada de teste real numa fila com agente logado: members-count,
  agent-offering, agent-state-change (CC-Agent-State correto: Waiting/
  Receiving), bridge-agent-fail, todos chegando certos no canal Redis
  b2bcall:events.

typecheck do workspace inteiro limpo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
2026-08-28 11:41:09 -03:00

139 lines
6.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).
## 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`.