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:
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user