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

5.5 KiB

Trunks (Troncos SIP)

Agente.md seções 41-42. Primeiro recurso que grava configuração real no filesystem do FreeSWITCH (gateways Sofia) em vez de só responder via mod_xml_curl.

Modelo

trunks (tenant-scoped, RLS): host, proxy, realm, register, username/password_enc (AES-256-GCM, mesmo padrão de sip_password_enc), from_user/from_domain, register_proxy/outbound_proxy, expire_seconds, retry_seconds, dtmf_mode, ping/ping_frequency, transport, max_cps/max_channels, e status/status_updated_at (refletidos pelos eventos do FreeSWITCH, nunca escritos manualmente pela API).

Mecanismo: por que arquivo + rescan, e não XML Curl "configuration"

Ao contrário de Extensions (resolvido 100% via mod_xml_curl na hora do lookup), gateways Sofia não têm um binding XML Curl limpo e específico sem reativar a seção configuration inteira — e isso já causou problemas reais na fase XML Curl (chamadas HTTP desnecessárias no boot pra configs de outros módulos). Em vez disso, b2bcall-fs-config:

  1. Gera um arquivo <trunk_id>.xml por trunk habilitado em sip_profiles/external/ (volume Docker compartilhado com o FreeSWITCH — o profile external da config vanilla já tem <X-PRE-PROCESS cmd="include" data="external/*.xml"/>, então não precisou editar a config do profile).
  2. Roda sofia profile external rescan via ESL — relê os gateways sem derrubar chamadas em andamento (diferente de restart).

Achado real (fase Realtime Monitoring): rescan só lê arquivos novos/alterados — apagar o arquivo .xml de um trunk removido não tira o gateway da memória do Sofia, ele fica "fantasma" indefinidamente (confirmado criando e apagando um trunk de teste via API e checando sofia status gateway depois do rescan). Corrigido: trunk-sync.ts agora roda sofia profile external killgw <nome> pra cada gateway removido, antes do rescan — descarrega um gateway específico na hora, sem precisar reiniciar o profile inteiro. Reverificado ponta a ponta (criar → aparece em sofia status gateway → apagar via API → some imediatamente).

Gatilho de sincronização

apps/api não roda no Docker (ainda está no host) e fs-config não expõe porta pro host — então a notificação "algo mudou, resincroniza" vai por Redis pub/sub (b2bcall:trunks:sync), o mesmo mecanismo já usado pra eventos normalizados. apps/api publica depois de criar/apagar um trunk; fs-config também roda uma sincronização completa ao subir (cobre trunks criados enquanto ele estava fora do ar).

Achado real: a primeira sincronização no boot corria antes da conexão ESL do fs-config terminar de se estabelecer, gerando um erro cosmético ("FreeSWITCH ESL nao conectado") — a escrita dos arquivos funcionava, só o rescan falhava. Corrigido com FreeSwitchTelephonyProvider.waitUntilConnected() (timeout de 5s) antes de tentar o rescan.

Status do trunk (secao 42)

b2bcall-fs-events já escutava sofia::gateway_state desde a fase Event Socket (normalizado pra GATEWAY_UP/GATEWAY_DOWN); nesta fase, ele passou a também escrever esse estado de volta em Trunk.status. Como o nome do gateway no FreeSWITCH é o Trunk.id (UUID) e o evento não diz de qual tenant é, a busca percorre os tenants ativos (packages/database's withTenantContext) até achar o trunk dono daquele id — aceitável dado que mudança de estado de trunk é rara, não é um evento de alto volume por chamada.

Mapeamento de estados brutos do Sofia pro enum interno (apps/freeswitch-events/src/trunk-status.ts):

UP, REGED       → REGISTERED/UP
TRYING, REGISTER → TRYING
FAILED, FAIL_WAIT → FAILED
DOWN            → DOWN
NOREG, UNREGED  → UNREGISTERED
qualquer outro  → UNKNOWN

Verificado

Criei um trunk de teste apontando pra um host inexistente (sip.trunk-inexistente.invalid, nunca resolve — RFC 2606) via API:

  • fs-config sincronizou (count: 1), escreveu o arquivo .xml, rodou o rescan com sucesso.
  • sofia status gateway no FreeSWITCH mostrou o gateway real, estado FAIL_WAIT (esperado — host não existe).
  • Confirma o pipeline API → Postgres → fs-config → arquivo XML → rescan → FreeSWITCH funcionando ponta a ponta sem precisar de nenhum tronco/ credencial real.

O que falta

  • Quota de troncos (max_trunks, secao 59) — depende de Plans/Entitlements.
  • GET /trunks não mostra sofia status gateway ao vivo, só o último status conhecido no banco — resolvido na fase Realtime Monitoring via WebSocket (ver docs/REALTIME.md).

Correção: GATEWAY_UP/DOWNTrunk.status (fase Realtime Monitoring)

Documentado antes como "não confirmado com evento real" — na verdade nunca funcionava, e a causa raiz não tinha nada a ver com o tipo de falha do gateway. b2bcall-fs-events assinava os eventos CUSTOM do ESL passando "CUSTOM" como o último elemento da lista pro comando event json, sem nenhum subclass depois — e o protocolo do FreeSWITCH exige que os nomes de subclass (sofia::gateway_state, sofia::register, callcenter::info, ...) venham imediatamente depois do token CUSTOM no mesmo comando event json, senão zero eventos CUSTOM chegam, de qualquer subclass. Achado e corrigido durante a fase Agentes/Realtime Monitoring (mesmo bug afetava callcenter::info, ver docs/AGENTS.md). Reverificado ponta a ponta: criar um trunk com register: true apontando pra um host inexistente → Trunk.status no banco passa de UNKNOWN pra FAILED sozinho, sem polling, assim que o Sofia tenta e falha o registro.