Files
B2BCall-dialer/docs/RECORDING.md
Matheus c24a86776c 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
2026-08-28 14:36:03 -03:00

6.9 KiB

Gravação e Object Storage

Agente.md secao 90-94. Fecha a gravação de chamadas do Call Center e a abstração de storage por trás dela — a especificação lista "Recording" e "Object Storage" como dois passos separados na ordem de implementação (secao 232), mas eles são acoplados o suficiente (Recording precisa de um lugar pra guardar bytes) que ficaram nesta única fase.

packages/storageObjectStorageProvider (secao 92)

Abstração pequena: putObject(key, sourceFilePath), getObjectStream(key), deleteObject(key). Dois backends:

  • LocalObjectStorageProvider: copia pro filesystem local, com uma checagem de path traversal (assertKeyInsideRoot) como última linha de defesa — mesmo a key sendo sempre construída no servidor, nunca vinda do client.
  • S3ObjectStorageProvider: @aws-sdk/client-s3, preparado pra AWS S3 e MinIO (forcePathStyle, endpoint customizável). Nunca exercitado nesta sessão — não existe servidor S3/MinIO disponível neste laboratório.

getObjectStorageProvider() escolhe o backend por STORAGE_PROVIDER (local/s3) — cada processo (fs-events, apps/api) monta o seu a partir das mesmas variáveis de ambiente, nunca hardcoda qual backend usar.

Path (secao 93)

buildRecordingObjectKey(tenantId, callId, recordedAt)tenants/{tenant_id}/recordings/YYYY/MM/DD/{call_id}.wav. Sempre montada no servidor a partir de dados já confiáveis — um tenant nunca consegue montar uma key que aponte pra dentro da pasta de outro.

Volumes: bind mount, não volume nomeado do Docker

Diferente dos outros compartilhamentos com o FreeSWITCH (gateways, filas), /recordings e /data/object-storage usam bind mount pra um diretório real do host (./data/recordings-spool, ./data/object-storage-local) — porque apps/api roda no host, não em Docker (mesma limitação de sempre, ver docs/NETWORK_ARCHITECTURE.md), e precisa ler os mesmos arquivos que fs-events escreve. LOCAL_STORAGE_ROOT tem valores diferentes por ambiente (.env: path do host; docker-compose.yml do fs-events: path Docker-interno pro mesmo diretório) — mesmo padrão já usado pra REDIS_URL.

Quem grava: apps/predictive-dialer/src/originate.ts

Só chamadas originadas pelo PredictiveDialerEngine com Campaign.recordingEnabled=true são gravadas nesta fase — é o único caminho de originate que o sistema controla hoje (chamadas manuais/ internas não passam por aqui ainda). Quando habilitado, o originate ganha duas channel variables extras:

  • RECORD_STEREO=true (secao 91): canal A = cliente, canal B = agente, quando tecnicamente adequado.
  • execute_on_answer='record_session /recordings/{uuid}.wav': roda assim que a chamada atende, antes da aplicação principal (&callcenter) começar.

O origination_uuid é pré-gerado em originate.ts (em vez de deixar o provider sortear um) porque o path de gravação precisa dele ANTES do comando de originate ser montado — esse mesmo 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.

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

Disparado a partir de CALL_ENDED, sempre depois de persistCallEvent terminar (encadeado via .then(), não em paralelo) — uploadRecordingIfPresentCall.talkTime/durationSeconds, que é exatamente o que persistCallEvent acabou de calcular nesse mesmo evento (mesma classe de corrida já corrigida uma vez na fase CDR, evitada aqui por ordenação em vez de retry).

Checa se /recordings/{callId}.wav existe (com até 5 tentativas de 300ms — record_session pode levar um instante pra terminar o flush depois do hangup); se não existir, não faz nada (chamada não gravada, comportamento normal). Se existir: sobe pro object storage, calcula retentionUntil a partir de Plan.recordingRetentionDays (secao 94, null = sem limite), cria a linha Recording, apaga o arquivo do spool local.

API (apps/api/src/recordings)

GET /recordings (filtros: campanha, agente, data), GET /recordings/:id, GET /recordings/:id/audio — o áudio nunca é exposto via URL direta pro storage (nem presigned): sempre passa pelo controller autenticado, que faz storage.getObjectStream() e manda o stream direto na resposta (reply.send(stream) do Fastify aceita um Readable). Permissions recordings.view/recordings.download, já existiam desde a fase RBAC.

Retenção (secao 94)

Plan.recordingRetentionDays/transcriptionRetentionDays (o segundo ainda sem uso — fase AI/Transcrição). runRetentionSweep (apps/api/src/recordings/retention-sweep.ts) roda no boot do apps/api e depois a cada hora (setIntervalapps/api já é um processo de longa duração, não precisou de um serviço dedicado só pra isso): varre Recording com status=AVAILABLE e retentionUntil vencido (fan-out por tenant, mesmo padrão de trunk-status.ts), apaga o objeto no storage, marca status=DELETED — a linha em si nunca é apagada, fica como registro de auditoria.

Correção real achada testando esta fase

Recording.sizeBytes é BigInt no Prisma (arquivos podem, em teoria, passar de 2^31 bytes) — o serializador JSON padrão do Fastify não sabe lidar com BigInt e quebrava GET /recordings com 500 (TypeError: Do not know how to serialize a BigInt). Mesma classe de bug já corrigida uma vez no logger (packages/shared/src/logger.ts, fase Event Socket). Corrigido convertendo sizeBytes pra number na resposta (tamanho de gravação nunca chega perto de Number.MAX_SAFE_INTEGER).

Verificado ponta a ponta

Campanha com recordingEnabled:true, 5 leads, 2 ANSWERED simulados:
  record_session cria /recordings/{uuid}.wav no container do FreeSWITCH
    (confirmado: RIFF WAVE, PCM 16-bit, ESTÉREO, 8000Hz — RECORD_STEREO
    funcionando)
  CALL_ENDED -> upload pro object storage local, path exatamente
    tenants/{tenant}/recordings/2026/08/28/{uuid}.wav
  arquivo do spool apagado depois do upload confirmado
  Recording criado com sizeBytes/checksum reais, durationSeconds batendo
    com o talk_time do Call

GET /recordings -> lista os 2, sizeBytes serializado como number (sem
  crash de BigInt)
GET /recordings/:id/audio -> download autenticado, md5 idêntico ao
  arquivo original no object storage

Teste de retenção: marquei retentionUntil como já vencido, rodei
  runRetentionSweep() -> objeto apagado do storage, status vira DELETED,
  GET /recordings (lista) não mostra mais, GET /recordings/:id ainda
  mostra (auditoria), GET /recordings/:id/audio -> 404

typecheck do workspace inteiro limpo.

O que falta

  • Chamadas manuais/internas (não originadas pelo discador) não são gravadas — sem um caminho de originate próprio pra elas ainda.
  • S3ObjectStorageProvider nunca testado contra um MinIO/S3 real.
  • max_recording_storage_gb (Plan) existe mas não é aplicado — nada verifica quota de espaço usado antes de gravar.
  • Transcrição (secao 95+, IA) — próxima fase.