Como transcrever áudios e fotos do WhatsApp em um agente de IA

Como um agente de IA no WhatsApp lê áudios, fotos e arquivos: baixe o link de 24 horas, converta e transcreva com gpt-transcribe, descreva imagens, em TypeScript.

Uma onda sonora, uma moldura de foto e um documento flutuando em um funil em direção a um núcleo verde brilhante.

O essencial

  • Todo arquivo que uma pessoa envia chega ao seu webhook como um link assinado que funciona por 24 horas, depois das quais a whasp apaga o arquivo, então copie o que for guardar. Um arquivo acima de 30 MB chega sem link.
  • Para ler uma mensagem de voz, baixe o áudio, converta para MP3 com ffmpeg, transcreva com o gpt-transcribe da OpenAI ou com um modelo Whisper hospedado por você e trate a transcrição como o texto da pessoa.
  • Para fotos, descreva a imagem uma única vez com um modelo de visão e guarde a descrição no histórico, em vez de reenviar a imagem a cada turno.
  • As respostas podem levar um arquivo por URL HTTPS pública de até 64 MB, mas não é possível enviar mensagens de voz: um arquivo de áudio que você envia chega como arquivo.
  • A whasp espera 10 segundos por um 2xx do seu webhook, então confirme primeiro e transcreva em segundo plano.

Um agente de IA no WhatsApp trata mensagens de voz, fotos e arquivos lendo o objeto media de cada evento message.received: baixe o link assinado dentro de 24 horas, transcreva o áudio com um modelo de speech-to-text como o gpt-transcribe da OpenAI, envie as imagens a um modelo de visão e guarde o resultado como texto. As respostas voltam como texto ou como arquivo por URL. Mensagens de voz não podem ser enviadas.

O que a whasp entrega quando alguém envia mídia?

A whasp entrega a mídia recebida como um link assinado dentro do evento message.received. O objeto media tem quatro campos: url, mime, filename e size. O link vale por 24 horas e a whasp apaga o arquivo depois das 24 horas, então copie-o se precisar dele. Imagens, áudio e MP4 abrem no navegador, e os outros arquivos são baixados.

Dois limites importam. Por enquanto, um arquivo acima de 30 MB chega sem link, com media igual a null. E os servidores da whasp não guardam mensagens: o conteúdo chega ao seu webhook uma única vez, nesse evento, então uma transcrição que você não guarda está perdida.

O evento também pode trazer location (latitude, longitude, nome, endereço, ao vivo), contacts (vCards como strings), reply_to (a mensagem citada, com o texto) e sender_name. Os campos documentados não incluem nenhuma flag que marque uma mensagem de voz, então decida pelo mime: tudo que começa com audio/ vai para a transcrição.

Como transcrever um áudio do WhatsApp?

Para transcrever uma mensagem de voz, baixe o arquivo, converta-o para um MP3 pequeno e envie a um modelo de speech-to-text. A conversão é uma precaução com um motivo. A API empresarial do próprio WhatsApp descreve uma gravação de voz como audio/ogg; codecs=opus. O guia de speech-to-text da OpenAI lista mp3, mp4, mpeg, mpga, m4a, wav e webm como formatos de entrada e não cita Ogg, enquanto os comentários de tipos no pacote npm openai listam também ogg e flac. Os dois discordam, e uma chamada ao ffmpeg resolve.

Mono, 16 kHz e 48 kbps bastam para fala e dão cerca de 0,36 MB por minuto, então o limite de arquivo de 25 MB da OpenAI equivale a mais de uma hora de áudio. Esta é a primeira metade do 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(); // lê OPENAI_API_KEY

export type Media = { url: string; mime: string; filename: string | null; size: number };

// O link assinado da whasp dispensa o header Authorization: um GET simples, dentro de 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());
}

// As mensagens de voz são Ogg/Opus. Recodifique para um MP3 mono pequeno, que qualquer modelo aceita.
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();
}

O código grava arquivos temporários para que o ffmpeg consiga buscar posições em qualquer contêiner que o arquivo use. Se o seu host não tem ffmpeg, um build estático do pacote npm ffmpeg-static funciona, ou você pode usar um modelo hospedado por você que decodifica Ogg sozinho.

A OpenAI lista o gpt-transcribe como o modelo recomendado para fala gravada, a $0,0045 por minuto na sua página de preços. Uma mensagem de voz de 30 segundos custa cerca de um quinto de centavo, e 1.000 delas custam cerca de $2,25. Se você sabe quais idiomas os seus usuários falam, o guia documenta um array languages para esse modelo, como ["en", "es"]. Ele também diz que a precisão varia por idioma.

Dá para transcrever no seu próprio servidor?

Dá, e duas opções de código aberto cobrem a maioria dos casos.

