Wanneer WhatsApp Web de mediadownload breekt: een eigen gepatchte container bouwen

· 10 min lezen

Een WhatsApp-bot kan perfect berichten ontvangen en toch volledig onbruikbaar worden zodra er een afbeelding binnenkomt. Dat was precies het probleem waar we tegenaan liepen met een kleine bot die aankondigingen uit een WhatsApp-groep haalt, whiteboardfoto’s herkent en ze doorstuurt naar een andere groep.

De fout zat niet in de botcode. De fout zat in een wijziging aan de binnenkant van WhatsApp Web, waar de gebruikte open-source library nog geen oplossing voor had.

In plaats van wachten op een nieuwe release hebben we de container zelf gepatcht. Inmiddels draait die gepatchte container in productie — met behoud van de oude ongepatchte container als directe rollback.

De architectuur vóór de storing

De bot gebruikt twee lagen:

WhatsApp
whatsapp-web.js via wwebjs-api
   ↓ webhook
Express-bot
   ├── bron-groep controleren
   ├── afbeelding downloaden
   ├── whiteboard detecteren
   ├── afbeelding doorsturen
   └── vorige afbeelding opruimen

De REST-wrapper wwebjs-api draait als Docker-container. De eigen bot ontvangt webhooks op een aparte Express-poort.

Voorheen werkte de keten als volgt:

  1. wwebjs-api ontvangt een WhatsApp-bericht.
  2. De webhook bevat een bericht-ID en de informatie dat er media aanwezig is.
  3. De bot vraagt de media op via downloadMediaAsData.
  4. De afbeelding wordt als buffer aan sharp gegeven.
  5. Een eenvoudige pixelanalyse bepaalt of het waarschijnlijk een whiteboardfoto is.
  6. De foto wordt als MessageMedia doorgestuurd.

De tekstberichten bleven werken. Alleen het downloaden van media faalde. Dat maakte de fout extra verwarrend: de WhatsApp Web-sessie leek gezond, webhooks kwamen binnen, maar iedere afbeelding liep vast op hetzelfde punt.

De foutmelding: r: r

De eerste fout was weinig behulpzaam:

r: r

In sommige omgevingen verscheen dezelfde oorzaak als een IndexedDB-fout:

DataError: Failed to execute 'get' on 'IDBObjectStore':
No key or key range specified.

De relevante GitHub-meldingen waren:

De meldingen beschreven hetzelfde patroon dat wij zagen: tekst werkte nog, maar iedere inkomende afbeelding kon niet worden gedownload.

Wat WhatsApp Web had veranderd

whatsapp-web.js verwachtte dat een intern WhatsApp-ID-object er ongeveer zo uitzag:

{
  fromMe: false,
  remote: "...@g.us",
  id: "...",
  _serialized: "false_...@g.us_..."
}

Na een WhatsApp Web-update stond de serialized waarde bij sommige bericht-ID’s niet langer onder _serialized, maar onder $1:

{
  fromMe: false,
  remote: "...@g.us",
  id: "...",
  $1: "false_...@g.us_..."
}

De library bleef echter dit doen:

message.id._serialized

Het resultaat was undefined. Die ongeldige waarde werd vervolgens aan de interne WhatsApp Web-lookup doorgegeven. De browser probeerde feitelijk een IndexedDB-object op te zoeken zonder geldige sleutel. WhatsApp Web gaf daar de geminificeerde fout r: r voor terug.

Dit verklaarde ook waarom opnieuw inloggen, de sessie verwijderen of de container opnieuw bouwen niet hielp. Het probleem zat in de JavaScript-code die tegen de nieuwe WhatsApp Web-objectstructuur aanliep.

De officiële oplossing was er nog niet

Er was inmiddels een relevante pull request:

Die pull request voegde onder andere een helper toe die _serialized en $1 accepteert en herstelde _serialized in message-modellen die vanuit de webpagina naar Node.js worden teruggestuurd.

Maar de pull request was nog niet gemerged. De officiële release was nog steeds gebaseerd op versie 1.34.7. Ook de edge-container was geen echte oplossing: die bouwde wel tegen de upstream main, maar bevatte de benodigde fix voor de mediadownload op dat moment niet.

Daarmee waren er drie opties:

