commit d8a626f6c33e9b9834d6cb67f24f1b96807e8e72 Author: Noah Eyer Date: Thu May 28 11:11:27 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e3d003 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +data/ +node_modules/ +config/contacts.json +config/settings.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6381793 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:20-bookworm-slim + +RUN apt-get update && apt-get install -y \ + chromium \ + fonts-noto-color-emoji \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ + PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY src/ ./src/ + +RUN mkdir -p data config + +EXPOSE 3000 + +CMD find /app/data -name "SingletonLock" -o -name "SingletonCookie" -o -name "SingletonSocket" | xargs rm -f 2>/dev/null; node src/index.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2e29ed --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# đŸ“Č WhatsApp Reminder + +Ein selbst gehosteter, dockerisierter Bot der automatisch personalisierte WhatsApp-Erinnerungen nach einem monatlichen Plan versendet — jeden Monat eine andere Person aus einer frei konfigurierbaren Rotation. + +Basiert auf [whatsapp-web.js](https://github.com/pedroslopez/whatsapp-web.js) und lĂ€uft vollstĂ€ndig auf deinem eigenen Server. Kein WhatsApp Business-Konto nötig, keine Cloud-Dienste, komplett kostenlos. + +--- + +## Features + +- Schickt jeden Monat eine personalisierte Nachricht an den Kontakt der laut Plan dran ist +- Überspringt Monate ohne zugewiesenen Kontakt (z.B. der eigene Monat) +- Cron-basierte Planung mit ZeitzonenunterstĂŒtzung +- Session bleibt ĂŒber Neustarts erhalten — QR-Code einmal scannen, fertig +- Einfaches Web-Interface zur Übersicht und zum manuellen Auslösen +- Docker Healthcheck inklusive + +--- + +## Voraussetzungen + +- Docker + Docker Compose + +Das wars. + +--- + +## Einrichtung + +**1. Repo klonen** + +```bash +git clone ssh://git@gitea.aontech.ch:222/neyer/whatsapp-reminder.git +cd whatsapp-reminder +``` + +**2. Kontakte konfigurieren** + +`config/contacts.json` aus der Vorlage erstellen: + +```bash +cp config/contacts.example.json config/contacts.json +``` + +Dann anpassen: + +```json +[ + { "id": "alice", "name": "Alice MĂŒller", "phone": "+41791234567" }, + { "id": "bob", "name": "Bob Schmidt", "phone": "+41797654321" }, + { "id": "carol", "name": "Carol Weber", "phone": "+41791112233" } +] +``` + +> Telefonnummern im internationalen Format, Leerzeichen und Bindestriche werden automatisch entfernt. + +**3. Zeitplan und Nachricht konfigurieren** + +`config/settings.json` aus der Vorlage erstellen: + +```bash +cp config/settings.example.json config/settings.json +``` + +Dann anpassen: + +```json +{ + "amount": 25, + "currency": "CHF", + "cron": "0 7 1 * *", + "timezone": "Europe/Zurich", + "message": "Hallo {vorname}, diesen Monat bist du wieder dran mit {amount} {currency} ;)\nDanke.", + "schedule": [ + "alice", + "bob", + null, + "carol", + "alice", + "bob", + null, + "carol", + "alice", + "bob", + null, + "carol" + ] +} +``` + +- `schedule` — Array mit 12 EintrĂ€gen (Januar → Dezember). Kontakt-`id` oder `null` zum Überspringen. +- `cron` — Standard-Cron-Ausdruck. `"0 7 1 * *"` = jeden 1. des Monats um 07:00 Uhr. +- Der Zeitplan wiederholt sich jedes Jahr automatisch — kein jĂ€hrliches Anpassen nötig. + +**VerfĂŒgbare Platzhalter in `message`:** + +| Platzhalter | Beschreibung | +|---|---| +| `{vorname}` | Vorname des Kontakts | +| `{name}` | VollstĂ€ndiger Name | +| `{amount}` | Betrag aus den Einstellungen | +| `{currency}` | WĂ€hrung aus den Einstellungen | +| `{month}` | Name des aktuellen Monats | +| `{year}` | Aktuelles Jahr | + +**4. Container starten** + +```bash +docker compose up --build -d +``` + +**5. QR-Code scannen** + +Im Browser öffnen: + +``` +http://:3001/qr +``` + +In WhatsApp: **VerknĂŒpfte GerĂ€te → GerĂ€t verknĂŒpfen** → QR-Code scannen. + +Fertig. Die Session wird in `data/` gespeichert und bleibt auch nach Neustarts erhalten. + +--- + +## Web-Interface + +| URL | Beschreibung | +|---|---| +| `http://:3001` | Übersicht mit Monatsplan | +| `http://:3001/qr` | QR-Code zur Erstanmeldung | +| `http://:3001/send-now` | Erinnerung fĂŒr diesen Monat manuell senden | +| `http://:3001/status` | JSON-Status (wird vom Healthcheck genutzt) | + +Einen bestimmten Monat manuell auslösen: + +``` +http://:3001/send-now?year=2026&month=5 +``` + +--- + +## Konfigurationsreferenz + +### `config/contacts.json` + +```json +[ + { + "id": "eindeutige-id", + "name": "VollstĂ€ndiger Name", + "phone": "+41791234567" + } +] +``` + +### `config/settings.json` + +```json +{ + "amount": 25, + "currency": "CHF", + "cron": "0 7 1 * *", + "timezone": "Europe/Zurich", + "message": "Hallo {vorname}, ...", + "schedule": ["id-oder-null", ...] +} +``` + +Änderungen an beiden Dateien werden live ĂŒbernommen — kein Neustart des Containers nötig. + +--- + +## Port + +Das Web-Interface lĂ€uft standardmĂ€ssig auf Port `3001`. Änderbar in `docker-compose.yml`: + +```yaml +ports: + - "3001:3000" # 3001 durch einen freien Port ersetzen +``` + +--- + +## Sicherheit + +- Die WhatsApp-Session wird lokal in `data/` auf deinem eigenen Server gespeichert. +- Das Web-Interface hat keine Authentifizierung — Port 3001 nicht ins öffentliche Internet exponieren. +- Dieses Projekt nutzt WhatsApp Web intern. Es ist inoffiziell und steht in keiner Verbindung zu WhatsApp/Meta. + +--- + +## Lizenz + +MIT diff --git a/config/contacts.example.json b/config/contacts.example.json new file mode 100644 index 0000000..bf1611f --- /dev/null +++ b/config/contacts.example.json @@ -0,0 +1,7 @@ +[ + { "id": "alice", "name": "Alice MĂŒller", "phone": "+41791234567" }, + { "id": "bob", "name": "Bob Schmidt", "phone": "+41797654321" }, + { "id": "carol", "name": "Carol Weber", "phone": "+41791112233" }, + { "id": "dave", "name": "Dave Keller", "phone": "+41798887766" }, + { "id": "eve", "name": "Eve Brunner", "phone": "+41795556644" } +] diff --git a/config/settings.example.json b/config/settings.example.json new file mode 100644 index 0000000..7fe88db --- /dev/null +++ b/config/settings.example.json @@ -0,0 +1,21 @@ +{ + "amount": 25, + "currency": "CHF", + "cron": "0 7 1 * *", + "timezone": "Europe/Zurich", + "message": "Hallo {vorname}, diesen Monat bist du wieder dran mit {amount} {currency} ;)\nDanke.", + "schedule": [ + "alice", + "bob", + null, + "carol", + "dave", + "eve", + "alice", + "bob", + null, + "carol", + "dave", + "eve" + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6c7cf98 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + whatsapp-reminder: + build: . + container_name: whatsapp-reminder + restart: unless-stopped + ports: + - "3001:3000" + volumes: + - ./data:/app/data # WhatsApp-Session (bleibt nach Neustart erhalten) + - ./config:/app/config # contacts.json + settings.json + environment: + - TZ=Europe/Zurich + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/status', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 60s diff --git a/package.json b/package.json new file mode 100644 index 0000000..a01ec59 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "whatsapp-reminder", + "version": "1.0.0", + "main": "src/index.js", + "dependencies": { + "node-cron": "^3.0.3", + "qrcode": "^1.5.3", + "qrcode-terminal": "^0.12.0", + "whatsapp-web.js": "^1.23.0" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..d0b26b7 --- /dev/null +++ b/src/index.js @@ -0,0 +1,235 @@ +const { Client, LocalAuth } = require('whatsapp-web.js'); +const qrcode = require('qrcode-terminal'); +const qrcodeLib = require('qrcode'); +const cron = require('node-cron'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const CONTACTS_PATH = path.join(__dirname, '..', 'config', 'contacts.json'); +const SETTINGS_PATH = path.join(__dirname, '..', 'config', 'settings.json'); +const DATA_PATH = path.join(__dirname, '..', 'data'); + +let currentQR = null; +let isReady = false; + +function loadSettings() { + return JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8')); +} + +function loadContacts() { + return JSON.parse(fs.readFileSync(CONTACTS_PATH, 'utf8')); +} + +// Gibt den Kontakt zurĂŒck der im angegebenen Monat dran ist (wiederholt sich jedes Jahr). +function getContactForMonth(year, month) { + const settings = loadSettings(); + const contacts = loadContacts(); + + const schedule = settings.schedule; + if (!Array.isArray(schedule) || schedule.length !== 12) { + console.log('schedule in settings.json muss ein Array mit 12 EintrĂ€gen sein.'); + return null; + } + + const contactId = schedule[month - 1]; + if (!contactId) { + console.log(`Monat ${month}/${year}: kein Kontakt (null) — nichts zu tun.`); + return null; + } + + const contact = contacts.find(c => c.id === contactId); + if (!contact) { + console.log(`Kontakt-ID "${contactId}" nicht in contacts.json gefunden.`); + return null; + } + + return contact; +} + +async function sendReminders(overrideYear = null, overrideMonth = null) { + const settings = loadSettings(); + const now = new Date(); + const year = overrideYear ?? now.getFullYear(); + const month = overrideMonth ?? (now.getMonth() + 1); + + const monthName = new Date(year, month - 1, 1).toLocaleString('de-DE', { month: 'long' }); + const contact = getContactForMonth(year, month); + + if (!contact) { + return { skipped: true, reason: `Kein Kontakt fĂŒr ${monthName} ${year}` }; + } + + const vorname = contact.name.split(' ')[0]; + const message = settings.message + .replace(/{vorname}/g, vorname) + .replace(/{name}/g, contact.name) + .replace(/{amount}/g, settings.amount ?? '') + .replace(/{currency}/g, settings.currency ?? '') + .replace(/{month}/g, monthName) + .replace(/{year}/g, year); + + const phone = contact.phone.replace(/\D/g, ''); + const chatId = `${phone}@c.us`; + + console.log(`[${monthName} ${year}] Sende an: ${contact.name} (${contact.phone})`); + + try { + await client.sendMessage(chatId, message); + console.log(`[OK] Nachricht gesendet.`); + return { sent: true, contact: contact.name }; + } catch (err) { + console.error(`[FEHLER] ${err.message}`); + return { sent: false, error: err.message }; + } +} + +// Web-Interface +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + + if (url.pathname === '/qr') { + if (!currentQR) { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + return res.end(htmlPage('Status', isReady + ? '

