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') // --- CONFIGURATION --- const config = { port : process.env.PORT || 3000, 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() })) // --- 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') qrcode.generate(qr, { small: true }) io.emit('qr', { qr, timestamp: Date.now() }) }) client.on('ready', async () => { logger.info('WhatsApp client ready'); 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 }