Opção Onde roda Entrada de áudio Licença
OpenAI gpt-transcribe API da OpenAI Converta para MP3 antes; arquivos de até 25 MB Paga, por minuto
faster-whisper Seu servidor, Python Decodifica com o PyAV, que embute as bibliotecas do FFmpeg MIT
whisper.cpp Seu servidor, C e C++ WAV de 16 bits por padrão, então converta com o ffmpeg; inclui um servidor HTTP com uma API parecida com a da OpenAI MIT

Escolha a API hospedada quando quiser o mínimo de código. Hospede por conta própria quando o áudio não puder sair da sua infraestrutura, quando o volume fizer o preço por minuto pesar ou quando a API lidar mal com algum dos seus idiomas.

Como o agente lê uma foto?

Para ler uma foto, baixe os bytes e envie-os a um modelo de visão como uma data URL em base64, junto com uma pergunta. Os dois principais provedores aceitam o mesmo tipo de entrada. O guia de visão da OpenAI lista PNG, JPEG, WEBP e GIF não animado. A documentação de visão da Anthropic lista JPEG, PNG, GIF e WebP, com 10 MB por imagem na API do Claude. A segunda metade do media.ts usa o gpt-6-luna da OpenAI, que a página do modelo lista como aceitando entrada de texto e de imagem:

// media.ts, continuação. Fotos e PDFs: uma chamada que devolve texto que você pode 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, // não guarde o arquivo do usuário no provedor
    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"];

// Tudo o que uma pessoa pode enviar, como o texto que o seu agente lê.
export async function toText(m: Inbound): Promise<string> {
  const lines: string[] = [];
  if (m.reply_to) lines.push(`(respondendo a: "${m.reply_to.text}")`);
  if (m.text) lines.push(m.text);
  const f = m.media;
  if (f?.mime.startsWith("audio/")) {
    lines.push(`[mensagem de voz, transcrita: ${await transcribe(f)}]`);
  } else if (f && IMAGES.includes(f.mime)) {
    lines.push(`[foto: ${await look(f, "Descreva esta foto para um assistente de chat em menos de 80 palavras. Copie exatamente qualquer texto visível.")}]`);
  } else if (f?.mime === "application/pdf") {
    lines.push(`[PDF ${f.filename}: ${await look(f, "Resuma este documento para um assistente de chat em menos de 150 palavras.")}]`);
  } else if (f) {
    lines.push(`[arquivo ${f.filename ?? "sem nome"}, ${f.mime}, ${f.size} bytes: não é possível ler]`);
  }
  if (m.location) lines.push(`[localização: ${m.location.name ?? ""} ${m.location.address ?? ""} (${m.location.latitude}, ${m.location.longitude})]`);
  if (m.contacts?.length) lines.push(`[compartilhou ${m.contacts.length} cartão(ões) de contato]`);
  return lines.join("\n") || "[mensagem vazia: um arquivo acima de 30 MB chega sem link]";
}

toText devolve texto simples para qualquer mensagem, que é o que o seu histórico e o seu prompt querem. Uma mensagem pode trazer text e media ao mesmo tempo, então ele lê os dois. Quando nada é legível, devolve um marcador, e o modelo pode responder com “esse arquivo não chegou, você pode mandar um menor?” Os [colchetes] correspondem à linha do modelo de system prompt que diz ao agente o que eles significam.

A função look descreve uma vez e guarda texto, não pixels. O arquivo some da whasp depois de 24 horas, e a documentação da Anthropic observa que, quando as imagens vão codificadas em base64 em um chat de vários turnos, cada requisição reenvia os bytes da imagem. Guarde a descrição na sua memória de conversa e peça detalhe no prompt da descrição: copiar exatamente o texto visível é o que permite ao agente, na semana que vem, responder a uma pergunta sobre um recibo. Se perguntas de acompanhamento sobre a imagem importarem, copie também o arquivo para o seu próprio armazenamento.

Para usar o Claude no mesmo trabalho, envie os bytes como um bloco image, com a imagem antes do texto, o que a documentação da Anthropic diz funcionar melhor. O claude-haiku-5-5 é o modelo que a Anthropic indica para trabalho de alto volume e sensível à latência:

import Anthropic from "@anthropic-ai/sdk";

const claude = new Anthropic(); // lê 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: "Descreva esta foto para um assistente de chat em menos de 80 palavras. Copie exatamente qualquer texto visível." },
      ],
    }],
  });
  return message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
}

E documentos, localizações e contatos?

Um PDF vai para o modelo do mesmo jeito que uma foto. O guia de PDF da OpenAI o aceita como um input_file com file_data em base64 e um filename, até 50 MB por arquivo, e entrega ao modelo tanto o texto extraído quanto as imagens das páginas, que é o ramo application/pdf acima. Para arquivos Word ou Excel, extraia o texto com uma biblioteca de sua confiança ou peça à pessoa que envie um PDF.

