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
|
# Docker volumes / data
|
||||||
infrastructure/**/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
|
- [ ] Cross-tenant reports pra platform admin (secao 157) — sem console
|
||||||
de plataforma ainda
|
de plataforma ainda
|
||||||
|
|
||||||
## PHASE 18+ — ver `agente.md` seções 90 em diante (Recordings, AI,
|
## PHASE 18 — Recording / Object Storage (agente.md secao 90-94)
|
||||||
Billing, Frontend, Security, Tests)
|
- [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/database": "workspace:*",
|
||||||
"@b2bcall/entitlements": "workspace:*",
|
"@b2bcall/entitlements": "workspace:*",
|
||||||
"@b2bcall/shared": "workspace:*",
|
"@b2bcall/shared": "workspace:*",
|
||||||
|
"@b2bcall/storage": "workspace:*",
|
||||||
"@b2bcall/telephony": "workspace:*",
|
"@b2bcall/telephony": "workspace:*",
|
||||||
"@fastify/cors": "11.3.0",
|
"@fastify/cors": "11.3.0",
|
||||||
"@fastify/helmet": "13.1.1",
|
"@fastify/helmet": "13.1.1",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { SuppressionModule } from "./suppression/suppression.module";
|
|||||||
import { DispositionsModule } from "./dispositions/dispositions.module";
|
import { DispositionsModule } from "./dispositions/dispositions.module";
|
||||||
import { CallsModule } from "./calls/calls.module";
|
import { CallsModule } from "./calls/calls.module";
|
||||||
import { ReportsModule } from "./reports/reports.module";
|
import { ReportsModule } from "./reports/reports.module";
|
||||||
|
import { RecordingsModule } from "./recordings/recordings.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -32,6 +33,7 @@ import { ReportsModule } from "./reports/reports.module";
|
|||||||
DispositionsModule,
|
DispositionsModule,
|
||||||
CallsModule,
|
CallsModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
|
RecordingsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import cors from "@fastify/cors";
|
|||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { DomainExceptionFilter } from "./common/filters/domain-exception.filter";
|
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() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
@@ -51,6 +54,15 @@ async function bootstrap() {
|
|||||||
const port = Number(process.env.API_PORT ?? 3000);
|
const port = Number(process.env.API_PORT ?? 3000);
|
||||||
await app.listen(port, "127.0.0.1");
|
await app.listen(port, "127.0.0.1");
|
||||||
console.log(`b2bcall-api ouvindo em http://127.0.0.1:${port}`);
|
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();
|
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/shared packages/shared
|
||||||
COPY packages/telephony packages/telephony
|
COPY packages/telephony packages/telephony
|
||||||
COPY packages/database packages/database
|
COPY packages/database packages/database
|
||||||
|
COPY packages/storage packages/storage
|
||||||
COPY apps/freeswitch-events apps/freeswitch-events
|
COPY apps/freeswitch-events apps/freeswitch-events
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile --filter @b2bcall/freeswitch-events...
|
RUN pnpm install --frozen-lockfile --filter @b2bcall/freeswitch-events...
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@b2bcall/database": "workspace:*",
|
"@b2bcall/database": "workspace:*",
|
||||||
"@b2bcall/shared": "workspace:*",
|
"@b2bcall/shared": "workspace:*",
|
||||||
|
"@b2bcall/storage": "workspace:*",
|
||||||
"@b2bcall/telephony": "workspace:*",
|
"@b2bcall/telephony": "workspace:*",
|
||||||
"esl": "11.2.1",
|
"esl": "11.2.1",
|
||||||
"ioredis": "^6.0.0"
|
"ioredis": "^6.0.0"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createLogger } from "@b2bcall/shared";
|
|||||||
import { updateTrunkStatusFromGatewayEvent } from "./trunk-status";
|
import { updateTrunkStatusFromGatewayEvent } from "./trunk-status";
|
||||||
import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve";
|
import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve";
|
||||||
import { persistCallEvent } from "./cdr";
|
import { persistCallEvent } from "./cdr";
|
||||||
|
import { uploadRecordingIfPresent } from "./recording";
|
||||||
|
|
||||||
const logger = createLogger("b2bcall-fs-events");
|
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) });
|
logger.error("falha ao publicar evento normalizado no Redis", { error: String(err) });
|
||||||
});
|
});
|
||||||
|
|
||||||
persistCallEvent(normalized).catch((err) => {
|
// .then() em vez de esperar aqui (await bloquearia o processamento do
|
||||||
logger.error("falha ao persistir CDR", { error: String(err), type: normalized.type });
|
// 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}`, {
|
logger.info(`evento: ${normalized.type}`, {
|
||||||
callUuid: normalized.callUuid,
|
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";
|
import type { FreeSwitchTelephonyProvider } from "@b2bcall/telephony";
|
||||||
|
|
||||||
export interface OriginateIdentifiers {
|
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 é
|
* Perna "atendida" sintética (modo simulação): quando o outcome sorteado é
|
||||||
* ANSWERED, origina uma chamada de verdade no FreeSWITCH (`null/dummy`,
|
* ANSWERED, origina uma chamada de verdade no FreeSWITCH (`null/dummy`,
|
||||||
@@ -33,12 +62,18 @@ export async function originateSimulatedAnswerLeg(
|
|||||||
ids: OriginateIdentifiers,
|
ids: OriginateIdentifiers,
|
||||||
queueId: string,
|
queueId: string,
|
||||||
domain: string,
|
domain: string,
|
||||||
|
recordingEnabled: boolean,
|
||||||
): Promise<{ uuid: string }> {
|
): Promise<{ uuid: string }> {
|
||||||
|
const originationUuid = randomUUID();
|
||||||
return provider.originate({
|
return provider.originate({
|
||||||
destination: "null/dummy",
|
destination: "null/dummy",
|
||||||
application: "callcenter",
|
application: "callcenter",
|
||||||
applicationArgs: `${queueId}@${domain}`,
|
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;
|
callerIdName?: string;
|
||||||
callerIdNumber?: string;
|
callerIdNumber?: string;
|
||||||
ringTimeoutSeconds: number;
|
ringTimeoutSeconds: number;
|
||||||
|
recordingEnabled: boolean;
|
||||||
},
|
},
|
||||||
): Promise<{ uuid: string }> {
|
): Promise<{ uuid: string }> {
|
||||||
|
const originationUuid = randomUUID();
|
||||||
return provider.originate({
|
return provider.originate({
|
||||||
destination: `sofia/gateway/${params.trunkId}/${params.phoneNumber}`,
|
destination: `sofia/gateway/${params.trunkId}/${params.phoneNumber}`,
|
||||||
application: "callcenter",
|
application: "callcenter",
|
||||||
applicationArgs: `${params.queueId}@${params.domain}`,
|
applicationArgs: `${params.queueId}@${params.domain}`,
|
||||||
channelVariables: channelVars(ids),
|
channelVariables: {
|
||||||
|
...channelVars(ids),
|
||||||
|
...recordingVars(originationUuid, params.recordingEnabled),
|
||||||
|
origination_uuid: originationUuid,
|
||||||
|
},
|
||||||
callerIdName: params.callerIdName,
|
callerIdName: params.callerIdName,
|
||||||
callerIdNumber: params.callerIdNumber,
|
callerIdNumber: params.callerIdNumber,
|
||||||
timeoutSeconds: params.ringTimeoutSeconds,
|
timeoutSeconds: params.ringTimeoutSeconds,
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ async function runSimulatedAttempt(
|
|||||||
{ tenantId: tenant.id, attemptId, campaignId: campaign.id, leadId },
|
{ tenantId: tenant.id, attemptId, campaignId: campaign.id, leadId },
|
||||||
campaign.queueId,
|
campaign.queueId,
|
||||||
tenant.telephonyDomain ?? "",
|
tenant.telephonyDomain ?? "",
|
||||||
|
campaign.recordingEnabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
registerQueuedAttempt(uuid, {
|
registerQueuedAttempt(uuid, {
|
||||||
@@ -228,6 +229,7 @@ async function runRealAttempt(
|
|||||||
callerIdName: campaign.callerIdName ?? undefined,
|
callerIdName: campaign.callerIdName ?? undefined,
|
||||||
callerIdNumber: campaign.callerIdNumber ?? undefined,
|
callerIdNumber: campaign.callerIdNumber ?? undefined,
|
||||||
ringTimeoutSeconds: campaign.ringTimeout,
|
ringTimeoutSeconds: campaign.ringTimeout,
|
||||||
|
recordingEnabled: campaign.recordingEnabled,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- freeswitch_external_gateways:/etc/freeswitch/sip_profiles/external
|
- freeswitch_external_gateways:/etc/freeswitch/sip_profiles/external
|
||||||
- freeswitch_callcenter_queues:/etc/freeswitch/autoload_configs/callcenter_queues.conf.d
|
- 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
|
# 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
|
# configurados, e o Event Socket (8021) só deve ser alcançável por outros
|
||||||
# containers na rede interna do compose (agente.md secao 22).
|
# 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
|
# REDIS_URL do .env que aponta pra localhost (uso pelo apps/api, que
|
||||||
# ainda roda no host) — ver docs/NETWORK_ARCHITECTURE.md.
|
# ainda roda no host) — ver docs/NETWORK_ARCHITECTURE.md.
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
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:
|
predictive-dialer:
|
||||||
build:
|
build:
|
||||||
@@ -138,6 +157,10 @@ services:
|
|||||||
# por default. Ver docs/PREDICTIVE_DIALER.md.
|
# por default. Ver docs/PREDICTIVE_DIALER.md.
|
||||||
DIALER_SIMULATION: ${DIALER_SIMULATION:-true}
|
DIALER_SIMULATION: ${DIALER_SIMULATION:-true}
|
||||||
ALLOW_REAL_OUTBOUND_CALLS: ${ALLOW_REAL_OUTBOUND_CALLS:-false}
|
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:
|
secrets:
|
||||||
freeswitch_pat:
|
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[]
|
calls Call[]
|
||||||
callLegs CallLeg[]
|
callLegs CallLeg[]
|
||||||
callEvents CallEvent[]
|
callEvents CallEvent[]
|
||||||
|
recordings Recording[]
|
||||||
|
|
||||||
@@map("tenants")
|
@@map("tenants")
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,10 @@ model Plan {
|
|||||||
maxMonthlyCalls Int? @map("max_monthly_calls")
|
maxMonthlyCalls Int? @map("max_monthly_calls")
|
||||||
maxRecordingStorageGb Int? @map("max_recording_storage_gb")
|
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")
|
recordingEnabled Boolean @default(true) @map("recording_enabled")
|
||||||
aiEnabled Boolean @default(false) @map("ai_enabled")
|
aiEnabled Boolean @default(false) @map("ai_enabled")
|
||||||
aiTranscriptionEnabled Boolean @default(false) @map("ai_transcription_enabled")
|
aiTranscriptionEnabled Boolean @default(false) @map("ai_transcription_enabled")
|
||||||
@@ -1007,6 +1012,7 @@ model Call {
|
|||||||
disposition Disposition? @relation(fields: [dispositionId], references: [id])
|
disposition Disposition? @relation(fields: [dispositionId], references: [id])
|
||||||
legs CallLeg[]
|
legs CallLeg[]
|
||||||
events CallEvent[]
|
events CallEvent[]
|
||||||
|
recording Recording?
|
||||||
|
|
||||||
@@index([tenantId, createdAt])
|
@@index([tenantId, createdAt])
|
||||||
@@index([tenantId, queueId])
|
@@index([tenantId, queueId])
|
||||||
@@ -1060,3 +1066,55 @@ model CallEvent {
|
|||||||
@@index([tenantId, callId, occurredAt])
|
@@index([tenantId, callId, occurredAt])
|
||||||
@@map("call_events")
|
@@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':
|
'@b2bcall/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
'@b2bcall/storage':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/storage
|
||||||
'@b2bcall/telephony':
|
'@b2bcall/telephony':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/telephony
|
version: link:../../packages/telephony
|
||||||
@@ -127,6 +130,9 @@ importers:
|
|||||||
'@b2bcall/shared':
|
'@b2bcall/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
'@b2bcall/storage':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/storage
|
||||||
'@b2bcall/telephony':
|
'@b2bcall/telephony':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/telephony
|
version: link:../../packages/telephony
|
||||||
@@ -242,6 +248,19 @@ importers:
|
|||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
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:
|
packages/telephony:
|
||||||
dependencies:
|
dependencies:
|
||||||
esl:
|
esl:
|
||||||
@@ -260,6 +279,78 @@ importers:
|
|||||||
|
|
||||||
packages:
|
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':
|
'@babel/helper-string-parser@7.29.7':
|
||||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -786,6 +877,30 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
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':
|
'@socket.io/component-emitter@3.1.2':
|
||||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||||
|
|
||||||
@@ -930,6 +1045,9 @@ packages:
|
|||||||
better-result@2.10.0:
|
better-result@2.10.0:
|
||||||
resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==}
|
resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==}
|
||||||
|
|
||||||
|
bowser@2.14.1:
|
||||||
|
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
|
||||||
|
|
||||||
c12@3.3.4:
|
c12@3.3.4:
|
||||||
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
|
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1628,6 +1746,171 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
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':
|
'@babel/helper-string-parser@7.29.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2078,6 +2361,39 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.18
|
'@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': {}
|
'@socket.io/component-emitter@3.1.2': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
@@ -2259,6 +2575,8 @@ snapshots:
|
|||||||
|
|
||||||
better-result@2.10.0: {}
|
better-result@2.10.0: {}
|
||||||
|
|
||||||
|
bowser@2.14.1: {}
|
||||||
|
|
||||||
c12@3.3.4(magicast@0.5.4):
|
c12@3.3.4(magicast@0.5.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
chokidar: 5.0.0
|
chokidar: 5.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user