feat: media-send pacing na élke send (niet alleen tussen pagina's)

MEDIA_SEND_DELAY_MS pauzeert nu centraal in sendImageMessage ná elke
media-send, zodat ook afbeeldingen naar verschillende ontvangers niet
overlappen in de ene chromium-tab (staged-ghost race-risico). Per-pagina
delay uit messageHandler verwijderd.
This commit is contained in:
Eddy de Vink 2026-09-26 22:26:18 +02:00
commit 7673239933
5 changed files with 83 additions and 20 deletions

View file

@ -25,7 +25,7 @@ MAX_REQUESTS_PER_DAY=5
ACK_STUCK_TIMEOUT_MS=300000 # 5 min zonder ack 2 = stuck ACK_STUCK_TIMEOUT_MS=300000 # 5 min zonder ack 2 = stuck
ACK_PRUNED_AFTER_MS=1800000 # 30 min: stale trackings opruimen (loop stopt dan) ACK_PRUNED_AFTER_MS=1800000 # 30 min: stale trackings opruimen (loop stopt dan)
ACK_CHECK_INTERVAL_MS=30000 # checkinterval (alleen actief als er iets pending is) ACK_CHECK_INTERVAL_MS=30000 # checkinterval (alleen actief als er iets pending is)
MEDIA_SEND_DELAY_MS=500 # pauze tussen pagina's van een dienst (niet vóór de eerste) MEDIA_SEND_DELAY_MS=1500 # wacht ná élke media-send (media-pipeline is één tab; voorkomt overlappende sends)
# Admin notifications # Admin notifications
ADMIN_NUMBER=31612345678@c.us ADMIN_NUMBER=31612345678@c.us

View file

@ -10,13 +10,18 @@ zag in de chat maar **één** bericht (ghost-sends: id geretourneerd, nooit echt
- **`/chat/fetchMessages`-acks en de lokale message-store van wwebjs-api zijn een lokale - **`/chat/fetchMessages`-acks en de lokale message-store van wwebjs-api zijn een lokale
registratie, géén bezorgbewijs.** Ze kunnen ack 1/2/3 tonen voor berichten die nooit registratie, géén bezorgbewijs.** Ze kunnen ack 1/2/3 tonen voor berichten die nooit
aankwamen. aankwamen.
- **Enige geldige bewijzen van levering:** (1) de **live `message_ack`-progressie** zoals - **Ook de live `message_ack`-progressie is sinds 2026-09-26 géén bezorgbewijs meer:**
die vlak ná verzending via de webhook binnenkomt; (2) de **zichtbare werkelijkheid op de staged-ghost van dienst 5219 blz 2 kreeg via de webhook een `ack=2` afgegeven voor een
het apparaat** van de ontvanger/betrokkene. bericht dat nooit getransmitteerd was (stond niet eens in de WhatsApp-app van de eigenaar).
De bridge resolvet media-sends **optimistisch op lokale staging** en fabriceert daarna
ack-events + delivery-info. Ack-tracking (stopt bij ack ≥ 2) kan deze klasse per ontwerp
niet vangen.
- **Enige geldige bewijzen van levering:** (1) de **zichtbare werkelijkheid op het
apparaat** van de ontvanger/betrokkene; (2) `getInfo` (`message.getInfo()`): bij een
staged-ghost blijft `read` leeg terwijl `delivery` gevuld kan zijn — het enige
betrouwbare bridge-signaal is `read`/`readRemaining`.
- **Trek NOOIT een leveringsconclusie uit een later gelezen fetchMessages-ack.** Claim geen - **Trek NOOIT een leveringsconclusie uit een later gelezen fetchMessages-ack.** Claim geen
"bezorgd/gelezen" op basis van de store — dat geeft verkeerde adviezen. "bezorgd/gelezen" op basis van de store — dat geeft verkeerde adviezen.
- Dit is de reden dat ack-tracking bestaat: het hangt af van de live events, niet van de
store.
## Waar draait de bot? ## Waar draait de bot?
@ -176,13 +181,47 @@ Sommige mediabestand-verzendingen keren `success` + message-ID terug maar worden
- **Pruning** na `ACK_PRUNED_AFTER_MS` (30 min); de check-loop is **activity-driven** (draait alleen bij openstaande tracking, geen polling in rust). - **Pruning** na `ACK_PRUNED_AFTER_MS` (30 min); de check-loop is **activity-driven** (draait alleen bij openstaande tracking, geen polling in rust).
- Vereiste aan de bridge: `message_ack` staat sinds 2026-09-24 in wwebjs-api `.env` **niet** meer in `DISABLED_CALLBACKS`; container is daarvoor herstart. - Vereiste aan de bridge: `message_ack` staat sinds 2026-09-24 in wwebjs-api `.env` **niet** meer in `DISABLED_CALLBACKS`; container is daarvoor herstart.
- Env-vars: `ACK_STUCK_TIMEOUT_MS` (default 300000), `ACK_PRUNED_AFTER_MS` (default 1800000), `ACK_CHECK_INTERVAL_MS` (default 30000). - Env-vars: `ACK_STUCK_TIMEOUT_MS` (default 300000), `ACK_PRUNED_AFTER_MS` (default 1800000), `ACK_CHECK_INTERVAL_MS` (default 30000).
- **Media-spreiding:** `MEDIA_SEND_DELAY_MS` (default **500**) pauzeert tussen de pagina's van één - **Media-spreiding:** `MEDIA_SEND_DELAY_MS` (default **500**, nu **1500**) pauzeert **na élke
dienst (niet vóór de eerste). Doel: de wwebjs media-pipeline (één chromium-tab) per-verzending media-send** (in `sendImageMessage`), niet alleen tussen pagina's van één dienst. De
lucht geven en gelijktijdige storage/upload-conflicten verminderen. 0 = uitschakelen. wwebjs-media-pipeline is EEN chromium-tab; opeenvolgende media-sends — óók naar
verschillende ontvangers — overlappen anders en veroorzaken de staged-ghost-race.
0 = uitschakelen. (Tijdelijke mitigatie; permanente fix = fork: send óf écht verzenden
óf zichtbaar falen, zie RCA.)
Logregels: `[ack] track <id> (<bestand> → <chat>)`, `[ack] delivered ack=<n> <id>`, `[ack] pruned <n> stale entries`. Logregels: `[ack] track <id> (<bestand> → <chat>)`, `[ack] delivered ack=<n> <id>`, `[ack] pruned <n> stale entries`.
**Ack-semantiek (wwebjs 1.34.7):** ack 1 = verstuurd naar server (één grijs vinkje), ack 2 = bezorgd op apparaat (twee vinkjes), ack 3 = geopend/gelezen. Let op: eerdere notities die "één grijs vinkje (ack 2)" noemden, zaten er dus naast. **Ack-semantiek (wwebjs 1.34.7):** ack 1 = verstuurd naar server (één grijs vinkje), ack 2 = bezorgd op apparaat (twee vinkjes), ack 3 = geopend/gelezen. Let op: eerdere notities die "één grijs vinkje (ack 2)" noemden, zaten er dus naast. **Let op 2 (2026-09-26):** óók deze acks zijn geen leveringsbewijs — zie RCA hieronder.
### RCA 2026-09-26: staged-ghost = race-conditie, géén bestandprobleem
Casus: dienst 5219 (2 pagina's), 09:29 gevraagd in de groep, bot stuurt beide pagina's
prive. App toont alleen blz 1; **blz 2 stond in het geheel niet in de WhatsApp-app van de
eigenaar** (geen transmissie). Toch: bridge retourneerde success + message-ID (`3EB040AD…`),
store toont het bericht, live `message_ack` gaf `ack=2`, `getInfo` toonde `delivery` gevuld
maar `read` leeg. Geen enkele fout in de container-logs.
Mechanisme (bewezen uit fork-broncode + logs):
- De send loopt via `WAWebSendMsgChatAction.addAndSendMsgToChat(chat, message)` →
`[msgPromise, sendMsgResultPromise]`. De bridge zet `waitUntilMsgSent: true`
(stock wwebjs-api, `clientController.js`), maar de promise resolve **ook optimistisch op
lokale staging** — een echte serverbevestiging wordt nooit afgewacht. HTTP-success +
message-ID betekenen dus **niet** "verzonden".
- **Patroon (gebruiker, consistent met logs):** de ghost treedt alleen op bij **2+ media
direct achter elkaar**, en dan altijd de **2e** van het paar; enkele sends falen nooit.
- Verklaring: de bot wacht per pagina op het HTTP-signaal (staging), niet op echte
transmissie → blz 2 wordt gestart terwijl blz 1's **echte** upload/transmissie nog in
de lucht is. Twee echte media-transmissies overlappen in de ene chromium-tab → de 2e
verliest (intermitterend) → staged-ghost.
- Gevolgen: **`MEDIA_SEND_DELAY_MS` kan dit niet oplossen** (wacht op optimistisch
signaal); **poppler/re-render maskeert het alleen** (nieuwe poging = nieuwe race-rol,
soms gewonnen — wassen neus); **`sendMessageViaUi` lost het óók niet op** (wacht op
dezelfde promises, alleen omzeilt het de media-editor); **ack-tracking kan het niet
vangen** (stopt bij gefabriceerde ack ≥ 2).
Enige betrouwbare signalen: apparaat-waarheid; `getInfo` `read`/`readRemaining` (blijft
leeg bij staged). Aanbevolen richting (nog niet geïmplementeerd): de fork moet ná
`addAndSendMsgToChat` écht wachten op transmissiebevestiging — pas dan serialiseren
multi-page sends vanzelf en is de race weg.
## Media bezorgtest (afbeeldingen die WhatsApp weigert) ## Media bezorgtest (afbeeldingen die WhatsApp weigert)
@ -200,6 +239,15 @@ ack-tracking (zie hierboven), re-render via poppler en een verse bezorgtest.
**Ack-telling:** ack 1 = één grijs vinkje (niet bezorgd), ack 2 = bezorgd op apparaat, **Ack-telling:** ack 1 = één grijs vinkje (niet bezorgd), ack 2 = bezorgd op apparaat,
ack 3 = geopend. (Eerdere notities "één grijs vinkje (ack 2)" zaten er dus naast.) ack 3 = geopend. (Eerdere notities "één grijs vinkje (ack 2)" zaten er dus naast.)
> **⚠️ 2026-09-26: onderscheid twee foutklassen.** De hierboven beschreven
> bestand × ontvanger/offline-klasse (ack 1-hang, ontvanger offline of app weigert
> render) is niet hetzelfde als de **staged-ghost-race** (zie RCA hierboven): daar is het
> bestand nooit het probleem en **re-render (poppler) is een wassen neus** — het maskert
> de race alleen. Check bij een "niet aangekomen bladzijde" eerst: was het de 2e pagina
> van een paar binnen ~1s? Dan is het de race, geen bestand. Echte remedie is nog in
> ontwerp (fork: wachten op transmissiebevestiging); voor nu: opnieuw versturen (eventuele
> nieuwe poging) en apparaat als waarheid.
**Na elke omruiling van de dienstenmap: de volledige media-test doorlopen vóór ingebruikname.** **Na elke omruiling van de dienstenmap: de volledige media-test doorlopen vóór ingebruikname.**
**Afbeeldingsformaat:** sinds `07b511e` leidt `sendImageMessage` de mimetype af uit de **Afbeeldingsformaat:** sinds `07b511e` leidt `sendImageMessage` de mimetype af uit de

View file

@ -145,6 +145,15 @@ Bij elke interactie met de groep controleert de bot of het pinned bericht nog be
## Ack-tracking (ghost-send detectie, sinds 2026-09-24) ## Ack-tracking (ghost-send detectie, sinds 2026-09-24)
> **⚠️ 2026-09-26 — ack-tracking is voor media ongeldig gebleken.** Zie de RCA in
> AGENTS.md ("staged-ghost = race-conditie"): de bridge resolvet media-sends optimistisch
> op lokale staging en fabriceert daarna óók de live `message_ack`-events (bewijs: dienst
> 5219 blz 2 kreeg `ack=2` voor een bericht dat nooit getransmitteerd was). Daardoor:
> (1) de tracker stopt bij gefabriceerde ack ≥ 2 en mist de ghost; (2) "live ack" is géén
> bezorgbewijs meer — enige waarheid is het apparaat en `getInfo` (`read` blijft leeg).
> De klasse is een **race bij 2+ media achter elkaar** (altijd de 2e pagina), niet een
> bestandprobleem; `MEDIA_SEND_DELAY_MS` en re-render (poppler) lossen het niet op.
Sommige mediabestand-verzendingen keren `success` + message-ID terug maar worden nooit Sommige mediabestand-verzendingen keren `success` + message-ID terug maar worden nooit
**bezorgd**: ze blijven op één grijs vinkje hangen (ack 1) en de ontvanger ziet niets **bezorgd**: ze blijven op één grijs vinkje hangen (ack 1) en de ontvanger ziet niets
aankomen. Dit is een bestand×ontvanger-interactie in WhatsApp's mediaverwerking (niet de aankomen. Dit is een bestand×ontvanger-interactie in WhatsApp's mediaverwerking (niet de
@ -181,15 +190,17 @@ sessie — zie AGENTS.md). De bot detecteert dit via `message_ack`-events en waa
| `ACK_STUCK_TIMEOUT_MS` | Leeftijd zonder ack 2 = stuck | `300000` (5 min) | | `ACK_STUCK_TIMEOUT_MS` | Leeftijd zonder ack 2 = stuck | `300000` (5 min) |
| `ACK_PRUNED_AFTER_MS` | Stale tracking opruimen | `1800000` (30 min) | | `ACK_PRUNED_AFTER_MS` | Stale tracking opruimen | `1800000` (30 min) |
| `ACK_CHECK_INTERVAL_MS` | Checkinterval (alleen actief bij pending) | `30000` | | `ACK_CHECK_INTERVAL_MS` | Checkinterval (alleen actief bij pending) | `30000` |
| `MEDIA_SEND_DELAY_MS` | Pauze tussen pagina's van één dienst (niet vóór de eerste) — geeft de wwebjs media-pipeline lucht | `500` | | `MEDIA_SEND_DELAY_MS` | Wacht ná élke media-send (media-pipeline is één tab; voorkomt overlappende sends naar elk doel) | `1500` |
Opvallende logregels: `[ack] track …`, `[ack] delivered ack=…`, `[ack] pruned …`. Opvallende logregels: `[ack] track …`, `[ack] delivered ack=…`, `[ack] pruned …`.
> **Let op — bridgestore is geen bezorgbewijs (2026-09-24):** de lokale store van wwebjs-api > **Let op — bridgestore is geen bezorgbewijs (2026-09-24):** de lokale store van wwebjs-api
> (`/chat/fetchMessages`-acks) kan ack 1/2/3 tonen voor berichten die de ontvanger nooit zag > (`/chat/fetchMessages`-acks) kan ack 1/2/3 tonen voor berichten die de ontvanger nooit zag
> (ghost-sends). Trek hier géén leveringsconclusie uit. Ack-tracking gebruikt de **live > (ghost-sends). Trek hier géén leveringsconclusie uit. **En sinds 2026-09-26 geldt hetzelfde
> `message_ack`-progressie**, niet de store; bevestiging komt van het apparaat van de > voor de live `message_ack`-progressie** (zie waarschuwing bovenin deze sectie): ook die
> ontvanger. > kan gefabriceerd zijn bij een staged-ghost. Enig betrouwbaar bridgesignaal is `getInfo`
> (`message.getInfo()` → `read`/`readRemaining`); eindoordeel is altijd het apparaat van
> de ontvanger.
## Spam protectie ## Spam protectie

View file

@ -68,20 +68,16 @@ async function handleWebhook(payload) {
return return
} }
// Pacing gebeurt nu centraal in sendImageMessage (na élke media-send), zodat óók
// afbeeldingen naar verschillende ontvangers niet overlappen in de ene chromium-tab.
const pngPaths = getPngFilePaths(dienst) const pngPaths = getPngFilePaths(dienst)
if (!pngPaths || pngPaths.length === 0) { if (!pngPaths || pngPaths.length === 0) {
await sendTextMessage(author, `Geen pagina's gevonden voor dienst ${dienstnummer}.`) await sendTextMessage(author, `Geen pagina's gevonden voor dienst ${dienstnummer}.`)
return return
} }
// Defensieve spreiding tussen pagina's (niet vóór de eerste): verlaagt de kans op
// gelijktijdige storage/upload-conflicten in de wwebjs-media-pipeline.
const mediaSendDelayMs = parseInt(process.env.MEDIA_SEND_DELAY_MS, 10) || 500
for (let i = 0; i < pngPaths.length; i++) { for (let i = 0; i < pngPaths.length; i++) {
const pngPath = pngPaths[i] const pngPath = pngPaths[i]
if (i > 0 && mediaSendDelayMs > 0) {
await new Promise(resolve => setTimeout(resolve, mediaSendDelayMs))
}
const filename = pngPath.split('/').pop() const filename = pngPath.split('/').pop()
try { try {
await sendImageMessage(author, pngPath, filename) await sendImageMessage(author, pngPath, filename)

View file

@ -71,6 +71,14 @@ async function sendImageMessage(chatId, filePath, filename) {
}) })
const messageId = response.data?.message?._data?.id?.id const messageId = response.data?.message?._data?.id?.id
registerSend(messageId, chatId, filename) registerSend(messageId, chatId, filename)
// Pacing na élke media-send: de wwebjs-media-pipeline is EEN chromium-tab. Twee
// opeenvolgende media-sends (pagina's van één dienst óf afbeeldingen naar
// verschillende ontvangers) mogen niet overlappen — dat veroorzaakt de staged-
// ghost-race (2e send verliest). Wacht dus altijd MEDIA_SEND_DELAY_MS ná een send.
const mediaSendDelayMs = parseInt(process.env.MEDIA_SEND_DELAY_MS, 10) || 500
if (mediaSendDelayMs > 0) {
await sleep(mediaSendDelayMs)
}
return response.data return response.data
}, `sendImageMessage ${filename} to ${chatId}`) }, `sendImageMessage ${filename} to ${chatId}`)
} }