Files
wapp/output.md
2025-12-12 09:12:03 +01:00

38 KiB

===== package.json =====

{
  "name": "wapp",
  "version": "1.0.0",
  "description": "WhatsApp automation server with API and database",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "postinstall": "patch-package"
  },
  "dependencies": {
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "qrcode": "^1.5.4",
    "qrcode-terminal": "^0.12.0",
    "sequelize": "^6.32.1",
    "socket.io": "^4.7.2",
    "sqlite3": "^5.1.6",
    "whatsapp-web.js": "github:pedroslopez/whatsapp-web.js"
  },
  "devDependencies": {
    "nodemon": "^3.0.1",
    "patch-package": "^8.0.1",
    "postinstall-postinstall": "^2.1.0"
  }
}

===== README.md =====

# WhatsApp Web.js Server

Complete WhatsApp automation server with REST API, WebSocket, and SQLite storage.

## Features
**REST API** - Send messages, retrieve contacts/chats/messages
✅ **WebSocket** - Real-time event streaming
✅ **SQLite** - Persistent storage for all data
✅ **Media Storage** - Automatic media file handling
✅ **Authentication** - API key protection
✅ **QR Code Web UI** - Easy WhatsApp authentication via browser
✅ **All Original Commands** - Full command handler support

## Quick Start

### Local Development

```bash
npm install
npm start

Visit http://localhost:3001/qr to scan the WhatsApp QR code.

Docker Deployment

docker-compose up -d --build

Visit http://your-server:3001/qr to authenticate WhatsApp.

QR Code Authentication - Web-UI

The server provides a web interface for easy WhatsApp authentication:

  • URL: http://localhost:3001/qr
  • Features:
    • Real-time QR code display
    • Live connection status updates
    • Auto-refresh on new QR codes
    • Mobile-friendly design

The QR code is also printed in the console logs for terminal access.```

===== .env.example =====

# Server Configuration
PORT=3001
API_KEY=your-strong-api-key-here

# Database & Storage
DB_PATH=./data/whatsapp.db
MEDIA_PATH=./media

# WhatsApp Client
HEADLESS=false
REJECT_CALLS=true

# CORS (for WebSocket)
CORS_ORIGIN=*```

===== docker-compose.yml =====
```yaml
services:
  wapp:
    user: "1000:1000"
    build:
      context: /opt/apps/wapp
      dockerfile: Dockerfile
    ports:
      - "3001:3001"
    container_name: wapp
    restart: unless-stopped
    networks:
      - traefik_net
    environment:
      # Server configuration
      - PORT=3001
      - NODE_ENV=production

      # API Security
      - API_KEY=${API_KEY:-your-secure-api-key-here}

      # Paths (pointing to volume mounts)
      - DB_PATH=/app/data/whatsapp.db
      - MEDIA_PATH=/app/media

      # WhatsApp configuration
      # Set HEADLESS=false to see the browser window (useful for debugging)
      - HEADLESS=true
      - REJECT_CALLS=false

      # WebSocket URL for QR page (set this to your public URL when using Traefik - )
      # Example:  - WS_URL=https://wapp.appmodel.nl
      - WS_URL=https://wapp.appmodel.nl

      # CORS configuration
      - CORS_ORIGIN=*

    volumes:
      # Persistent data volumes
      - whatsapp-data:/app/data
      - whatsapp-media:/app/media
      - whatsapp-cache:/app/.wwebjs_cache
      - whatsapp-auth:/app/.wwebjs_auth

    #  Uncomment labels below when ready to use Traefik
    labels:
       - "traefik.enable=true"
       - "traefik.http.routers.wapp.rule=Host(`wapp.appmodel.nl`)"
       - "traefik.http.routers.wapp.entrypoints=websecure"
       - "traefik.http.routers.wapp.tls=true"
       - "traefik.http.routers.wapp.tls.certresolver=letsencrypt"
       - "traefik.http.services.wapp.loadbalancer.server.port=3001"

    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

networks:
  traefik_net:
    external: true
    name: traefik_net

volumes:
  whatsapp-data:
    driver: local
  whatsapp-media:
    driver: local
  whatsapp-cache:
    driver: local
  whatsapp-auth:
    driver: local

===== .dockerignore =====

node_modules
npm-debug.log
.env
.git
.gitignore
.idea
.wwebjs_auth
.wwebjs_cache
data
media
*.md
test
markmap.svg

===== server.js =====

require('dotenv').config()
const express = require('express')
const http = require('http')
const socketIo = require('socket.io')
const { Sequelize, DataTypes, Op } = require('sequelize')
const { Client, LocalAuth, Location, Poll, List, Buttons } = require('whatsapp-web.js')
const path = require('path')
const fs = require('fs').promises
const crypto = require('crypto')
const qrcode = require('qrcode-terminal')
const QRCode = require('qrcode')

