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:
@@ -3,7 +3,7 @@
|
||||
# Build a partir da raiz do monorepo (context: .), só com os pacotes que
|
||||
# este serviço realmente usa. Roda via `tsx` direto (sem etapa de `tsc build`
|
||||
# nem dist/): os pacotes internos (@b2bcall/shared, @b2bcall/telephony) ainda
|
||||
# não tem pipeline de build próprio — ver docs/FREESWITCH_EVENTS.md.
|
||||
# não tem pipeline de build próprio — ver docs/EVENT_SOCKET.md.
|
||||
FROM node:22-slim
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@11.24.0 --activate
|
||||
@@ -14,10 +14,15 @@ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
|
||||
COPY packages/types packages/types
|
||||
COPY packages/shared packages/shared
|
||||
COPY packages/telephony packages/telephony
|
||||
COPY packages/database packages/database
|
||||
COPY apps/freeswitch-events apps/freeswitch-events
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter @b2bcall/freeswitch-events...
|
||||
|
||||
# `prisma generate` só precisa do schema, não de uma conexão real.
|
||||
ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder"
|
||||
RUN pnpm --filter @b2bcall/database exec prisma generate
|
||||
|
||||
WORKDIR /repo/apps/freeswitch-events
|
||||
|
||||
CMD ["pnpm", "exec", "tsx", "src/main.ts"]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@b2bcall/database": "workspace:*",
|
||||
"@b2bcall/shared": "workspace:*",
|
||||
"@b2bcall/telephony": "workspace:*",
|
||||
"esl": "11.2.1",
|
||||
|
||||
@@ -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();
|
||||
|
||||
48
apps/freeswitch-events/src/trunk-status.ts
Normal file
48
apps/freeswitch-events/src/trunk-status.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user