diff --git a/apps/freeswitch-config/src/trunk-sync.ts b/apps/freeswitch-config/src/trunk-sync.ts index dab0818..8fe09be 100644 --- a/apps/freeswitch-config/src/trunk-sync.ts +++ b/apps/freeswitch-config/src/trunk-sync.ts @@ -73,13 +73,12 @@ export async function syncTrunks(): Promise { await mkdir(GATEWAYS_DIR, { recursive: true }); const existing = await readdir(GATEWAYS_DIR).catch(() => [] as string[]); const wanted = new Set(files.map((f) => f.name)); + const removed = existing.filter((f) => !wanted.has(f)); - await Promise.all( - existing.filter((f) => !wanted.has(f)).map((f) => rm(join(GATEWAYS_DIR, f))), - ); + await Promise.all(removed.map((f) => rm(join(GATEWAYS_DIR, f)))); 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 { const provider = getProvider(); @@ -90,6 +89,18 @@ export async function syncTrunks(): Promise { logger.warn("ESL ainda nao conectado, rescan sera tentado na proxima sincronizacao"); 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 " 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 // em andamento (diferente de "restart"). const result = await provider.runApi("sofia profile external rescan"); diff --git a/apps/freeswitch-events/src/main.ts b/apps/freeswitch-events/src/main.ts index 3ebfde5..409101b 100644 --- a/apps/freeswitch-events/src/main.ts +++ b/apps/freeswitch-events/src/main.ts @@ -10,7 +10,7 @@ const REDIS_CHANNEL = "b2bcall:events"; // Eventos consumidos (agente.md secao 23). HEARTBEAT só é logado, nunca // normalizado/publicado — não representa uma chamada. -const SUBSCRIBED_EVENTS = [ +const PLAIN_EVENTS = [ "HEARTBEAT", "CHANNEL_CREATE", "CHANNEL_ORIGINATE", @@ -25,9 +25,23 @@ const SUBSCRIBED_EVENTS = [ "CHANNEL_STATE", "CHANNEL_CALLSTATE", "BACKGROUND_JOB", - "CUSTOM", ] 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 { const value = process.env[name]; if (!value) { @@ -57,14 +71,22 @@ async function main() { logger.info("conectado ao FreeSWITCH via ESL"); // 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)); 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; call.on(eventName, (raw) => handleEvent(eventName, raw)); } + call.on("CUSTOM", (raw) => handleEvent("CUSTOM", raw)); }); client.on("reconnecting", (retryMs) => { diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 0d9c587..8eabc52 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -96,14 +96,43 @@ FreeSWITCH (`agent list`). ## O que falta -- Estados derivados de chamada (RINGING, IN_CALL, WRAP_UP, RESERVED) — - dependem de `callcenter::info` (CUSTOM event), que **ainda não foi - provado funcionando** nesta sessão (mesma lacuna de `sofia::gateway_state` - documentada em docs/TRUNKS.md). Não implementado; precisa de uma chamada - real passando pela fila pra testar. +- 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. -- Monitoramento de filas/ramais em tempo real (secao 54-55) — depende de - WebSocket multi-tenant. - 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`. diff --git a/docs/EVENT_SOCKET.md b/docs/EVENT_SOCKET.md index ef8ad67..64d6fdf 100644 --- a/docs/EVENT_SOCKET.md +++ b/docs/EVENT_SOCKET.md @@ -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 — não existe nenhuma (fase Queues). - `normalizeEslEvent()`: traduz eventos ESL crus pro vocabulário interno - (agente.md secao 24). Mapeamento de `callcenter::info` → `AGENT_STATUS_CHANGED` - é best-effort (nomes de campo inferidos da documentação, não testados — - revisar na fase Queues/Agents). + (agente.md secao 24). Mapeamento de `callcenter::info`/`sofia::*` com nomes + de campo confirmados contra uma fila real na fase Realtime Monitoring — + ver "Achados" abaixo e docs/AGENTS.md. ## 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 to serialize a BigInt`) porque a lib `esl` usa `bigint` nos campos de 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 diff --git a/docs/TRUNKS.md b/docs/TRUNKS.md index dbed45a..28b5c54 100644 --- a/docs/TRUNKS.md +++ b/docs/TRUNKS.md @@ -29,6 +29,16 @@ módulos). Em vez disso, `b2bcall-fs-config`: 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 ` 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 @@ -82,12 +92,23 @@ Criei um trunk de teste apontando pra um host inexistente ## 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. - `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. diff --git a/packages/telephony/src/normalize-event.ts b/packages/telephony/src/normalize-event.ts index 0987a56..aaffe6e 100644 --- a/packages/telephony/src/normalize-event.ts +++ b/packages/telephony/src/normalize-event.ts @@ -22,10 +22,12 @@ function baseFields(headers: RawHeaders, extra: Record = {}) { * 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. * - * CUSTOM/callcenter::info: os nomes exatos de campo (`CC-Action`, - * `CC-Agent-Status`, ...) foram inferidos da documentação do mod_callcenter, - * não testados contra uma fila real ainda (isso só será possível na fase - * Queues/Agents). Revisar então. + * CUSTOM/callcenter::info: nomes de campo confirmados contra uma fila real + * (fase Realtime Monitoring, originate null/dummy &callcenter(...) com um + * agente logado) — `CC-Agent-Status` (usado numa versão anterior) nã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( eventName: string | undefined, @@ -110,14 +112,50 @@ function normalizeCustomEvent(headers: RawHeaders, occurredAt: string): Normaliz } case "callcenter::info": { - if (headers["CC-Action"] === "agent-state-change") { - return emit("AGENT_STATUS_CHANGED", { - queue: headers["CC-Queue"], - agent: headers["CC-Agent"], - status: headers["CC-Agent-Status"], - }); + switch (headers["CC-Action"]) { + case "agent-state-change": + return emit("AGENT_STATUS_CHANGED", { + agent: headers["CC-Agent"], + 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: diff --git a/packages/telephony/src/types.ts b/packages/telephony/src/types.ts index c9ab2d4..86ed22d 100644 --- a/packages/telephony/src/types.ts +++ b/packages/telephony/src/types.ts @@ -53,6 +53,10 @@ export type NormalizedEventType = | "EXTENSION_REGISTERED" | "EXTENSION_UNREGISTERED" | "AGENT_STATUS_CHANGED" + | "AGENT_OFFERED_CALL" + | "AGENT_BRIDGE_FAILED" + | "QUEUE_MEMBER_COUNT" + | "QUEUE_MEMBER_LEFT" | "GATEWAY_UP" | "GATEWAY_DOWN" | "BACKGROUND_JOB_COMPLETED";