Pedido do usuário: "antes de ir para IVR no monitoramento tem que mostrar quantos ramais estão online e o status de cada ramal criado". apps/api roda no host e não alcança o ESL do FreeSWITCH diretamente (freeswitch:8021 só existe na rede interna do Docker) — mesmo padrão já documentado em platform-freeswitch.controller.ts. Por isso, seguido o mesmo caminho já usado por Trunk.status: apps/freeswitch-events (conexão ESL permanente) já consumia sofia::register/unregister/expire pra outros fins, então virou a fonte de verdade — grava `Extension.registeredAt` a cada evento, mais uma reconciliação completa (`show registrations`) ao conectar/reconectar no ESL pra não ficar com dado desatualizado se o serviço caiu no meio de uma sessão de registro. GET /extensions agora devolve `registeredAt`; a página /app/monitoramento ganhou um painel "Ramais" com status ao vivo (online/offline, desde quando, grupo de captura) e um instrumento "Ramais online: X de Y" — a mesma conexão SSE que já existia só precisou aprender a atualizar esse novo estado a partir dos eventos EXTENSION_REGISTERED/UNREGISTERED, que já estavam sendo publicados e só apareciam no log de eventos. Testado: reconciliação confirmada rodando no container real (2 ramais já registrados antes do restart do fs-events foram marcados online corretamente); GET /extensions confirmado devolvendo registeredAt (null num ramal recém-criado, sem registro). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
204 lines
7.9 KiB
TypeScript
204 lines
7.9 KiB
TypeScript
import Redis from "ioredis";
|
|
import type { FreeSwitchEventData } from "esl";
|
|
import { FreeSwitchTelephonyProvider, normalizeEslEvent } from "@b2bcall/telephony";
|
|
import { createLogger } from "@b2bcall/shared";
|
|
import { updateTrunkStatusFromGatewayEvent } from "./trunk-status";
|
|
import { updateExtensionRegistrationStatus, reconcileExtensionRegistrations } from "./extension-status";
|
|
import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve";
|
|
import { persistCallEvent } from "./cdr";
|
|
import { uploadRecordingIfPresent } from "./recording";
|
|
|
|
const logger = createLogger("b2bcall-fs-events");
|
|
|
|
const REDIS_CHANNEL = "b2bcall:events";
|
|
|
|
// Eventos consumidos (agente.md secao 23). HEARTBEAT só é logado, nunca
|
|
// normalizado/publicado — não representa uma chamada.
|
|
const PLAIN_EVENTS = [
|
|
"HEARTBEAT",
|
|
"CHANNEL_CREATE",
|
|
"CHANNEL_ORIGINATE",
|
|
"CHANNEL_PROGRESS",
|
|
"CHANNEL_PROGRESS_MEDIA",
|
|
"CHANNEL_ANSWER",
|
|
"CHANNEL_BRIDGE",
|
|
"CHANNEL_UNBRIDGE",
|
|
"CHANNEL_HANGUP",
|
|
"CHANNEL_HANGUP_COMPLETE",
|
|
"CHANNEL_DESTROY",
|
|
"CHANNEL_STATE",
|
|
"CHANNEL_CALLSTATE",
|
|
"BACKGROUND_JOB",
|
|
] 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) {
|
|
throw new Error(`${name} nao definido no ambiente`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function main() {
|
|
const redis = new Redis(requireEnv("REDIS_URL"));
|
|
redis.on("error", (err) => logger.error("erro na conexao com Redis", { error: String(err) }));
|
|
|
|
const provider = new FreeSwitchTelephonyProvider({
|
|
host: requireEnv("ESL_HOST"),
|
|
port: Number(process.env.ESL_PORT ?? 8021),
|
|
password: requireEnv("ESL_PASSWORD"),
|
|
logger: {
|
|
debug: () => {},
|
|
info: (msg) => logger.debug(msg),
|
|
error: (msg, data) => logger.error(msg, { detail: data }),
|
|
},
|
|
});
|
|
|
|
const client = provider.eslClient;
|
|
|
|
client.on("connect", async (call) => {
|
|
logger.info("conectado ao FreeSWITCH via ESL");
|
|
|
|
// Re-executado a cada reconexao (secao 195: "resubscribe" apos reconectar).
|
|
// 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"));
|
|
|
|
// 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));
|
|
|
|
reconcileExtensionRegistrations(provider).catch((err) => {
|
|
logger.error("falha na reconciliacao de registrations de ramais", { error: String(err) });
|
|
});
|
|
});
|
|
|
|
client.on("reconnecting", (retryMs) => {
|
|
logger.warn("reconectando ao FreeSWITCH apos perda de conexao", { retryMs });
|
|
});
|
|
|
|
client.on("error", (err) => {
|
|
logger.error("erro no client ESL", { error: String(err) });
|
|
});
|
|
|
|
// callcenter::info/sofia::gateway_state não carregam b2bcall_tenant_id
|
|
// (só existe como channel variable a partir do Predictive Engine) — pra
|
|
// esses tipos, o tenant é resolvido pelo id do agente/fila embutido no
|
|
// nome FreeSWITCH (tenant-resolve.ts). Sem isso, o WebSocket multi-tenant
|
|
// (docs/REALTIME.md) não teria como saber pra qual tenant emitir.
|
|
async function resolveTenantId(normalized: ReturnType<typeof normalizeEslEvent>): Promise<string | undefined> {
|
|
if (!normalized || normalized.tenantId) return normalized?.tenantId;
|
|
switch (normalized.type) {
|
|
case "AGENT_STATUS_CHANGED":
|
|
case "AGENT_OFFERED_CALL":
|
|
case "AGENT_BRIDGE_FAILED":
|
|
return resolveTenantIdForAgent(normalized.data.agent as string | undefined);
|
|
case "QUEUE_MEMBER_COUNT":
|
|
case "QUEUE_MEMBER_LEFT":
|
|
return resolveTenantIdForQueue(normalized.data.queue as string | undefined);
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async function handleEvent(eventName: string, raw: FreeSwitchEventData) {
|
|
// Para eventos JSON (event_json), os campos reais do evento FreeSWITCH
|
|
// (Event-Name, Unique-ID, Event-Subclass, variable_*, ...) vem em
|
|
// `raw.body`; `raw.headers` são só os headers do protocolo ESL.
|
|
const normalized = normalizeEslEvent(eventName, raw.body);
|
|
if (!normalized) {
|
|
logger.debug("evento sem mapeamento normalizado", { eventName });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
normalized.tenantId = await resolveTenantId(normalized);
|
|
} catch (err) {
|
|
logger.error("falha ao resolver tenant do evento", { error: String(err), type: normalized.type });
|
|
}
|
|
|
|
redis.publish(REDIS_CHANNEL, JSON.stringify(normalized)).catch((err) => {
|
|
logger.error("falha ao publicar evento normalizado no Redis", { error: String(err) });
|
|
});
|
|
|
|
// .then() em vez de esperar aqui (await bloquearia o processamento do
|
|
// próximo evento ESL) — mas o upload da gravação só roda DEPOIS do
|
|
// persistCallEvent terminar de verdade, nunca em paralelo com ele:
|
|
// uploadRecordingIfPresent lê Call.talkTime/durationSeconds, que é
|
|
// exatamente o que persistCallEvent acabou de calcular no CALL_ENDED
|
|
// (mesma classe de corrida já corrigida uma vez neste arquivo, ver
|
|
// docs/CDR.md — aqui evitada por ordenação, não por retry).
|
|
persistCallEvent(normalized)
|
|
.then(() => {
|
|
if (normalized.type === "CALL_ENDED" && normalized.tenantId && normalized.callUuid) {
|
|
return uploadRecordingIfPresent(normalized.tenantId, normalized.callUuid);
|
|
}
|
|
return undefined;
|
|
})
|
|
.catch((err) => {
|
|
logger.error("falha ao persistir CDR/gravacao", { error: String(err), type: normalized.type });
|
|
});
|
|
|
|
logger.info(`evento: ${normalized.type}`, {
|
|
callUuid: normalized.callUuid,
|
|
tenantId: normalized.tenantId,
|
|
});
|
|
|
|
if (normalized.type === "GATEWAY_UP" || normalized.type === "GATEWAY_DOWN") {
|
|
const gateway = normalized.data.gateway as string | undefined;
|
|
const state = normalized.data.state as string | undefined;
|
|
updateTrunkStatusFromGatewayEvent(gateway, state).catch((err) => {
|
|
logger.error("falha ao atualizar status do trunk", { error: String(err), gateway });
|
|
});
|
|
}
|
|
|
|
if (normalized.type === "EXTENSION_REGISTERED" || normalized.type === "EXTENSION_UNREGISTERED") {
|
|
const user = normalized.data.user as string | undefined;
|
|
const host = normalized.data.host as string | undefined;
|
|
updateExtensionRegistrationStatus(user, host, normalized.type === "EXTENSION_REGISTERED").catch((err) => {
|
|
logger.error("falha ao atualizar status de registro do ramal", { error: String(err), user, host });
|
|
});
|
|
}
|
|
}
|
|
|
|
provider.connect();
|
|
|
|
const shutdown = async () => {
|
|
logger.info("encerrando b2bcall-fs-events");
|
|
await provider.disconnect();
|
|
redis.disconnect();
|
|
process.exit(0);
|
|
};
|
|
process.on("SIGTERM", shutdown);
|
|
process.on("SIGINT", shutdown);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
logger.error("falha fatal ao iniciar b2bcall-fs-events", { error: String(err) });
|
|
process.exit(1);
|
|
});
|