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:
146
docs/RECORDING.md
Normal file
146
docs/RECORDING.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# 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/storage` — `ObjectStorageProvider` (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) — `uploadRecordingIfPresent`
|
||||
lê `Call.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 (`setInterval` — `apps/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.
|
||||
Reference in New Issue
Block a user