feat: ack-tracking voor ghost-send detectie (message_ack)
This commit is contained in:
parent
1d5a233f68
commit
7b4e307438
4 changed files with 122 additions and 1 deletions
77
src/ackTracker.js
Normal file
77
src/ackTracker.js
Normal 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
|
||||||
|
}
|
||||||
35
src/index.js
35
src/index.js
|
|
@ -6,7 +6,8 @@ const axios = require('axios')
|
||||||
const { handleWebhook } = require('./messageHandler')
|
const { handleWebhook } = require('./messageHandler')
|
||||||
const { loadDiensten } = require('./dienstService')
|
const { loadDiensten } = require('./dienstService')
|
||||||
const { resetDay, getYesterdayTotal } = require('./dailyStats')
|
const { resetDay, getYesterdayTotal } = require('./dailyStats')
|
||||||
const { sendTextMessage } = require('./whatsappApi')
|
const { sendTextMessage, notifyAdmin } = require('./whatsappApi')
|
||||||
|
const ackTracker = require('./ackTracker')
|
||||||
|
|
||||||
async function notifyTelegramOnline() {
|
async function notifyTelegramOnline() {
|
||||||
const url = process.env.TELEGRAM_BOT_URL
|
const url = process.env.TELEGRAM_BOT_URL
|
||||||
|
|
@ -114,4 +115,36 @@ async function startServer() {
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'))
|
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()
|
startServer()
|
||||||
|
|
@ -5,6 +5,7 @@ const { findDienst, getPngFilePaths } = require('./dienstService')
|
||||||
const { sendTextMessage, sendImageMessage, notifyAdmin, sendChatSeen } = require('./whatsappApi')
|
const { sendTextMessage, sendImageMessage, notifyAdmin, sendChatSeen } = require('./whatsappApi')
|
||||||
const { checkLimit, increment, MAX_REQUESTS_PER_DAY } = require('./rateLimiter')
|
const { checkLimit, increment, MAX_REQUESTS_PER_DAY } = require('./rateLimiter')
|
||||||
const { increment: incrementDaily } = require('./dailyStats')
|
const { increment: incrementDaily } = require('./dailyStats')
|
||||||
|
const { handleAck } = require('./ackTracker')
|
||||||
|
|
||||||
const ALLOWED_GROUP = process.env.ALLOWED_GROUP
|
const ALLOWED_GROUP = process.env.ALLOWED_GROUP
|
||||||
const ALLOWED_GROUP_NAMES = (process.env.ALLOWED_GROUP_NAME || '')
|
const ALLOWED_GROUP_NAMES = (process.env.ALLOWED_GROUP_NAME || '')
|
||||||
|
|
@ -67,6 +68,13 @@ function extractDienstnummer(body) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleWebhook(payload) {
|
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 isWWebEvent = payload.dataType === 'message'
|
||||||
const isAppiumEvent = Boolean(payload.sender && payload.chatId)
|
const isAppiumEvent = Boolean(payload.sender && payload.chatId)
|
||||||
if (!isWWebEvent && !isAppiumEvent) return
|
if (!isWWebEvent && !isAppiumEvent) return
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
|
const { registerSend } = require('./ackTracker')
|
||||||
|
|
||||||
const API_BASE = process.env.WHATSAPP_API_ENDPOINT
|
const API_BASE = process.env.WHATSAPP_API_ENDPOINT
|
||||||
const SESSION = process.env.WHATSAPP_SESSION
|
const SESSION = process.env.WHATSAPP_SESSION
|
||||||
|
|
@ -91,6 +92,8 @@ async function sendImageMessage(chatId, filePath, filename) {
|
||||||
filename: filename
|
filename: filename
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const messageId = response.data?.message?._data?.id?.id
|
||||||
|
registerSend(messageId, chatId, filename)
|
||||||
return response.data
|
return response.data
|
||||||
}, `sendImageMessage ${filename} to ${chatId}`)
|
}, `sendImageMessage ${filename} to ${chatId}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue