feat: complete dienst-bot v1.0 - webhook receiver, PNG sender, PM2 ready
This commit is contained in:
parent
3e6b33f103
commit
6e44a68fe1
1898 changed files with 119938 additions and 0 deletions
367
docs/superpowers/plans/2025-05-11-dienst-bot-plan.md
Normal file
367
docs/superpowers/plans/2025-05-11-dienst-bot-plan.md
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
# Dienst Bot Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a Node.js webhook service that listens for WhatsApp group messages mentioning @EdGpt with a 4-digit dienstnummer, and sends corresponding PNG images privately back to the sender.
|
||||
|
||||
**Architecture:** Express.js webhook receiver on port 3002 receives POST callbacks from wwebjs-api. Parses messages for @EdGpt mentions + 4-digit numbers, looks up PNGs via dienst-tijden.json, and sends them privately via the wwebjs-api REST endpoint.
|
||||
|
||||
**Tech Stack:** Node.js, Express, axios, dotenv
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Project scaffold and package.json
|
||||
|
||||
**Files:**
|
||||
- Create: `package.json`
|
||||
|
||||
- [ ] **Step 1: Create package.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "dienst-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "WhatsApp dienstnummer bot - sends PNG dienstschema's via webhook",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"express": "^4.18.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Install dependencies**
|
||||
|
||||
Run: `npm install`
|
||||
Expected: `node_modules` created, `package-lock.json` generated
|
||||
|
||||
- [ ] **Step 3: Update .env with new config vars**
|
||||
|
||||
Append to existing `.env`:
|
||||
|
||||
```
|
||||
WEBHOOK_PORT=3002
|
||||
ALLOWED_GROUP=120363407356808413@g.us
|
||||
BOT_NAME=EdGpt
|
||||
DIENSTEN_JSON=./diensten/dienst-tijden.json
|
||||
DIENSTEN_PNG=./diensten/png
|
||||
ADMIN_NUMBER=31620248011@c.us
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git init
|
||||
git add package.json package-lock.json .env
|
||||
git commit -m "feat: scaffold project with dependencies and env config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: dienstService module
|
||||
|
||||
**Files:**
|
||||
- Create: `src/dienstService.js`
|
||||
|
||||
- [ ] **Step 1: Write dienstService.js**
|
||||
|
||||
```js
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
let dienstenCache = null
|
||||
|
||||
function loadDiensten() {
|
||||
if (dienstenCache) return dienstenCache
|
||||
const jsonPath = process.env.DIENSTEN_JSON
|
||||
const raw = fs.readFileSync(jsonPath, 'utf-8')
|
||||
dienstenCache = JSON.parse(raw)
|
||||
return dienstenCache
|
||||
}
|
||||
|
||||
function findDienst(dienstnummer) {
|
||||
const diensten = loadDiensten()
|
||||
return diensten.find(d => d.dienstnummer === dienstnummer) || null
|
||||
}
|
||||
|
||||
function getPngFilePaths(dienst) {
|
||||
const pngDir = process.env.DIENSTEN_PNG
|
||||
return dienst.paginas.map(p => path.join(pngDir, p.bestandsnaam))
|
||||
}
|
||||
|
||||
module.exports = { loadDiensten, findDienst, getPngFilePaths }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test lookup manually**
|
||||
|
||||
Run: `node -e "require('dotenv').config(); const ds = require('./src/dienstService'); console.log(ds.findDienst('4001')); console.log(ds.getPngFilePaths(ds.findDienst('4001')));"`
|
||||
Expected: prints dienst object for 4001 with paginas array, then array of PNG file paths
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/dienstService.js
|
||||
git commit -m "feat: add dienstService for JSON lookup and PNG file paths"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: WhatsApp API helper
|
||||
|
||||
**Files:**
|
||||
- Create: `src/whatsappApi.js`
|
||||
|
||||
- [ ] **Step 1: Write whatsappApi.js**
|
||||
|
||||
```js
|
||||
const axios = require('axios')
|
||||
const fs = require('fs')
|
||||
|
||||
const API_BASE = process.env.WHATSAPP_API_ENDPOINT
|
||||
const SESSION = process.env.WHATSAPP_SESSION
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async function notifyAdmin(errorText) {
|
||||
const adminNumber = process.env.ADMIN_NUMBER
|
||||
if (!adminNumber) {
|
||||
console.error('ADMIN_NUMBER not configured, skipping admin notification')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await sendTextMessage(adminNumber, `[dienst-bot fout] ${errorText}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to notify admin:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sendTextMessage, sendImageMessage, notifyAdmin }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/whatsappApi.js
|
||||
git commit -m "feat: add whatsappApi helper for sending messages and images"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: messageHandler module
|
||||
|
||||
**Files:**
|
||||
- Create: `src/messageHandler.js`
|
||||
|
||||
- [ ] **Step 1: Write messageHandler.js**
|
||||
|
||||
```js
|
||||
const { findDienst, getPngFilePaths } = require('./dienstService')
|
||||
const { sendTextMessage, sendImageMessage, notifyAdmin } = require('./whatsappApi')
|
||||
|
||||
const BOT_NAME = (process.env.BOT_NAME || 'EdGpt').toLowerCase()
|
||||
const ALLOWED_GROUP = process.env.ALLOWED_GROUP
|
||||
|
||||
function extractDienstnummer(body) {
|
||||
const match = body.match(/\b(\d{4})\b/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
function isBotMentioned(message) {
|
||||
if (message._data?.mentionedJidList?.length > 0) {
|
||||
return true
|
||||
}
|
||||
if (message.body && message.body.toLowerCase().includes(`@${BOT_NAME}`)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function handleWebhook(payload) {
|
||||
if (payload.dataType !== 'message') return
|
||||
if (payload.sessionId !== process.env.WHATSAPP_SESSION) return
|
||||
|
||||
const message = payload.data?.message
|
||||
if (!message) return
|
||||
if (message.fromMe) return
|
||||
if (message.from !== ALLOWED_GROUP) return
|
||||
if (!isBotMentioned(message)) return
|
||||
|
||||
const dienstnummer = extractDienstnummer(message.body || '')
|
||||
if (!dienstnummer) return
|
||||
|
||||
const author = message.author
|
||||
if (!author) return
|
||||
|
||||
const dienst = findDienst(dienstnummer)
|
||||
if (!dienst) {
|
||||
await sendTextMessage(author, `Dienstnummer ${dienstnummer} niet gevonden.`)
|
||||
return
|
||||
}
|
||||
|
||||
const pngPaths = getPngFilePaths(dienst)
|
||||
if (!pngPaths || pngPaths.length === 0) {
|
||||
await sendTextMessage(author, `Geen pagina's gevonden voor dienst ${dienstnummer}.`)
|
||||
return
|
||||
}
|
||||
|
||||
for (const pngPath of pngPaths) {
|
||||
const filename = pngPath.split('/').pop()
|
||||
try {
|
||||
await sendImageMessage(author, pngPath, filename)
|
||||
} catch (err) {
|
||||
const errorMsg = `Fout bij verzenden ${filename} naar ${author}: ${err.message}`
|
||||
console.error(errorMsg)
|
||||
await notifyAdmin(errorMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleWebhook }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/messageHandler.js
|
||||
git commit -m "feat: add messageHandler for webhook payload processing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Express webhook server (index.js)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/index.js`
|
||||
|
||||
- [ ] **Step 1: Write src/index.js**
|
||||
|
||||
```js
|
||||
require('dotenv').config()
|
||||
const express = require('express')
|
||||
const { handleWebhook } = require('./messageHandler')
|
||||
const { loadDiensten } = require('./dienstService')
|
||||
|
||||
const app = express()
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
app.post('/webhook', async (req, res) => {
|
||||
try {
|
||||
await handleWebhook(req.body)
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
console.error('Webhook processing error:', err.message)
|
||||
res.status(500).json({ success: false, error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' })
|
||||
})
|
||||
|
||||
const PORT = process.env.WEBHOOK_PORT || 3002
|
||||
|
||||
loadDiensten()
|
||||
|
||||
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}`)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Start the server and verify**
|
||||
|
||||
Run: `node src/index.js`
|
||||
Expected: prints "dienst-bot listening on port 3002"
|
||||
|
||||
Run in another terminal: `curl http://localhost:3002/health`
|
||||
Expected: `{"status":"ok"}`
|
||||
|
||||
Stop the server with Ctrl+C.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/index.js
|
||||
git commit -m "feat: add Express webhook server with health endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update wwebjs-api configuration
|
||||
|
||||
**Files:**
|
||||
- Modify: wwebjs-api `.env` (location depends on where wwebjs-api runs - check Docker/container setup)
|
||||
|
||||
- [ ] **Step 1: Find the wwebjs-api .env or docker-compose config**
|
||||
|
||||
Check where wwebjs-api is configured. It may be in a Docker container with environment variables set via docker-compose.yml or a .env file in the wwebjs-api project directory.
|
||||
|
||||
The required addition is:
|
||||
```
|
||||
BASE_WEBHOOK_URL=http://localhost:3002/webhook
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Restart wwebjs-api**
|
||||
|
||||
After adding `BASE_WEBHOOK_URL`, restart the wwebjs-api service so it picks up the new config and starts sending webhook callbacks.
|
||||
|
||||
- [ ] **Step 3: Verify webhook is receiving events**
|
||||
|
||||
Run dienst-bot: `node src/index.js`
|
||||
Send a test message in the WhatsApp group mentioning @EdGpt with a dienstnummer (e.g. "@EdGpt 4001")
|
||||
Expected: console log in dienst-bot, PNG images sent privately to sender
|
||||
|
||||
---
|
||||
|
||||
### Task 7: PM2 setup
|
||||
|
||||
- [ ] **Step 1: Start with PM2**
|
||||
|
||||
Run: `pm2 start src/index.js --name dienst-bot`
|
||||
Expected: process starts, shows "online" status
|
||||
|
||||
- [ ] **Step 2: Verify PM2 process**
|
||||
|
||||
Run: `pm2 logs dienst-bot --lines 5`
|
||||
Expected: shows "dienst-bot listening on port 3002"
|
||||
|
||||
- [ ] **Step 3: Save PM2 process list**
|
||||
|
||||
Run: `pm2 save`
|
||||
Expected: saves process list for auto-restart on reboot
|
||||
|
||||
- [ ] **Step 4: Final commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: complete dienst-bot with PM2 setup"
|
||||
```
|
||||
181
docs/superpowers/specs/2025-05-11-dienst-bot-design.md
Normal file
181
docs/superpowers/specs/2025-05-11-dienst-bot-design.md
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
# Dienst Bot - WhatsApp Dienstnummer Bot
|
||||
|
||||
## Samenvatting
|
||||
|
||||
Een Node.js webhook service die luistert naar berichten in een WhatsApp groep via de wwebjs-api. Wanneer een groepsllid EdGpt noemt (@EdGpt) met een 4-cijferig dienstnummer, zoekt de bot de bijbehorende PNG-afbeeldingen op en stuurt deze privé terug naar de afzender.
|
||||
|
||||
## Context
|
||||
|
||||
- **wwebjs-api**: REST API wrapper voor whatsapp-web.js, draait op `localhost:3000`
|
||||
- **Sessie**: EdGpt, actief in WhatsApp groep TestGpt
|
||||
- **Diensten**: Gescande dienstschema's opgeslagen als PNG in `diensten/png/`, met metadata in `diensten/dienst-tijden.json`
|
||||
- **dienst-tijden.json**: Array van objecten met `dienstnummer` (4 cijfers), `startTime`, `endTime`, en `paginas` (array met `bestandsnaam` voor PNG)
|
||||
|
||||
## Architectuur
|
||||
|
||||
```
|
||||
WhatsApp groep bericht ("@EdGpt 4001")
|
||||
→ wwebjs-api (localhost:3000)
|
||||
→ webhook POST naar dienst-bot (localhost:3002/webhook)
|
||||
→ bericht parsing: @EdGpt + 4-cijferig dienstnummer
|
||||
→ lookup PNGs via dienst-tijden.json
|
||||
→ stuur PNGs privé via wwebjs-api REST endpoint
|
||||
```
|
||||
|
||||
Twee componenten:
|
||||
1. **wwebjs-api** (bestaand) - met `BASE_WEBHOOK_URL=http://localhost:3002/webhook` geconfigureerd
|
||||
2. **dienst-bot** (nieuw) - Express.js webhook receiver op poort 3002
|
||||
|
||||
## Webhook Payload
|
||||
|
||||
wwebjs-api stuurt per message event een POST naar de webhook URL:
|
||||
|
||||
```json
|
||||
{
|
||||
"dataType": "message",
|
||||
"sessionId": "EdGpt",
|
||||
"data": {
|
||||
"message": {
|
||||
"from": "120363407356808413@g.us",
|
||||
"author": "31620248011@c.us",
|
||||
"body": "@EdGpt 4001",
|
||||
"fromMe": false,
|
||||
"hasMedia": false,
|
||||
"_data": {
|
||||
"mentionedJidList": ["31620248011@c.us"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Belangrijke velden:
|
||||
- `from`: groep-ID bij groepsberichten (eindigt op `@g.us`)
|
||||
- `author`: de afzender van het bericht binnen de groep
|
||||
- `fromMe`: `true` als het bericht van de bot zelf komt
|
||||
- `mentionedJidList`: array van genoemde contact-IDs
|
||||
|
||||
## Berichtverwerking (Core Logic)
|
||||
|
||||
1. **Filter op dataType**: negeer alles behalve `dataType === "message"`
|
||||
2. **Filter op afzender**: negeer als `fromMe === true` (bot's eigen berichten)
|
||||
3. **Filter op groep**: negeer als `from !== ALLOWED_GROUP`
|
||||
4. **Check mention**: controleer of het bot-nummer voorkomt in `mentionedJidList` OF of de tekst `@EdGpt` (case-insensitive) bevat
|
||||
5. **Extract dienstnummer**: regex `\b(\d{4})\b` op message body - neem het eerste match
|
||||
6. **Lookup**: zoek dienstnummer in `dienst-tijden.json`, krijg lijst met PNG bestandsnamen
|
||||
7. **Stuur PNGs**: voor elke PNG, stuur privé naar afzender via wwebjs-api
|
||||
|
||||
## Afbeelding Versturen
|
||||
|
||||
Per PNG bestand:
|
||||
1. Lees file van schijf uit `DIENSTEN_PNG` directory
|
||||
2. Converteer naar base64
|
||||
3. Stuur via wwebjs-api endpoint:
|
||||
|
||||
```
|
||||
POST {WHATSAPP_API_ENDPOINT}/client/sendMessage/EdGpt
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"chatId": "{author}",
|
||||
"contentType": "MessageMedia",
|
||||
"content": {
|
||||
"mimetype": "image/png",
|
||||
"data": "{base64_string}",
|
||||
"filename": "{bestandsnaam}.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Elke PNG wordt als apart privébericht verstuurd.
|
||||
|
||||
## Foutafhandeling
|
||||
|
||||
| Situatie | Actie |
|
||||
|----------|-------|
|
||||
| Ongeldig dienstnummer (niet in JSON) | Stuur privé bericht: "Dienstnummer XXXX niet gevonden." |
|
||||
| Geen PNGs gevonden voor dienst | Stuur privé bericht: "Geen pagina's gevonden voor dienst XXXX." |
|
||||
| Geen dienstnummer in bericht | Negeer stil (geen actie) |
|
||||
| Fout bij verzenden PNG | Log error + stuur WhatsApp foutmelding naar admin |
|
||||
| Webhook verwerking fout | Log error + stuur WhatsApp foutmelding naar admin, return 500 naar wwebjs-api |
|
||||
|
||||
## Configuratie
|
||||
|
||||
### `.env` (dienst-bot project)
|
||||
|
||||
```env
|
||||
# WhatsApp API Configuration
|
||||
WHATSAPP_API_ENDPOINT=http://localhost:3000
|
||||
WHATSAPP_SESSION=EdGpt
|
||||
|
||||
# Webhook Configuration
|
||||
WEBHOOK_PORT=3002
|
||||
ALLOWED_GROUP=120363407356808413@g.us
|
||||
BOT_NAME=EdGpt
|
||||
|
||||
# Paths
|
||||
DIENSTEN_JSON=./diensten/dienst-tijden.json
|
||||
DIENSTEN_PNG=./diensten/png
|
||||
|
||||
# Admin notifications
|
||||
ADMIN_NUMBER=31620248011@c.us
|
||||
```
|
||||
|
||||
### wwebjs-api `.env` (toevoeging)
|
||||
|
||||
```env
|
||||
BASE_WEBHOOK_URL=http://localhost:3002/webhook
|
||||
```
|
||||
|
||||
## Bestandsstructuur
|
||||
|
||||
```
|
||||
dienst_echo_whatsappgroep/
|
||||
├── .env # configuratie
|
||||
├── package.json # dependencies: express, dotenv, axios
|
||||
├── src/
|
||||
│ ├── index.js # Express server, webhook endpoint
|
||||
│ ├── messageHandler.js # bericht parsing + dienst lookup + dispatch
|
||||
│ └── dienstService.js # JSON laden + PNG file reading
|
||||
├── diensten/ # bestaande data
|
||||
│ ├── dienst-tijden.json
|
||||
│ └── png/
|
||||
└── docs/
|
||||
└── superpowers/
|
||||
└── specs/
|
||||
└── 2025-05-11-dienst-bot-design.md
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
### `src/index.js`
|
||||
- Express server op `WEBHOOK_PORT`
|
||||
- POST `/webhook` endpoint
|
||||
- Laadt `.env` en start server
|
||||
- Delegeert verwerking naar `messageHandler`
|
||||
|
||||
### `src/messageHandler.js`
|
||||
- `handleWebhook(payload)`: hoofdfunctie, voert alle filterstappen uit
|
||||
- Parse message body voor dienstnummer
|
||||
- Roept `dienstService` aan voor lookup
|
||||
- Stuurt PNGs of foutmelding via wwebjs-api
|
||||
|
||||
### `src/dienstService.js`
|
||||
- `loadDiensten()`: laadt en cache `dienst-tijden.json`
|
||||
- `findDienst(nummer)`: retourneer dienst-object of null
|
||||
- `getPngFiles(dienst)`: retourneer array van absolute bestandspaden
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `express` - HTTP server voor webhook endpoint
|
||||
- `dotenv` - .env variabelen laden
|
||||
- `axios` - HTTP client voor wwebjs-api calls
|
||||
- `fs` - file reading voor PNG bestanden (built-in)
|
||||
- `path` - pad constructie (built-in)
|
||||
|
||||
## Operationeel
|
||||
|
||||
- Starten: `pm2 start src/index.js --name dienst-bot`
|
||||
- Loggen: `pm2 logs dienst-bot`
|
||||
- Herstarten: `pm2 restart dienst-bot`
|
||||
- Stoppen: `pm2 stop dienst-bot`
|
||||
Loading…
Reference in a new issue