✓ Verbunden und bereit

Erinnerung jetzt senden

' + : '

Kein QR-Code verfĂŒgbar — Client startet noch, bitte kurz warten...

' + )); + } + const qrDataUrl = await qrcodeLib.toDataURL(currentQR); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + return res.end(htmlPage('QR-Code scannen', ` +

Öffne WhatsApp → VerknĂŒpfte GerĂ€teGerĂ€t verknĂŒpfen

+ +

Seite neu laden falls der Code abgelaufen ist.

+ `)); + } + + if (url.pathname === '/send-now') { + if (!isReady) { + res.writeHead(503, { 'Content-Type': 'text/html; charset=utf-8' }); + return res.end(htmlPage('Nicht bereit', '

Client ist noch nicht verbunden. Erst QR-Code scannen.

')); + } + // Optionale Query-Parameter: ?year=2026&month=5 + const year = url.searchParams.has('year') ? parseInt(url.searchParams.get('year')) : null; + const month = url.searchParams.has('month') ? parseInt(url.searchParams.get('month')) : null; + const result = await sendReminders(year, month); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + if (result.skipped) { + return res.end(htmlPage('Übersprungen', `

⏭ ${result.reason}

ZurĂŒck

`)); + } + return res.end(htmlPage(result.sent ? 'Gesendet' : 'Fehler', ` + ${result.sent + ? `

✓ Nachricht an ${result.contact} gesendet

` + : `

✗ Fehler: ${result.error}

`} +

