feat(recording): gravacao de chamadas + object storage abstraction

Fecha agente.md secao 90-94. A especificacao lista "Recording" e "Object
Storage" como dois passos separados na ordem de implementacao (secao
232), mas ficaram numa unica fase — sao acoplados o suficiente (Recording
precisa de um lugar pra guardar bytes) pra fazer sentido construir juntos.

## packages/storage — ObjectStorageProvider (secao 92)

Abstracao pequena: putObject/getObjectStream/deleteObject. Dois backends:
LocalObjectStorageProvider (filesystem, com checagem de path traversal
mesmo a key sendo sempre montada no servidor) e S3ObjectStorageProvider
(@aws-sdk/client-s3, preparado pra AWS S3 e MinIO via endpoint/
forcePathStyle customizaveis — nunca exercitado nesta sessao, sem
servidor S3 disponivel neste laboratorio). Escolhido por STORAGE_PROVIDER
env.

buildRecordingObjectKey (secao 93):
tenants/{tenant_id}/recordings/YYYY/MM/DD/{call_id}.wav, sempre montada
no servidor a partir de dados confiaveis.

## Bind mounts, nao volumes nomeados

/recordings e /data/object-storage usam bind mount pra um diretorio real
do host — apps/api roda no host, nao em Docker, e precisa enxergar os
mesmos arquivos que fs-events escreve. LOCAL_STORAGE_ROOT tem valores
diferentes por ambiente (mesmo padrao ja usado pra REDIS_URL).

## Quem grava: apps/predictive-dialer

So' chamadas originadas pelo discador com Campaign.recordingEnabled sao
gravadas nesta fase (unico caminho de originate que o sistema controla
hoje). RECORD_STEREO=true + execute_on_answer='record_session ...'
adicionados ao originate; origination_uuid pre-gerado (em vez de deixar o
provider sortear) porque o path de gravacao precisa dele antes do
comando de originate ser montado — o mesmo uuid vira Call.id no CDR.

## Quem sobe: apps/freeswitch-events/src/recording.ts

Em CALL_ENDED, encadeado depois do persistCallEvent terminar (nao em
paralelo) — uploadRecordingIfPresent le Call.talkTime/durationSeconds,
que e' exatamente o que persistCallEvent acabou de calcular no mesmo
evento (mesma classe de corrida ja corrigida uma vez na fase CDR, aqui
evitada por ordenacao). Sobe pro storage, cria Recording (retentionUntil
a partir de Plan.recordingRetentionDays), apaga o spool local.

## API + retencao

GET /recordings, GET /recordings/:id, GET /recordings/:id/audio (stream
autenticado, nunca URL direta pro storage). runRetentionSweep (secao 94)
no boot do apps/api + a cada hora — apaga o objeto, marca status=DELETED
(linha nunca apagada, fica como auditoria).

## Bug real achado testando esta fase

Recording.sizeBytes (BigInt) quebrava GET /recordings com 500 — Fastify
nao serializa BigInt nativamente (mesma classe de bug ja corrigida uma
vez no logger, fase Event Socket). Corrigido convertendo pra number na
resposta.

Verificado ponta a ponta: gravacao real criada (RIFF WAVE, PCM 16-bit,
ESTEREO 8000Hz — RECORD_STEREO confirmado), upload com path exato da
secao 93, download via API com md5 identico ao objeto original, varredura
de retencao apagando objeto + status DELETED + list/download bloqueados
depois. typecheck do workspace inteiro limpo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
2026-08-28 14:36:03 -03:00
parent 56499f4b99
commit c24a86776c
26 changed files with 1196 additions and 7 deletions

View File

@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import type { FreeSwitchTelephonyProvider } from "@b2bcall/telephony";
export interface OriginateIdentifiers {
@@ -20,6 +21,34 @@ function channelVars(ids: OriginateIdentifiers): Record<string, string> {
};
}
const RECORDINGS_SPOOL_DIR = process.env.RECORDINGS_SPOOL_DIR ?? "/recordings";
/**
* Gravação (agente.md secao 90-91): quando habilitada, grava pro spool
* local do FreeSWITCH via `execute_on_answer='record_session ...'` — roda
* assim que a chamada atende, antes da aplicação principal (`&callcenter`)
* começar. `RECORD_STEREO=true` grava canal A (cliente) e canal B (agente)
* separados, quando tecnicamente adequado (secao 91) — o mod_callcenter
* bridgeia um leg só por vez, então isso vale pra qualquer chamada que
* chega a conectar com um agente.
*
* O nome do arquivo usa `origination_uuid` pré-gerado aqui (em vez de
* deixar o provider sortear um) — precisamos saber o uuid ANTES de montar
* o comando de originate, já que o path de gravação entra no mesmo
* comando. Esse uuid vira `Call.id` no CDR (fase CDR: `Call.id` = o
* próprio `freeswitch_uuid`), então o arquivo gravado e o registro do
* `Call` sempre têm o mesmo nome — é assim que
* `apps/freeswitch-events/src/recording.ts` acha o arquivo certo.
*/
function recordingVars(originationUuid: string, recordingEnabled: boolean): Record<string, string> {
if (!recordingEnabled) return {};
const path = `${RECORDINGS_SPOOL_DIR}/${originationUuid}.wav`;
return {
RECORD_STEREO: "true",
execute_on_answer: `record_session ${path}`,
};
}
/**
* Perna "atendida" sintética (modo simulação): quando o outcome sorteado é
* ANSWERED, origina uma chamada de verdade no FreeSWITCH (`null/dummy`,
@@ -33,12 +62,18 @@ export async function originateSimulatedAnswerLeg(
ids: OriginateIdentifiers,
queueId: string,
domain: string,
recordingEnabled: boolean,
): Promise<{ uuid: string }> {
const originationUuid = randomUUID();
return provider.originate({
destination: "null/dummy",
application: "callcenter",
applicationArgs: `${queueId}@${domain}`,
channelVariables: channelVars(ids),
channelVariables: {
...channelVars(ids),
...recordingVars(originationUuid, recordingEnabled),
origination_uuid: originationUuid,
},
});
}
@@ -61,13 +96,19 @@ export async function originateRealPstnLeg(
callerIdName?: string;
callerIdNumber?: string;
ringTimeoutSeconds: number;
recordingEnabled: boolean;
},
): Promise<{ uuid: string }> {
const originationUuid = randomUUID();
return provider.originate({
destination: `sofia/gateway/${params.trunkId}/${params.phoneNumber}`,
application: "callcenter",
applicationArgs: `${params.queueId}@${params.domain}`,
channelVariables: channelVars(ids),
channelVariables: {
...channelVars(ids),
...recordingVars(originationUuid, params.recordingEnabled),
origination_uuid: originationUuid,
},
callerIdName: params.callerIdName,
callerIdNumber: params.callerIdNumber,
timeoutSeconds: params.ringTimeoutSeconds,

View File

@@ -176,6 +176,7 @@ async function runSimulatedAttempt(
{ tenantId: tenant.id, attemptId, campaignId: campaign.id, leadId },
campaign.queueId,
tenant.telephonyDomain ?? "",
campaign.recordingEnabled,
);
registerQueuedAttempt(uuid, {
@@ -228,6 +229,7 @@ async function runRealAttempt(
callerIdName: campaign.callerIdName ?? undefined,
callerIdNumber: campaign.callerIdNumber ?? undefined,
ringTimeoutSeconds: campaign.ringTimeout,
recordingEnabled: campaign.recordingEnabled,
},
);