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
174 lines
6.4 KiB
YAML
174 lines
6.4 KiB
YAML
services:
|
|
postgres:
|
|
image: postgres:18-alpine
|
|
container_name: b2bcall-postgres
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_USER: ${POSTGRES_USER}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
POSTGRES_DB: ${POSTGRES_DB}
|
|
ports:
|
|
- "127.0.0.1:5432:5432"
|
|
volumes:
|
|
- postgres_data:/var/lib/postgresql
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
|
|
redis:
|
|
image: redis:7-alpine
|
|
container_name: b2bcall-redis
|
|
restart: unless-stopped
|
|
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
|
|
ports:
|
|
- "127.0.0.1:6379:6379"
|
|
volumes:
|
|
- redis_data:/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD} ping | grep -q PONG"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
|
|
fs-config:
|
|
build:
|
|
context: .
|
|
dockerfile: apps/freeswitch-config/Dockerfile
|
|
container_name: b2bcall-fs-config
|
|
restart: unless-stopped
|
|
depends_on:
|
|
- postgres
|
|
- redis
|
|
environment:
|
|
# Hostname interno do compose (postgres/redis/freeswitch), nao
|
|
# localhost — ver docs/NETWORK_ARCHITECTURE.md.
|
|
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
|
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
|
FS_CONFIG_USER: ${FS_CONFIG_USER}
|
|
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
|
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
|
ESL_HOST: freeswitch
|
|
ESL_PORT: "8021"
|
|
ESL_PASSWORD: ${ESL_PASSWORD}
|
|
SOFIA_EXTERNAL_GATEWAYS_DIR: /gateways
|
|
CALLCENTER_QUEUES_DIR: /callcenter-queues
|
|
volumes:
|
|
# Compartilhado com o FreeSWITCH (agente.md secao 41-42): fs-config
|
|
# escreve os XML de gateway aqui, o profile "external" ja inclui
|
|
# sip_profiles/external/*.xml automaticamente (config vanilla).
|
|
- freeswitch_external_gateways:/gateways
|
|
# Compartilhado com o FreeSWITCH (agente.md secao 50-51): fs-config
|
|
# escreve um XML de fila por arquivo aqui, incluido via
|
|
# X-PRE-PROCESS no nosso callcenter.conf.xml (mesmo padrao acima).
|
|
- freeswitch_callcenter_queues:/callcenter-queues
|
|
# Sem porta publicada: so o FreeSWITCH (mesma rede do compose) chama isto.
|
|
healthcheck:
|
|
test: ["CMD", "node", "-e", "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 10s
|
|
|
|
freeswitch:
|
|
build:
|
|
context: ./infrastructure/freeswitch
|
|
secrets:
|
|
- freeswitch_pat
|
|
container_name: b2bcall-freeswitch
|
|
restart: unless-stopped
|
|
depends_on:
|
|
- fs-config
|
|
environment:
|
|
ESL_PASSWORD: ${ESL_PASSWORD}
|
|
FS_CONFIG_USER: ${FS_CONFIG_USER}
|
|
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
|
|
volumes:
|
|
- freeswitch_external_gateways:/etc/freeswitch/sip_profiles/external
|
|
- freeswitch_callcenter_queues:/etc/freeswitch/autoload_configs/callcenter_queues.conf.d
|
|
# Bind mount (não volume nomeado do Docker) porque apps/api roda no
|
|
# host e precisa enxergar os mesmos arquivos que o FreeSWITCH grava
|
|
# (agente.md secao 90-91) — ver docs/RECORDING.md.
|
|
- ./data/recordings-spool:/recordings
|
|
# Nenhuma porta publicada no host: SIP/RTP ainda não têm troncos reais
|
|
# configurados, e o Event Socket (8021) só deve ser alcançável por outros
|
|
# containers na rede interna do compose (agente.md secao 22).
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "fs_cli -p \"$$ESL_PASSWORD\" -x status | grep -q 'is ready'"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 20s
|
|
|
|
fs-events:
|
|
build:
|
|
context: .
|
|
dockerfile: apps/freeswitch-events/Dockerfile
|
|
container_name: b2bcall-fs-events
|
|
restart: unless-stopped
|
|
depends_on:
|
|
- freeswitch
|
|
- redis
|
|
- postgres
|
|
environment:
|
|
ESL_HOST: freeswitch
|
|
ESL_PORT: "8021"
|
|
ESL_PASSWORD: ${ESL_PASSWORD}
|
|
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
|
|
# Hostnames internos do compose (freeswitch/redis), diferente do
|
|
# REDIS_URL do .env que aponta pra localhost (uso pelo apps/api, que
|
|
# ainda roda no host) — ver docs/NETWORK_ARCHITECTURE.md.
|
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
|
# Gravações (agente.md secao 90-93) — mesmo padrão do REDIS_URL
|
|
# acima: path Docker-interno aqui, path do host no .env pro
|
|
# apps/api enxergar os mesmos arquivos (ver docs/RECORDING.md).
|
|
RECORDINGS_SPOOL_DIR: /recordings
|
|
STORAGE_PROVIDER: ${STORAGE_PROVIDER:-local}
|
|
LOCAL_STORAGE_ROOT: /data/object-storage
|
|
S3_BUCKET: ${S3_BUCKET:-}
|
|
S3_REGION: ${S3_REGION:-}
|
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
|
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-}
|
|
volumes:
|
|
- ./data/recordings-spool:/recordings
|
|
- ./data/object-storage-local:/data/object-storage
|
|
|
|
predictive-dialer:
|
|
build:
|
|
context: .
|
|
dockerfile: apps/predictive-dialer/Dockerfile
|
|
container_name: b2bcall-predictive-dialer
|
|
restart: unless-stopped
|
|
depends_on:
|
|
- freeswitch
|
|
- redis
|
|
- postgres
|
|
environment:
|
|
ESL_HOST: freeswitch
|
|
ESL_PORT: "8021"
|
|
ESL_PASSWORD: ${ESL_PASSWORD}
|
|
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
|
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
|
# Real Outbound Safety (agente.md secao 186): as DUAS precisam estar
|
|
# explicitamente ligadas pra originar PSTN de verdade — nunca ativado
|
|
# por default. Ver docs/PREDICTIVE_DIALER.md.
|
|
DIALER_SIMULATION: ${DIALER_SIMULATION:-true}
|
|
ALLOW_REAL_OUTBOUND_CALLS: ${ALLOW_REAL_OUTBOUND_CALLS:-false}
|
|
# Só usado pra montar o path do record_session (agente.md secao 91)
|
|
# — quem de fato grava e' o processo FreeSWITCH, não este worker;
|
|
# não precisa do volume montado aqui, só saber o path Docker-interno.
|
|
RECORDINGS_SPOOL_DIR: /recordings
|
|
|
|
secrets:
|
|
freeswitch_pat:
|
|
environment: FREESWITCH_PAT
|
|
|
|
volumes:
|
|
postgres_data:
|
|
redis_data:
|
|
freeswitch_external_gateways:
|
|
freeswitch_callcenter_queues:
|