// --- CONFIGURATION ---
const config = {
	port       : process.env.PORT || 3001,
	dbPath     : process.env.DB_PATH || path.join(__dirname, 'data', 'whatsapp.db'),
	mediaPath  : process.env.MEDIA_PATH || path.join(__dirname, 'media'),
	headless   : process.env.HEADLESS !== 'false',
	rejectCalls: process.env.REJECT_CALLS !== 'false',
	apiKey     : process.env.API_KEY || 'test-key'
}

// Ensure directories exist
async function ensureDirectories() {
	await fs.mkdir(path.dirname(config.dbPath), { recursive: true })
	await fs.mkdir(config.mediaPath, { recursive: true })
}

// Simple logger
const logger = {
	info : (msg) => console.log(`[${ new Date().toISOString() }] INFO: ${ msg }`),
	error: (msg, err) => console.error(`[${ new Date().toISOString() }] ERROR: ${ msg }`, err || ''),
	warn : (msg) => console.warn(`[${ new Date().toISOString() }] WARN: ${ msg }`)
}

// --- DATABASE SETUP ---
const sequelize = new Sequelize({
	dialect: 'sqlite',
	storage: config.dbPath,
	logging: process.env.NODE_ENV === 'development' ? logger.info : false
})

// Models
const Contact = sequelize.define('Contact', {
	id           : { type: DataTypes.STRING, primaryKey: true },
	phoneNumber  : { type: DataTypes.STRING, allowNull: true },
	name         : DataTypes.STRING,
	pushname     : DataTypes.STRING,
	isBlocked    : { type: DataTypes.BOOLEAN, defaultValue: false },
	isMyContact  : { type: DataTypes.BOOLEAN, defaultValue: false },
	labels       : { type: DataTypes.JSON, defaultValue: [] },
	profilePicUrl: DataTypes.STRING,
	isGroup: { type: DataTypes.BOOLEAN, defaultValue: false },
	isNewsletter: { type: DataTypes.BOOLEAN, defaultValue: false },
	isUser: { type: DataTypes.BOOLEAN, defaultValue: true }
}, { indexes: [{ fields: ['phoneNumber'] }] })

const Chat = sequelize.define('Chat', {
	id         : { type: DataTypes.STRING, primaryKey: true },
	name       : DataTypes.STRING,
	isGroup    : { type: DataTypes.BOOLEAN, defaultValue: false },
	isMuted    : { type: DataTypes.BOOLEAN, defaultValue: false },
	unreadCount: { type: DataTypes.INTEGER, defaultValue: 0 },
	timestamp  : DataTypes.INTEGER,
	pinned     : { type: DataTypes.BOOLEAN, defaultValue: false },
	description: DataTypes.TEXT
}, { indexes: [{ fields: ['isGroup'] }] })

const Message = sequelize.define('Message', {
	id       : { type: DataTypes.STRING, primaryKey: true },
	from     : { type: DataTypes.STRING, allowNull: false },
	to       : { type: DataTypes.STRING, allowNull: false },
	body     : DataTypes.TEXT,
	type     : DataTypes.STRING,
	timestamp: { type: DataTypes.INTEGER, index: true },
	hasMedia : { type: DataTypes.BOOLEAN, defaultValue: false },
	mediaId  : DataTypes.STRING,
	fromMe   : { type: DataTypes.BOOLEAN, defaultValue: false },
	ack      : { type: DataTypes.INTEGER, defaultValue: 0 }
}, { indexes: [{ fields: ['from', 'timestamp'] }] })

const Media = sequelize.define('Media', {
	id       : { type: DataTypes.STRING, primaryKey: true },
	messageId: { type: DataTypes.STRING, references: { model: Message, key: 'id' } },
	mimetype : DataTypes.STRING,
	filename : DataTypes.STRING,
	filePath : DataTypes.STRING
})

const Call = sequelize.define('Call', {
	id       : { type: DataTypes.STRING, primaryKey: true },
	from     : DataTypes.STRING,
	isVideo  : { type: DataTypes.BOOLEAN, defaultValue: false },
	timestamp: DataTypes.INTEGER,
	rejected : { type: DataTypes.BOOLEAN, defaultValue: false }
})

Message.hasOne(Media, { foreignKey: 'messageId', as: 'media' })

// --- EXPRESS & SOCKET.IO ---
const app = express()
const server = http.createServer(app)
const io = socketIo(server, {
	cors: { origin: process.env.CORS_ORIGIN || '*', methods: ['GET', 'POST'] }
})

app.use(express.json({ limit: '50mb' }))

// Middleware
function validateApiKey(req, res, next) {
	const apiKey = req.headers['x-api-key'] || req.query.apiKey
	if (!apiKey || apiKey !== config.apiKey) {
		return res.status(401).json({ success: false, error: 'Unauthorized' })
	}
	next()
}

