WhatsApp-Sprachnachrichten transkribieren und Fotos lesen

Wie ein WhatsApp-KI-Agent Sprachnachrichten, Fotos und Dateien liest: 24-Stunden-Link laden, mit gpt-transcribe transkribieren, Bilder beschreiben, in TypeScript.

Eine Schallwelle, ein Fotorahmen und ein Dokument schweben durch einen Trichter auf einen grün leuchtenden Kern zu.

Das Wichtigste

  • Jede Datei, die eine Person schickt, erreicht deinen Webhook als signierter Link, der 24 Stunden gilt, danach löscht whasp die Datei, kopiere also, was du behalten willst. Eine Datei über 30 MB kommt ohne Link an.
  • Um eine Sprachnachricht zu lesen, lade sie herunter, konvertiere sie mit ffmpeg zu MP3, transkribiere sie mit gpt-transcribe von OpenAI oder einem selbst gehosteten Whisper-Modell und behandle das Transkript als Text der Person.
  • Beschreibe Fotos einmal mit einem Vision-Modell und speichere die Beschreibung in deinem Verlauf, statt das Bild in jeder Runde erneut zu senden.
  • Antworten können eine Datei per öffentlicher HTTPS-URL bis 64 MB mitschicken, Sprachnachrichten lassen sich aber nicht senden: Eine Audiodatei, die du schickst, kommt als Datei an.
  • whasp wartet 10 Sekunden auf ein 2xx von deinem Webhook, bestätige also zuerst und transkribiere im Hintergrund.

Ein WhatsApp-KI-Agent verarbeitet Sprachnachrichten, Fotos und Dateien, indem er das media-Objekt jedes message.received-Events liest: Lade den signierten Link innerhalb von 24 Stunden herunter, transkribiere Audio mit einem Speech-to-Text-Modell wie gpt-transcribe von OpenAI, schicke Bilder an ein Vision-Modell und speichere das Ergebnis als Text. Antworten gehen als Text oder als Datei per URL zurück. Sprachnachrichten lassen sich nicht senden.

Was liefert whasp, wenn jemand Medien schickt?

whasp liefert eingehende Medien als signierten Link im message.received-Event. Das media-Objekt hat vier Felder: url, mime, filename und size. Der Link ist 24 Stunden gültig, und whasp löscht die Datei nach 24 Stunden, kopiere sie also, wenn du sie brauchst. Bilder, Audio und MP4 öffnen sich im Browser, andere Dateien werden heruntergeladen.

Zwei Grenzen zählen. Eine Datei über 30 MB kommt vorerst ohne Link an, mit media auf null. Und die Server von whasp speichern keine Nachrichten: Der Inhalt erreicht deinen Webhook einmal, in diesem Event, ein Transkript, das du nicht speicherst, ist also weg.

Das Event kann außerdem location (Breitengrad, Längengrad, Name, Adresse, live), contacts (vCards als Strings), reply_to (die zitierte Nachricht samt Text) und sender_name tragen. Die dokumentierten Felder enthalten kein Flag, das eine Sprachnachricht kennzeichnet, entscheide also über mime: Alles, was mit audio/ beginnt, geht in die Transkription.

Wie transkribierst du eine WhatsApp-Sprachnachricht?

Um eine Sprachnachricht zu transkribieren, lade die Datei herunter, konvertiere sie in ein kleines MP3 und schicke sie an ein Speech-to-Text-Modell. Die Konvertierung ist eine Vorsichtsmaßnahme mit Grund. Die Business API von WhatsApp beschreibt eine Sprachaufnahme als audio/ogg; codecs=opus. Der Speech-to-Text-Leitfaden von OpenAI nennt mp3, mp4, mpeg, mpga, m4a, wav und webm als Eingabeformate und erwähnt Ogg nicht, während die Typkommentare im npm-Paket openai auch ogg und flac aufführen. Die beiden widersprechen sich, und ein ffmpeg-Aufruf klärt es.

Mono, 16 kHz und 48 kbps reichen für Sprache völlig aus und ergeben etwa 0,36 MB pro Minute, das Dateilimit von 25 MB bei OpenAI entspricht also über einer Stunde Audio. Das ist die erste Hälfte von 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(); // liest OPENAI_API_KEY

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

