diff --git a/README.md b/README.md index 3c60b71..3f82cb6 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,11 @@ Bij elke interactie met de groep controleert de bot of het pinned bericht nog be ## Foutafhandeling - **Ongeldig dienstnummer**: de bot stuurt een melding terug naar de afzender -- **Fout bij verzenden PNG**: de admin krijgt een WhatsApp melding -- **Webhook fout**: wordt gelogd in PM2 logs +- **Fout bij verzenden tekst/PNG**: maximaal 3 pogingen met exponential backoff (1s, 2s, 4s) voor 5xx HTTP-fouten en connectieproblemen (ECONNRESET, ECONNREFUSED, ETIMEDOUT). Bij definitieve fout krijgt de admin een WhatsApp-melding. +- **EADDRINUSE (poort 3002 in gebruik)**: de server wacht 5 seconden en herhaalt de bind-poging, met `SO_REUSEADDR` om TIME_WAIT te omzeilen +- **Graceful shutdown**: de server handelt SIGTERM en SIGINT af door actieve connecties netjes te sluiten (10s timeout) +- **Restart-loop preventie**: de LaunchDaemon heeft `ThrottleInterval=10` — maximaal 1 herstart per 10 seconden +- **Webhook fout**: wordt gelogd in de logs ## Spam protectie diff --git a/src/index.js b/src/index.js index 852707e..8319573 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ require('dotenv').config() +const http = require('http') const cron = require('node-cron') const express = require('express') const { handleWebhook } = require('./messageHandler') @@ -23,6 +24,15 @@ app.get('/health', (req, res) => { res.json({ status: 'ok' }) }) +app.get('/ping', (req, res) => { + const adminNumber = process.env.ADMIN_NUMBER + if (adminNumber) { + sendTextMessage(adminNumber, 'pong') + .catch(err => console.error('Failed to send pong:', err.message)) + } + res.json({ pong: true }) +}) + const PORT = process.env.WEBHOOK_PORT || 3002 loadDiensten() @@ -42,9 +52,42 @@ cron.schedule('0 10 * * *', () => { .catch(err => console.error('Failed to send daily summary:', err.message)) }) -app.listen(PORT, () => { - console.log(`dienst-bot listening on port ${PORT}`) - console.log(`Allowed group: ${process.env.ALLOWED_GROUP}`) - console.log(`Bot name: ${process.env.BOT_NAME}`) - console.log('Daily summary cron job scheduled for 10:00') -}) \ No newline at end of file +async function startServer() { + const server = http.createServer(app) + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error(`Port ${PORT} is in use, retrying in 5 seconds...`) + setTimeout(() => { + server.close() + server.listen(PORT) + }, 5000) + } else { + throw err + } + }) + + server.listen({ port: PORT, reuseAddr: true }, () => { + console.log(`dienst-bot listening on port ${PORT}`) + console.log(`Allowed group: ${process.env.ALLOWED_GROUP}`) + console.log(`Bot name: ${process.env.BOT_NAME}`) + console.log('Daily summary cron job scheduled for 10:00') + }) + + function shutdown(signal) { + console.log(`${signal} received, shutting down gracefully...`) + server.close(() => { + console.log('Server closed') + process.exit(0) + }) + setTimeout(() => { + console.error('Forced shutdown after timeout') + process.exit(1) + }, 10000) + } + + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) +} + +startServer() \ No newline at end of file diff --git a/src/messageHandler.js b/src/messageHandler.js index 9158c34..0cdf600 100644 --- a/src/messageHandler.js +++ b/src/messageHandler.js @@ -1,5 +1,5 @@ const { findDienst, getPngFilePaths } = require('./dienstService') -const { sendTextMessage, sendImageMessage, notifyAdmin } = require('./whatsappApi') +const { sendTextMessage, sendImageMessage, notifyAdmin, sendChatSeen } = require('./whatsappApi') const { checkLimit, increment, MAX_REQUESTS_PER_DAY } = require('./rateLimiter') const { increment: incrementDaily } = require('./dailyStats') const { checkAndRepinPinnedMessage } = require('./pinnedMessageService') @@ -27,6 +27,10 @@ async function handleWebhook(payload) { incrementDaily() + sendChatSeen(ALLOWED_GROUP).catch(err => + console.error('Failed to mark chat as seen:', err.message) + ) + console.log(`Processing dienst ${dienstnummer} for ${message.author}`) const author = message.author diff --git a/src/whatsappApi.js b/src/whatsappApi.js index d8299d3..72b29dd 100644 --- a/src/whatsappApi.js +++ b/src/whatsappApi.js @@ -3,31 +3,65 @@ const fs = require('fs') const API_BASE = process.env.WHATSAPP_API_ENDPOINT const SESSION = process.env.WHATSAPP_SESSION +const MAX_RETRIES = 3 +const RETRY_DELAY_MS = 1000 + +async function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function isRetryableError(error) { + if (error.response) { + const status = error.response.status + return status === 500 || status === 502 || status === 503 || status === 504 + } + return error.code === 'ECONNRESET' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' +} + +async function withRetry(fn, operation) { + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + return await fn() + } catch (err) { + if (attempt < MAX_RETRIES && isRetryableError(err)) { + const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1) + console.warn(`${operation} attempt ${attempt}/${MAX_RETRIES} failed (${err.message}), retrying in ${delay}ms`) + await sleep(delay) + } else { + throw err + } + } + } +} async function sendTextMessage(chatId, text) { - const url = `${API_BASE}/client/sendMessage/${SESSION}` - const response = await axios.post(url, { - chatId, - contentType: 'string', - content: text - }) - return response.data + return withRetry(async () => { + const url = `${API_BASE}/client/sendMessage/${SESSION}` + const response = await axios.post(url, { + chatId, + contentType: 'string', + content: text + }) + return response.data + }, `sendTextMessage to ${chatId}`) } async function sendImageMessage(chatId, filePath, filename) { - const fileBuffer = fs.readFileSync(filePath) - const base64Data = fileBuffer.toString('base64') - const url = `${API_BASE}/client/sendMessage/${SESSION}` - const response = await axios.post(url, { - chatId, - contentType: 'MessageMedia', - content: { - mimetype: 'image/png', - data: base64Data, - filename: filename - } - }) - return response.data + return withRetry(async () => { + const fileBuffer = fs.readFileSync(filePath) + const base64Data = fileBuffer.toString('base64') + const url = `${API_BASE}/client/sendMessage/${SESSION}` + const response = await axios.post(url, { + chatId, + contentType: 'MessageMedia', + content: { + mimetype: 'image/png', + data: base64Data, + filename: filename + } + }) + return response.data + }, `sendImageMessage ${filename} to ${chatId}`) } async function notifyAdmin(errorText) { @@ -43,4 +77,10 @@ async function notifyAdmin(errorText) { } } -module.exports = { sendTextMessage, sendImageMessage, notifyAdmin } +async function sendChatSeen(chatId) { + const url = `${API_BASE}/client/sendSeen/${SESSION}` + const response = await axios.post(url, { chatId }) + return response.data +} + +module.exports = { sendTextMessage, sendImageMessage, notifyAdmin, sendChatSeen }