function checkWhatsAppReady(req, res, next) {
	if (!client.info) return res.status(503).json({ success: false, error: 'Client not ready' })
	next()
}

// --- REST API ---
app.post('/api/send', [validateApiKey, checkWhatsAppReady], async (req, res) => {
	try {
		const { phoneNumber, message } = req.body
		if (!phoneNumber || !message) {
			return res.status(400).json({ success: false, error: 'Missing phoneNumber or message' })
		}

		const chatId = phoneNumber.includes('@') ? phoneNumber : `${ phoneNumber }@c.us`
		const sent = await client.sendMessage(chatId, message)

		await Message.create({
			id       : sent.id._serialized,
			from     : client.info.wid._serialized,
			to       : chatId,
			body     : typeof message === 'string' ? message : JSON.stringify(message),
			type     : sent.type,
			timestamp: sent.timestamp,
			fromMe   : true,
			ack      : sent.ack
		})

		io.emit('message_sent', { id: sent.id._serialized, phoneNumber, message })
		res.json({ success: true, messageId: sent.id._serialized })
	} catch (error) {
		logger.error('Error sending message', error)
		res.status(500).json({ success: false, error: error.message })
	}
})

app.get('/api/contacts', [validateApiKey], async (req, res) => {
	try {
		const contacts = await Contact.findAll({ order: [['name', 'ASC']], limit: 1000 })
		res.json({ success: true, data: contacts })
	} catch (error) {
		logger.error('Error fetching contacts', error)
		res.status(500).json({ success: false, error: error.message })
	}
})

app.get('/api/chats', [validateApiKey], async (req, res) => {
	try {
		const chats = await Chat.findAll({ order: [['timestamp', 'DESC']], limit: 1000 })
		res.json({ success: true, data: chats })
	} catch (error) {
		logger.error('Error fetching chats', error)
		res.status(500).json({ success: false, error: error.message })
	}
})

app.get('/api/messages/:chatId', [validateApiKey], async (req, res) => {
	try {
		const { chatId } = req.params
		const messages = await Message.findAll({
			where  : { [Op.or]: [{ from: chatId }, { to: chatId }] },
			order  : [['timestamp', 'DESC']],
			limit  : 100,
			include: [{ model: Media, as: 'media' }]
		})
		res.json({ success: true, data: messages })
	} catch (error) {
		logger.error('Error fetching messages', error)
		res.status(500).json({ success: false, error: error.message })
	}
})

app.get('/api/status', [validateApiKey], async (req, res) => {
	try {
		const status = {
			connected  : !!client.info,
			user       : client.info,
			uptime     : process.uptime(),
			wwebVersion: client.info ? await client.getWWebVersion() : null
		}
		res.json({ success: true, data: status })
	} catch (error) {
		res.status(500).json({ success: false, error: error.message })
	}
})

app.get('/health', (req, res) => res.json({ status: 'OK', timestamp: Date.now() }))

// QR Code storage
let currentQR = null
let qrTimestamp = null