// Der signierte Link von whasp braucht keinen Authorization-Header: ein einfaches GET, innerhalb von 24 Stunden.
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());
}

// Sprachnachrichten sind Ogg/Opus. In kleines Mono-MP3 umkodieren, damit jedes Modell sie akzeptiert.
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();
}

Der Code schreibt temporäre Dateien, damit ffmpeg in jedem Container suchen kann, den die Datei nutzt. Hat dein Host kein ffmpeg, funktioniert ein statischer Build aus dem npm-Paket ffmpeg-static, oder du nimmst ein selbst gehostetes Modell, das Ogg selbst dekodiert.

OpenAI führt gpt-transcribe als empfohlenes Modell für aufgenommene Sprache, zu $0,0045 pro Minute auf seiner Preisseite. Eine Sprachnachricht von 30 Sekunden kostet etwa ein Fünftel Cent, und 1.000 davon kosten etwa $2,25. Wenn du weißt, welche Sprachen deine Nutzer sprechen, dokumentiert der Leitfaden für dieses Modell ein languages-Array, etwa ["de", "en"]. Er sagt auch, dass die Genauigkeit je nach Sprache variiert.

Kannst du stattdessen auf dem eigenen Server transkribieren?

Ja, und zwei Open-Source-Optionen decken die meisten Fälle ab.

Option Wo es läuft Audio-Eingabe Lizenz
OpenAI gpt-transcribe API von OpenAI Zuerst in MP3 konvertieren; Dateien bis 25 MB Kostenpflichtig, pro Minute
faster-whisper Dein Server, Python Dekodiert mit PyAV, das die FFmpeg-Bibliotheken mitbringt MIT
whisper.cpp Dein Server, C und C++ Standardmäßig 16-Bit-WAV, also mit ffmpeg konvertieren; enthält einen HTTP-Server mit OpenAI-ähnlicher API MIT

Wähle die gehostete API, wenn du möglichst wenig Code willst. Hoste selbst, wenn Audio deine Infrastruktur nicht verlassen darf, wenn das Volumen den Minutenpreis spürbar macht oder wenn die API eine deiner Sprachen schlecht beherrscht.

Wie liest ein Agent ein Foto?

Um ein Foto zu lesen, lade die Bytes herunter und schicke sie mit einer Frage als Base64-Data-URL an ein Vision-Modell. Beide großen Anbieter akzeptieren dieselbe Art von Eingabe. Der Vision-Leitfaden von OpenAI nennt PNG, JPEG, WEBP und nicht animierte GIFs. Die Vision-Docs von Anthropic nennen JPEG, PNG, GIF und WebP, mit 10 MB pro Bild bei der Claude API. Die zweite Hälfte von media.ts nutzt gpt-6-luna von OpenAI, das laut seiner Modellseite Text- und Bildeingaben akzeptiert:

// media.ts, Fortsetzung. Fotos und PDFs: ein Aufruf, der Text liefert, den du speichern kannst.
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, // die Datei des Nutzers nicht beim Anbieter behalten
    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"];

// Alles, was eine Person senden kann, als Text, den dein Agent liest.
export async function toText(m: Inbound): Promise<string> {
  const lines: string[] = [];
  if (m.reply_to) lines.push(`(Antwort auf: "${m.reply_to.text}")`);
  if (m.text) lines.push(m.text);
  const f = m.media;
  if (f?.mime.startsWith("audio/")) {
    lines.push(`[Sprachnachricht, transkribiert: ${await transcribe(f)}]`);
  } else if (f && IMAGES.includes(f.mime)) {
    lines.push(`[Foto: ${await look(f, "Beschreibe dieses Foto für einen Chat-Assistenten in unter 80 Wörtern. Übernimm sichtbaren Text wörtlich.")}]`);
  } else if (f?.mime === "application/pdf") {
    lines.push(`[PDF ${f.filename}: ${await look(f, "Fasse dieses Dokument für einen Chat-Assistenten in unter 150 Wörtern zusammen.")}]`);
  } else if (f) {
    lines.push(`[Datei ${f.filename ?? "ohne Namen"}, ${f.mime}, ${f.size} Bytes: nicht lesbar]`);
  }
  if (m.location) lines.push(`[Standort: ${m.location.name ?? ""} ${m.location.address ?? ""} (${m.location.latitude}, ${m.location.longitude})]`);
  if (m.contacts?.length) lines.push(`[${m.contacts.length} Kontaktkarte(n) geteilt]`);
  return lines.join("\n") || "[leere Nachricht: Eine Datei über 30 MB kommt ohne Link an]";
}

