Files
wapp/server.js
2025-12-06 19:37:12 +01:00

746 lines
22 KiB
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 = `
<!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 }