OptieBeoordeling
Wachten op een nieuwe upstream-releaseGeen controle over timing
Appium gebruiken voor alle mediaMogelijk, maar andere eventflow en minder betrouwbare message-ID’s
Zelf een gepatchte container bouwenMeeste controle, bestaande API behouden

We kozen voor de derde optie.

Waarom niet meteen alles naar Appium?

We hebben al een tweede WhatsApp-route via een fysieke Android-telefoon. Daarover schreef ik eerder in WhatsApp automatiseren via de telefoon zelf.

Die Appium-oplossing blijft waardevol als fallback, maar is voor dit probleem geen directe vervanging van downloadMedia().

De Appium-route werkt ongeveer zo:

WhatsApp-notificatie
Android NotificationListener
nieuw bestand in WhatsApp Images
ADB pull
media-URL naar subscriber

Daar ontbreken echter een aantal eigenschappen die de bestaande bot gebruikt:

  • een betrouwbare originele message-ID;
  • de oorspronkelijke groeps-JID in dezelfde vorm;
  • directe koppeling tussen webhookbericht en media-object;
  • dezelfde response-structuur voor verzonden berichten en cleanup.

Daarom wilden we eerst de bestaande Web-API herstellen. Appium blijft de tweede verdedigingslinie.

Het plan

We hebben het werk opgesplitst in een paar duidelijke delen:

  1. De productiecontainer en sessievolumes vastleggen.
  2. De upstream-code naar onze eigen Forgejo-repositories brengen.
  3. De $1/_serialized-patch gecontroleerd toepassen.
  4. De gepatchte library als vaste npm-archive verpakken.
  5. Een eigen wwebjs-api-image bouwen.
  6. De image eerst geïsoleerd testen.
  7. Daarna de echte media-download en forwarding testen.
  8. De productiecontainer vervangen zonder de WhatsApp-sessies te verwijderen.
  9. De ongepatchte container als rollback beschikbaar houden.

Een belangrijk uitgangspunt was: geen wijzigingen in een draaiende node_modules-map. Alles moest reproduceerbaar worden vastgelegd in git en Docker.

Eigen Forgejo-repositories

Omdat GitHub de upstream is, maar niet onze eigen release-infrastructuur, hebben we twee eigen repositories aangemaakt:

De upstream-repositories blijven als referentie gekoppeld:

wwebjs/whatsapp-web.js
avoylenko/wwebjs-api

Onze repositories bevatten alleen broncode, buildconfiguratie en de patch. Productiegegevens zijn niet meegenomen:

  • geen .env;
  • geen WhatsApp-sessies;
  • geen logs;
  • geen lokale productieconfiguratie;
  • geen groeps-ID’s of telefoonnummers.

De repositories zijn inmiddels publiek toegankelijk. Dat is hier verantwoord, omdat de live API niet via internet wordt gepubliceerd maar alleen binnen de vertrouwde netwerkzone bereikbaar is.

De patch

De patch doet twee dingen.

1. ID’s normaliseren

De page-side utility gebruikt nu een fallback:

window.WWebJS.getMsgKeyId = (key) => {
    if (!key) return undefined;
    if (key._serialized) return key._serialized;
    if (key.$1) return key.$1;

    if (
        typeof key.fromMe !== 'undefined' &&
        typeof key.remote === 'string' &&
        typeof key.id === 'string'
    ) {
        return `${key.fromMe}_${key.remote}_${key.id}`;
    }

    return undefined;
};

De volgorde is bewust:

  1. bestaand _serialized behouden;
  2. $1 gebruiken als WhatsApp Web dat levert;
  3. als laatste redmiddel de serialized ID opbouwen uit de aanwezige componenten.

2. Message-modellen herstellen

Wanneer WhatsApp Web een berichtmodel teruggeeft met $1, wordt _serialized opnieuw toegevoegd aan het model. Daardoor blijven ook oudere Node.js-code en de bestaande Message-structuur werken.

Dat is belangrijker dan alleen één regel in downloadMedia() aanpassen. Dezelfde ID-vorm wordt ook gebruikt door andere acties, zoals:

  • message lookup;
  • replies;
  • reactions;
  • forwarding;
  • verwijderen;
  • getChats();
  • ophalen van het laatste bericht in een chat.