ZurĂŒck

+ `)); + } + + if (url.pathname === '/status') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ ready: isReady, qr_pending: !!currentQR })); + } + + // Startseite mit MonatsĂŒbersicht + let scheduleHtml = ''; + try { + const settings = loadSettings(); + const contacts = loadContacts(); + const now = new Date(); + const thisYear = now.getFullYear(); + const thisMonth = now.getMonth() + 1; + const monthNames = ['', 'Januar','Februar','MĂ€rz','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']; + const plan = settings.schedule ?? []; + const rows = plan.map((id, i) => { + const m = i + 1; + const contact = id ? contacts.find(c => c.id === id) : null; + const isCurrent = m === thisMonth; + return ` + ${monthNames[m]} + ${contact ? contact.name : '—'} + ${isCurrent ? '← aktueller Monat' : ''} + `; + }).join(''); + scheduleHtml = `

Plan ${thisYear}

${rows}
`; + } catch (e) { + scheduleHtml = `

Fehler beim Laden des Plans: ${e.message}

`; + } + + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(htmlPage('WhatsApp Reminder', ` +

Status: ${isReady + ? '✓ Verbunden' + : currentQR + ? 'QR-Code scannen' + : 'Startet...'}

+

▶ Erinnerung fĂŒr diesen Monat jetzt senden

