# Trascrivere messaggi vocali e foto su WhatsApp con un agente AI

> Come un agente AI su WhatsApp legge messaggi vocali, foto e file: scarica il link di 24 ore, converti e trascrivi con gpt-transcribe, descrivi le immagini, in TypeScript.

Source: https://whasp.ai/it/blog/trascrivere-messaggi-vocali-whatsapp-agente-ai · 2026-10-20

## In breve

- Ogni file inviato da una persona arriva al tuo webhook come link firmato valido 24 ore, dopo le quali whasp elimina il file, quindi copia ciò che vuoi tenere. Un file oltre 30 MB arriva senza link.
- Per leggere un messaggio vocale, scaricalo, convertilo in MP3 con ffmpeg, trascrivilo con gpt-transcribe di OpenAI o con un modello Whisper self-hosted e tratta la trascrizione come il testo della persona.
- Per le foto, descrivi l'immagine una sola volta con un modello di visione e salva la descrizione nella cronologia, invece di rimandare l'immagine a ogni turno.
- Le risposte possono portare un file tramite URL HTTPS pubblico fino a 64 MB, ma i messaggi vocali non si possono inviare: un file audio che mandi arriva come file.
- whasp aspetta 10 secondi un 2xx dal tuo webhook, quindi conferma subito e trascrivi in background.

Un agente AI su WhatsApp gestisce messaggi vocali, foto e file leggendo l'oggetto `media` di ogni evento `message.received`: scarica il link firmato entro 24 ore, trascrivi l'audio con un modello speech-to-text come gpt-transcribe di OpenAI, manda le immagini a un modello di visione e salva il risultato come testo. Le risposte tornano come testo o come file tramite URL. I messaggi vocali non si possono inviare.

## Cosa consegna whasp quando qualcuno invia un file multimediale?

whasp consegna i contenuti multimediali in entrata come link firmato dentro l'evento `message.received`. L'oggetto `media` ha quattro campi: `url`, `mime`, `filename` e `size`. Il link è valido 24 ore e whasp elimina il file dopo 24 ore, quindi copialo se ti serve. Immagini, audio e MP4 si aprono nel browser, gli altri file si scaricano.

Due limiti contano. Per ora un file oltre 30 MB arriva senza link, con `media` impostato a null. E i server di whasp non conservano i messaggi: il contenuto arriva al tuo webhook una sola volta, in questo evento, quindi una trascrizione che non salvi è persa.

L'evento può portare anche `location` (latitudine, longitudine, nome, indirizzo, live), `contacts` (vCard come stringhe), `reply_to` (il messaggio citato, con il suo testo) e `sender_name`. I campi documentati non includono un flag che segni un messaggio vocale, quindi decidi dal `mime`: tutto ciò che inizia con `audio/` va alla trascrizione.

## Come si trascrive un messaggio vocale di WhatsApp?

Per trascrivere un messaggio vocale, scarica il file, convertilo in un MP3 leggero e mandalo a un modello speech-to-text. La conversione è una precauzione con una ragione. L'API business di WhatsApp [descrive una registrazione vocale](https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/reference/messages/audio) come `audio/ogg; codecs=opus`. La [guida speech-to-text](https://developers.openai.com/api/docs/guides/speech-to-text) di OpenAI elenca mp3, mp4, mpeg, mpga, m4a, wav e webm come formati di input e non nomina Ogg, mentre i commenti sui tipi nel pacchetto npm `openai` elencano anche ogg e flac. I due non concordano, e una chiamata a ffmpeg risolve la questione.

Mono, 16 kHz e 48 kbps bastano e avanzano per il parlato e fanno circa 0,36 MB al minuto, quindi il limite di file di 25 MB di OpenAI vale oltre un'ora di audio. Questa è la prima metà di `media.ts`:

```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(); // legge OPENAI_API_KEY

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

// Il link firmato di whasp non vuole l'header Authorization: basta un GET, entro 24 ore.
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());
}

// I messaggi vocali sono Ogg/Opus. Ricodifica in un piccolo MP3 mono così ogni modello li accetta.
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();
}
```

