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:
17
packages/storage/package.json
Normal file
17
packages/storage/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@b2bcall/storage",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1110.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.1",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
37
packages/storage/src/index.ts
Normal file
37
packages/storage/src/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type { ObjectStorageProvider, PutObjectResult } from "./types";
|
||||
export { buildRecordingObjectKey } from "./path";
|
||||
export { LocalObjectStorageProvider } from "./local-provider";
|
||||
export { S3ObjectStorageProvider } from "./s3-provider";
|
||||
|
||||
import type { ObjectStorageProvider } from "./types";
|
||||
import { LocalObjectStorageProvider } from "./local-provider";
|
||||
import { S3ObjectStorageProvider } from "./s3-provider";
|
||||
|
||||
let provider: ObjectStorageProvider | undefined;
|
||||
|
||||
/**
|
||||
* Fábrica lida por env (`STORAGE_PROVIDER=local|s3`, default `local`) —
|
||||
* cada processo (apps/freeswitch-events, apps/api) monta seu próprio
|
||||
* provider a partir das mesmas variáveis, nunca hardcoda qual backend usar.
|
||||
*/
|
||||
export function getObjectStorageProvider(): ObjectStorageProvider {
|
||||
if (provider) return provider;
|
||||
|
||||
const kind = process.env.STORAGE_PROVIDER ?? "local";
|
||||
if (kind === "s3") {
|
||||
const bucket = process.env.S3_BUCKET;
|
||||
if (!bucket) throw new Error("S3_BUCKET nao configurado (STORAGE_PROVIDER=s3)");
|
||||
provider = new S3ObjectStorageProvider({
|
||||
bucket,
|
||||
region: process.env.S3_REGION,
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
|
||||
});
|
||||
} else {
|
||||
const root = process.env.LOCAL_STORAGE_ROOT ?? "/data/object-storage";
|
||||
provider = new LocalObjectStorageProvider(root);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
53
packages/storage/src/local-provider.ts
Normal file
53
packages/storage/src/local-provider.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { mkdir, rm, stat, copyFile } from "node:fs/promises";
|
||||
import { dirname, join, normalize, relative } from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
import type { ObjectStorageProvider, PutObjectResult } from "./types";
|
||||
|
||||
/**
|
||||
* Backend local (agente.md secao 92) — raiz configurável via
|
||||
* `LOCAL_STORAGE_ROOT`. Cada `key` (já construída por `buildRecordingObjectKey`,
|
||||
* nunca fornecida crua pelo client) vira um caminho dentro dessa raiz;
|
||||
* `assertKeyInsideRoot` é a última linha de defesa contra path traversal
|
||||
* (`../..`) caso algum bug em outro lugar deixe passar uma key malformada.
|
||||
*/
|
||||
export class LocalObjectStorageProvider implements ObjectStorageProvider {
|
||||
constructor(private readonly root: string) {}
|
||||
|
||||
private resolvePath(key: string): string {
|
||||
const full = normalize(join(this.root, key));
|
||||
const rel = relative(this.root, full);
|
||||
if (rel.startsWith("..") || rel === "") {
|
||||
throw new Error(`Object key fora da raiz de storage: ${key}`);
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
async putObject(key: string, sourceFilePath: string): Promise<PutObjectResult> {
|
||||
const destPath = this.resolvePath(key);
|
||||
await mkdir(dirname(destPath), { recursive: true });
|
||||
await copyFile(sourceFilePath, destPath);
|
||||
|
||||
const [{ size }, checksum] = await Promise.all([stat(destPath), sha256File(destPath)]);
|
||||
return { sizeBytes: size, checksum };
|
||||
}
|
||||
|
||||
async getObjectStream(key: string): Promise<Readable> {
|
||||
return createReadStream(this.resolvePath(key));
|
||||
}
|
||||
|
||||
async deleteObject(key: string): Promise<void> {
|
||||
await rm(this.resolvePath(key), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
14
packages/storage/src/path.ts
Normal file
14
packages/storage/src/path.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Estrutura de path segura (agente.md secao 93):
|
||||
* `tenants/{tenant_id}/recordings/YYYY/MM/DD/{call_id}.wav`. Sempre
|
||||
* construída no servidor a partir de dados já confiáveis (tenantId do JWT/
|
||||
* contexto do worker, callId do próprio Call) — nunca aceita um object key
|
||||
* vindo do client, então um tenant nunca consegue montar uma key que
|
||||
* aponte pra dentro da pasta de outro tenant.
|
||||
*/
|
||||
export function buildRecordingObjectKey(tenantId: string, callId: string, recordedAt: Date, format = "wav"): string {
|
||||
const yyyy = recordedAt.getUTCFullYear();
|
||||
const mm = String(recordedAt.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(recordedAt.getUTCDate()).padStart(2, "0");
|
||||
return `tenants/${tenantId}/recordings/${yyyy}/${mm}/${dd}/${callId}.${format}`;
|
||||
}
|
||||
78
packages/storage/src/s3-provider.ts
Normal file
78
packages/storage/src/s3-provider.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
23
packages/storage/src/types.ts
Normal file
23
packages/storage/src/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
/**
|
||||
* ObjectStorageProvider (agente.md secao 92) — abstração sobre onde os
|
||||
* bytes de uma gravação (ou qualquer outro artefato binário futuro:
|
||||
* transcrição, export de relatório) realmente moram. O domínio nunca deve
|
||||
* falar diretamente com filesystem ou S3 — só com esta interface.
|
||||
*/
|
||||
export interface PutObjectResult {
|
||||
sizeBytes: number;
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
export interface ObjectStorageProvider {
|
||||
/** Envia o conteúdo de `sourceFilePath` (arquivo local) pra `key`. */
|
||||
putObject(key: string, sourceFilePath: string): Promise<PutObjectResult>;
|
||||
|
||||
/** Stream de leitura pro conteúdo de `key` — usado pra servir download
|
||||
* autenticado via apps/api, nunca expondo a key/URL bruta ao client. */
|
||||
getObjectStream(key: string): Promise<Readable>;
|
||||
|
||||
deleteObject(key: string): Promise<void>;
|
||||
}
|
||||
8
packages/storage/tsconfig.json
Normal file
8
packages/storage/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user