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:
38
TODO.md
38
TODO.md
@@ -2141,10 +2141,40 @@ tela de IVR no frontend")
|
|||||||
colheu o dígito com DTMF real (`uuid_recv_dtmf`) e bridged com o
|
colheu o dígito com DTMF real (`uuid_recv_dtmf`) e bridged com o
|
||||||
ramal certo — confirma que o compilador produz XML funcionalmente
|
ramal certo — confirma que o compilador produz XML funcionalmente
|
||||||
idêntico ao testado manualmente na PHASE 56
|
idêntico ao testado manualmente na PHASE 56
|
||||||
- [ ] Sem pipeline de upload/TTS de prompt de áudio (texto livre/tom
|
- [ ] Sem TTS (texto→voz); sem sub-menu (IVR dentro de IVR) nem destino
|
||||||
padrão só); sem sub-menu (IVR dentro de IVR) nem destino "fila";
|
"fila"; "Rotas de Entrada" ainda não tem um seletor dedicado de
|
||||||
"Rotas de Entrada" ainda não tem um seletor dedicado de "IVR" como
|
"IVR" como destino (usuário copia contexto/`ivr_entry` da tela de IVR)
|
||||||
destino (usuário copia contexto/`ivr_entry` da tela de IVR)
|
|
||||||
|
## PHASE 59 — Upload de áudio pro prompt do IVR (pedido do usuário:
|
||||||
|
"adiciona upload de áudio pro prompt do IVR")
|
||||||
|
- [x] `POST /ivr-menus/:id/prompt` (multipart, `@fastify/multipart` —
|
||||||
|
primeiro upload de arquivo binário desta API) — só aceita WAV
|
||||||
|
(cabeçalho RIFF/WAVE validado; sem `mod_shout` nesta implantação,
|
||||||
|
MP3 nunca funcionaria). Gravado num bind mount NOVO
|
||||||
|
(`./data/ivr-prompts` no host ↔ `/ivr-prompts` no container
|
||||||
|
freeswitch) — mesmo raciocínio já usado 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 FreeSWITCH lê ao vivo
|
||||||
|
durante `play_and_get_digits`. Um fetch em rede (S3/HTTP) durante
|
||||||
|
a chamada foi descartado de propósito — latência desnecessária
|
||||||
|
pra um prompt de poucos segundos
|
||||||
|
- [x] `GET /ivr-menus/:id/prompt` (autenticado, `ivr.view`) serve o
|
||||||
|
preview — mesmo princípio do player de gravações, nunca uma URL
|
||||||
|
direta pro storage/disco. Deletar o menu ou trocar/remover o
|
||||||
|
prompt sempre limpa o arquivo do disco (achado real: minha
|
||||||
|
primeira versão do delete de menu deixava o .wav órfão — corrigido
|
||||||
|
antes de commitar, confirmado com teste real de upload+delete)
|
||||||
|
- [x] Tela "Telefonia > IVR" ganhou upload/troca/remoção de áudio por
|
||||||
|
menu + player `<audio>` de preview (proxy autenticado, mesmo
|
||||||
|
padrão de `/api/recordings/[id]/audio`)
|
||||||
|
- [x] Testado ponta a ponta com um WAV real de 44.1kHz/mono (não o
|
||||||
|
formato "nativo" de telefonia, de propósito): 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 —
|
||||||
|
confirma que áudio de qualquer sample rate/formato WAV comum
|
||||||
|
funciona sem transcodificação manual
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@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",
|
||||||
|
"@fastify/multipart": "^10.1.1",
|
||||||
"@fastify/rate-limit": "11.2.0",
|
"@fastify/rate-limit": "11.2.0",
|
||||||
"@nestjs/common": "^12.0.1",
|
"@nestjs/common": "^12.0.1",
|
||||||
"@nestjs/core": "^12.0.1",
|
"@nestjs/core": "^12.0.1",
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} 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 { XMLValidator } from "fast-xml-parser";
|
||||||
import { getPrismaClient, withTenantContext, Prisma } from "@b2bcall/database";
|
import { getPrismaClient, withTenantContext, Prisma } from "@b2bcall/database";
|
||||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
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 { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { CreateIvrMenuDto, UpdateIvrMenuDto } from "./dto/create-ivr-menu.dto";
|
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
|
* 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`
|
* 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, {
|
await recordAuditEvent(prisma, {
|
||||||
action: "IVR_MENU_DELETE",
|
action: "IVR_MENU_DELETE",
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -256,4 +290,118 @@ export class IvrMenusController {
|
|||||||
entityId: id,
|
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 helmet from "@fastify/helmet";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import multipart from "@fastify/multipart";
|
||||||
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";
|
import { runRetentionSweep } from "./recordings/retention-sweep";
|
||||||
@@ -38,6 +39,14 @@ async function bootstrap() {
|
|||||||
timeWindow: "1 minute",
|
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(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
whitelist: true,
|
whitelist: true,
|
||||||
|
|||||||
31
apps/frontend/src/app/api/ivr-menus/[id]/prompt/route.ts
Normal file
31
apps/frontend/src/app/api/ivr-menus/[id]/prompt/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
|
import { getSession } from "@/lib/session";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy autenticado pro áudio do prompt de IVR — mesmo princípio do
|
||||||
|
* player de gravações (`/api/recordings/[id]/audio`): o elemento
|
||||||
|
* `<audio>` não manda `Authorization: Bearer ...`, só cookie, então
|
||||||
|
* reencaminha com o access token do lado do servidor.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return new NextResponse(null, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const upstream = await fetch(`${API_BASE_URL}/ivr-menus/${id}/prompt`, {
|
||||||
|
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!upstream.ok || !upstream.body) {
|
||||||
|
return new NextResponse(null, { status: upstream.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
const contentType = upstream.headers.get("content-type");
|
||||||
|
if (contentType) headers.set("Content-Type", contentType);
|
||||||
|
headers.set("Content-Disposition", "inline");
|
||||||
|
|
||||||
|
return new NextResponse(upstream.body, { status: 200, headers });
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requireSession } from "@/lib/session";
|
import { requireSession } from "@/lib/session";
|
||||||
import { apiFetch, ApiError } from "@/lib/api";
|
import { apiFetch, ApiError, API_BASE_URL } from "@/lib/api";
|
||||||
import type { IvrMenu } from "@/lib/callcenter-types";
|
import type { IvrMenu } from "@/lib/callcenter-types";
|
||||||
|
|
||||||
function extractErrorMessage(err: unknown): string {
|
function extractErrorMessage(err: unknown): string {
|
||||||
@@ -53,3 +53,39 @@ export async function deleteIvrMenu(id: string): Promise<{ ok: true } | { ok: fa
|
|||||||
return { ok: false, error: extractErrorMessage(err) };
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload de arquivo binário — não usa `apiFetch` de propósito: aquele
|
||||||
|
// helper sempre manda `Content-Type: application/json`, e um multipart
|
||||||
|
// precisa do boundary calculado pelo próprio runtime (nunca setado à
|
||||||
|
// mão), então o fetch aqui é cru.
|
||||||
|
export async function uploadIvrMenuPrompt(menuId: string, formData: FormData): Promise<{ ok: true; menu: IvrMenu } | { ok: false; error: string }> {
|
||||||
|
const session = await requireSession();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/ivr-menus/${menuId}/prompt`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ message: "Falha ao enviar áudio" }));
|
||||||
|
const message = Array.isArray(body.message) ? body.message.join(" ") : (body.message ?? "Falha ao enviar áudio");
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
const menu = (await res.json()) as IvrMenu;
|
||||||
|
revalidatePath("/app/telefonia/ivr");
|
||||||
|
return { ok: true, menu };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Falha inesperada ao enviar áudio." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteIvrMenuPrompt(menuId: string): Promise<{ ok: true; menu: IvrMenu } | { ok: false; error: string }> {
|
||||||
|
const session = await requireSession();
|
||||||
|
try {
|
||||||
|
const menu = await apiFetch<IvrMenu>(`/ivr-menus/${menuId}/prompt`, session.accessToken, { method: "DELETE" });
|
||||||
|
revalidatePath("/app/telefonia/ivr");
|
||||||
|
return { ok: true, menu };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useRef, useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ListTree, Plus, Trash2, X } from "lucide-react";
|
import { ListTree, Music, Plus, Trash2, Upload, X } from "lucide-react";
|
||||||
import { Panel, PanelHeader } from "@/components/ui/panel";
|
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
||||||
@@ -10,7 +10,9 @@ import { Pill } from "@/components/ui/pill";
|
|||||||
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
||||||
import { ALLOWED_IVR_DIGITS, IVR_ENTRY_DESTINATION, type IvrMenu } from "@/lib/callcenter-types";
|
import { ALLOWED_IVR_DIGITS, IVR_ENTRY_DESTINATION, type IvrMenu } from "@/lib/callcenter-types";
|
||||||
import type { Extension } from "@/lib/extension-types";
|
import type { Extension } from "@/lib/extension-types";
|
||||||
import { createIvrMenu, deleteIvrMenu, type IvrMenuOptionInput } from "./actions";
|
import { createIvrMenu, deleteIvrMenu, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions";
|
||||||
|
|
||||||
|
const CUSTOM_PROMPT_PREFIX = "/ivr-prompts/";
|
||||||
|
|
||||||
function slugifyContext(name: string): string {
|
function slugifyContext(name: string): string {
|
||||||
return (
|
return (
|
||||||
@@ -66,6 +68,7 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
|
|||||||
Rota de entrada: contexto <span className="text-foreground">{menu.context}</span> · destino{" "}
|
Rota de entrada: contexto <span className="text-foreground">{menu.context}</span> · destino{" "}
|
||||||
<span className="text-foreground">{IVR_ENTRY_DESTINATION}</span>
|
<span className="text-foreground">{IVR_ENTRY_DESTINATION}</span>
|
||||||
</p>
|
</p>
|
||||||
|
<PromptControl menu={menu} />
|
||||||
<Table>
|
<Table>
|
||||||
<THead>
|
<THead>
|
||||||
<TR>
|
<TR>
|
||||||
@@ -93,6 +96,67 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PromptControl({ menu }: { menu: IvrMenu }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const hasCustomPrompt = menu.greeting?.startsWith(CUSTOM_PROMPT_PREFIX) ?? false;
|
||||||
|
|
||||||
|
function onFileChosen(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setError(null);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await uploadIvrMenuPrompt(menu.id, formData);
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
e.target.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemove() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteIvrMenuPrompt(menu.id);
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
|
||||||
|
<Music className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
{hasCustomPrompt ? (
|
||||||
|
<>
|
||||||
|
<span className="text-foreground">Prompt de áudio enviado</span>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption -- prompt de voz, sem faixa de legenda aplicável */}
|
||||||
|
<audio controls preload="none" src={`/api/ivr-menus/${menu.id}/prompt`} className="h-8" />
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onRemove} disabled={pending}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" aria-hidden /> Remover
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Sem áudio enviado — toca um tom padrão</span>
|
||||||
|
)}
|
||||||
|
<input ref={fileInputRef} type="file" accept="audio/wav,.wav" className="hidden" onChange={onFileChosen} disabled={pending} />
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()} disabled={pending}>
|
||||||
|
<Upload className="h-3.5 w-3.5" aria-hidden /> {pending ? "Enviando…" : hasCustomPrompt ? "Trocar áudio (WAV)" : "Enviar áudio (WAV)"}
|
||||||
|
</Button>
|
||||||
|
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface OptionRow {
|
interface OptionRow {
|
||||||
digit: string;
|
digit: string;
|
||||||
destinationNumber: string;
|
destinationNumber: string;
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ services:
|
|||||||
# host e precisa enxergar os mesmos arquivos que o FreeSWITCH grava
|
# host e precisa enxergar os mesmos arquivos que o FreeSWITCH grava
|
||||||
# (agente.md secao 90-91) — ver docs/RECORDING.md.
|
# (agente.md secao 90-91) — ver docs/RECORDING.md.
|
||||||
- ./data/recordings-spool:/recordings
|
- ./data/recordings-spool:/recordings
|
||||||
|
# Prompts de áudio do IVR (PHASE 59) — mesmo raciocínio do bind
|
||||||
|
# mount acima, mas na direção contrária: apps/api (host) ESCREVE o
|
||||||
|
# arquivo que o usuário sobe, e o FreeSWITCH precisa LER esse mesmo
|
||||||
|
# arquivo ao vivo durante `play_and_get_digits`. Só funciona como
|
||||||
|
# bind mount de disco local — um fetch em rede (S3/HTTP) durante
|
||||||
|
# uma chamada ativa seria latência/confiabilidade desnecessárias
|
||||||
|
# pra um prompt de poucos segundos.
|
||||||
|
- ./data/ivr-prompts:/ivr-prompts
|
||||||
# Event Socket (8021) NUNCA publicado — só alcançável por outros
|
# Event Socket (8021) NUNCA publicado — só alcançável por outros
|
||||||
# containers na rede interna do compose (agente.md secao 22).
|
# containers na rede interna do compose (agente.md secao 22).
|
||||||
# SIP (5060) e RTP (16384-16584, range fixo no Dockerfile) publicados
|
# SIP (5060) e RTP (16384-16584, range fixo no Dockerfile) publicados
|
||||||
|
|||||||
@@ -150,10 +150,38 @@ recém-criado atendeu, tocou o prompt, colheu o dígito com DTMF real
|
|||||||
compilador produz XML funcionalmente idêntico ao testado manualmente na
|
compilador produz XML funcionalmente idêntico ao testado manualmente na
|
||||||
PHASE 56.
|
PHASE 56.
|
||||||
|
|
||||||
|
## Upload de áudio pro prompt (PHASE 59)
|
||||||
|
|
||||||
|
`POST /ivr-menus/:id/prompt` (multipart, campo `file`) — só aceita WAV
|
||||||
|
(cabeçalho RIFF/WAVE validado antes de gravar; sem `mod_shout` nesta
|
||||||
|
implantação, MP3 nunca funcionaria mesmo). Gravado num bind mount NOVO
|
||||||
|
compartilhado com o container do FreeSWITCH (`./data/ivr-prompts` no
|
||||||
|
host ↔ `/ivr-prompts` no container) — mesmo raciocínio já usado pras
|
||||||
|
gravações de chamada (`./data/recordings-spool`), mas na direção
|
||||||
|
contrária: aqui é `apps/api` (host) que ESCREVE e o FreeSWITCH que LÊ.
|
||||||
|
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, e esta é a MESMA convenção já usada 3x neste projeto
|
||||||
|
pra arquivos que o FreeSWITCH precisa enxergar (gateways externos, filas
|
||||||
|
do callcenter, spool de gravação).
|
||||||
|
|
||||||
|
`greeting` passa a guardar o path como o FreeSWITCH enxerga
|
||||||
|
(`/ivr-prompts/<tenantId>/<menuId>.wav`), nunca o path do host.
|
||||||
|
`GET /ivr-menus/:id/prompt` (autenticado, `ivr.view`) serve o preview —
|
||||||
|
mesmo princípio do player de gravações, nunca uma URL direta pro
|
||||||
|
storage/disco. Deletar o menu ou trocar/remover o prompt sempre limpa o
|
||||||
|
arquivo do disco (nunca deixa órfão).
|
||||||
|
|
||||||
|
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/exportaria): 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.
|
||||||
|
|
||||||
## O que falta
|
## O que falta
|
||||||
|
|
||||||
- Sem pipeline de upload/TTS de prompt de áudio — hoje é texto livre
|
- Sem TTS (texto→voz) — só upload de arquivo WAV já gravado.
|
||||||
(tom padrão ou um caminho/URL que o FreeSWITCH já sabe tocar).
|
|
||||||
- Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem
|
- Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem
|
||||||
destino "fila" — só ramal, dentro do contexto `default`.
|
destino "fila" — só ramal, dentro do contexto `default`.
|
||||||
- Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID →
|
- Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID →
|
||||||
|
|||||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -69,6 +69,9 @@ importers:
|
|||||||
'@fastify/helmet':
|
'@fastify/helmet':
|
||||||
specifier: 13.1.1
|
specifier: 13.1.1
|
||||||
version: 13.1.1
|
version: 13.1.1
|
||||||
|
'@fastify/multipart':
|
||||||
|
specifier: ^10.1.1
|
||||||
|
version: 10.1.1
|
||||||
'@fastify/rate-limit':
|
'@fastify/rate-limit':
|
||||||
specifier: 11.2.0
|
specifier: 11.2.0
|
||||||
version: 11.2.0
|
version: 11.2.0
|
||||||
@@ -689,9 +692,15 @@ packages:
|
|||||||
'@fastify/ajv-compiler@4.0.6':
|
'@fastify/ajv-compiler@4.0.6':
|
||||||
resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
|
resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
|
||||||
|
|
||||||
|
'@fastify/busboy@3.2.2':
|
||||||
|
resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==}
|
||||||
|
|
||||||
'@fastify/cors@11.3.0':
|
'@fastify/cors@11.3.0':
|
||||||
resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==}
|
resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==}
|
||||||
|
|
||||||
|
'@fastify/deepmerge@3.2.1':
|
||||||
|
resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
|
||||||
|
|
||||||
'@fastify/error@4.2.0':
|
'@fastify/error@4.2.0':
|
||||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||||
|
|
||||||
@@ -713,6 +722,9 @@ packages:
|
|||||||
'@fastify/merge-json-schemas@0.2.1':
|
'@fastify/merge-json-schemas@0.2.1':
|
||||||
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
||||||
|
|
||||||
|
'@fastify/multipart@10.1.1':
|
||||||
|
resolution: {integrity: sha512-jyRHgnFVdchZRKjJRf6kGPEiDp3Bg4MLo4d6/krt8Z4RutLrqL5IYWihx8a4tnv7Tu7JfuHcyC+dU9zPzTxiSg==}
|
||||||
|
|
||||||
'@fastify/proxy-addr@5.1.0':
|
'@fastify/proxy-addr@5.1.0':
|
||||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||||
|
|
||||||
@@ -3288,11 +3300,15 @@ snapshots:
|
|||||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||||
fast-uri: 4.1.3
|
fast-uri: 4.1.3
|
||||||
|
|
||||||
|
'@fastify/busboy@3.2.2': {}
|
||||||
|
|
||||||
'@fastify/cors@11.3.0':
|
'@fastify/cors@11.3.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
fastify-plugin: 6.0.0
|
fastify-plugin: 6.0.0
|
||||||
toad-cache: 3.7.4
|
toad-cache: 3.7.4
|
||||||
|
|
||||||
|
'@fastify/deepmerge@3.2.1': {}
|
||||||
|
|
||||||
'@fastify/error@4.2.0': {}
|
'@fastify/error@4.2.0': {}
|
||||||
|
|
||||||
'@fastify/fast-json-stringify-compiler@5.1.0':
|
'@fastify/fast-json-stringify-compiler@5.1.0':
|
||||||
@@ -3320,6 +3336,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
dequal: 2.0.3
|
||||||
|
|
||||||
|
'@fastify/multipart@10.1.1':
|
||||||
|
dependencies:
|
||||||
|
'@fastify/busboy': 3.2.2
|
||||||
|
'@fastify/deepmerge': 3.2.1
|
||||||
|
'@fastify/error': 4.2.0
|
||||||
|
fastify-plugin: 6.0.0
|
||||||
|
secure-json-parse: 4.1.0
|
||||||
|
|
||||||
'@fastify/proxy-addr@5.1.0':
|
'@fastify/proxy-addr@5.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/forwarded': 3.0.2
|
'@fastify/forwarded': 3.0.2
|
||||||
|
|||||||
Reference in New Issue
Block a user