Il codice scrive file temporanei così ffmpeg può fare seek in qualunque contenitore usi il file. Se il tuo host non ha ffmpeg, funziona una build statica dal pacchetto npm `ffmpeg-static`, oppure puoi usare un modello self-hosted che decodifica Ogg da solo.

OpenAI indica `gpt-transcribe` come modello raccomandato per il parlato registrato, a $0,0045 al minuto sulla sua [pagina dei prezzi](https://developers.openai.com/api/docs/pricing). Un messaggio vocale di 30 secondi costa circa un quinto di centesimo, e 1.000 messaggi costano circa $2,25. Se sai quali lingue parlano i tuoi utenti, la guida documenta un array `languages` per questo modello, per esempio `["it", "en"]`. Dice anche che l'accuratezza varia da lingua a lingua.

### Si può trascrivere sul proprio server?

Si può, e due opzioni open source coprono la maggior parte dei casi.

| Opzione | Dove gira | Input audio | Licenza |
| --- | --- | --- | --- |
| OpenAI `gpt-transcribe` | API di OpenAI | Converti prima in MP3; file fino a 25 MB | A pagamento, al minuto |
| [faster-whisper](https://github.com/SYSTRAN/faster-whisper) | Il tuo server, Python | Decodifica con PyAV, che include le librerie FFmpeg | MIT |
| [whisper.cpp](https://github.com/ggml-org/whisper.cpp) | Il tuo server, C e C++ | WAV a 16 bit di default, quindi converti con ffmpeg; include un server HTTP con un'API simile a quella di OpenAI | MIT |

Scegli l'API ospitata quando vuoi meno codice. Fai self-hosting quando l'audio non può uscire dalla tua infrastruttura, quando il volume rende rilevante il prezzo al minuto, o quando l'API gestisce male una delle tue lingue.

## Come fa un agente a leggere una foto?

Per leggere una foto, scarica i byte e mandali a un modello di visione come data URL in base64 insieme a una domanda. I due provider principali accettano lo stesso tipo di input. La [guida alla visione](https://developers.openai.com/api/docs/guides/images-vision) di OpenAI elenca PNG, JPEG, WEBP e GIF non animate. La [documentazione sulla visione](https://platform.claude.com/docs/en/build-with-claude/vision) di Anthropic elenca JPEG, PNG, GIF e WebP, con 10 MB per immagine sulla Claude API. La seconda metà di `media.ts` usa `gpt-6-luna` di OpenAI, che la sua pagina del modello indica come capace di ricevere testo e immagini:

```ts
// media.ts, continua. Foto e PDF: una sola chiamata che restituisce testo da salvare.
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, // non far conservare il file dell'utente al provider
    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"];

// Tutto ciò che una persona può inviare, come testo che il tuo agente legge.
export async function toText(m: Inbound): Promise<string> {
  const lines: string[] = [];
  if (m.reply_to) lines.push(`(in risposta a: "${m.reply_to.text}")`);
  if (m.text) lines.push(m.text);
  const f = m.media;
  if (f?.mime.startsWith("audio/")) {
    lines.push(`[messaggio vocale, trascritto: ${await transcribe(f)}]`);
  } else if (f && IMAGES.includes(f.mime)) {
    lines.push(`[foto: ${await look(f, "Descrivi questa foto per un assistente di chat in meno di 80 parole. Copia esattamente ogni testo visibile.")}]`);
  } else if (f?.mime === "application/pdf") {
    lines.push(`[PDF ${f.filename}: ${await look(f, "Riassumi questo documento per un assistente di chat in meno di 150 parole.")}]`);
  } else if (f) {
    lines.push(`[file ${f.filename ?? "senza nome"}, ${f.mime}, ${f.size} byte: non leggibile]`);
  }
  if (m.location) lines.push(`[posizione: ${m.location.name ?? ""} ${m.location.address ?? ""} (${m.location.latitude}, ${m.location.longitude})]`);
  if (m.contacts?.length) lines.push(`[condivise ${m.contacts.length} schede contatto]`);
  return lines.join("\n") || "[messaggio vuoto: un file oltre 30 MB arriva senza link]";
}
```

`toText` restituisce testo semplice per qualunque messaggio, ed è ciò che vogliono la tua cronologia e il tuo prompt. Un messaggio può portare sia `text` sia `media`, quindi legge entrambi. Quando nulla è leggibile, restituisce un segnaposto, e il modello può rispondere con «quel file non è arrivato, puoi mandarne uno più piccolo?». Le `[parentesi quadre]` corrispondono alla riga del [modello di system prompt](https://whasp.ai/it/blog/agente-ai-whatsapp-prompt-formattazione) che dice all'agente cosa significano.

La funzione `look` descrive una volta sola e conserva testo, non pixel. Il file sparisce da whasp dopo 24 ore, e la documentazione di Anthropic fa notare che quando le immagini sono codificate in base64 in una chat multi-turno, ogni richiesta rimanda i byte dell'immagine. Salva la descrizione nella tua [memoria della conversazione](https://whasp.ai/it/blog/memoria-conversazione-agente-ai-whatsapp) e chiedi dettaglio nel prompt della descrizione: copiare esattamente il testo visibile è ciò che permette all'agente di rispondere, la settimana prossima, a una domanda su uno scontrino. Se le domande successive sull'immagine contano, copia il file anche nel tuo storage.

Per usare Claude per lo stesso lavoro, invia i byte come blocco `image`, con l'immagine prima del testo, cosa che secondo la documentazione di Anthropic funziona meglio. `claude-haiku-5-5` è il modello che Anthropic indica per i lavori ad alto volume e sensibili alla latenza:

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

const claude = new Anthropic(); // legge 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: "Descrivi questa foto per un assistente di chat in meno di 80 parole. Copia esattamente ogni testo visibile." },
      ],
    }],
  });
  return message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
}
```

## E per documenti, posizioni e contatti?

Un PDF arriva al modello come una foto. La [guida ai PDF](https://developers.openai.com/api/docs/guides/pdf-files) di OpenAI lo accetta come `input_file` con `file_data` in base64 e un `filename`, fino a 50 MB per file, e dà al modello sia il testo estratto sia le immagini delle pagine, ed è il ramo `application/pdf` qui sopra. Per i file Word o Excel, estrai il testo con una libreria di cui ti fidi, oppure chiedi alla persona di mandare un PDF.

Una posizione arriva come coordinate con nome e indirizzo facoltativi, quindi descrivila in una frase e lascia che sia il modello a decidere cosa farne. Un contatto arriva come testo vCard: analizza le righe `FN` e `TEL` se il tuo agente ha bisogno di nomi e numeri. Una risposta a un messaggio precedente porta il testo di quel messaggio in `reply_to`, ed è per questo che `toText` lo include: senza, un semplice «sì» non ha significato.

Considera inaffidabile tutto ciò che estrai. Una trascrizione, una descrizione o il testo dentro un PDF sono parole di un'altra persona, e qualcuno può dire o scrivere «ignora le tue istruzioni» in uno qualsiasi di questi. Tieni quelle stringhe come dati nel prompt e mai come istruzioni.

## Come si rimanda un file indietro?

Per inviare un file, passa un URL HTTPS pubblico in `media` su `POST /v1/messages`. L'URL può puntare a un file fino a 64 MB. whasp lo scarica una volta all'invio e cancella la sua copia quando l'invio finisce. Quando invii sia `text` sia `media`, il telefono manda prima il file e poi il testo come secondo messaggio WhatsApp, per ora senza didascalia, e conta comunque come un solo messaggio API.

```ts
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: "Ecco la tua ricevuta.",
  }),
});
```

Il file deve restare raggiungibile mentre il messaggio aspetta in coda e viene inviato. Mantieni il link valido finché per quel messaggio non arriva `message.sent` o `message.failed`.

I messaggi vocali sono l'unica cosa che l'API non può inviare. I messaggi vocali in uscita non sono supportati, e un file audio che invii arriva come file, non come messaggio vocale. La regola pratica è rispondere a un messaggio vocale con del testo. Se vuoi comunque risposte parlate, genera l'audio con un modello text-to-speech e invialo come file, sapendo cosa vedrà la persona.

## Come lo colleghi al webhook?

Conferma subito l'evento ed elaboralo in background, perché whasp aspetta 10 secondi un 2xx e poi riprova con backoff, e la trascrizione più una chiamata al modello possono richiedere di più. Dopo il controllo della firma descritto in [gestire i webhook di WhatsApp](https://whasp.ai/it/blog/webhook-whatsapp-firma-retry-duplicati), il lato ricevente si presenta così:

```ts
if (event.type === "message.received") {
  // Rispondi subito 2xx. La trascrizione e la chiamata al modello partono dopo la risposta.
  void handleInbound(event.id, event.data.message).catch(console.error);
}
return new Response(null, { status: 204 });
```

`handleInbound` chiama `toText`, salva il risultato, interroga il tuo modello e invia la risposta, come in [creare un agente con l'API di OpenAI](https://whasp.ai/it/blog/collegare-whatsapp-chatgpt-api-openai). Salta un id di evento che hai già gestito, o un retry ti farà pagare due volte la stessa trascrizione. Il fire-and-forget funziona su un server che resta acceso. Su un host serverless, metti invece l'evento in una coda.

## Cosa conservare, e per quanto tempo?

Conserva la trascrizione o la descrizione, ed elimina l'audio e l'immagine quando hai finito. whasp rimuove la sua copia dopo 24 ore, e il resto è una tua decisione. Una registrazione vocale è un dato personale, quindi scrivi per quanto tempo la tieni ed eliminala quando una persona lo chiede. [Opt-out e consenso per gli agenti AI](https://whasp.ai/it/blog/opt-out-consenso-whatsapp-agenti-ai) tratta le regole. Passa `store: false` alla chiamata al modello, come fa `look`, se non vuoi che OpenAI conservi la risposta.

## Cosa fare adesso

Aggiungi `toText` al gestore che hai già, mandati un messaggio vocale e una foto e leggi cosa vede l'agente. Poi pianifica i casi d'uso che ne dipendono: [12 agenti AI che funzionano meglio su WhatsApp](https://whasp.ai/it/blog/casi-uso-agente-ai-whatsapp) ne elenca diversi in cui voce e foto sono l'input principale. La [documentazione di whasp](https://whasp.ai/docs) descrive ogni campo dell'oggetto `media`.

## Domande

### Un agente AI su WhatsApp può inviare messaggi vocali?

Non su whasp. I messaggi vocali in uscita non sono supportati, e un file audio che invii arriva come file, non come messaggio vocale. Rispondi ai messaggi vocali con del testo.

### Per quanto tempo posso scaricare un file mandato al mio agente?

Per 24 ore. Il link al file nell'evento message.received è firmato e valido 24 ore, e dopo whasp elimina il file, quindi copialo se ti serve.

### Cosa succede quando qualcuno manda un file oltre 30 MB?

Il messaggio arriva comunque, ma il suo campo media è null perché non c'è nessun link. Il tuo agente può accorgersene e chiedere un file più piccolo.

### Quale modello conviene usare per trascrivere i messaggi vocali di WhatsApp?

La guida speech-to-text di OpenAI raccomanda gpt-transcribe per il parlato registrato. Se l'audio deve restare sui tuoi server, faster-whisper e whisper.cpp sono opzioni con licenza MIT.

### Mi serve ffmpeg per trascrivere i messaggi vocali di WhatsApp?

È la strada sicura. L'API business di WhatsApp descrive le registrazioni vocali come Ogg con codec Opus, e la guida di OpenAI elenca mp3, mp4, mpeg, mpga, m4a, wav e webm, quindi convertire in MP3 elimina il dubbio.
