Wanneer WhatsApp Web de mediadownload breekt: een eigen gepatchte container bouwen
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:
wwebjs-apiontvangt een WhatsApp-bericht.- De webhook bevat een bericht-ID en de informatie dat er media aanwezig is.
- De bot vraagt de media op via
downloadMediaAsData. - De afbeelding wordt als buffer aan
sharpgegeven. - Een eenvoudige pixelanalyse bepaalt of het waarschijnlijk een whiteboardfoto is.
- De foto wordt als
MessageMediadoorgestuurd.
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:
- Issue #201833: Message.downloadMedia() throws opaque r: r
- Issue #201830: downloadMedia() en de wijziging van
_serializednaar$1
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:
| Optie | Beoordeling |
|---|---|
| Wachten op een nieuwe upstream-release | Geen controle over timing |
| Appium gebruiken voor alle media | Mogelijk, maar andere eventflow en minder betrouwbare message-ID’s |
| Zelf een gepatchte container bouwen | Meeste 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:
- De productiecontainer en sessievolumes vastleggen.
- De upstream-code naar onze eigen Forgejo-repositories brengen.
- De
$1/_serialized-patch gecontroleerd toepassen. - De gepatchte library als vaste npm-archive verpakken.
- Een eigen
wwebjs-api-image bouwen. - De image eerst geïsoleerd testen.
- Daarna de echte media-download en forwarding testen.
- De productiecontainer vervangen zonder de WhatsApp-sessies te verwijderen.
- 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:
- bestaand
_serializedbehouden; $1gebruiken als WhatsApp Web dat levert;- 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=mediaook 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:
- de oorzaak onderzocht;
- de patch uitgebreid en vastgelegd;
- de library naar Forgejo gebracht;
- een eigen API-build gemaakt;
- een gepinde Docker-image gebouwd;
- echte media-download getest;
- forwarding via de EBS-bot getest;
- de container in productie genomen;
- 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.