import { access, rm } from "node:fs/promises"; import { join } from "node:path"; import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { getObjectStorageProvider, buildRecordingObjectKey } from "@b2bcall/storage"; import { createLogger } from "@b2bcall/shared"; import { enqueueTranscriptionJobIfEligible } from "./ai-trigger"; const logger = createLogger("b2bcall-fs-events"); const SPOOL_DIR = process.env.RECORDINGS_SPOOL_DIR ?? "/recordings"; const STORAGE_KIND = (process.env.STORAGE_PROVIDER ?? "local").toUpperCase() as "LOCAL" | "S3"; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } async function fileExists(path: string): Promise { try { await access(path); return true; } catch { return false; } } /** * Sobe pro object storage a gravação de uma chamada que acabou de * terminar (agente.md secao 90-93) — chamado a partir de CALL_ENDED em * main.ts, sempre depois do CDR já ter persistido `Call` (precisa de * `Call.createdAt`/`talkTime` pra `recordedAt`/`durationSeconds`). * * `originateSimulatedAnswerLeg`/`originateRealPstnLeg` * (apps/predictive-dialer) só setam `execute_on_answer='record_session * ...'` quando `Campaign.recordingEnabled` é true — pra qualquer outra * chamada, o arquivo simplesmente não existe, e essa função não faz nada * (checagem por existência de arquivo, não por reconsultar a campanha). */ export async function uploadRecordingIfPresent(tenantId: string, callId: string): Promise { const spoolPath = join(SPOOL_DIR, `${callId}.wav`); // record_session termina de gravar no CHANNEL_HANGUP_COMPLETE, mas pode // levar um instante pra flush terminar — tenta algumas vezes antes de // desistir, em vez de perder a gravação por uma corrida. let exists = await fileExists(spoolPath); for (let attempt = 0; !exists && attempt < 5; attempt++) { await sleep(300); exists = await fileExists(spoolPath); } if (!exists) return; const prisma = getPrismaClient(); try { const call = await withTenantContext(prisma, tenantId, (tx) => tx.call.findUniqueOrThrow({ where: { id: callId } })); const tenant = await prisma.tenant.findUniqueOrThrow({ where: { id: tenantId }, include: { plan: true } }); const recordedAt = call.createdAt; const objectKey = buildRecordingObjectKey(tenantId, callId, recordedAt); const storage = getObjectStorageProvider(); const { sizeBytes, checksum } = await storage.putObject(objectKey, spoolPath); const retentionDays = tenant.plan.recordingRetentionDays; const retentionUntil = retentionDays ? new Date(recordedAt.getTime() + retentionDays * 24 * 60 * 60 * 1000) : null; await withTenantContext(prisma, tenantId, (tx) => tx.recording.create({ data: { tenantId, callId, storageProvider: STORAGE_KIND, objectKey, format: "wav", durationSeconds: call.talkTime ?? call.durationSeconds, channels: 2, sizeBytes, checksum, recordedAt, retentionUntil, }, }), ); await rm(spoolPath, { force: true }); logger.info("gravacao enviada pro object storage", { callId, objectKey, sizeBytes }); await enqueueTranscriptionJobIfEligible(tenantId, callId, tenant, call.queueId, call.campaignId); } catch (err) { logger.error("falha ao processar gravacao", { error: String(err), callId }); await withTenantContext(prisma, tenantId, (tx) => tx.recording .create({ data: { tenantId, callId, storageProvider: STORAGE_KIND, objectKey: "", recordedAt: new Date(), status: "FAILED", }, }) .catch(() => undefined), ); } }