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

3
.gitignore vendored
View File

@@ -30,3 +30,6 @@ Thumbs.db
# Docker volumes / data
infrastructure/**/data/
# Gravações (bind mount, ver docs/RECORDING.md) — nunca commitar áudio de
# chamadas de tenants nem os arquivos do object storage local.
/data/

44
TODO.md
View File

@@ -426,8 +426,48 @@
- [ ] Cross-tenant reports pra platform admin (secao 157) — sem console
de plataforma ainda
## PHASE 18+ver `agente.md` seções 90 em diante (Recordings, AI,
Billing, Frontend, Security, Tests)
## PHASE 18 — Recording / Object Storage (agente.md secao 90-94)
- [x] `packages/storage`: `ObjectStorageProvider` (secao 92) — Local
(filesystem, com checagem de path traversal) e S3-compatible
(`@aws-sdk/client-s3`, preparado pra MinIO — nunca exercitado nesta
sessão, sem servidor S3 disponível). Escolhido por
`STORAGE_PROVIDER` env, cada processo monta o seu
- [x] `buildRecordingObjectKey` (secao 93):
`tenants/{tenant_id}/recordings/YYYY/MM/DD/{call_id}.wav`, sempre
montada no servidor
- [x] Bind mounts (não volumes nomeados) pro spool de gravação e pro
storage local — `apps/api` roda no host, precisa enxergar os mesmos
arquivos que `fs-events` escreve (mesmo padrão de `REDIS_URL`)
- [x] `apps/predictive-dialer`: `RECORD_STEREO=true` +
`execute_on_answer='record_session ...'` quando
`Campaign.recordingEnabled` — `origination_uuid` pré-gerado pra
poder montar o path de gravação antes do originate
- [x] `apps/freeswitch-events/src/recording.ts`: em CALL_ENDED (encadeado
depois do CDR terminar, não em paralelo — evita a mesma corrida já
corrigida na fase CDR), sobe a gravação, cria `Recording`
(`retentionUntil` a partir de `Plan.recordingRetentionDays`), apaga
o spool local
- [x] `GET /recordings`, `GET /recordings/:id`, `GET /recordings/:id/audio`
(stream autenticado, nunca URL direta pro storage)
- [x] Retenção (secao 94): `runRetentionSweep` no boot do `apps/api` + a
cada hora — apaga o objeto, marca `status=DELETED` (linha nunca
apagada, fica como auditoria)
- [x] **Bug real, achado no teste desta fase**: `Recording.sizeBytes`
(BigInt) quebrava `GET /recordings` com 500 — Fastify não serializa
BigInt nativamente (mesma classe de bug já corrigida uma vez no
logger). Corrigido convertendo pra `number` na resposta.
- [x] Testado ponta a ponta: gravação real criada (RIFF WAVE, PCM 16-bit,
ESTÉREO 8000Hz — RECORD_STEREO confirmado), upload com path exato da
secao 93, download via API com md5 idêntico ao objeto original,
varredura de retenção apagando objeto + status DELETED + list/
download bloqueados depois
- [ ] Chamadas manuais/internas não são gravadas — só o caminho do
discador tem originate próprio
- [ ] `max_recording_storage_gb` (Plan) existe mas não é aplicado
- [ ] Transcrição (secao 95+, fase IA)
## PHASE 19+ — ver `agente.md` seções 95 em diante (AI, Billing, Frontend,
Security, Tests)
---

View File

@@ -13,6 +13,7 @@
"@b2bcall/database": "workspace:*",
"@b2bcall/entitlements": "workspace:*",
"@b2bcall/shared": "workspace:*",
"@b2bcall/storage": "workspace:*",
"@b2bcall/telephony": "workspace:*",
"@fastify/cors": "11.3.0",
"@fastify/helmet": "13.1.1",

View File

@@ -14,6 +14,7 @@ import { SuppressionModule } from "./suppression/suppression.module";
import { DispositionsModule } from "./dispositions/dispositions.module";
import { CallsModule } from "./calls/calls.module";
import { ReportsModule } from "./reports/reports.module";
import { RecordingsModule } from "./recordings/recordings.module";
@Module({
imports: [
@@ -32,6 +33,7 @@ import { ReportsModule } from "./reports/reports.module";
DispositionsModule,
CallsModule,
ReportsModule,
RecordingsModule,
],
})
export class AppModule {}

