From 9e4d061952a2d20f4ea686ac712840d24de675ef Mon Sep 17 00:00:00 2001 From: conconfianzatartamudez-beep Date: Tue, 11 Aug 2026 19:53:22 -0500 Subject: [PATCH] Notas de voz: grabar desde el panel y enviarlas por WhatsApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En mitad de una conversación con el bot, alguien del equipo graba diez segundos con su voz. Quien está del otro lado deja de hablarle a un sistema y oye a una persona. Después el bot sigue. Botón de micrófono en el compositor. Al tocarlo, una barra ocupa el sitio del cuadro de escribir con los controles que la gente ya conoce: papelera, pausa / seguir, el tiempo corriendo y enviar. Al enviar se manda por la Cloud API de Meta y el bot queda pausado en ese chat, igual que al responder por escrito. EL FORMATO ES TODO EL PROBLEMA, y no es evidente. WhatsApp solo trata como nota de voz el audio Opus dentro de un OGG, y el navegador no sabe grabar eso: Chrome graba audio/mp4 —que Meta acepta al subir, NO entrega y el celular tampoco reproduce— o audio/webm, que WhatsApp ni admite. La clave: el webm de Chrome YA lleva Opus dentro. media/oggOpus.ts le cambia la caja —lee los paquetes del WebM y los escribe en páginas OGG— sin recomprimir nada y sin ninguna dependencia: ~200 líneas de TypeScript que corren dentro del Worker. Detalles que costaron: el CRC de OGG no es el CRC32 de siempre (sin invertir entrada ni salida), hay que acumular el gránulo o la duración sale inventada, y la última página lleva su marca de fin. Verificado contra un decodificador real, no solo con pruebas propias: se generó un webm/opus con ffmpeg, se convirtió con este mismo código y ffprobe lee el resultado como ogg/opus, 48 kHz, mono, con la duración exacta del original. TRES TRAMPAS DE META QUE ESTÁN ANOTADAS EN EL CÓDIGO · Meta ACEPTA la subida y el envío (devuelve su wamid) de un audio que después no puede procesar. El fallo llega más tarde y por otro sitio, en el webhook de estados: 131053 "uploaded with mimetype as audio/mp4, however on processing it is of type application/octet-stream". En el panel figuraba como enviado. · Preguntarle GET / qué tipo recibió NO sirve: devuelve el que le declaraste tú. Probado y descartado. Lo que sí distingue el archivo es su estructura: los MP4 de MediaRecorder llevan cajas `moof`. · Con FormData el cuerpo puede irse en chunked y la subida morir con un "Network connection lost" que parece un problema de red. El multipart se arma a mano. Cuando el archivo no es de los que WhatsApp procesa, se manda como documento en vez de perderse: llega igual, aunque sin la ondita de nota de voz. REGLAS QUE SIGUE · Primero se envía y solo después se guarda: si WhatsApp lo rechaza, en el hilo no queda un audio que la persona nunca escuchó (misma regla que /reply). · Enviar una nota de voz pausa el bot. · No se manda a un contacto cuyo identificador no sea un teléfono de verdad (8 a 15 dígitos, sin letras): antes que mandar la voz de alguien a un número equivocado, no se manda. · Los errores de Meta se traducen: la ventana de 24 horas y la llave caducada se explican en una frase, no con un JSON. Aditivo: sin WHATSAPP_PHONE_NUMBER_ID y WHATSAPP_ACCESS_TOKEN el botón avisa y no rompe nada. 463 pruebas en verde (26 nuevas) y tsc limpio. Co-Authored-By: Claude Opus 5 --- src/admin/routes.ts | 71 +++++++++ src/admin/views/conversations.ts | 165 +++++++++++++++++++- src/media/notaDeVoz.ts | 227 +++++++++++++++++++++++++++ src/media/oggOpus.ts | 255 +++++++++++++++++++++++++++++++ test/nota-de-voz.test.ts | 173 +++++++++++++++++++++ test/ogg-opus.test.ts | 129 ++++++++++++++++ 6 files changed, 1019 insertions(+), 1 deletion(-) create mode 100644 src/media/notaDeVoz.ts create mode 100644 src/media/oggOpus.ts create mode 100644 test/nota-de-voz.test.ts create mode 100644 test/ogg-opus.test.ts diff --git a/src/admin/routes.ts b/src/admin/routes.ts index 5a077866..bb0d7f36 100644 --- a/src/admin/routes.ts +++ b/src/admin/routes.ts @@ -12,6 +12,14 @@ * middleware that has access to `c.env` rather than at module-init time. */ import { parsePeerBots } from "./projects"; +import { + MAX_BYTES_AUDIO, + enviarNotaDeVoz, + formatoGrabable, + motivoEntendible, + telefonoDe, + tipoBase, +} from "../media/notaDeVoz"; import { Hono } from "hono"; import { generateText } from "ai"; import { createModel } from "../llm/provider"; @@ -588,6 +596,69 @@ adminApp.post("/conversations/:id/reply", async (c) => { ); }); +/** + * NOTA DE VOZ: se graba en el panel, se manda por WhatsApp y el bot se pausa, + * igual que al responder por escrito. Si una persona del equipo acaba de + * hablarle con su voz, lo último que puede pasar es que el bot conteste encima. + * + * El orden importa: PRIMERO se envía. Si WhatsApp lo rechaza no se guarda nada + * ni se pausa nada, para que el hilo nunca muestre un audio que no llegó (misma + * regla que /reply). + */ +adminApp.post("/conversations/:id/audio", async (c) => { + const id = c.req.param("id"); + const db = new Db(c.env.DB); + const convs = new ConversationsRepo(db); + const conv = await convs.getById(id); + if (!conv) return c.html(`✗ Conversación no encontrada.`); + + const form = await c.req.formData().catch(() => null); + // Se comprueba por forma, no con `instanceof File`: en los tipos del worker + // el valor del formulario no es la clase File del DOM. + const archivo = form?.get("audio") as + | { size?: number; type?: string; arrayBuffer?: () => Promise } + | null; + if (!archivo || typeof archivo.arrayBuffer !== "function" || !archivo.size) { + return c.html(`No llegó ninguna grabación.`); + } + if (archivo.size > MAX_BYTES_AUDIO) { + return c.html( + `✗ La grabación pesa demasiado (el tope de WhatsApp son 16 MB).`, + ); + } + const mime = tipoBase(archivo.type || ""); + if (!formatoGrabable(mime)) { + return c.html( + `✗ WhatsApp no acepta el formato «${escapeHtml(mime || "desconocido")}».`, + ); + } + + const telefono = telefonoDe(conv.channel_user_id); + if (!telefono) { + return c.html( + `✗ Este contacto no tiene un número de WhatsApp válido. Respóndele por escrito.`, + ); + } + + try { + await enviarNotaDeVoz(c.env, telefono, await archivo.arrayBuffer(), mime); + } catch (e) { + console.error("[nota-de-voz] no se pudo enviar:", e); + return c.html(`✗ ${escapeHtml(motivoEntendible(e))}`); + } + + const msgs = new MessagesRepo(db); + await msgs.append(id, "owner", "🎤 Nota de voz del equipo."); + await convs.touchLastMessage(id); + await convs.setPausedUntil(id, Date.now() + TAKEOVER_MS); + + c.header("X-Sent", "1"); + return c.html( + `✓ Nota de voz enviada. El bot queda pausado en este chat.` + + `
${await renderThreadLive(c.env, id)}
`, + ); +}); + // Pause the bot in this conversation without sending anything (owner wants the // customer for themselves). Returns the refreshed thread fragment. adminApp.post("/conversations/:id/pause", async (c) => { diff --git a/src/admin/views/conversations.ts b/src/admin/views/conversations.ts index 651f37ef..d022038c 100644 --- a/src/admin/views/conversations.ts +++ b/src/admin/views/conversations.ts @@ -328,7 +328,7 @@ function renderComposer(convId: string): string { return `
-