// QR Code HTML page
app.get('/qr', (req, res) => {
	const html = `
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>WhatsApp QR Code</title>
	<style>
		* { margin: 0; padding: 0; box-sizing: border-box; }
		body {
			font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
			background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
			min-height: 100vh;
			display: flex;
			justify-content: center;
			align-items: center;
			padding: 20px;
		}
		.container {
			background: white;
			border-radius: 20px;
			padding: 40px;
			box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
			max-width: 500px;
			width: 100%;
			text-align: center;
		}
		h1 {
			color: #333;
			margin-bottom: 10px;
			font-size: 28px;
		}
		.subtitle {
			color: #666;
			margin-bottom: 30px;
			font-size: 14px;
		}
		#qr-container {
			background: #f5f5f5;
			border-radius: 15px;
			padding: 20px;
			margin: 20px 0;
			min-height: 300px;
			display: flex;
			justify-content: center;
			align-items: center;
		}
		#qr-code {
			max-width: 100%;
			height: auto;
			border-radius: 10px;
		}
		.status {
			padding: 15px;
			border-radius: 10px;
			margin: 20px 0;
			font-weight: 500;
		}
		.status.waiting {
			background: #fff3cd;
			color: #856404;
		}
		.status.ready {
			background: #d4edda;
			color: #155724;
		}
		.status.error {
			background: #f8d7da;
			color: #721c24;
		}
		.info {
			color: #666;
			font-size: 13px;
			line-items: 1.6;
		}
		.loader {
			border: 4px solid #f3f3f3;
			border-top: 4px solid #667eea;
			border-radius: 50%;
			width: 40px;
			height: 40px;
			animation: spin 1s linear infinite;
			margin: 0 auto;
		}
		@keyframes spin {
			0% { transform: rotate(0deg); }
			100% { transform: rotate(360deg); }
		}
		.timestamp {
			font-size: 11px;
			color: #999;
			margin-top: 10px;
		}
	</style>
</head>
<body>
	<div class="container">
		<h1>🔗 WhatsApp Connection</h1>
		<p class="subtitle">Scan the QR code with your WhatsApp mobile app</p>

		<div id="status" class="status waiting">
			Waiting for QR code...
		</div>

		<div id="qr-container">
			<div class="loader"></div>
		</div>

		<div class="info">
			<p><strong>How to scan:</strong></p>
			<p>1. Open WhatsApp on your phone</p>
			<p>2. Tap Menu (⋮) > Linked Devices</p>
			<p>3. Tap "Link a Device"</p>
			<p>4. Point your phone at this screen</p>
		</div>

		<div class="timestamp" id="timestamp"></div>
	</div>

	<script>
		const statusEl = document.getElementById('status');
		const qrContainer = document.getElementById('qr-container');
		const timestampEl = document.getElementById('timestamp');
		let socket;

		function connectWebSocket() {
			socket = io('${process.env.WS_URL || ''}');

			socket.on('connect', () => {
				console.log('WebSocket connected');
			});

			socket.on('qr', (data) => {
				console.log('QR code received');
				statusEl.textContent = '📱 Scan this QR code with WhatsApp';
				statusEl.className = 'status waiting';
				qrContainer.innerHTML = '<img id="qr-code" src="/qr/image" alt="QR Code">';
				timestampEl.textContent = 'Generated: ' + new Date(data.timestamp).toLocaleString();
			});

			socket.on('ready', (data) => {
				console.log('WhatsApp ready');
				statusEl.textContent = '✅ Connected successfully!';
				statusEl.className = 'status ready';
				qrContainer.innerHTML = '<div style="font-size: 48px;">✅</div><p style="margin-top: 10px; color: #155724;">WhatsApp is connected and ready</p>';
				timestampEl.textContent = '';
			});

			socket.on('status', (data) => {
				if (data.connected) {
					statusEl.textContent = '✅ Already connected';
					statusEl.className = 'status ready';
					qrContainer.innerHTML = '<div style="font-size: 48px;">✅</div><p style="margin-top: 10px; color: #155724;">WhatsApp is already connected</p>';
				}
			});

			socket.on('disconnect', () => {
				console.log('WebSocket disconnected');
				statusEl.textContent = '⚠️ Connection lost - Reconnecting...';
				statusEl.className = 'status error';
			});

			socket.on('error', (error) => {
				console.error('Socket error:', error);
				statusEl.textContent = '❌ Error: ' + (error.message || 'Unknown error');
				statusEl.className = 'status error';
			});
		}

		// Initial check
		fetch('/qr/check')
			.then(r => r.json())
			.then(data => {
				if (data.hasQR) {
					statusEl.textContent = '📱 Scan this QR code with WhatsApp';
					statusEl.className = 'status waiting';
					qrContainer.innerHTML = '<img id="qr-code" src="/qr/image" alt="QR Code">';
					timestampEl.textContent = 'Generated: ' + new Date(data.timestamp).toLocaleString();
				} else if (data.connected) {
					statusEl.textContent = '✅ Already connected';
					statusEl.className = 'status ready';
					qrContainer.innerHTML = '<div style="font-size: 48px;">✅</div><p style="margin-top: 10px; color: #155724;">WhatsApp is already connected</p>';
					timestampEl.textContent = '';
				}
			})
			.catch(err => {
				console.error('Check failed:', err);
			});

		// Connect WebSocket for live updates
		connectWebSocket();

		// Auto-refresh QR image every 30 seconds if waiting
		setInterval(() => {
			const img = document.getElementById('qr-code');
			if (img && statusEl.classList.contains('waiting')) {
				img.src = '/qr/image?t=' + Date.now();
			}
		}, 30000);
	</script>
	<script src="/socket.io/socket.io.js"></script>
</body>
</html>
	`
	res.send(html)
})

// QR Code check endpoint
app.get('/qr/check', (req, res) => {
	res.json({
		hasQR: !!currentQR,
		connected: !!client.info,
		timestamp: qrTimestamp
	})
})

// QR Code image endpoint
app.get('/qr/image', async (req, res) => {
	try {
		if (!currentQR) {
			// Generate a placeholder
			res.setHeader('Content-Type', 'image/png')
			const placeholder = await QRCode.toBuffer('Waiting for QR code...', {
				width: 300,
				color: { dark: '#ccc', light: '#fff' }
			})
			return res.send(placeholder)
		}

		res.setHeader('Content-Type', 'image/png')
		res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
		const qrImage = await QRCode.toBuffer(currentQR, {
			width: 300,
			margin: 2,
			color: { dark: '#000', light: '#fff' }
		})
		res.send(qrImage)
	} catch (error) {
		logger.error('Error generating QR image', error)
		res.status(500).send('Error generating QR code')
	}
})

