Transcribir audios de WhatsApp y leer fotos con un agente de IA
Cómo un agente de IA de WhatsApp lee notas de voz, fotos y archivos: descarga el enlace de 24 horas, transcribe con gpt-transcribe y describe imágenes, en TypeScript.

Lo esencial
- Cada archivo que envía una persona llega a tu webhook como un enlace firmado que funciona 24 horas, pasadas las cuales whasp borra el archivo, así que copia lo que quieras conservar. Un archivo de más de 30 MB llega sin enlace.
- Para leer una nota de voz, descárgala, conviértela a MP3 con ffmpeg, transcríbela con gpt-transcribe de OpenAI o con un modelo Whisper autoalojado y trata la transcripción como el texto de la persona.
- En las fotos, describe la imagen una vez con un modelo de visión y guarda la descripción en tu historial, en lugar de reenviar la imagen en cada turno.
- Las respuestas pueden llevar un archivo mediante una URL HTTPS pública de hasta 64 MB, pero las notas de voz no se pueden enviar: un archivo de audio que envíes llega como archivo.
- whasp espera 10 segundos un 2xx de tu webhook, así que confirma primero y transcribe en segundo plano.
Un agente de IA de WhatsApp maneja notas de voz, fotos y archivos leyendo el objeto media de cada evento message.received: descarga el enlace firmado antes de 24 horas, transcribe el audio con un modelo de voz a texto como gpt-transcribe de OpenAI, envía las imágenes a un modelo de visión y guarda el resultado como texto. Las respuestas vuelven como texto o como un archivo por URL. Las notas de voz no se pueden enviar.
¿Qué entrega whasp cuando alguien envía un archivo multimedia?
whasp entrega los archivos multimedia entrantes como un enlace firmado dentro del evento message.received. El objeto media tiene cuatro campos: url, mime, filename y size. El enlace es válido durante 24 horas y whasp borra el archivo pasadas esas 24 horas, así que cópialo si lo necesitas. Las imágenes, el audio y el MP4 se abren en el navegador, y los demás archivos se descargan.
Hay dos límites que importan. Por ahora, un archivo de más de 30 MB llega sin enlace, con media en null. Y los servidores de whasp no guardan mensajes: el contenido llega a tu webhook una sola vez, en este evento, así que una transcripción que no guardes se pierde.
El evento también puede llevar location (latitud, longitud, nombre, dirección, live), contacts (vCards como cadenas), reply_to (el mensaje citado, con su texto) y sender_name. Los campos documentados no incluyen ninguna marca que identifique una nota de voz, así que decide por mime: todo lo que empiece por audio/ va a transcripción.
¿Cómo se transcribe una nota de voz de WhatsApp?
Para transcribir una nota de voz, descarga el archivo, conviértelo a un MP3 pequeño y envíalo a un modelo de voz a texto. La conversión es una precaución con una razón detrás. La propia API de WhatsApp Business describe una grabación de voz como audio/ogg; codecs=opus. La guía de voz a texto de OpenAI enumera mp3, mp4, mpeg, mpga, m4a, wav y webm como formatos de entrada y no nombra Ogg, mientras que los comentarios de tipos del paquete npm openai incluyen también ogg y flac. Las dos fuentes no coinciden, y una llamada a ffmpeg lo resuelve.
Mono, 16 kHz y 48 kbps es más que suficiente para voz y equivale a unos 0,36 MB por minuto, así que el límite de archivo de 25 MB de OpenAI son más de una hora de audio. Esta es la primera mitad de media.ts:
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import OpenAI, { toFile } from "openai";
const run = promisify(execFile);
const openai = new OpenAI(); // lee OPENAI_API_KEY
export type Media = { url: string; mime: string; filename: string | null; size: number };
// El enlace firmado de whasp no necesita el header Authorization: un GET simple, dentro de las 24 horas.
async function download(media: Media): Promise<Buffer> {
const res = await fetch(media.url);
if (!res.ok) throw new Error(`media download failed: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
// Las notas de voz son Ogg/Opus. Recodifica a un MP3 mono pequeño para que cualquier modelo las acepte.
async function toMp3(audio: Buffer): Promise<Buffer> {
const dir = await mkdtemp(join(tmpdir(), "voice-"));
try {
await writeFile(join(dir, "in"), audio);
await run("ffmpeg", ["-v", "error", "-i", join(dir, "in"), "-vn", "-ac", "1", "-ar", "16000", "-b:a", "48k", join(dir, "out.mp3")]);
return await readFile(join(dir, "out.mp3"));
} finally {
await rm(dir, { recursive: true, force: true });
}
}
export async function transcribe(media: Media): Promise<string> {
const mp3 = await toMp3(await download(media));
const file = await toFile(mp3, "voice.mp3", { type: "audio/mpeg" });
const result = await openai.audio.transcriptions.create({ file, model: "gpt-transcribe" });
return result.text.trim();
}
El código escribe archivos temporales para que ffmpeg pueda leer en cualquier orden el contenedor que use el archivo. Si tu host no tiene ffmpeg, sirve una compilación estática del paquete npm ffmpeg-static, o puedes usar un modelo autoalojado que decodifique Ogg por sí mismo.
OpenAI lista gpt-transcribe como el modelo recomendado para voz grabada, a $0,0045 el minuto en su página de precios. Una nota de voz de 30 segundos cuesta unos $0,002, y 1.000 de ellas cuestan unos $2,25. Si sabes qué idiomas hablan tus usuarios, la guía documenta un arreglo languages para este modelo, como ["en", "es"]. También dice que la precisión varía según el idioma.
¿Puedes transcribir en tu propio servidor?
Puedes, y dos opciones de código abierto cubren la mayoría de los casos.
| Opción | Dónde se ejecuta | Entrada de audio | Licencia |
|---|---|---|---|
OpenAI gpt-transcribe |
API de OpenAI | Convierte antes a MP3; archivos de hasta 25 MB | De pago, por minuto |
| faster-whisper | Tu servidor, Python | Decodifica con PyAV, que incluye las librerías de FFmpeg | MIT |
| whisper.cpp | Tu servidor, C y C++ | WAV de 16 bits por defecto, así que convierte con ffmpeg; incluye un servidor HTTP con una API similar a la de OpenAI | MIT |
Elige la API alojada cuando quieras escribir el mínimo de código. Autoaloja cuando el audio no pueda salir de tu infraestructura, cuando el volumen haga que importe el precio por minuto o cuando la API maneje mal alguno de tus idiomas.
¿Cómo lee una foto un agente?
Para leer una foto, descarga los bytes y envíalos a un modelo de visión como una URL de datos en base64, junto con una pregunta. Los dos proveedores principales aceptan el mismo tipo de entrada. La guía de visión de OpenAI enumera PNG, JPEG, WEBP y GIF no animado. La documentación de visión de Anthropic enumera JPEG, PNG, GIF y WebP, con 10 MB por imagen en la API de Claude. La segunda mitad de media.ts usa gpt-6-luna de OpenAI, que su página de modelo indica que acepta texto e imagen como entrada:
// media.ts, continuación. Fotos y PDF: una llamada que devuelve texto que puedes guardar.
export async function look(media: Media, ask: string): Promise<string> {
const data = (await download(media)).toString("base64");
const part =
media.mime === "application/pdf"
? { type: "input_file" as const, filename: media.filename ?? "file.pdf", file_data: `data:application/pdf;base64,${data}` }
: { type: "input_image" as const, image_url: `data:${media.mime};base64,${data}`, detail: "auto" as const };
const response = await openai.responses.create({
model: "gpt-6-luna",
store: false, // no conserves el archivo del usuario en el proveedor
input: [{ role: "user", content: [{ type: "input_text", text: ask }, part] }],
});
return response.output_text;
}
type Inbound = {
text: string | null;
media: Media | null;
location?: { latitude: number; longitude: number; name?: string; address?: string };
contacts?: string[];
reply_to?: { id: string | null; text: string };
};
const IMAGES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
// Todo lo que una persona puede enviar, como el texto que lee tu agente.
export async function toText(m: Inbound): Promise<string> {
const lines: string[] = [];
if (m.reply_to) lines.push(`(respondiendo a: "${m.reply_to.text}")`);
if (m.text) lines.push(m.text);
const f = m.media;
if (f?.mime.startsWith("audio/")) {
lines.push(`[nota de voz, transcrita: ${await transcribe(f)}]`);
} else if (f && IMAGES.includes(f.mime)) {
lines.push(`[foto: ${await look(f, "Describe esta foto para un asistente de chat en menos de 80 palabras. Copia exactamente cualquier texto visible.")}]`);
} else if (f?.mime === "application/pdf") {
lines.push(`[PDF ${f.filename}: ${await look(f, "Resume este documento para un asistente de chat en menos de 150 palabras.")}]`);
} else if (f) {
lines.push(`[archivo ${f.filename ?? "sin nombre"}, ${f.mime}, ${f.size} bytes: no se puede leer]`);
}
if (m.location) lines.push(`[ubicación: ${m.location.name ?? ""} ${m.location.address ?? ""} (${m.location.latitude}, ${m.location.longitude})]`);
if (m.contacts?.length) lines.push(`[compartió ${m.contacts.length} tarjeta(s) de contacto]`);
return lines.join("\n") || "[mensaje vacío: un archivo de más de 30 MB llega sin enlace]";
}
toText devuelve texto plano para cualquier mensaje, que es lo que quieren tu historial y tu prompt. Un mensaje puede llevar text y media a la vez, así que lee ambos. Cuando no hay nada legible, devuelve un marcador, y el modelo puede responder con “ese archivo no llegó, ¿puedes enviar uno más pequeño?” Los [corchetes] coinciden con la línea de la plantilla de system prompt que le dice al agente qué significan.
La función look describe la imagen una vez y conserva texto, no píxeles. El archivo desaparece de whasp tras 24 horas, y la documentación de Anthropic señala que, cuando las imágenes se codifican en base64 en un chat de varios turnos, cada solicitud reenvía los bytes de la imagen. Guarda la descripción en tu memoria de conversación y pide detalle en el prompt de la descripción: copiar el texto visible tal cual es lo que permite al agente responder la semana que viene una pregunta sobre un recibo. Si te importan las preguntas de seguimiento sobre la imagen, copia también el archivo a tu propio almacenamiento.
Para usar Claude en el mismo trabajo, envía los bytes como un bloque image, con la imagen antes del texto, que según la documentación de Anthropic es lo que mejor funciona. claude-haiku-5-5 es el modelo que Anthropic orienta al trabajo de alto volumen y sensible a la latencia:
import Anthropic from "@anthropic-ai/sdk";
const claude = new Anthropic(); // lee ANTHROPIC_API_KEY
export async function lookWithClaude(base64: string, mediaType: "image/jpeg" | "image/png" | "image/gif" | "image/webp"): Promise<string> {
const message = await claude.messages.create({
model: "claude-haiku-5-5",
max_tokens: 300,
messages: [{
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: mediaType, data: base64 } },
{ type: "text", text: "Describe esta foto para un asistente de chat en menos de 80 palabras. Copia exactamente cualquier texto visible." },
],
}],
});
return message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
}
¿Y los documentos, las ubicaciones y los contactos?
Un PDF llega al modelo igual que una foto. La guía de PDF de OpenAI lo acepta como un input_file con file_data en base64 y un filename, hasta 50 MB por archivo, y le da al modelo tanto el texto extraído como las imágenes de las páginas, que es la rama application/pdf de arriba. Para archivos de Word o Excel, extrae el texto con una librería de tu confianza, o pide a la persona que envíe un PDF.
Una ubicación llega como coordenadas con un nombre y una dirección opcionales, así que descríbela en una frase y deja que el modelo decida qué hacer con ella. Un contacto llega como texto vCard: analiza las líneas FN y TEL si tu agente necesita nombres y números. Una respuesta a un mensaje anterior lleva el texto de ese mensaje en reply_to, y por eso toText lo incluye: sin él, un “sí” suelto no significa nada.
Trata todo lo que extraigas como no confiable. Una transcripción, una descripción o el texto de un PDF son palabras de la otra persona, y alguien puede decir o escribir “ignora tus instrucciones” en cualquiera de ellos. Mantén esas cadenas como datos en el prompt y nunca como instrucciones.
¿Cómo se envía un archivo de vuelta?
Para enviar un archivo, pasa una URL pública HTTPS en media en POST /v1/messages. La URL puede apuntar a un archivo de hasta 64 MB. whasp lo descarga una vez al enviar y borra su copia cuando termina el envío. Cuando envías text y media a la vez, el teléfono envía primero el archivo y el texto como un segundo mensaje de WhatsApp, sin pie de foto por ahora, y aun así cuenta como un solo mensaje de la API.
await fetch("https://api.whasp.ai/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WHASP_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "evt_2bX9kq7TfLw3:receipt",
},
body: JSON.stringify({
to: "+15555550111",
media: { url: "https://files.example.com/receipt.pdf?sig=abc123", filename: "receipt.pdf" },
text: "Aquí tienes tu recibo.",
}),
});
El archivo tiene que seguir accesible mientras el mensaje espera en la cola y se envía. Mantén el enlace válido hasta que llegue message.sent o message.failed para ese mensaje.
Las notas de voz son lo único que la API no puede enviar. Las notas de voz salientes no están soportadas, y un archivo de audio que envíes llega como archivo, no como nota de voz. La regla práctica es responder a una nota de voz con texto. Si aun así quieres respuestas habladas, genera el audio con un modelo de texto a voz y envíalo como archivo, sabiendo lo que verá la persona.
¿Cómo lo conectas al webhook?
Confirma el evento primero y procésalo en segundo plano, porque whasp espera 10 segundos un 2xx y después reintenta con backoff, y la transcripción más una llamada al modelo pueden tardar más. Tras la comprobación de la firma de cómo manejar los webhooks de WhatsApp, la parte receptora queda así:
if (event.type === "message.received") {
// Responde 2xx ya. La transcripción y la llamada al modelo se ejecutan después de la respuesta.
void handleInbound(event.id, event.data.message).catch(console.error);
}
return new Response(null, { status: 204 });
handleInbound llama a toText, guarda el resultado, consulta tu modelo y envía la respuesta, como en crear un agente con la API de OpenAI. Omite un id de evento que ya hayas procesado, o un reintento pagará dos veces la misma transcripción. El fire-and-forget funciona en un servidor que sigue en marcha. En un host serverless, mete el evento en una cola.
¿Qué debes conservar y por cuánto tiempo?
Conserva la transcripción o la descripción, y borra el audio y la imagen cuando termines con ellos. whasp elimina su copia a las 24 horas, y el resto es decisión tuya. Una grabación de voz es un dato personal, así que anota cuánto tiempo la conservas y bórrala cuando una persona lo pida. Baja y consentimiento en agentes de IA cubre las reglas. Pasa store: false a la llamada al modelo, como hace look, si no quieres que OpenAI conserve la respuesta.
Qué hacer ahora
Añade toText al manejador que ya tienes, envíate una nota de voz y una foto y lee lo que ve el agente. Después planifica los casos de uso que dependen de eso: 12 agentes de IA que mejor funcionan en WhatsApp enumera varios donde la voz y las fotos son la entrada principal. La documentación de whasp describe todos los campos del objeto media.
Preguntas
¿Puede un agente de IA de WhatsApp enviar notas de voz?
No en whasp. Las notas de voz salientes no están soportadas, y un archivo de audio que envíes llega como archivo, no como nota de voz. Responde a las notas de voz con texto.
¿Durante cuánto tiempo puedo descargar un archivo que alguien envió a mi agente?
Durante 24 horas. El enlace multimedia del evento message.received está firmado y es válido 24 horas, y después whasp borra el archivo, así que cópialo si lo necesitas.
¿Qué pasa cuando alguien envía un archivo de más de 30 MB?
El mensaje llega igualmente, pero su campo media es null porque no hay enlace. Tu agente puede detectarlo y pedir un archivo más pequeño.
¿Qué modelo debería transcribir las notas de voz de WhatsApp?
La guía de voz a texto de OpenAI recomienda gpt-transcribe para voz grabada. Si el audio tiene que quedarse en tus propios servidores, faster-whisper y whisper.cpp son opciones con licencia MIT.
¿Necesito ffmpeg para transcribir notas de voz de WhatsApp?
Es la vía segura. La propia API de negocio de WhatsApp describe las grabaciones de voz como Ogg con el códec Opus, y la guía de OpenAI enumera mp3, mp4, mpeg, mpga, m4a, wav y webm, así que convertir a MP3 elimina la duda.