+ ${scheduleHtml} + `)); +}); + +function htmlPage(title, body) { + return ` + ${title} – WhatsApp Reminder + +

${title}

${body}`; +} + +server.listen(3000, () => { + console.log('Web-Interface: http://localhost:3000'); + console.log('QR-Code: http://localhost:3000/qr'); +}); + +// WhatsApp Client +const client = new Client({ + authStrategy: new LocalAuth({ dataPath: DATA_PATH }), + puppeteer: { + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium', + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-accelerated-2d-canvas', + '--no-first-run', + '--no-zygote', + '--single-process', + '--disable-gpu' + ] + } +}); + +client.on('qr', (qr) => { + currentQR = qr; + console.log('\n>>> QR-Code bereit: http://localhost:3000/qr <<<'); + qrcode.generate(qr, { small: true }); +}); + +client.on('authenticated', () => { + currentQR = null; + console.log('Authentifiziert.'); +}); + +client.on('ready', () => { + isReady = true; + const settings = loadSettings(); + const schedule = settings.cron || '0 9 1 * *'; + const tz = settings.timezone || 'Europe/Zurich'; + + cron.schedule(schedule, async () => { + const now = new Date(); + console.log(`[CRON] ${now.toLocaleString('de-DE')} — prĂŒfe Monatsplan...`); + await sendReminders(); + }, { timezone: tz }); + + console.log(`WhatsApp bereit. Cron: "${schedule}" (${tz})`); +}); + +client.on('disconnected', (reason) => { + console.log('Verbindung getrennt:', reason); + isReady = false; + process.exit(1); +}); + +client.initialize();