Initial commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
data/
|
||||
node_modules/
|
||||
config/contacts.json
|
||||
config/settings.json
|
||||
+23
@@ -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
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
]
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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 → <strong>Verknüpfte Geräte</strong> → <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();
|
||||
Reference in New Issue
Block a user