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)); 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): Promise { 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); });