From 7b4e307438699be4a1dbe9b4a33f784fef70d2a4 Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Thu, 24 Sep 2026 22:08:36 +0200 Subject: [PATCH] feat: ack-tracking voor ghost-send detectie (message_ack) --- src/ackTracker.js | 77 +++++++++++++++++++++++++++++++++++++++++++ src/index.js | 35 +++++++++++++++++++- src/messageHandler.js | 8 +++++ src/whatsappApi.js | 3 ++ 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/ackTracker.js diff --git a/src/ackTracker.js b/src/ackTracker.js new file mode 100644 index 0000000..630baca --- /dev/null +++ b/src/ackTracker.js @@ -0,0 +1,77 @@ +// ackTracker.js +// Tracks delivery acks (message_ack) of outgoing media to detect "ghost" sends: +// a message returns success + a message ID but never receives a delivery ack +// (stuck below ack 2). Root cause is a stale "ghost" session — reported as +// wwebjs #201849 (valid ID, no ACK, message not in own chat history). +// +// Requires the bridge (wwebjs-api) to emit message_ack: remove 'message_ack' +// from DISABLED_CALLBACKS in the wwebjs-api .env and restart the container. + +const STUCK_TIMEOUT_MS = parseInt(process.env.ACK_STUCK_TIMEOUT_MS, 10) || 5 * 60 * 1000 +const AUTO_RESTART_SESSION = (process.env.ACK_AUTO_RESTART_SESSION || 'true').toLowerCase() === 'true' +const RESTART_COOLDOWN_MS = parseInt(process.env.ACK_RESTART_COOLDOWN_MS, 10) || 30 * 60 * 1000 + +// messageId -> { chatId, filename, sentAt, ack, notifiedStuck } +const sends = new Map() +let lastRestartAt = 0 + +function registerSend(messageId, chatId, filename) { + if (!messageId) return + sends.set(messageId, { + chatId: chatId || '', + filename: filename || '', + sentAt: Date.now(), + ack: 0, + notifiedStuck: false + }) +} + +function handleAck(messageId, ack) { + if (!messageId) return + const entry = sends.get(messageId) + if (!entry) return + if (ack > entry.ack) entry.ack = ack + if (ack >= 2) sends.delete(messageId) // delivered; nothing left to track +} + +function stuckEntries() { + const now = Date.now() + const stuck = [] + for (const [id, entry] of sends) { + if (entry.ack < 2 && now - entry.sentAt > STUCK_TIMEOUT_MS) { + stuck.push({ id, ...entry }) + } + } + return stuck +} + +function markNotified(messageId) { + const entry = sends.get(messageId) + if (entry) entry.notifiedStuck = true +} + +function pendingCount() { + return sends.size +} + +function canRestart() { + return AUTO_RESTART_SESSION && Date.now() - lastRestartAt > RESTART_COOLDOWN_MS +} + +function markRestarted() { + lastRestartAt = Date.now() + // After a session restart the old local IDs no longer ack; stop tracking them. + sends.clear() +} + +module.exports = { + registerSend, + handleAck, + stuckEntries, + markNotified, + pendingCount, + AUTO_RESTART_SESSION, + RESTART_COOLDOWN_MS, + canRestart, + markRestarted +} \ No newline at end of file diff --git a/src/index.js b/src/index.js index c8b77c7..0cafb47 100644 --- a/src/index.js +++ b/src/index.js @@ -6,7 +6,8 @@ const axios = require('axios') const { handleWebhook } = require('./messageHandler') const { loadDiensten } = require('./dienstService') const { resetDay, getYesterdayTotal } = require('./dailyStats') -const { sendTextMessage } = require('./whatsappApi') +const { sendTextMessage, notifyAdmin } = require('./whatsappApi') +const ackTracker = require('./ackTracker') async function notifyTelegramOnline() { const url = process.env.TELEGRAM_BOT_URL @@ -114,4 +115,36 @@ async function startServer() { process.on('SIGINT', () => shutdown('SIGINT')) } +async function restartSession() { + const url = `${process.env.WHATSAPP_API_ENDPOINT}/session/restart/${process.env.WHATSAPP_SESSION}` + try { + await axios.get(url, { timeout: 15000 }) + console.log(`[ack] Session ${process.env.WHATSAPP_SESSION} restarted (stuck ack detected)`) + } catch (err) { + console.error('[ack] Session restart failed:', err.message) + } +} + +function checkStuckAcks() { + const stuck = ackTracker.stuckEntries() + if (stuck.length === 0) return + for (const s of stuck) { + if (!s.notifiedStuck) { + ackTracker.markNotified(s.id) + notifyAdmin( + `[dienst-bot] hangend bericht: ${s.filename} → ${s.chatId} ` + + `(ack ${s.ack}, ${Math.round((Date.now() - s.sentAt) / 1000)}s oud). ` + + `Sessie ${process.env.WHATSAPP_SESSION} is mogelijk stale.` + ) + } + } + if (ackTracker.canRestart()) { + ackTracker.markRestarted() + restartSession() + } +} + +const ackCheckIntervalMs = parseInt(process.env.ACK_CHECK_INTERVAL_MS, 10) || 30000 +setInterval(checkStuckAcks, ackCheckIntervalMs) + startServer() \ No newline at end of file diff --git a/src/messageHandler.js b/src/messageHandler.js index ca1eba2..b904485 100644 --- a/src/messageHandler.js +++ b/src/messageHandler.js @@ -5,6 +5,7 @@ const { findDienst, getPngFilePaths } = require('./dienstService') const { sendTextMessage, sendImageMessage, notifyAdmin, sendChatSeen } = require('./whatsappApi') const { checkLimit, increment, MAX_REQUESTS_PER_DAY } = require('./rateLimiter') const { increment: incrementDaily } = require('./dailyStats') +const { handleAck } = require('./ackTracker') const ALLOWED_GROUP = process.env.ALLOWED_GROUP const ALLOWED_GROUP_NAMES = (process.env.ALLOWED_GROUP_NAME || '') @@ -67,6 +68,13 @@ function extractDienstnummer(body) { } async function handleWebhook(payload) { + if (payload.dataType === 'message_ack') { + if (payload.sessionId && payload.sessionId !== process.env.WHATSAPP_SESSION) return + const { message, ack } = payload.data || {} + const id = message && (message.id?.id || message._data?.id?.id) + if (id) handleAck(id, ack) + return + } const isWWebEvent = payload.dataType === 'message' const isAppiumEvent = Boolean(payload.sender && payload.chatId) if (!isWWebEvent && !isAppiumEvent) return diff --git a/src/whatsappApi.js b/src/whatsappApi.js index 9708c12..2c48f09 100644 --- a/src/whatsappApi.js +++ b/src/whatsappApi.js @@ -1,5 +1,6 @@ const axios = require('axios') const fs = require('fs') +const { registerSend } = require('./ackTracker') const API_BASE = process.env.WHATSAPP_API_ENDPOINT const SESSION = process.env.WHATSAPP_SESSION @@ -91,6 +92,8 @@ async function sendImageMessage(chatId, filePath, filename) { filename: filename } }) + const messageId = response.data?.message?._data?.id?.id + registerSend(messageId, chatId, filename) return response.data }, `sendImageMessage ${filename} to ${chatId}`) }