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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
44
TODO.md
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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();
|
||||
|
||||
90
apps/api/src/recordings/recordings.controller.ts
Normal file
90
apps/api/src/recordings/recordings.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
7
apps/api/src/recordings/recordings.module.ts
Normal file
7
apps/api/src/recordings/recordings.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { RecordingsController } from "./recordings.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [RecordingsController],
|
||||
})
|
||||
export class RecordingsModule {}
|
||||
45
apps/api/src/recordings/retention-sweep.ts
Normal file
45
apps/api/src/recordings/retention-sweep.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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...
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
103
apps/freeswitch-events/src/recording.ts
Normal file
103
apps/freeswitch-events/src/recording.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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
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.
|
||||
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
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"]
|
||||
}
|
||||
318
pnpm-lock.yaml
generated
318
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user