View File

@@ -8,6 +8,9 @@ import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import { AppModule } from "./app.module";
import { DomainExceptionFilter } from "./common/filters/domain-exception.filter";
import { runRetentionSweep } from "./recordings/retention-sweep";
const RETENTION_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@@ -51,6 +54,15 @@ async function bootstrap() {
const port = Number(process.env.API_PORT ?? 3000);
await app.listen(port, "127.0.0.1");
console.log(`b2bcall-api ouvindo em http://127.0.0.1:${port}`);
// Retenção de gravações (agente.md secao 94: "scheduler deverá aplicar
// retenção"). apps/api já é um processo de longa duração — não precisa
// de um serviço dedicado só pra isso; roda uma vez no boot e depois de
// hora em hora.
runRetentionSweep().catch((err) => console.error("falha na varredura de retencao (boot)", err));
setInterval(() => {
runRetentionSweep().catch((err) => console.error("falha na varredura de retencao", err));
}, RETENTION_SWEEP_INTERVAL_MS);
}
bootstrap();

View File

@@ -0,0 +1,90 @@
import { Controller, Get, NotFoundException, Param, Query, Res, UseGuards } from "@nestjs/common";
import type { FastifyReply } from "fastify";
import { getPrismaClient, withTenantContext, type Prisma, type Recording } from "@b2bcall/database";
import { getObjectStorageProvider } from "@b2bcall/storage";
import type { AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* Gravações (agente.md secao 90-94). O áudio nunca é exposto via URL
* direta pro object storage (nem presigned) — sempre passa por aqui, com
* a mesma checagem de permissão/tenant de qualquer outro dado sensível
* (mesmo princípio de "nunca confiar em id vindo do client sem checar
* contra o tenant do JWT", secao 31).
*/
// `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 (mesma classe de bug já corrigida uma vez no logger, ver
// packages/shared/src/logger.ts). Tamanho de gravação nunca chega perto
// de Number.MAX_SAFE_INTEGER, então converter pra number aqui é seguro.
function serializeRecording(recording: Recording) {
return { ...recording, sizeBytes: recording.sizeBytes != null ? Number(recording.sizeBytes) : null };
}
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("recordings")
export class RecordingsController {
@RequirePermission("recordings.view")
@Get()
async list(
@CurrentUser() user: AccessTokenClaims,
@Query("campaignId") campaignId?: string,
@Query("agentId") agentId?: string,
@Query("from") from?: string,
@Query("to") to?: string,
) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const where: Prisma.RecordingWhereInput = { tenantId, status: "AVAILABLE" };
if (from || to) {
where.recordedAt = {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
};
}
if (campaignId || agentId) {
where.call = {
...(campaignId ? { campaignId } : {}),
...(agentId ? { agentId } : {}),
};
}
const recordings = await withTenantContext(prisma, tenantId, (tx) =>
tx.recording.findMany({ where, orderBy: { recordedAt: "desc" }, take: 500 }),
);
return recordings.map(serializeRecording);
}
@RequirePermission("recordings.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const recording = await withTenantContext(prisma, tenantId, (tx) => tx.recording.findFirst({ where: { id, tenantId } }));
if (!recording) throw new NotFoundException();
return serializeRecording(recording);
}
@RequirePermission("recordings.download")
@Get(":id/audio")
async download(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Res() reply: FastifyReply) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const recording = await withTenantContext(prisma, tenantId, (tx) =>
tx.recording.findFirst({ where: { id, tenantId, status: "AVAILABLE" } }),
);
if (!recording) throw new NotFoundException();
const storage = getObjectStorageProvider();
const stream = await storage.getObjectStream(recording.objectKey);
reply.header("Content-Type", `audio/${recording.format}`);
reply.header("Content-Disposition", `attachment; filename="${recording.callId}.${recording.format}"`);
reply.send(stream);
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { RecordingsController } from "./recordings.controller";
@Module({
controllers: [RecordingsController],
})
export class RecordingsModule {}

View File

@@ -0,0 +1,45 @@
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { getObjectStorageProvider } from "@b2bcall/storage";
import { createLogger } from "@b2bcall/shared";
const logger = createLogger("b2bcall-api");
/**
* Aplica retenção de gravações (agente.md secao 94): Plan.recordingRetentionDays
* define `Recording.retentionUntil` no momento da criação (ver
* apps/freeswitch-events/src/recording.ts); esta varredura periódica
* apaga o objeto no storage e marca a linha como DELETED (nunca apaga a
* linha em si — fica como registro de auditoria de "isso existiu e foi
* retido pelo tempo configurado"). `retentionUntil = null` (plano sem
* limite) nunca é varrido.
*/
export async function runRetentionSweep(): Promise<void> {
const prisma = getPrismaClient();
const storage = getObjectStorageProvider();
const now = new Date();
const tenants = await prisma.tenant.findMany({ where: { status: "ACTIVE" }, select: { id: true } });
for (const tenant of tenants) {
const expired = await withTenantContext(prisma, tenant.id, (tx) =>
tx.recording.findMany({
where: { tenantId: tenant.id, status: "AVAILABLE", retentionUntil: { lte: now } },
}),
);
for (const recording of expired) {
try {
await storage.deleteObject(recording.objectKey);
await withTenantContext(prisma, tenant.id, (tx) =>
tx.recording.update({ where: { id: recording.id }, data: { status: "DELETED" } }),
);
logger.info("gravacao apagada por retencao", { recordingId: recording.id, tenantId: tenant.id });
} catch (err) {
logger.error("falha ao apagar gravacao por retencao", {
error: String(err),
recordingId: recording.id,
});
}
}
}
}

View File

@@ -15,6 +15,7 @@ COPY packages/types packages/types
COPY packages/shared packages/shared
COPY packages/telephony packages/telephony
COPY packages/database packages/database
COPY packages/storage packages/storage
COPY apps/freeswitch-events apps/freeswitch-events
RUN pnpm install --frozen-lockfile --filter @b2bcall/freeswitch-events...

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@b2bcall/database": "workspace:*",
"@b2bcall/shared": "workspace:*",
"@b2bcall/storage": "workspace:*",
"@b2bcall/telephony": "workspace:*",
"esl": "11.2.1",
"ioredis": "^6.0.0"

View File

@@ -5,6 +5,7 @@ import { createLogger } from "@b2bcall/shared";
import { updateTrunkStatusFromGatewayEvent } from "./trunk-status";
import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve";
import { persistCallEvent } from "./cdr";
import { uploadRecordingIfPresent } from "./recording";
const logger = createLogger("b2bcall-fs-events");
@@ -139,9 +140,23 @@ async function main() {
logger.error("falha ao publicar evento normalizado no Redis", { error: String(err) });
});
persistCallEvent(normalized).catch((err) => {
logger.error("falha ao persistir CDR", { error: String(err), type: normalized.type });
});
// .then() em vez de esperar aqui (await bloquearia o processamento do
// próximo evento ESL) — mas o upload da gravação só roda DEPOIS do
// persistCallEvent terminar de verdade, nunca em paralelo com ele:
// uploadRecordingIfPresent lê Call.talkTime/durationSeconds, que é
// exatamente o que persistCallEvent acabou de calcular no CALL_ENDED
// (mesma classe de corrida já corrigida uma vez neste arquivo, ver
// docs/CDR.md — aqui evitada por ordenação, não por retry).
persistCallEvent(normalized)
.then(() => {
if (normalized.type === "CALL_ENDED" && normalized.tenantId && normalized.callUuid) {
return uploadRecordingIfPresent(normalized.tenantId, normalized.callUuid);
}
return undefined;
})
.catch((err) => {
logger.error("falha ao persistir CDR/gravacao", { error: String(err), type: normalized.type });
});
logger.info(`evento: ${normalized.type}`, {
callUuid: normalized.callUuid,

View File

@@ -0,0 +1,103 @@
import { access, rm } from "node:fs/promises";
import { join } from "node:path";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { getObjectStorageProvider, buildRecordingObjectKey } from "@b2bcall/storage";
import { createLogger } from "@b2bcall/shared";
const logger = createLogger("b2bcall-fs-events");
const SPOOL_DIR = process.env.RECORDINGS_SPOOL_DIR ?? "/recordings";
const STORAGE_KIND = (process.env.STORAGE_PROVIDER ?? "local").toUpperCase() as "LOCAL" | "S3";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fileExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
/**
* Sobe pro object storage a gravação de uma chamada que acabou de
* terminar (agente.md secao 90-93) — chamado a partir de CALL_ENDED em
* main.ts, sempre depois do CDR já ter persistido `Call` (precisa de
* `Call.createdAt`/`talkTime` pra `recordedAt`/`durationSeconds`).
*
* `originateSimulatedAnswerLeg`/`originateRealPstnLeg`
* (apps/predictive-dialer) só setam `execute_on_answer='record_session
* ...'` quando `Campaign.recordingEnabled` é true — pra qualquer outra
* chamada, o arquivo simplesmente não existe, e essa função não faz nada
* (checagem por existência de arquivo, não por reconsultar a campanha).
*/
export async function uploadRecordingIfPresent(tenantId: string, callId: string): Promise<void> {
const spoolPath = join(SPOOL_DIR, `${callId}.wav`);
// record_session termina de gravar no CHANNEL_HANGUP_COMPLETE, mas pode
// levar um instante pra flush terminar — tenta algumas vezes antes de
// desistir, em vez de perder a gravação por uma corrida.
let exists = await fileExists(spoolPath);
for (let attempt = 0; !exists && attempt < 5; attempt++) {
await sleep(300);
exists = await fileExists(spoolPath);
}
if (!exists) return;
const prisma = getPrismaClient();
try {
const call = await withTenantContext(prisma, tenantId, (tx) => tx.call.findUniqueOrThrow({ where: { id: callId } }));
const tenant = await prisma.tenant.findUniqueOrThrow({ where: { id: tenantId }, include: { plan: true } });
const recordedAt = call.createdAt;
const objectKey = buildRecordingObjectKey(tenantId, callId, recordedAt);
const storage = getObjectStorageProvider();
const { sizeBytes, checksum } = await storage.putObject(objectKey, spoolPath);
const retentionDays = tenant.plan.recordingRetentionDays;
const retentionUntil = retentionDays
? new Date(recordedAt.getTime() + retentionDays * 24 * 60 * 60 * 1000)
: null;
await withTenantContext(prisma, tenantId, (tx) =>
tx.recording.create({
data: {
tenantId,
callId,
storageProvider: STORAGE_KIND,
objectKey,
format: "wav",
durationSeconds: call.talkTime ?? call.durationSeconds,
channels: 2,
sizeBytes,
checksum,
recordedAt,
retentionUntil,
},
}),
);
await rm(spoolPath, { force: true });
logger.info("gravacao enviada pro object storage", { callId, objectKey, sizeBytes });
} catch (err) {
logger.error("falha ao processar gravacao", { error: String(err), callId });
await withTenantContext(prisma, tenantId, (tx) =>
tx.recording
.create({
data: {
tenantId,
callId,
storageProvider: STORAGE_KIND,
objectKey: "",
recordedAt: new Date(),
status: "FAILED",
},
})
.catch(() => undefined),
);
}
}

View File

@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import type { FreeSwitchTelephonyProvider } from "@b2bcall/telephony";
export interface OriginateIdentifiers {
@@ -20,6 +21,34 @@ function channelVars(ids: OriginateIdentifiers): Record<string, string> {
};
}
const RECORDINGS_SPOOL_DIR = process.env.RECORDINGS_SPOOL_DIR ?? "/recordings";
/**
* Gravação (agente.md secao 90-91): quando habilitada, grava pro spool
* local do FreeSWITCH via `execute_on_answer='record_session ...'` — roda
* assim que a chamada atende, antes da aplicação principal (`&callcenter`)
* começar. `RECORD_STEREO=true` grava canal A (cliente) e canal B (agente)
* separados, quando tecnicamente adequado (secao 91) — o mod_callcenter
* bridgeia um leg só por vez, então isso vale pra qualquer chamada que
* chega a conectar com um agente.
*
* O nome do arquivo usa `origination_uuid` pré-gerado aqui (em vez de
* deixar o provider sortear um) — precisamos saber o uuid ANTES de montar
* o comando de originate, já que o path de gravação entra no mesmo
* comando. Esse 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 — é assim que
* `apps/freeswitch-events/src/recording.ts` acha o arquivo certo.
*/
function recordingVars(originationUuid: string, recordingEnabled: boolean): Record<string, string> {
if (!recordingEnabled) return {};
const path = `${RECORDINGS_SPOOL_DIR}/${originationUuid}.wav`;
return {
RECORD_STEREO: "true",
execute_on_answer: `record_session ${path}`,
};
}
/**
* Perna "atendida" sintética (modo simulação): quando o outcome sorteado é
* ANSWERED, origina uma chamada de verdade no FreeSWITCH (`null/dummy`,
@@ -33,12 +62,18 @@ export async function originateSimulatedAnswerLeg(
ids: OriginateIdentifiers,
queueId: string,
domain: string,
recordingEnabled: boolean,
): Promise<{ uuid: string }> {
const originationUuid = randomUUID();
return provider.originate({
destination: "null/dummy",
application: "callcenter",
applicationArgs: `${queueId}@${domain}`,
channelVariables: channelVars(ids),
channelVariables: {
...channelVars(ids),
...recordingVars(originationUuid, recordingEnabled),
origination_uuid: originationUuid,
},
});
}
@@ -61,13 +96,19 @@ export async function originateRealPstnLeg(
callerIdName?: string;
callerIdNumber?: string;
ringTimeoutSeconds: number;
recordingEnabled: boolean;
},
): Promise<{ uuid: string }> {
const originationUuid = randomUUID();
return provider.originate({
destination: `sofia/gateway/${params.trunkId}/${params.phoneNumber}`,
application: "callcenter",
applicationArgs: `${params.queueId}@${params.domain}`,
channelVariables: channelVars(ids),
channelVariables: {
...channelVars(ids),
...recordingVars(originationUuid, params.recordingEnabled),
origination_uuid: originationUuid,
},
callerIdName: params.callerIdName,
callerIdNumber: params.callerIdNumber,
timeoutSeconds: params.ringTimeoutSeconds,

View File

@@ -176,6 +176,7 @@ async function runSimulatedAttempt(
{ tenantId: tenant.id, attemptId, campaignId: campaign.id, leadId },
campaign.queueId,
tenant.telephonyDomain ?? "",
campaign.recordingEnabled,
);
registerQueuedAttempt(uuid, {
@@ -228,6 +229,7 @@ async function runRealAttempt(
callerIdName: campaign.callerIdName ?? undefined,
callerIdNumber: campaign.callerIdNumber ?? undefined,
ringTimeoutSeconds: campaign.ringTimeout,
recordingEnabled: campaign.recordingEnabled,
},
);

View File

@@ -87,6 +87,10 @@ services:
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).
@@ -116,6 +120,21 @@ services:
# 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:
@@ -138,6 +157,10 @@ services:
# 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:

146
docs/RECORDING.md Normal file
View 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`
`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.

View File

@@ -0,0 +1,51 @@
-- CreateEnum
CREATE TYPE "storage_provider" AS ENUM ('LOCAL', 'S3');
-- CreateEnum
CREATE TYPE "recording_status" AS ENUM ('AVAILABLE', 'DELETED', 'FAILED');
-- AlterTable
ALTER TABLE "plans" ADD COLUMN "recording_retention_days" INTEGER,
ADD COLUMN "transcription_retention_days" INTEGER;
-- CreateTable
CREATE TABLE "recordings" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"call_id" UUID NOT NULL,
"storage_provider" "storage_provider" NOT NULL,
"object_key" TEXT NOT NULL,
"format" TEXT NOT NULL DEFAULT 'wav',
"duration_seconds" INTEGER,
"channels" INTEGER NOT NULL DEFAULT 2,
"size_bytes" BIGINT,
"checksum" TEXT,
"recorded_at" TIMESTAMP(3) NOT NULL,
"retention_until" TIMESTAMP(3),
"status" "recording_status" NOT NULL DEFAULT 'AVAILABLE',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "recordings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "recordings_call_id_key" ON "recordings"("call_id");
-- CreateIndex
CREATE INDEX "recordings_tenant_id_recorded_at_idx" ON "recordings"("tenant_id", "recorded_at");
-- CreateIndex
CREATE INDEX "recordings_retention_until_idx" ON "recordings"("retention_until");
-- AddForeignKey
ALTER TABLE "recordings" ADD CONSTRAINT "recordings_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "recordings" ADD CONSTRAINT "recordings_call_id_fkey" FOREIGN KEY ("call_id") REFERENCES "calls"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Tabela de negocio tenant-scoped: RLS obrigatorio (ver docs/TENANT_ISOLATION.md).
ALTER TABLE "recordings" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "recordings" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "recordings"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);

View File

@@ -56,6 +56,7 @@ model Tenant {
calls Call[]
callLegs CallLeg[]
callEvents CallEvent[]
recordings Recording[]
@@map("tenants")
}
@@ -80,6 +81,10 @@ model Plan {
maxMonthlyCalls Int? @map("max_monthly_calls")
maxRecordingStorageGb Int? @map("max_recording_storage_gb")
// agente.md secao 94 — null = sem retenção automática (nunca apagado).
recordingRetentionDays Int? @map("recording_retention_days")
transcriptionRetentionDays Int? @map("transcription_retention_days")
recordingEnabled Boolean @default(true) @map("recording_enabled")
aiEnabled Boolean @default(false) @map("ai_enabled")
aiTranscriptionEnabled Boolean @default(false) @map("ai_transcription_enabled")
@@ -1007,6 +1012,7 @@ model Call {
disposition Disposition? @relation(fields: [dispositionId], references: [id])
legs CallLeg[]
events CallEvent[]
recording Recording?
@@index([tenantId, createdAt])
@@index([tenantId, queueId])
@@ -1060,3 +1066,55 @@ model CallEvent {
@@index([tenantId, callId, occurredAt])
@@map("call_events")
}
enum StorageProvider {
LOCAL
S3
@@map("storage_provider")
}
enum RecordingStatus {
AVAILABLE
DELETED
FAILED
@@map("recording_status")
}
// "recordings" (agente.md secao 90-94) — só chamadas originadas pelo
// PredictiveDialerEngine com Campaign.recordingEnabled são gravadas nesta
// fase (é o único caminho de originate que o sistema controla hoje; ver
// docs/RECORDING.md). `objectKey` segue a estrutura da secao 93
// (tenants/{tenant_id}/recordings/YYYY/MM/DD/{call_id}.wav) — nunca
// aceita um valor vindo do client, sempre construído no servidor.
model Recording {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
callId String @unique @map("call_id") @db.Uuid
storageProvider StorageProvider @map("storage_provider")
objectKey String @map("object_key")
format String @default("wav")
durationSeconds Int? @map("duration_seconds")
channels Int @default(2)
sizeBytes BigInt? @map("size_bytes")
checksum String?
recordedAt DateTime @map("recorded_at")
retentionUntil DateTime? @map("retention_until")
status RecordingStatus @default(AVAILABLE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
call Call @relation(fields: [callId], references: [id])
@@index([tenantId, recordedAt])
@@index([retentionUntil])
@@map("recordings")
}

View 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"
}
}

View 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;
}

View 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);
});
}

View 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}`;
}

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);
});
}

View 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>;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

318
pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ importers:
'@b2bcall/shared':
specifier: workspace:*
version: link:../../packages/shared
'@b2bcall/storage':
specifier: workspace:*
version: link:../../packages/storage
'@b2bcall/telephony':
specifier: workspace:*
version: link:../../packages/telephony
@@ -127,6 +130,9 @@ importers:
'@b2bcall/shared':
specifier: workspace:*
version: link:../../packages/shared
'@b2bcall/storage':
specifier: workspace:*
version: link:../../packages/storage
'@b2bcall/telephony':
specifier: workspace:*
version: link:../../packages/telephony
@@ -242,6 +248,19 @@ importers:
specifier: ^5.7.0
version: 5.9.3
packages/storage:
dependencies:
'@aws-sdk/client-s3':
specifier: 3.1110.0
version: 3.1110.0
devDependencies:
'@types/node':
specifier: ^22.20.1
version: 22.20.1
typescript:
specifier: ^5.7.0
version: 5.9.3
packages/telephony:
dependencies:
esl:
@@ -260,6 +279,78 @@ importers:
packages:
'@aws-sdk/checksums@3.1000.29':
resolution: {integrity: sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-s3@3.1110.0':
resolution: {integrity: sha512-40xbEcWjdaYKlZ4/NvndIJ3LotAEQAvHVQ7Z4NVy4Z4xGRN7xXJlHI9bMh/4aMJQ++6h5W5sv+wqjfk0rEKOBg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.977.9':
resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.70':
resolution: {integrity: sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.72':
resolution: {integrity: sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.15':
resolution: {integrity: sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.77':
resolution: {integrity: sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.81':
resolution: {integrity: sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.70':
resolution: {integrity: sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.14':
resolution: {integrity: sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.76':
resolution: {integrity: sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-sdk-s3@3.972.75':
resolution: {integrity: sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.44':
resolution: {integrity: sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.46':
resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1116.0':
resolution: {integrity: sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.974.5':
resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/xml-builder@3.972.40':
resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.3.0':
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
engines: {node: '>=18.0.0'}
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
@@ -786,6 +877,30 @@ packages:
'@types/react':
optional: true
'@smithy/core@3.33.3':
resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.5.2':
resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.7.2':
resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.11.3':
resolution: {integrity: sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.7.3':
resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.17.2':
resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==}
engines: {node: '>=18.0.0'}
'@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
@@ -930,6 +1045,9 @@ packages:
better-result@2.10.0:
resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==}
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
c12@3.3.4:
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
peerDependencies:
@@ -1628,6 +1746,171 @@ packages:
snapshots:
'@aws-sdk/checksums@3.1000.29':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/client-s3@3.1110.0':
dependencies:
'@aws-sdk/checksums': 3.1000.29
'@aws-sdk/core': 3.977.9
'@aws-sdk/credential-provider-node': 3.972.81
'@aws-sdk/middleware-sdk-s3': 3.972.75
'@aws-sdk/signature-v4-multi-region': 3.996.46
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/core@3.977.9':
dependencies:
'@aws-sdk/types': 3.974.5
'@aws-sdk/xml-builder': 3.972.40
'@aws/lambda-invoke-store': 0.3.0
'@smithy/core': 3.33.3
'@smithy/signature-v4': 5.7.3
'@smithy/types': 4.17.2
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.70':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.72':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.15':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/credential-provider-env': 3.972.70
'@aws-sdk/credential-provider-http': 3.972.72
'@aws-sdk/credential-provider-login': 3.972.77
'@aws-sdk/credential-provider-process': 3.972.70
'@aws-sdk/credential-provider-sso': 3.973.14
'@aws-sdk/credential-provider-web-identity': 3.972.76
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.77':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.81':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.70
'@aws-sdk/credential-provider-http': 3.972.72
'@aws-sdk/credential-provider-ini': 3.973.15
'@aws-sdk/credential-provider-process': 3.972.70
'@aws-sdk/credential-provider-sso': 3.973.14
'@aws-sdk/credential-provider-web-identity': 3.972.76
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.70':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.14':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/token-providers': 3.1116.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.76':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/middleware-sdk-s3@3.972.75':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/signature-v4-multi-region': 3.996.46
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.44':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/signature-v4-multi-region': 3.996.46
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.46':
dependencies:
'@aws-sdk/types': 3.974.5
'@smithy/signature-v4': 5.7.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/token-providers@3.1116.0':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/types@3.974.5':
dependencies:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.40':
dependencies:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws/lambda-invoke-store@0.3.0': {}
'@babel/helper-string-parser@7.29.7':
optional: true
@@ -2078,6 +2361,39 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
'@smithy/core@3.33.3':
dependencies:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/credential-provider-imds@4.5.2':
dependencies:
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/fetch-http-handler@5.7.2':
dependencies:
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/node-http-handler@4.11.3':
dependencies:
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/signature-v4@5.7.3':
dependencies:
'@smithy/core': 3.33.3
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/types@4.17.2':
dependencies:
tslib: 2.8.1
'@socket.io/component-emitter@3.1.2': {}
'@standard-schema/spec@1.1.0': {}
@@ -2259,6 +2575,8 @@ snapshots:
better-result@2.10.0: {}
bowser@2.14.1: {}
c12@3.3.4(magicast@0.5.4):
dependencies:
chokidar: 5.0.0