Een globale zoek-en-vervangactie zou andere paden kunnen breken. Daarom is gekozen voor één gecontroleerde helper en normalisatie op een centraal punt.

De eigen API-build

De gepatchte library is als npm-archive verpakt:

whatsapp-web.js-1.34.7.tgz

De eigen wwebjs-api-repository verwijst niet meer naar:

"whatsapp-web.js": "^1.34.7"

maar naar deze gepinde archive. De bijgewerkte package-lock.json legt de dependency vast.

Dat heeft twee voordelen:

  • een volgende build haalt niet ongemerkt een andere libraryversie op;
  • de Docker-build is onafhankelijk van een bewegende upstream-branch.

De aangepaste Docker-build neemt de archive mee in de dependency-stage. De uiteindelijke image bevat dus precies de librarycode die we hebben getest.

Eerste tests

Statische en lokale tests

De helper is getest met drie ID-vormen:

_serialized aanwezig → _serialized gebruiken
$1 aanwezig          → $1 gebruiken
alleen componenten   → samengestelde ID maken

Daarnaast is de librarycode syntactisch gecontroleerd.

De volledige upstream-integratietests konden niet zonder meer worden uitgevoerd, omdat die een aparte WhatsApp-testaccount en WWEBJS_TEST_REMOTE_ID vereisen. Dat is een beperking van de testomgeving, niet van de Docker-build.

API-smoketest

De image is eerst geïsoleerd gestart op een aparte poort, zonder productiesessies. Daarbij zijn gecontroleerd:

  • serverstart;
  • /ping;
  • aanwezigheid van de gepatchte helper in de container;
  • geladen libraryversie.

Echte media-download

Daarna is de image op de productieserver gestart met de bestaande WhatsApp-sessies. Een bestaand afbeeldingsbericht uit de bron-groep is via de echte route opgevraagd:

POST /message/downloadMediaAsData/<session>

Resultaat:

HTTP 200
JPEG
niet-lege binary response

De afbeelding kon lokaal door de whiteboarddetector worden verwerkt. Daarmee was de oorspronkelijke storing — geen afbeeldingen kunnen downloaden — opgelost in de werkelijke REST-keten.

De productie-uitrol

De oorspronkelijke productieopstelling gebruikte een lokaal gebouwde image. Die image is niet verwijderd.

De nieuwe container kreeg een eigen tag:

local/wwebjs-api:serialized-id-fix-d4aea97

De bestaande onderdelen bleven gelijk:

  • dezelfde API-poort;
  • dezelfde .env;
  • dezelfde sessiemappen;
  • dezelfde sessienamen;
  • dezelfde webhook-URL’s;
  • dezelfde EBS-bot.

Alleen de container-image werd vervangen.

Na de uitrol waren beide WhatsApp-sessies opnieuw verbonden. Er was geen QR-herkoppeling nodig. De sessievolumes bleven volledig intact.

De gecontroleerde forwardingstest

Voor de forwardingstest hebben we een echte afbeelding uit de bronchat gebruikt. De webhook werd aangeboden aan de draaiende EBS-service met dezelfde structuur als een inkomend wwebjs-bericht.

De verwerking verliep als volgt:

EBS-webhook
bericht-ID herkennen
media downloaden via patched wwebjs-api
151636 bytes ontvangen
whiteboarddetectie: positief
afbeelding doorsturen naar doelchat
doelbericht terugvinden
media uit doelbericht opnieuw downloaden

De EBS-service gaf terug:

{
  "success": true,
  "forwarded": true
}

In de logs verschenen onder andere:

[webhook] downloaded 151636 bytes
[detect] darkPct=6.7 bwPct=2.2
[webhook] forwarded to <doelchat>

Het nieuwe doelbericht is daarna opnieuw via WhatsApp Web opgezocht en als afbeelding gevalideerd.

Een onverwachte webhooknuance

Tijdens de eerste proef waarbij we een nieuwe afbeelding rechtstreeks naar de bron-groep stuurden, zagen we nog een tweede probleem in de eventflow.

wwebjs stuurde bij die proef afzonderlijke events zoals:

dataType=message
dataType=media

De huidige EBS-bot verwerkt alleen het message-event. Het eerste message-event bevatte kennelijk niet altijd voldoende media-informatie, terwijl het latere media-event door de bot werd genegeerd.