// --- WEBSOCKET ---
io.on('connection', (socket) => {
	logger.info(`WebSocket client connected: ${ socket.id }`)
	socket.emit('status', { connected: !!client.info })

	socket.on('disconnect', (reason) => {
		logger.info(`WebSocket client disconnected: ${ socket.id }, reason: ${ reason }`)
	})
})

// --- WHATSAPP CLIENT ---
const client = new Client({
	authStrategy: new LocalAuth({ clientId: 'whatsapp-server' }),
	puppeteer   : {
		headless: config.headless,
		args    : ['--no-sandbox', '--disable-setuid-sandbox']
	}
})

client.on('qr', (qr) => {
	logger.info('QR received - scan to authenticate')
	logger.info('QR Code available at: http://localhost:' + config.port + '/qr')
	qrcode.generate(qr, { small: true })

	// Store QR for web display
	currentQR = qr
	qrTimestamp = Date.now()

	io.emit('qr', { qr, timestamp: qrTimestamp })
})

client.on('ready', async () => {
	logger.info('WhatsApp client ready');

	//  Clear QR code once connected
	currentQR = null
	qrTimestamp = null

	const wwebVersion = await client.getWWebVersion();

	// Sync contacts with proper type detection
	const contacts = await client.getContacts();
	let contactsSynced = 0;

	for (const contact of contacts) {
		try {
			const id = contact.id?._serialized || contact.id || '';
			const name = contact.name || contact.pushname || 'Unknown';

			// Determine contact type and extract identifier
			let phoneNumber = null;
			let isGroup = false;
			let isNewsletter = false;
			let isUser = false;

			if (contact.id && typeof contact.id === 'object') {
				if (contact.id.server === 'c.us') {
					// Regular user contact
					phoneNumber = contact.id.user || contact.number;
					isUser = true;
				} else if (contact.id.server === 'g.us') {
					// Group chat
					isGroup = true;
					phoneNumber = contact.id._serialized; // Use group ID
				} else if (contact.id.server === 'newsletter') {
					// Newsletter
					isNewsletter = true;
					phoneNumber = contact.id._serialized; // Use newsletter ID
				}
			}

			// Fallback
			if (!phoneNumber) {
				phoneNumber = contact.number || contact.id?._serialized || null;
				isUser = true;
			}

			await Contact.upsert({
				id: id,
				phoneNumber: phoneNumber,
				name: name,
				pushname: contact.pushname || null,
				isBlocked: contact.isBlocked || false,
				isMyContact: contact.isMyContact || false,
				labels: contact.labels || [],
				profilePicUrl: contact.profilePicUrl || null,
				isGroup: isGroup,
				isNewsletter: isNewsletter,
				isUser: isUser
			});
			contactsSynced++;
		} catch (err) {
			logger.warn(`Failed to sync contact ${contact.id?._serialized || 'unknown'}: ${err.message}`);
		}
	}

	// Sync chats
	const chats = await client.getChats();
	let chatsSynced = 0;

	for (const chat of chats) {
		try {
			await Chat.upsert({
				id: chat.id._serialized,
				name: chat.name,
				isGroup: chat.isGroup,
				isMuted: chat.isMuted || false,
				unreadCount: chat.unreadCount || 0,
				timestamp: chat.timestamp || 0,
				pinned: chat.pinned || false,
				description: chat.description || null
			});
			chatsSynced++;
		} catch (err) {
			logger.warn(`Failed to sync chat ${chat.id._serialized}: ${err.message}`);
		}
	}

	logger.info(`Synced ${contactsSynced}/${contacts.length} contacts and ${chatsSynced}/${chats.length} chats`);
	io.emit('ready', { version: wwebVersion, user: client.info });
});

