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

@@ -0,0 +1,78 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import type { Readable } from "node:stream";
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import type { ObjectStorageProvider, PutObjectResult } from "./types";
export interface S3ProviderOptions {
bucket: string;
region?: string;
/** Endpoint custom pra S3-compatible (MinIO, secao 92) — omitido usa
* AWS S3 real. */
endpoint?: string;
accessKeyId?: string;
secretAccessKey?: string;
/** MinIO e a maioria dos S3-compatible precisam de path-style
* (`endpoint/bucket/key`) em vez do virtual-hosted-style padrão da AWS. */
forcePathStyle?: boolean;
}
/**
* Backend S3-compatible (agente.md secao 92) — preparado pra AWS S3 e
* MinIO. **Nunca exercitado nesta sessão**: não existe um servidor S3/MinIO
* disponível neste laboratório pra testar contra. A implementação segue a
* API padrão do `@aws-sdk/client-s3`; revisar com um MinIO real antes de
* confiar em produção.
*/
export class S3ObjectStorageProvider implements ObjectStorageProvider {
private readonly client: S3Client;
private readonly bucket: string;
constructor(options: S3ProviderOptions) {
this.bucket = options.bucket;
this.client = new S3Client({
region: options.region ?? "us-east-1",
endpoint: options.endpoint,
forcePathStyle: options.forcePathStyle,
credentials:
options.accessKeyId && options.secretAccessKey
? { accessKeyId: options.accessKeyId, secretAccessKey: options.secretAccessKey }
: undefined,
});
}
async putObject(key: string, sourceFilePath: string): Promise<PutObjectResult> {
const [{ size }, checksum] = await Promise.all([stat(sourceFilePath), sha256File(sourceFilePath)]);
await this.client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: createReadStream(sourceFilePath),
ContentLength: size,
}),
);
return { sizeBytes: size, checksum };
}
async getObjectStream(key: string): Promise<Readable> {
const result = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
return result.Body as Readable;
}
async deleteObject(key: string): Promise<void> {
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
}
}
function sha256File(path: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = createHash("sha256");
const stream = createReadStream(path);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("end", () => resolve(hash.digest("hex")));
stream.on("error", reject);
});
}