feat(ivr): upload de áudio pro prompt do IVR
Pedido do usuário: "adiciona upload de áudio pro prompt do IVR" — até aqui o prompt era só texto livre (na prática, sempre um tom padrão, nunca voz de verdade). `POST /ivr-menus/:id/prompt` (multipart via @fastify/multipart — primeiro upload de arquivo binário desta API) só aceita WAV (cabeçalho RIFF/WAVE validado antes de gravar; esta implantação do FreeSWITCH não tem mod_shout, então MP3 nunca funcionaria de qualquer forma). Gravado num bind mount NOVO (./data/ivr-prompts no host ↔ /ivr-prompts no container freeswitch) — mesma convenção já usada 3x neste projeto pra arquivo que o FreeSWITCH precisa enxergar de verdade (gateways externos, filas do callcenter, spool de gravação), mas na direção contrária: apps/api (host) escreve o que o usuário sobe, o FreeSWITCH lê ao vivo durante play_and_get_digits. Um fetch em rede (S3/HTTP) durante uma chamada ativa foi descartado de propósito — latência/confiabilidade desnecessárias pra um prompt de poucos segundos. GET /ivr-menus/:id/prompt (autenticado) serve o preview — mesmo princípio do player de gravações, nunca uma URL direta pro storage. Achado real corrigido antes de commitar: minha primeira versão do delete de menu deixava o .wav órfão no disco — agora deletar o menu ou trocar/remover o prompt sempre limpa o arquivo, confirmado com um teste real de upload+delete. Tela "Telefonia > IVR" ganhou upload/troca/remoção de áudio por menu + player de preview. Testado ponta a ponta com um WAV real de 44.1kHz/mono (não o formato "nativo" de telefonia, de propósito, pra confirmar que funciona com o que uma pessoa qualquer gravaria): softphone externo discou o DID, play_and_get_digits abriu e tocou o arquivo até o fim duas vezes (mod_sndfile resample automático, sem erro no log), colheu o dígito real e bridged corretamente com o ramal de destino. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"@b2bcall/telephony": "workspace:*",
|
||||
"@fastify/cors": "11.3.0",
|
||||
"@fastify/helmet": "13.1.1",
|
||||
"@fastify/multipart": "^10.1.1",
|
||||
"@fastify/rate-limit": "11.2.0",
|
||||
"@nestjs/common": "^12.0.1",
|
||||
"@nestjs/core": "^12.0.1",
|
||||
|
||||
@@ -11,8 +11,14 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { XMLValidator } from "fast-xml-parser";
|
||||
import { getPrismaClient, withTenantContext, Prisma } from "@b2bcall/database";
|
||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
@@ -23,6 +29,26 @@ import { RequirePermission } from "../common/decorators/require-permission.decor
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { CreateIvrMenuDto, UpdateIvrMenuDto } from "./dto/create-ivr-menu.dto";
|
||||
|
||||
// Path do HOST (apps/api roda fora do Docker) — o mesmo diretório
|
||||
// aparece como /ivr-prompts dentro do container freeswitch (ver
|
||||
// docker-compose.yml). `IvrMenu.greeting` grava o path como o
|
||||
// FreeSWITCH enxerga (container), nunca o path do host.
|
||||
const IVR_PROMPTS_HOST_ROOT = process.env.IVR_PROMPTS_HOST_ROOT ?? "/opt/b2bcall/data/ivr-prompts";
|
||||
const IVR_PROMPTS_CONTAINER_ROOT = "/ivr-prompts";
|
||||
|
||||
function isValidWavHeader(buf: Buffer): boolean {
|
||||
// RIFF....WAVE — cabeçalho mínimo, suficiente pra rejeitar qualquer
|
||||
// coisa que não seja WAV antes de gravar no disco compartilhado com o
|
||||
// FreeSWITCH (mod_sndfile está carregado e resample sozinho qualquer
|
||||
// sample rate/canais válidos; não há mod_shout nesta implantação, então
|
||||
// MP3 nunca funcionaria — melhor rejeitar cedo com mensagem clara).
|
||||
return buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WAVE";
|
||||
}
|
||||
|
||||
function promptHostPath(tenantId: string, menuId: string): string {
|
||||
return join(IVR_PROMPTS_HOST_ROOT, tenantId, `${menuId}.wav`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tela de autoria de IVR (PHASE 58, docs/INBOUND_ROUTES.md) — por cima do
|
||||
* editor genérico de dialplan (PHASE 56/57): criar/editar um `IvrMenu`
|
||||
@@ -248,6 +274,14 @@ export class IvrMenusController {
|
||||
});
|
||||
});
|
||||
|
||||
if (menu.greeting?.startsWith(IVR_PROMPTS_CONTAINER_ROOT)) {
|
||||
try {
|
||||
await unlink(promptHostPath(tenantId, id));
|
||||
} catch {
|
||||
// arquivo já não existia — sem problema, o menu já foi apagado.
|
||||
}
|
||||
}
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "IVR_MENU_DELETE",
|
||||
tenantId,
|
||||
@@ -256,4 +290,118 @@ export class IvrMenusController {
|
||||
entityId: id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload do prompt de áudio (PHASE 59, docs/INBOUND_ROUTES.md) —
|
||||
* único endpoint desta API que recebe um arquivo binário. Grava no
|
||||
* disco compartilhado com o FreeSWITCH (nunca no object storage de
|
||||
* gravações — aquele é lido por humanos depois da chamada via proxy
|
||||
* autenticado; este precisa ser lido pelo PRÓPRIO FreeSWITCH ao vivo
|
||||
* durante `play_and_get_digits`, então tem que ser um arquivo local
|
||||
* de verdade, não uma URL de rede) e recompila o dialplan do menu com
|
||||
* o novo `greeting` apontando pro path que o FreeSWITCH enxerga.
|
||||
*/
|
||||
@RequirePermission("ivr.manage")
|
||||
@Post(":id/prompt")
|
||||
async uploadPrompt(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Req() request: FastifyRequest) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const menu = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.ivrMenu.findFirst({ where: { id, deletedAt: null }, include: { options: true } }),
|
||||
);
|
||||
if (!menu) throw new NotFoundException();
|
||||
|
||||
const data = await request.file();
|
||||
if (!data) throw new BadRequestException("Nenhum arquivo enviado");
|
||||
|
||||
const buffer = await data.toBuffer();
|
||||
if (!isValidWavHeader(buffer)) {
|
||||
throw new BadRequestException(
|
||||
"Arquivo não é um WAV válido — só WAV é aceito (esta implantação do FreeSWITCH não tem suporte a MP3)",
|
||||
);
|
||||
}
|
||||
|
||||
const hostPath = promptHostPath(tenantId, id);
|
||||
await mkdir(join(IVR_PROMPTS_HOST_ROOT, tenantId), { recursive: true });
|
||||
await writeFile(hostPath, buffer);
|
||||
|
||||
const containerPath = `${IVR_PROMPTS_CONTAINER_ROOT}/${tenantId}/${id}.wav`;
|
||||
const updated = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.ivrMenu.update({ where: { id }, data: { greeting: containerPath } }),
|
||||
);
|
||||
|
||||
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
|
||||
digit: o.digit,
|
||||
destinationNumber: o.destinationNumber,
|
||||
destinationContext: o.destinationContext,
|
||||
}));
|
||||
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, updated, options);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "IVR_MENU_PROMPT_UPLOAD",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "ivr_menu",
|
||||
entityId: id,
|
||||
after: { filename: data.filename, sizeBytes: buffer.length },
|
||||
});
|
||||
|
||||
return this.get(user, id);
|
||||
}
|
||||
|
||||
@RequirePermission("ivr.manage")
|
||||
@Delete(":id/prompt")
|
||||
async removePrompt(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const menu = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.ivrMenu.findFirst({ where: { id, deletedAt: null }, include: { options: true } }),
|
||||
);
|
||||
if (!menu) throw new NotFoundException();
|
||||
|
||||
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.update({ where: { id }, data: { greeting: null } }));
|
||||
|
||||
try {
|
||||
await unlink(promptHostPath(tenantId, id));
|
||||
} catch {
|
||||
// arquivo já não existia — nada a fazer, greeting já voltou a null.
|
||||
}
|
||||
|
||||
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
|
||||
digit: o.digit,
|
||||
destinationNumber: o.destinationNumber,
|
||||
destinationContext: o.destinationContext,
|
||||
}));
|
||||
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, updated, options);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "IVR_MENU_PROMPT_DELETE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "ivr_menu",
|
||||
entityId: id,
|
||||
});
|
||||
|
||||
return this.get(user, id);
|
||||
}
|
||||
|
||||
/** Preview autenticado do prompt — mesmo princípio do player de
|
||||
* gravações (nunca uma URL direta pro storage/disco). */
|
||||
@RequirePermission("ivr.view")
|
||||
@Get(":id/prompt")
|
||||
async downloadPrompt(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Res() reply: FastifyReply) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const menu = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.findFirst({ where: { id, deletedAt: null } }));
|
||||
if (!menu || !menu.greeting?.startsWith(IVR_PROMPTS_CONTAINER_ROOT)) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
reply.header("Content-Type", "audio/wav");
|
||||
reply.header("Content-Disposition", "inline");
|
||||
reply.send(createReadStream(promptHostPath(tenantId, id)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ValidationPipe } from "@nestjs/common";
|
||||
import helmet from "@fastify/helmet";
|
||||
import cors from "@fastify/cors";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import multipart from "@fastify/multipart";
|
||||
import { AppModule } from "./app.module";
|
||||
import { DomainExceptionFilter } from "./common/filters/domain-exception.filter";
|
||||
import { runRetentionSweep } from "./recordings/retention-sweep";
|
||||
@@ -38,6 +39,14 @@ async function bootstrap() {
|
||||
timeWindow: "1 minute",
|
||||
});
|
||||
|
||||
// Upload de prompt de áudio do IVR (PHASE 59) — único endpoint desta
|
||||
// API que recebe um arquivo binário; limite de tamanho aqui evita um
|
||||
// upload gigante travar o processo (ver ivr-menus.controller.ts pra
|
||||
// validação de formato/conteúdo).
|
||||
await app.register(multipart, {
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
|
||||
Reference in New Issue
Block a user