toText liefert für jede Nachricht einfachen Text, genau das, was dein Verlauf und dein Prompt wollen. Eine Nachricht kann text und media zugleich tragen, deshalb liest die Funktion beides. Ist nichts lesbar, gibt sie einen Platzhalter zurück, und das Modell kann antworten: „Die Datei ist nicht angekommen, kannst du eine kleinere schicken?“ Die [Klammern] passen zu der Zeile in der Vorlage für den System-Prompt, die dem Agenten sagt, was sie bedeuten.

Die Funktion look beschreibt einmal und behält Text, nicht Pixel. Die Datei verschwindet nach 24 Stunden bei whasp, und die Docs von Anthropic weisen darauf hin, dass bei Base64-kodierten Bildern in einem Chat mit mehreren Runden jede Anfrage die Bild-Bytes erneut sendet. Speichere die Beschreibung in deinem Konversationsgedächtnis und verlange im Prompt für die Beschreibung Details: Sichtbaren Text wörtlich zu übernehmen sorgt dafür, dass der Agent nächste Woche eine Frage zu einem Kassenbon beantworten kann. Wenn Folgefragen zum Bild wichtig sind, kopiere die Datei zusätzlich in deinen eigenen Speicher.

Um dieselbe Aufgabe mit Claude zu erledigen, schicke die Bytes als image-Block, mit dem Bild vor dem Text, was laut den Docs von Anthropic am besten funktioniert. claude-haiku-5-5 ist das Modell, das Anthropic für Arbeit mit hohem Volumen und niedriger Latenz positioniert:

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

const claude = new Anthropic(); // liest 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: "Beschreibe dieses Foto für einen Chat-Assistenten in unter 80 Wörtern. Übernimm sichtbaren Text wörtlich." },
      ],
    }],
  });
  return message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
}

Was ist mit Dokumenten, Standorten und Kontakten?

Ein PDF geht auf dieselbe Weise ans Modell wie ein Foto. Der PDF-Leitfaden von OpenAI akzeptiert es als input_file mit Base64-file_data und einem filename, bis zu 50 MB pro Datei, und gibt dem Modell sowohl den extrahierten Text als auch die Seitenbilder, das ist der application/pdf-Zweig oben. Bei Word- oder Excel-Dateien extrahierst du den Text mit einer Bibliothek, der du vertraust, oder bittest die Person, ein PDF zu schicken.

Ein Standort kommt als Koordinaten mit optionalem Namen und optionaler Adresse an, beschreibe ihn also in einem Satz und lass das Modell entscheiden, was es damit tut. Ein Kontakt kommt als vCard-Text an: Parse die Zeilen FN und TEL, wenn dein Agent Namen und Nummern braucht. Eine Antwort auf eine frühere Nachricht trägt deren Text in reply_to, weshalb toText ihn einbezieht: Ohne ihn hat ein bloßes „ja“ keine Bedeutung.

Behandle alles, was du extrahierst, als nicht vertrauenswürdig. Ein Transkript, eine Bildbeschreibung oder Text in einem PDF sind die Worte der anderen Person, und jemand kann in jedem davon „ignoriere deine Anweisungen“ sagen oder schreiben. Behalte diese Strings im Prompt als Daten und nie als Anweisungen.

Wie sendest du eine Datei zurück?

Um eine Datei zu senden, übergib eine öffentliche HTTPS-URL in media bei POST /v1/messages. Die URL kann auf eine Datei bis 64 MB zeigen. whasp lädt sie beim Senden einmal herunter und löscht seine Kopie, wenn das Senden endet. Sendest du text und media zusammen, sendet das Telefon zuerst die Datei und den Text als zweite WhatsApp-Nachricht, vorerst ohne Bildunterschrift, und es zählt trotzdem als eine API-Nachricht.

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: "Hier ist dein Beleg.",
  }),
});

Die Datei muss erreichbar bleiben, während die Nachricht in der Warteschlange liegt und gesendet wird. Halte den Link gültig, bis message.sent oder message.failed für diese Nachricht eintrifft.