client.on('message', async (msg) => {
	try {
		const messageData = {
			id       : msg.id._serialized,
			from     : msg.from,
			to       : msg.to || msg.from,
			body     : msg.body || '',
			type     : msg.type,
			timestamp: msg.timestamp,
			hasMedia : msg.hasMedia,
			fromMe   : msg.fromMe,
			ack      : msg.ack || 0
		}

		if (msg.location) {
			Object.assign(messageData, {
				locationLatitude : msg.location.latitude,
				locationLongitude: msg.location.longitude,
				locationName     : msg.location.name,
				locationAddress  : msg.location.address
			})
		}

		await Message.create(messageData)

		if (msg.hasMedia) {
			const media = await msg.downloadMedia()
			const mediaId = crypto.randomUUID()
			const fileExt = media.filename ? path.extname(media.filename) : media.mimetype.split('/')[1] || 'bin'
			const filename = `${ mediaId }${ fileExt }`
			const filePath = path.join(config.mediaPath, filename)

			await fs.writeFile(filePath, media.data, 'base64')
			await Media.create({
				id       : mediaId,
				messageId: msg.id._serialized,
				mimetype : media.mimetype,
				filename : media.filename || filename,
				filePath
			})
		}

		// Update contact info for incoming messages (safely)
		if (!msg.fromMe && config.syncOnReady) {
			try {
				// Only attempt if SYNC_ON_READY is true (you trust the API)
				const contact = await msg.getContact()
				await Contact.upsert({
					id           : contact.id._serialized,
					phoneNumber  : contact.number || contact.id.user,
					name         : contact.name || contact.pushname,
					pushname     : contact.pushname,
					isBlocked    : contact.isBlocked || false,
					isMyContact  : contact.isMyContact || false,
					labels       : contact.labels || [],
					profilePicUrl: contact.profilePicUrl || null
				})
			} catch (err) {
				// Silently fail - this is a known issue with whatsapp-web.js
				logger.warn(`Failed to update contact: ${ err.message }`)
			}
		}

		io.emit('message', {
			id       : msg.id._serialized,
			from     : msg.from,
			body     : msg.body,
			type     : msg.type,
			timestamp: msg.timestamp,
			hasMedia : msg.hasMedia,
			fromMe   : msg.fromMe
		})

		// --- ALL ORIGINAL COMMAND HANDLERS ---
		if (msg.body === '!ping reply') {
			await msg.reply('pong')
		} else if (msg.body === '!ping') {
			await client.sendMessage(msg.from, 'pong')
		} else if (msg.body.startsWith('!sendto ')) {
			const parts = msg.body.split(' ')
			const number = parts[1].includes('@c.us') ? parts[1] : `${ parts[1] }@c.us`
			await client.sendMessage(number, parts.slice(2).join(' '))
		} else if (msg.body.startsWith('!subject ')) {
			const chat = await msg.getChat()
			if (chat.isGroup) await chat.setSubject(msg.body.slice(9))
		} else if (msg.body.startsWith('!desc ')) {
			const chat = await msg.getChat()
			if (chat.isGroup) await chat.setDescription(msg.body.slice(6))
		} else if (msg.body === '!leave') {
			const chat = await msg.getChat()
			if (chat.isGroup) await chat.leave()
		} else if (msg.body.startsWith('!join ')) {
			try {
				await client.acceptInvite(msg.body.split(' ')[1])
				await msg.reply('Joined the group!')
			} catch (e) {
				await msg.reply('Invalid invite code.')
			}
		} else if (msg.body === '!groupinfo') {
			const chat = await msg.getChat()
			if (chat.isGroup) {
				await msg.reply(`*Group Details*\nName: ${ chat.name }\nDescription: ${ chat.description }\nParticipants: ${ chat.participants.length }`)
			}
		} else if (msg.body === '!chats') {
			const chats = await client.getChats()
			await client.sendMessage(msg.from, `The bot has ${ chats.length } chats open.`)
		} else if (msg.body === '!info') {
			const info = client.info
			await client.sendMessage(msg.from, `*Connection Info*\nUser: ${ info.pushname }\nNumber: ${ info.wid.user }\nPlatform: ${ info.platform }`)
		} else if (msg.body === '!mediainfo' && msg.hasMedia) {
			const media = await msg.downloadMedia()
			await msg.reply(`*Media Info*\nMime: ${ media.mimetype }\nSize: ${ media.data.length }`)
		} else if (msg.body === '!location') {
			await msg.reply(new Location(37.422, -122.084, { name: 'Googleplex' }))
		} else if (msg.body === '!reaction') {
			await msg.react('👍')
		} else if (msg.body === '!sendpoll') {
			await msg.reply(new Poll('Winter or Summer?', ['Winter', 'Summer']))
		}

	} catch (error) {
		logger.error('Error in message handler', error)
		io.emit('error', { source: 'message_handler', error: error.message })
	}
})

client.on('call', async (call) => {
	logger.info(`Call from ${ call.from }`)
	await Call.create({
		id       : call.id,
		from     : call.from,
		isVideo  : call.isVideo,
		timestamp: Math.floor(Date.now() / 1000),
		rejected : config.rejectCalls
	})

	io.emit('call', { id: call.id, from: call.from, isVideo: call.isVideo })

	if (config.rejectCalls) await call.reject()
	await client.sendMessage(call.from, `[${ call.isVideo ? 'Video' : 'Audio' }] Call from ${ call.from } ${ config.rejectCalls ? '(Auto-rejected)' : '' }`)
})

// Graceful shutdown
process.on('SIGINT', async () => {
	logger.info('Shutting down...')
	await client.destroy()
	await sequelize.close()
	server.close(() => process.exit(0))
})

