feat: ack-tracking voor ghost-send detectie (message_ack)

This commit is contained in:
Eddy de Vink 2026-09-24 22:00:49 +02:00
commit 35b4516790
5 changed files with 128 additions and 1 deletions

View file

@ -21,6 +21,12 @@ DIENSTEN_DIR=../Whatsapp_Dienst/diensten
# Rate limiting
MAX_REQUESTS_PER_DAY=5
# Ack tracking (ghost-send detectie; vereist message_ack aangezet in wwebjs-api .env)
ACK_STUCK_TIMEOUT_MS=300000 # 5 min zonder ack 2 = stuck
ACK_AUTO_RESTART_SESSION=true # sessie automatisch recyclen bij stuck
ACK_RESTART_COOLDOWN_MS=1800000 # min. 30 min tussen sessie-restarts
ACK_CHECK_INTERVAL_MS=30000 # checkinterval
# Admin notifications
ADMIN_NUMBER=31612345678@c.us

77
src/ackTracker.js Normal file
View file

@ -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
}

View file

@ -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
@ -102,4 +103,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()

View file

@ -2,6 +2,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
@ -11,6 +12,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
}
if (payload.dataType !== 'message') return
if (payload.sessionId !== process.env.WHATSAPP_SESSION) return

View file

@ -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
@ -60,6 +61,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}`)
}