feat: implement Trunks with real FreeSWITCH gateway sync

- trunks table (tenant-scoped, RLS): host/proxy/realm, register,
  username/password_enc (AES-256-GCM), dtmf_mode, ping, transport, and a
  status/status_updated_at pair meant to be driven by FreeSWITCH events
- apps/api/src/trunks: CRUD (POST/GET/GET:id/DELETE), same RBAC/tenant
  pattern as Extensions, password never exposed in any GET
- packages/telephony: buildGatewayXml() generates a Sofia gateway XML file
- b2bcall-fs-config now writes sip_profiles/external/<trunk_id>.xml (shared
  Docker volume with FreeSWITCH -- the vanilla external profile already
  includes external/*.xml) and runs 'sofia profile external rescan' over
  ESL; syncs on boot and on demand via Redis pub/sub
  (b2bcall:trunks:sync), since apps/api runs on the host and fs-config has
  no port published to reach directly
- added FreeSwitchTelephonyProvider.waitUntilConnected() to fix a startup
  race: the first sync ran before the ESL connection had settled, logging
  a harmless but noisy error
- verified end-to-end with a fake host: create trunk -> gateway file
  written -> FreeSWITCH shows the real gateway (FAIL_WAIT, expected) ->
  delete -> file removed (cleanup also correctly swept the stale
  'example.com' gateway that had been copied into the volume from the
  vanilla image)
- apps/freeswitch-events/src/trunk-status.ts: written to update Trunk.status
  from sofia::gateway_state events, using the same normalizeEslEvent path
  already proven for CHANNEL_* events

- KNOWN GAP, documented rather than glossed over: monitored fs-events for
  ~90s while the gateway visibly transitioned states in FreeSWITCH
  (FAIL_WAIT/DOWN) and no sofia::gateway_state event was observed arriving.
  CUSTOM/sofia::* events have not actually been proven working end-to-end
  in this session -- only CHANNEL_* events have been. Needs verification
  against a real SIP target before the status auto-update can be trusted
  in production. See docs/TRUNKS.md and TODO.md.

- docs/TRUNKS.md
This commit is contained in:
2026-08-28 08:01:56 -03:00
parent c03c6d4eaa
commit 4c638ad496
20 changed files with 896 additions and 6 deletions

View File

@@ -2,6 +2,7 @@ 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";
const logger = createLogger("b2bcall-fs-events");
@@ -92,6 +93,14 @@ async function main() {
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 });
});
}
}
provider.connect();

View File

@@ -0,0 +1,48 @@
import { getPrismaClient, withTenantContext, type TrunkStatus } from "@b2bcall/database";
import { createLogger } from "@b2bcall/shared";
const logger = createLogger("b2bcall-fs-events");
const GATEWAY_STATE_MAP: Record<string, TrunkStatus> = {
UP: "UP",
REGED: "REGISTERED",
TRYING: "TRYING",
REGISTER: "TRYING",
FAILED: "FAILED",
FAIL_WAIT: "FAILED",
DOWN: "DOWN",
NOREG: "UNREGISTERED",
UNREGED: "UNREGISTERED",
};
/**
* O gateway name no FreeSWITCH é o Trunk.id (ver buildGatewayXml em
* packages/telephony) — mas RLS exige contexto de tenant, e o evento não
* diz de qual tenant é. Como o número de tenants é pequeno, procura em
* cada um até achar (mesmo padrão de fan-out usado em fs-config para
* sincronizar trunks).
*/
export async function updateTrunkStatusFromGatewayEvent(
gatewayName: string | undefined,
rawState: string | undefined,
): Promise<void> {
if (!gatewayName || !rawState) return;
const status = GATEWAY_STATE_MAP[rawState.toUpperCase()] ?? "UNKNOWN";
const prisma = getPrismaClient();
const tenants = await prisma.tenant.findMany({ where: { status: "ACTIVE" }, select: { id: true } });
for (const tenant of tenants) {
const result = await withTenantContext(prisma, tenant.id, (tx) =>
tx.trunk.updateMany({
where: { id: gatewayName, tenantId: tenant.id },
data: { status, statusUpdatedAt: new Date() },
}),
);
if (result.count > 0) {
logger.info("status do trunk atualizado", { trunkId: gatewayName, status, rawState });
return;
}
}
}