// Start
async function start() {
	await ensureDirectories()
	await sequelize.authenticate()
	await sequelize.sync({ force: false })

	server.listen(config.port, () => {
		logger.info(`🚀 Server running on port ${ config.port }`)
		logger.info(`🔑 API Key: ${ config.apiKey }`)
		logger.info(`💾 Database: ${ config.dbPath }`)
		logger.info(`📱 Initializing WhatsApp...`)
	})

	client.initialize()
}

if (require.main === module) {
	start()
}

module.exports = { app, server, io, client, sequelize }```

===== markmap.svg =====

<style>.markmap{--markmap-max-width: 9999px;--markmap-a-color: #0097e6;--markmap-a-hover-color: #00a8ff;--markmap-code-bg: #f0f0f0;--markmap-code-color: #555;--markmap-highlight-bg: #ffeaa7;--markmap-table-border: 1px solid currentColor;--markmap-font: 300 16px/20px sans-serif;--markmap-circle-open-bg: #fff;--markmap-text-color: #333;--markmap-highlight-node-bg: #ff02;font:var(--markmap-font);color:var(--markmap-text-color)}.markmap-link{fill:none}.markmap-node>circle{cursor:pointer}.markmap-foreign{display:inline-block}.markmap-foreign p{margin:0}.markmap-foreign a{color:var(--markmap-a-color)}.markmap-foreign a:hover{color:var(--markmap-a-hover-color)}.markmap-foreign code{padding:.25em;font-size:calc(1em - 2px);color:var(--markmap-code-color);background-color:var(--markmap-code-bg);border-radius:2px}.markmap-foreign pre{margin:0}.markmap-foreign pre>code{display:block}.markmap-foreign del{text-decoration:line-through}.markmap-foreign em{font-style:italic}.markmap-foreign strong{font-weight:700}.markmap-foreign mark{background:var(--markmap-highlight-bg)}.markmap-foreign table,.markmap-foreign th,.markmap-foreign td{border-collapse:collapse;border:var(--markmap-table-border)}.markmap-foreign img{display:inline-block}.markmap-foreign svg{fill:currentColor}.markmap-foreign>div{width:var(--markmap-max-width);text-align:left}.markmap-foreign>div>div{display:inline-block}.markmap-highlight rect{fill:var(--markmap-highlight-node-bg)}.markmap-dark .markmap{--markmap-code-bg: #1a1b26;--markmap-code-color: #ddd;--markmap-circle-open-bg: #444;--markmap-text-color: #eee}</style>

Features
npm install
Installation
WhatsApp Web.js Server
```

===== dev-docker-compose.yml =====

services:
  wapp:
    #user: "1000:1000"
    build:
      #context: /opt/apps/wapp
      context: .
      dockerfile: Dockerfile
    ports:
      - "3001:3001"
    container_name: wapp
    restart: unless-stopped
    networks:
      - traefik_net
    environment:
      # Server configuration
      - PORT=3001
      - NODE_ENV=production

      # API Security
      - API_KEY=${API_KEY:-your-secure-api-key-here}

      # Paths (pointing to volume mounts)
      - DB_PATH=/app/data/whatsapp.db
      - MEDIA_PATH=/app/media

      # WhatsApp configuration
      # Set HEADLESS=false to see the browser window (useful for debugging)
      - HEADLESS=true
      - REJECT_CALLS=false

      # WebSocket URL for QR page (set this to your public URL when using Traefik - )
      # Example: - WS_URL=https://wapp.appmodel.nl
      - WS_URL=https://wapp.appmodel.nl

      # CORS configuration
      - CORS_ORIGIN=*

    volumes:
      # Persistent data volumes
      - whatsapp-data:/app/data
      - whatsapp-media:/app/media
      - whatsapp-cache:/app/.wwebjs_cache
      - whatsapp-auth:/app/.wwebjs_auth

    #  Uncomment labels below when ready to use Traefik
    labels:
       - "traefik.enable=true"
       - "traefik.http.routers.wapp.rule=Host(`wapp.appmodel.nl`)"
       - "traefik.http.routers.wapp.entrypoints=websecure"
       - "traefik.http.routers.wapp.tls=true"
       - "traefik.http.routers.wapp.tls.certresolver=letsencrypt"
       - "traefik.http.services.wapp.loadbalancer.server.port=3001"

    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

networks:
  traefik_net:
    external: true
    name: traefik_net

volumes:
  whatsapp-data:
    driver: local
  whatsapp-media:
    driver: local
  whatsapp-cache:
    driver: local
  whatsapp-auth:
    driver: local

===== Dockerfile =====

# ==================== BUILD STAGE ====================
FROM node:20-alpine AS builder
WORKDIR /app

# Install chromium dependencies for whatsapp-web.js
RUN apk add --no-cache \
    chromium \
    nss \
    freetype \
    harfbuzz \
    ca-certificates \
    ttf-freefont \
    git