Uma localização chega como coordenadas com nome e endereço opcionais, então descreva-a em uma frase e deixe o modelo decidir o que fazer com ela. Um contato chega como texto vCard: faça o parse das linhas FN e TEL se o seu agente precisar de nomes e números. Uma resposta a uma mensagem anterior traz o texto dessa mensagem em reply_to, e é por isso que o toText o inclui: sem ele, um simples “sim” não tem sentido.

Trate tudo o que você extrair como não confiável. Uma transcrição, uma descrição ou o texto dentro de um PDF são palavras da outra pessoa, e alguém pode dizer ou escrever “ignore suas instruções” em qualquer um deles. Mantenha essas strings como dados no prompt e nunca como instruções.

Como enviar um arquivo de volta?

Para enviar um arquivo, passe uma URL HTTPS pública em media no POST /v1/messages. A URL pode apontar para um arquivo de até 64 MB. A whasp o baixa uma vez no envio e apaga a cópia dela quando o envio termina. Quando você envia text e media juntos, o celular manda o arquivo primeiro e o texto como uma segunda mensagem do WhatsApp, sem legenda por enquanto, e ainda conta como uma única mensagem da 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: "Aqui está o seu recibo.",
  }),
});

O arquivo precisa continuar acessível enquanto a mensagem espera na fila e é enviada. Mantenha o link válido até chegar message.sent ou message.failed para aquela mensagem.

Mensagens de voz são a única coisa que a API não consegue enviar. Mensagens de voz enviadas não são suportadas, e um arquivo de áudio que você envia chega como arquivo, não como mensagem de voz. A regra prática é responder a uma mensagem de voz com texto. Se ainda assim você quer respostas faladas, gere o áudio com um modelo de text-to-speech e envie-o como arquivo, sabendo o que a pessoa vai ver.

Como ligar tudo ao webhook?

Confirme o evento primeiro e processe em segundo plano, porque a whasp espera 10 segundos por um 2xx e depois tenta de novo com backoff, e a transcrição mais a chamada ao modelo podem levar mais que isso. Depois da verificação de assinatura de tratamento de webhooks do WhatsApp, o lado receptor fica assim:

if (event.type === "message.received") {
  // Responda 2xx agora. A transcrição e a chamada ao modelo rodam depois da resposta.
  void handleInbound(event.id, event.data.message).catch(console.error);
}
return new Response(null, { status: 204 });

handleInbound chama toText, guarda o resultado, consulta o seu modelo e envia a resposta, como em criar um agente com a API da OpenAI. Ignore um id de evento que você já tratou, ou uma nova tentativa vai pagar pela mesma transcrição duas vezes. Disparar e esquecer (fire-and-forget) funciona em um servidor que fica sempre ligado. Em um host serverless, coloque o evento em uma fila.

O que guardar, e por quanto tempo?

Guarde a transcrição ou a descrição e apague o áudio e a imagem quando terminar de usá-los. A whasp remove a cópia dela depois de 24 horas, e o resto é decisão sua. Uma gravação de voz é dado pessoal, então defina por escrito quanto tempo a guarda e apague quando a pessoa pedir. Descadastro (opt-out) e consentimento para agentes de IA cobre as regras. Passe store: false na chamada ao modelo, como o look faz, se você não quer que a OpenAI guarde a resposta.

O que fazer agora

Adicione o toText ao handler que você já tem, mande para si mesmo uma mensagem de voz e uma foto e leia o que o agente enxerga. Depois planeje os casos de uso que dependem disso: 12 agentes de IA que funcionam melhor no WhatsApp lista vários em que voz e fotos são a entrada principal. A documentação da whasp descreve cada campo do objeto media.

Perguntas

Um agente de IA no WhatsApp pode enviar mensagens de voz?

Na whasp, não. Mensagens de voz enviadas não são suportadas, e um arquivo de áudio que você envia chega como arquivo, não como mensagem de voz. Responda às mensagens de voz com texto.

Por quanto tempo posso baixar um arquivo que alguém enviou ao meu agente?

Por 24 horas. O link de mídia no evento message.received é assinado e vale por 24 horas, e a whasp apaga o arquivo depois disso, então copie-o se precisar dele.

O que acontece quando alguém envia um arquivo acima de 30 MB?

A mensagem chega do mesmo jeito, mas o campo media vem null porque não há link. Seu agente pode perceber isso e pedir um arquivo menor.

Qual modelo deve transcrever as mensagens de voz do WhatsApp?

O guia de speech-to-text da OpenAI recomenda o gpt-transcribe para fala gravada. Se o áudio precisa ficar nos seus próprios servidores, faster-whisper e whisper.cpp são opções com licença MIT.

Preciso do ffmpeg para transcrever mensagens de voz do WhatsApp?

É o caminho seguro. A API empresarial do próprio WhatsApp descreve gravações de voz como Ogg com o codec Opus, e o guia da OpenAI lista mp3, mp4, mpeg, mpga, m4a, wav e webm, então converter para MP3 elimina a dúvida.

Todos os artigos