Sprachnachrichten sind das Einzige, was die API nicht senden kann. Ausgehende Sprachnachrichten werden nicht unterstützt, und eine Audiodatei, die du schickst, kommt als Datei an, nicht als Sprachnachricht. Die praktische Regel: Beantworte eine Sprachnachricht mit Text. Willst du trotzdem gesprochene Antworten, erzeuge das Audio mit einem Text-to-Speech-Modell und sende es als Datei, im Wissen, was die Person sehen wird.

Wie bindest du das in den Webhook ein?

Bestätige das Event zuerst und verarbeite es im Hintergrund, denn whasp wartet 10 Sekunden auf ein 2xx und wiederholt dann mit Backoff, und Transkription plus Modellaufruf können länger dauern. Nach der Signaturprüfung aus Webhooks für WhatsApp verarbeiten sieht die Empfangsseite so aus:

if (event.type === "message.received") {
  // Jetzt mit 2xx antworten. Transkription und Modellaufruf laufen nach der Antwort.
  void handleInbound(event.id, event.data.message).catch(console.error);
}
return new Response(null, { status: 204 });

handleInbound ruft toText auf, speichert das Ergebnis, fragt dein Modell und sendet die Antwort, wie im Leitfaden zum Bau eines Agenten mit der OpenAI API. Überspringe eine Event-ID, die du schon verarbeitet hast, sonst zahlst du bei einem Retry zweimal für dieselbe Transkription. Fire-and-forget funktioniert auf einem Server, der durchläuft. Auf einem Serverless-Host schiebst du das Event stattdessen in eine Queue.

Was solltest du aufbewahren, und wie lange?

Behalte das Transkript oder die Beschreibung und lösche Audio und Bild, wenn du sie nicht mehr brauchst. whasp entfernt seine Kopie nach 24 Stunden, der Rest ist deine Entscheidung. Eine Sprachaufnahme besteht aus personenbezogenen Daten, halte also fest, wie lange du sie behältst, und lösche sie, wenn eine Person darum bittet. Opt-out und Einwilligung bei KI-Agenten behandelt die Regeln. Übergib store: false im Modellaufruf, wie es look tut, wenn OpenAI die Antwort nicht behalten soll.

Was tust du als Nächstes?

Füge toText zu dem Handler hinzu, den du schon hast, schick dir selbst eine Sprachnachricht und ein Foto und lies, was der Agent sieht. Plane dann die Anwendungsfälle, die davon abhängen: 12 KI-Agenten, die auf WhatsApp am besten funktionieren listet mehrere, bei denen Sprache und Fotos die Haupteingabe sind. Die whasp-Docs beschreiben jedes Feld des media-Objekts.

Fragen

Kann ein WhatsApp-KI-Agent Sprachnachrichten senden?

Nicht über whasp. Ausgehende Sprachnachrichten werden nicht unterstützt, und eine Audiodatei, die du schickst, kommt als Datei an, nicht als Sprachnachricht. Beantworte Sprachnachrichten mit Text.

Wie lange kann ich eine Datei herunterladen, die jemand an meinen Agenten geschickt hat?

24 Stunden lang. Der Medienlink im message.received-Event ist signiert und 24 Stunden gültig, danach löscht whasp die Datei, kopiere sie also, wenn du sie brauchst.

Was passiert, wenn jemand eine Datei über 30 MB schickt?

Die Nachricht kommt trotzdem an, aber ihr Feld media ist null, weil es keinen Link gibt. Dein Agent kann das erkennen und um eine kleinere Datei bitten.

Welches Modell sollte WhatsApp-Sprachnachrichten transkribieren?

Der Speech-to-Text-Leitfaden von OpenAI empfiehlt gpt-transcribe für aufgenommene Sprache. Wenn Audio auf deinen eigenen Servern bleiben muss, sind faster-whisper und whisper.cpp Optionen unter MIT-Lizenz.

Brauche ich ffmpeg, um WhatsApp-Sprachnachrichten zu transkribieren?

Es ist der sichere Weg. Die Business API von WhatsApp beschreibt Sprachaufnahmen als Ogg mit dem Opus-Codec, und der Leitfaden von OpenAI nennt mp3, mp4, mpeg, mpga, m4a, wav und webm, die Konvertierung zu MP3 beseitigt also die Frage.

Alle Artikel