# Copy package files
COPY package*.json ./

# Install dependencies
RUN npm ci --production=false

# Copy patches directory if it exists
COPY patches ./patches

# Run postinstall (patch-package)
RUN npm run postinstall || true

# Copy application files
COPY server.js ./

# ==================== RUNTIME STAGE ====================
FROM node:20-alpine
WORKDIR /app

# Install chromium and runtime dependencies
RUN apk add --no-cache \
    chromium \
    nss \
    freetype \
    harfbuzz \
    ca-certificates \
    ttf-freefont

# Tell Puppeteer to use installed chromium
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
    PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser

#RUN groupadd -r whatsapp && useradd -r -g whatsapp whatsapp
#DEV  Create non-root user
RUN addgroup -S whatsapp || true && \
    adduser -D -S -G whatsapp whatsapp || true

# Copy dependencies from builder
COPY --from=builder --chown=whatsapp:whatsapp /app/node_modules ./node_modules

# Copy application files
COPY --chown=whatsapp:whatsapp server.js ./
COPY --chown=whatsapp:whatsapp package*.json ./

# Create volume directories
RUN mkdir -p /app/data /app/media /app/.wwebjs_cache /app/.wwebjs_auth && \
    chown -R whatsapp:whatsapp /app

USER whatsapp

EXPOSE 3001

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
    CMD node -e "require('http').get('http://localhost:3001/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"

CMD ["node", "server.js"]

===== .aiignore =====

node_modules
.idea
.git
package-lock.json```

===== patches/whatsapp-web.js+1.21.0.patch =====

diff --git a/node_modules/whatsapp-web.js/src/util/Injected.js b/node_modules/whatsapp-web.js/src/util/Injected.js index xxxxxxx..yyyyyyy 100644 --- a/node_modules/whatsapp-web.js/src/util/Injected.js +++ b/node_modules/whatsapp-web.js/src/util/Injected.js @@ -628,16 +628,28 @@ exports.ExposeStore = () => { return Object.defineProperty(window, 'WWebJS', { get() { return { getContactModel: (contact) => {

  •                // Safe method extraction with fallbacks
    
  •                const getContactMethod = (method) => {
    
  •                  if (!window.Store.ContactMethods || !window.Store.ContactMethods[method]) {
    
  •                    return null;
    
  •                  }
    
  •                  return window.Store.ContactMethods[method];
    
  •                };
    
  •                return {
                     id: contact.id,
                     name: contact.name,
                     type: contact.type,
    
  •                  isMe: contact.isMe,
    
  •                  isUser: contact.isUser,
    
  •                  isGroup: contact.isGroup,
    
  •                  isBroadcast: contact.isBroadcast,
    
  •                  isBusiness: contact.isBusiness,
    
  •                  isEnterprise: contact.isEnterprise,
    
  •                  isMe: contact.isMe || false,
    
  •                  isUser: contact.isUser || false,
    
  •                  isGroup: contact.isGroup || false,
    
  •                  isBroadcast: contact.isBroadcast || false,
    
  •                  isBusiness: contact.isBusiness || false,
    
  •                  isEnterprise: contact.isEnterprise || false,
    
  •                  isContactSyncEnabled: contact.isContactSyncEnabled || false,
    
  •                  isContactLocked: contact.isContactLocked || false,
                     verifiedLevel: contact.verifiedLevel,
                     verifiedName: contact.verifiedName,
                     pushname: contact.pushname,
    

@@ -646,11 +658,23 @@ exports.ExposeStore = () => { isMe: contact.isMe, isUser: contact.isUser, isWAContact: contact.isWAContact,

  •                  isMyContact: contact.isMyContact,
    
  •                  // Fallback for changed API
    
  •                  isMyContact: contact.isMyContact !== undefined
    
  •                    ? contact.isMyContact
    
  •                    : contact.type === 'in' || false,
                     isBlocked: contact.isBlocked,
    
  •                  labels: contact.labels ? contact.labels.map(l => l.id) : []
    
  •                  labels: contact.labels ? contact.labels.map(l => l.id) : [],
    
  •                  // Additional properties
    
  •                  displayName: contact.displayName || contact.pushname || contact.name,
    
  •                  formattedName: contact.formattedName || contact.name,
    
  •                  formattedShortName: contact.formattedShortName || contact.name,
    
  •                  shortName: contact.shortName || contact.name,
    
  •                  isSaved: contact.isSaved !== undefined ? contact.isSaved : false,
    
  •                  isMuted: contact.muteExpiration !== undefined ? contact.muteExpiration > 0 : false,
    
  •          muteExpiration: contact.muteExpiration || 0,
    
  •                  disappearingModeDuration: contact.disappearingModeDuration || 0
                   };
                 },
                 });
               }
             });
           }```
    

===== DIRECTORY TREE =====