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
This commit is contained in:
2026-08-28 11:41:09 -03:00
parent a05104a05f
commit b940ce3e63
7 changed files with 174 additions and 36 deletions

View File

@@ -73,13 +73,12 @@ export async function syncTrunks(): Promise<void> {
await mkdir(GATEWAYS_DIR, { recursive: true }); await mkdir(GATEWAYS_DIR, { recursive: true });
const existing = await readdir(GATEWAYS_DIR).catch(() => [] as string[]); const existing = await readdir(GATEWAYS_DIR).catch(() => [] as string[]);
const wanted = new Set(files.map((f) => f.name)); const wanted = new Set(files.map((f) => f.name));
const removed = existing.filter((f) => !wanted.has(f));
await Promise.all( await Promise.all(removed.map((f) => rm(join(GATEWAYS_DIR, f))));
existing.filter((f) => !wanted.has(f)).map((f) => rm(join(GATEWAYS_DIR, f))),
);
await Promise.all(files.map((f) => writeFile(join(GATEWAYS_DIR, f.name), f.xml, "utf8"))); await Promise.all(files.map((f) => writeFile(join(GATEWAYS_DIR, f.name), f.xml, "utf8")));
logger.info("gateways sincronizados", { count: files.length }); logger.info("gateways sincronizados", { count: files.length, removed: removed.length });
try { try {
const provider = getProvider(); const provider = getProvider();
@@ -90,6 +89,18 @@ export async function syncTrunks(): Promise<void> {
logger.warn("ESL ainda nao conectado, rescan sera tentado na proxima sincronizacao"); logger.warn("ESL ainda nao conectado, rescan sera tentado na proxima sincronizacao");
return; return;
} }
// Achado real: "rescan" só lê arquivos novos/alterados — um gateway cujo
// arquivo foi apagado continua vivo na memória do Sofia indefinidamente
// (confirmado deletando um trunk de teste e checando `sofia status
// gateway` depois do rescan). "killgw <nome>" descarrega um gateway
// específico na hora, sem precisar reiniciar o profile inteiro.
for (const file of removed) {
const gatewayName = file.replace(/\.xml$/, "");
const result = await provider.runApi(`sofia profile external killgw ${gatewayName}`);
logger.info("gateway removido do FreeSWITCH", { gatewayName, result: result.trim() });
}
// "rescan" relê os arquivos de gateway do profile sem derrubar chamadas // "rescan" relê os arquivos de gateway do profile sem derrubar chamadas
// em andamento (diferente de "restart"). // em andamento (diferente de "restart").
const result = await provider.runApi("sofia profile external rescan"); const result = await provider.runApi("sofia profile external rescan");

View File

@@ -10,7 +10,7 @@ const REDIS_CHANNEL = "b2bcall:events";
// Eventos consumidos (agente.md secao 23). HEARTBEAT só é logado, nunca // Eventos consumidos (agente.md secao 23). HEARTBEAT só é logado, nunca
// normalizado/publicado — não representa uma chamada. // normalizado/publicado — não representa uma chamada.
const SUBSCRIBED_EVENTS = [ const PLAIN_EVENTS = [
"HEARTBEAT", "HEARTBEAT",
"CHANNEL_CREATE", "CHANNEL_CREATE",
"CHANNEL_ORIGINATE", "CHANNEL_ORIGINATE",
@@ -25,9 +25,23 @@ const SUBSCRIBED_EVENTS = [
"CHANNEL_STATE", "CHANNEL_STATE",
"CHANNEL_CALLSTATE", "CHANNEL_CALLSTATE",
"BACKGROUND_JOB", "BACKGROUND_JOB",
"CUSTOM",
] as const; ] as const;
// mod_event_socket exige que os subclasses de CUSTOM venham logo depois do
// token "CUSTOM" no mesmo comando `event json` — subscrever só "CUSTOM" sem
// nada depois não entrega nenhum CUSTOM event (bug real encontrado nesta
// fase: sofia::gateway_state, sofia::register e callcenter::info nunca
// chegavam por causa disso, apesar do estado real do FreeSWITCH mudar).
const CUSTOM_SUBCLASSES = [
"sofia::register",
"sofia::unregister",
"sofia::expire",
"sofia::gateway_state",
"callcenter::info",
] as const;
const SUBSCRIBED_EVENTS = [...PLAIN_EVENTS, "CUSTOM", ...CUSTOM_SUBCLASSES] as const;
function requireEnv(name: string): string { function requireEnv(name: string): string {
const value = process.env[name]; const value = process.env[name];
if (!value) { if (!value) {
@@ -57,14 +71,22 @@ async function main() {
logger.info("conectado ao FreeSWITCH via ESL"); logger.info("conectado ao FreeSWITCH via ESL");
// Re-executado a cada reconexao (secao 195: "resubscribe" apos reconectar). // Re-executado a cada reconexao (secao 195: "resubscribe" apos reconectar).
await call.event_json(...SUBSCRIBED_EVENTS); // Cast: os tipos do pacote `esl` só conhecem os nomes de evento "planos"
// do FreeSWITCH, não os subclasses de CUSTOM (ex.: "sofia::register"),
// que são um recurso real do protocolo mas não modelado no `EventName`.
await call.event_json(...(SUBSCRIBED_EVENTS as unknown as Parameters<typeof call.event_json>));
call.on("HEARTBEAT", () => logger.debug("heartbeat")); call.on("HEARTBEAT", () => logger.debug("heartbeat"));
for (const eventName of SUBSCRIBED_EVENTS) { // O client ESL emite sempre "CUSTOM" como nome de evento (o subclass
// real vem no header Event-Subclass, lido dentro de normalizeEslEvent)
// — os nomes em CUSTOM_SUBCLASSES existem só pra compor o comando
// `event json`, nunca como nome de listener.
for (const eventName of PLAIN_EVENTS) {
if (eventName === "HEARTBEAT") continue; if (eventName === "HEARTBEAT") continue;
call.on(eventName, (raw) => handleEvent(eventName, raw)); call.on(eventName, (raw) => handleEvent(eventName, raw));
} }
call.on("CUSTOM", (raw) => handleEvent("CUSTOM", raw));
}); });
client.on("reconnecting", (retryMs) => { client.on("reconnecting", (retryMs) => {

View File

@@ -96,14 +96,43 @@ FreeSWITCH (`agent list`).
## O que falta ## O que falta
- Estados derivados de chamada (RINGING, IN_CALL, WRAP_UP, RESERVED) — - Estados derivados de chamada (RINGING, IN_CALL, WRAP_UP, RESERVED) — o
dependem de `callcenter::info` (CUSTOM event), que **ainda não foi mecanismo de entrega do `callcenter::info` foi corrigido e confirmado
provado funcionando** nesta sessão (mesma lacuna de `sofia::gateway_state` funcionando (ver seção abaixo), mas gravar esses estados de volta em
documentada em docs/TRUNKS.md). Não implementado; precisa de uma chamada `Agent.state` ainda não foi implementado — hoje o campo só muda via
real passando pela fila pra testar. 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. - Tela do agente (secao 49) — fase Frontend.
- Monitoramento de filas/ramais em tempo real (secao 54-55) — depende de
WebSocket multi-tenant.
- Quota de agentes (`max_agents`) — depende de Plans/Entitlements. - Quota de agentes (`max_agents`) — depende de Plans/Entitlements.
- `PauseReason.maxDuration` existe no modelo mas não é aplicado - `PauseReason.maxDuration` existe no modelo mas não é aplicado
automaticamente ainda (ninguém força o fim da pausa ao expirar). 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`.

View File

@@ -22,9 +22,9 @@ reagir a `connect`/`reconnecting`/`error` e resubscrever a cada `connect`
`mod_callcenter` mas não foram exercitados contra uma fila real ainda — `mod_callcenter` mas não foram exercitados contra uma fila real ainda —
não existe nenhuma (fase Queues). não existe nenhuma (fase Queues).
- `normalizeEslEvent()`: traduz eventos ESL crus pro vocabulário interno - `normalizeEslEvent()`: traduz eventos ESL crus pro vocabulário interno
(agente.md secao 24). Mapeamento de `callcenter::info``AGENT_STATUS_CHANGED` (agente.md secao 24). Mapeamento de `callcenter::info`/`sofia::*` com nomes
é best-effort (nomes de campo inferidos da documentação, não testados de campo confirmados contra uma fila real na fase Realtime Monitoring
revisar na fase Queues/Agents). ver "Achados" abaixo e docs/AGENTS.md.
## Achados durante os testes ## Achados durante os testes
@@ -41,6 +41,19 @@ reagir a `connect`/`reconnecting`/`error` e resubscrever a cada `connect`
3. O logger JSON de `packages/shared` quebrava (`TypeError: Do not know how 3. O logger JSON de `packages/shared` quebrava (`TypeError: Do not know how
to serialize a BigInt`) porque a lib `esl` usa `bigint` nos campos de to serialize a BigInt`) porque a lib `esl` usa `bigint` nos campos de
estatística de erro. Corrigido com um `replacer` no `JSON.stringify`. estatística de erro. Corrigido com um `replacer` no `JSON.stringify`.
4. **Bug real, só descoberto na fase Realtime Monitoring**: nenhum evento
CUSTOM (`sofia::gateway_state`, `sofia::register`, `callcenter::info`)
nunca chegou nesta sessão até então, apesar da subscrição incluir
`"CUSTOM"` na lista de `SUBSCRIBED_EVENTS`. Causa: `event_json(...events)`
manda `"CUSTOM"` como último token do comando `event json`, sem nenhum
subclass depois — o mod_event_socket do FreeSWITCH exige que os
subclasses venham imediatamente depois do token `CUSTOM` no mesmo
comando pra serem entregues. Corrigido: `SUBSCRIBED_EVENTS` agora termina
em `"CUSTOM", "sofia::register", "sofia::unregister", "sofia::expire",
"sofia::gateway_state", "callcenter::info"`, e o loop que registra
listeners `.on()` só usa os nomes "planos" (`PLAIN_EVENTS`) mais um único
`.on("CUSTOM", ...)` — os nomes de subclass nunca viram listener, só
compõem o comando de subscrição.
## Eventos consumidos e publicados ## Eventos consumidos e publicados

View File

@@ -29,6 +29,16 @@ módulos). Em vez disso, `b2bcall-fs-config`:
2. Roda `sofia profile external rescan` via ESL — relê os gateways sem 2. Roda `sofia profile external rescan` via ESL — relê os gateways sem
derrubar chamadas em andamento (diferente de `restart`). 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 ## Gatilho de sincronização
`apps/api` não roda no Docker (ainda está no host) e `fs-config` não expõe `apps/api` não roda no Docker (ainda está no host) e `fs-config` não expõe
@@ -82,12 +92,23 @@ Criei um trunk de teste apontando pra um host inexistente
## O que falta ## O que falta
- Propagação de `GATEWAY_UP`/`DOWN` → `Trunk.status` não confirmada com
evento real disparado por uma mudança de estado ao vivo nesta sessão de
testes (o gateway ficou em `FAIL_WAIT` por falha de DNS, que pode não
disparar o mesmo ciclo de eventos que uma rejeição SIP normal) — vale
reverificar com um destino que responda de verdade (outro FreeSWITCH, por
exemplo) antes de confiar nisso em produção.
- Quota de troncos (`max_trunks`, secao 59) — depende de Plans/Entitlements. - Quota de troncos (`max_trunks`, secao 59) — depende de Plans/Entitlements.
- `GET /trunks` não mostra `sofia status gateway` ao vivo, só o último - `GET /trunks` não mostra `sofia status gateway` ao vivo, só o último
status conhecido no banco — suficiente por enquanto, sem WebSocket ainda. status conhecido no banco — resolvido na fase Realtime Monitoring via
WebSocket (ver docs/REALTIME.md).
## Correção: `GATEWAY_UP`/`DOWN` → `Trunk.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.

View File

@@ -22,10 +22,12 @@ function baseFields(headers: RawHeaders, extra: Record<string, unknown> = {}) {
* B2BCall (agente.md secao 24). Retorna `null` quando o evento não tem * B2BCall (agente.md secao 24). Retorna `null` quando o evento não tem
* mapeamento definido ainda — o chamador decide se loga em debug ou ignora. * mapeamento definido ainda — o chamador decide se loga em debug ou ignora.
* *
* CUSTOM/callcenter::info: os nomes exatos de campo (`CC-Action`, * CUSTOM/callcenter::info: nomes de campo confirmados contra uma fila real
* `CC-Agent-Status`, ...) foram inferidos da documentação do mod_callcenter, * (fase Realtime Monitoring, originate null/dummy &callcenter(...) com um
* não testados contra uma fila real ainda (isso só será possível na fase * agente logado) — `CC-Agent-Status` (usado numa versão anterior) não
* Queues/Agents). Revisar então. * existe; o campo real é `CC-Agent-State`. CC-Action observados nesse teste:
* `agent-offering`, `agent-state-change`, `bridge-agent-fail`,
* `member-queue-end`, `members-count`.
*/ */
export function normalizeEslEvent( export function normalizeEslEvent(
eventName: string | undefined, eventName: string | undefined,
@@ -110,14 +112,50 @@ function normalizeCustomEvent(headers: RawHeaders, occurredAt: string): Normaliz
} }
case "callcenter::info": { case "callcenter::info": {
if (headers["CC-Action"] === "agent-state-change") { switch (headers["CC-Action"]) {
return emit("AGENT_STATUS_CHANGED", { case "agent-state-change":
queue: headers["CC-Queue"], return emit("AGENT_STATUS_CHANGED", {
agent: headers["CC-Agent"], agent: headers["CC-Agent"],
status: headers["CC-Agent-Status"], state: headers["CC-Agent-State"],
}); });
case "agent-offering":
return emit("AGENT_OFFERED_CALL", {
queue: headers["CC-Queue"],
agent: headers["CC-Agent"],
memberUuid: headers["CC-Member-UUID"],
memberSessionUuid: headers["CC-Member-Session-UUID"],
});
case "bridge-agent-fail":
return emit("AGENT_BRIDGE_FAILED", {
queue: headers["CC-Queue"],
agent: headers["CC-Agent"],
hangupCause: headers["CC-Hangup-Cause"],
memberUuid: headers["CC-Member-UUID"],
memberSessionUuid: headers["CC-Member-Session-UUID"],
});
case "members-count":
return emit("QUEUE_MEMBER_COUNT", {
queue: headers["CC-Queue"],
count: headers["CC-Count"] ? Number(headers["CC-Count"]) : undefined,
});
case "member-queue-end":
return emit("QUEUE_MEMBER_LEFT", {
queue: headers["CC-Queue"],
memberUuid: headers["CC-Member-UUID"],
memberSessionUuid: headers["CC-Member-Session-UUID"],
joinedAt: headers["CC-Member-Joined-Time"],
leftAt: headers["CC-Member-Leaving-Time"],
cause: headers["CC-Cause"],
cancelReason: headers["CC-Cancel-Reason"],
});
default:
return null;
} }
return null;
} }
default: default:

View File

@@ -53,6 +53,10 @@ export type NormalizedEventType =
| "EXTENSION_REGISTERED" | "EXTENSION_REGISTERED"
| "EXTENSION_UNREGISTERED" | "EXTENSION_UNREGISTERED"
| "AGENT_STATUS_CHANGED" | "AGENT_STATUS_CHANGED"
| "AGENT_OFFERED_CALL"
| "AGENT_BRIDGE_FAILED"
| "QUEUE_MEMBER_COUNT"
| "QUEUE_MEMBER_LEFT"
| "GATEWAY_UP" | "GATEWAY_UP"
| "GATEWAY_DOWN" | "GATEWAY_DOWN"
| "BACKGROUND_JOB_COMPLETED"; | "BACKGROUND_JOB_COMPLETED";