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:
@@ -73,13 +73,12 @@ export async function syncTrunks(): Promise<void> {
|
||||
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<void> {
|
||||
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 <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
|
||||
// em andamento (diferente de "restart").
|
||||
const result = await provider.runApi("sofia profile external rescan");
|
||||
|
||||
@@ -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<typeof call.event_json>));
|
||||
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user