diff --git a/.env.example b/.env.example index 8e0258b..82580e9 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # Server Configuration PORT=3001 -API_KEY=your-strong-api-key-here +WAPP_API_KEY=your-strong-api-key-here # Database & Storage DB_PATH=./data/whatsapp.db diff --git a/dev-docker-compose.yml b/dev-docker-compose.yml index 5aab15d..93a01b5 100644 --- a/dev-docker-compose.yml +++ b/dev-docker-compose.yml @@ -17,7 +17,7 @@ services: - NODE_ENV=production # API Security - - API_KEY=${API_KEY:-your-secure-api-key-here} + - WAPP_API_KEY=${WAPP_API_KEY:-your-secure-api-key-here} # Paths (pointing to volume mounts) - DB_PATH=/app/data/whatsapp.db diff --git a/docker-compose.yml b/docker-compose.yml index 00b43bd..d083e13 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: - NODE_ENV=production # API Security - - API_KEY=${API_KEY:-your-secure-api-key-here} + - WAPP_API_KEY=${WAPP_API_KEY:-your-secure-api-key-here} # Paths (pointing to volume mounts) - DB_PATH=/app/data/whatsapp.db diff --git a/output.md b/output.md deleted file mode 100644 index 9a0430f..0000000 --- a/output.md +++ /dev/null @@ -1,1173 +0,0 @@ -===== package.json ===== -```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 ===== -```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 - -```bash -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 ===== -```javascript -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 = ` - - - - - - WhatsApp QR Code - - - -
-

🔗 WhatsApp Connection

-

Scan the QR code with your WhatsApp mobile app

- -
- Waiting for QR code... -
- -
-
-
- -
-

How to scan:

-

1. Open WhatsApp on your phone

-

2. Tap Menu (⋮) > Linked Devices

-

3. Tap "Link a Device"

-

4. Point your phone at this screen

-
- -
-
- - - - - - ` - 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 ===== -``` -
Features
npm install
Installation
WhatsApp Web.js Server
``` - -===== dev-docker-compose.yml ===== -```yaml -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 ===== -```text -``` diff --git a/server.js b/server.js index 3a5d5a5..fd22ee0 100644 --- a/server.js +++ b/server.js @@ -21,7 +21,7 @@ const config = { 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' + apiKey : process.env.WAPP_API_KEY || 'your-secure-api-key-here' } // Ensure directories exist