Initial commit

This commit is contained in:
2026-05-28 11:11:27 +02:00
commit d8a626f6c3
8 changed files with 514 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
data/
node_modules/
config/contacts.json
config/settings.json
+23
View File
@@ -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
+195
View File
@@ -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://<server-ip>: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://<ip>:3001` | Übersicht mit Monatsplan |
| `http://<ip>:3001/qr` | QR-Code zur Erstanmeldung |
| `http://<ip>:3001/send-now` | Erinnerung für diesen Monat manuell senden |
| `http://<ip>:3001/status` | JSON-Status (wird vom Healthcheck genutzt) |
Einen bestimmten Monat manuell auslösen:
```
http://<ip>: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
+7
View File
@@ -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" }
]
+21
View File
@@ -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"
]
}
+18
View File
@@ -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
+11
View File
@@ -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"
}
}
+235
View File
@@ -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
? '<p style="color:green;font-size:1.1em">✓ Verbunden und bereit</p><p><a href="/send-now">Erinnerung jetzt senden</a></p>'
: '<p>Kein QR-Code verfügbar — Client startet noch, bitte kurz warten...</p>'
));
}
const qrDataUrl = await qrcodeLib.toDataURL(currentQR);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(htmlPage('QR-Code scannen', `
<p>Öffne WhatsApp &rarr; <strong>Verknüpfte Geräte</strong> &rarr; <strong>Gerät verknüpfen</strong></p>
<img src="${qrDataUrl}" style="width:280px;height:280px;border:1px solid #ddd;border-radius:8px;" />
<p><small>Seite neu laden falls der Code abgelaufen ist.</small></p>
`));
}
if (url.pathname === '/send-now') {
if (!isReady) {
res.writeHead(503, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(htmlPage('Nicht bereit', '<p>Client ist noch nicht verbunden. Erst QR-Code scannen.</p>'));
}
// 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', `<p style="color:orange">⏭ ${result.reason}</p><p><a href="/">Zurück</a></p>`));
}
return res.end(htmlPage(result.sent ? 'Gesendet' : 'Fehler', `
${result.sent
? `<p style="color:green">✓ Nachricht an ${result.contact} gesendet</p>`
: `<p style="color:red">✗ Fehler: ${result.error}</p>`}
<p><a href="/">Zurück</a></p>
`));
}
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 `<tr style="${isCurrent ? 'font-weight:bold;background:#e7f7f0' : ''}">
<td style="padding:4px 12px">${monthNames[m]}</td>
<td style="padding:4px 12px">${contact ? contact.name : '—'}</td>
${isCurrent ? '<td style="padding:4px 12px;color:green">← aktueller Monat</td>' : '<td></td>'}
</tr>`;
}).join('');
scheduleHtml = `<h2>Plan ${thisYear}</h2><table border="0" cellspacing="0">${rows}</table>`;
} catch (e) {
scheduleHtml = `<p style="color:red">Fehler beim Laden des Plans: ${e.message}</p>`;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(htmlPage('WhatsApp Reminder', `
<p>Status: ${isReady
? '<span style="color:green">✓ Verbunden</span>'
: currentQR
? '<span style="color:orange"><a href="/qr">QR-Code scannen</a></span>'
: '<span style="color:gray">Startet...</span>'}</p>
<p><a href="/send-now">▶ Erinnerung für diesen Monat jetzt senden</a></p>
${scheduleHtml}
`));
});
function htmlPage(title, body) {
return `<!DOCTYPE html><html><head>
<meta charset="utf-8"><title>${title} WhatsApp Reminder</title>
<style>body{font-family:sans-serif;max-width:650px;margin:40px auto;padding:0 20px}
a{color:#075e54}h1,h2{color:#075e54}table{border-collapse:collapse}
td{border-bottom:1px solid #eee}</style>
</head><body><h1>${title}</h1>${body}</body></html>`;
}
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();