Dat betekent:

  • de gepatchte media-download werkt aantoonbaar;
  • de forwardingroute werkt aantoonbaar;
  • de natuurlijke verwerking van ieder nieuw media-event verdient nog een afzonderlijke verbetering.

Mogelijke vervolgstappen zijn:

  • dataType=media ook verwerken;
  • het message-event kort uitstellen als media nog niet beschikbaar is;
  • beide events op message-ID samenvoegen;
  • de media-URL uit het webhookevent gebruiken wanneer die beschikbaar is.

We hebben deze nuance bewust vastgelegd in plaats van te doen alsof één succesvolle handmatige webhook automatisch bewijst dat iedere nieuwe WhatsApp-afbeelding perfect wordt afgehandeld.

Rollback getest

Rollback is geen theoretische belofte gebleven.

We hebben de originele ongepatchte image met dezelfde sessievolumes gestart. Beide WhatsApp-sessies werden opnieuw:

CONNECTED

Daarna is de gepatchte container weer gestart en opnieuw gecontroleerd.

De rollback vereist dus geen:

  • QR-scan;
  • nieuwe WhatsApp-koppeling;
  • verwijdering van sessiedata;
  • wijziging aan de EBS-bot.

De oude image en een backup van de Composeconfiguratie blijven op de server aanwezig. Als de gepatchte image onverwacht problemen geeft, kan de productieomgeving terug naar de vorige container.

Wat we hiervan geleerd hebben

1. Een gezonde sessie betekent niet dat media werkt

WhatsApp Web kan tekstberichten blijven verwerken terwijl een interne mediafunctie breekt. Healthchecks moeten daarom meer testen dan alleen processtatus of sessieverbinding.

2. Geminificeerde browserfouten verbergen vaak een datamodelwijziging

r: r zag eruit als een willekeurige Puppeteer-fout. De werkelijke oorzaak was een gewijzigde propertynaam in een intern WhatsApp-object.

3. Een fork is soms sneller dan wachten op upstream

Een open-sourceproject kan een goede oplossing al in review hebben, maar een productieprobleem wacht niet altijd op de releasecyclus. Een kleine, goed afgebakende fork geeft controle — zolang de patch reproduceerbaar en terug te draaien blijft.

4. Pinnen is belangrijker dan “edge” gebruiken

Een dependency op main of een algemene edge-tag is geen vaste release. Voor een productiecontainer willen we weten welke broncode erin zit. Daarom gebruiken we een vaste commit en een eigen image-tag.

5. Rollback hoort bij de oplossing

Een patch is pas verantwoord in productie als de ongepatchte situatie nog direct terug te zetten is. De sessiemappen zijn daarbij minstens zo belangrijk als de image zelf.

Open source en vervolg

De eigen repositories zijn publiek beschikbaar:

De patch is specifiek gericht op de gewijzigde WhatsApp Web-ID-vorm. Zodra upstream een officiële oplossing uitbrengt, kunnen we opnieuw vergelijken, de eigen patch eventueel verwijderen en teruggaan naar een officiële release.

Tot die tijd draait de eigen container in productie. De oorspronkelijke container blijft beschikbaar als rollback, en Appium blijft onze alternatieve route wanneer WhatsApp Web of Puppeteer opnieuw grotere problemen geeft.

Conclusie

De storing begon met een kleine interne wijziging: _serialized werd $1. Voor WhatsApp Web was dat waarschijnlijk een normale refactor. Voor iedere library die op de oude propertynaam vertrouwde, betekende het dat mediaberichten niet langer konden worden opgezocht.

De officiële oplossing bestond al als pull request, maar was nog niet beschikbaar als release. Daarom hebben we zelf:

  1. de oorzaak onderzocht;
  2. de patch uitgebreid en vastgelegd;
  3. de library naar Forgejo gebracht;
  4. een eigen API-build gemaakt;
  5. een gepinde Docker-image gebouwd;
  6. echte media-download getest;
  7. forwarding via de EBS-bot getest;
  8. de container in productie genomen;
  9. rollback naar de ongepatchte container gecontroleerd.

De belangrijkste uitkomst is niet alleen dat de afbeeldingen weer kunnen worden gedownload. Het is dat de oplossing nu van onszelf is, reproduceerbaar gebouwd kan worden en op ieder moment kan worden teruggedraaid.