├── .gitignore ├── jest.config.js ├── views ├── partials │ ├── header.ejs │ ├── navbar.ejs │ ├── messages.ejs │ ├── status-card.ejs │ ├── qr-container.ejs │ ├── actions.ejs │ ├── api-info.ejs │ ├── sidebar.ejs │ ├── head.ejs │ └── scripts.ejs ├── dashboard.ejs ├── api-keys.ejs ├── login.ejs ├── auto-reply.ejs ├── documentation.ejs ├── register.ejs ├── contacts.ejs ├── chat.ejs └── blaster.ejs ├── supabaseClient.js ├── package.json ├── stop.sh ├── tests ├── endpoints.test.js └── auth.test.js ├── start.sh ├── setup.sh ├── public └── css │ └── dashboard.css ├── static └── index.html └── whatsapp-service.js /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | package-lock.json 4 | auth_info_baileys -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | verbose: true, 4 | }; -------------------------------------------------------------------------------- /views/partials/header.ejs: -------------------------------------------------------------------------------- 1 |
2 | 3 |

WhatsApp Webhook API

4 |

Scan QR code untuk menghubungkan WhatsApp Anda

5 |
-------------------------------------------------------------------------------- /views/partials/navbar.ejs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /views/partials/messages.ejs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /views/partials/status-card.ejs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Connection Status

5 |

Current status of the WhatsApp connection.

6 |
7 | 8 |
9 |
10 |
Connecting...
11 |
Loading connection status...
12 |
13 |
-------------------------------------------------------------------------------- /supabaseClient.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { createClient } = require('@supabase/supabase-js'); 3 | 4 | const supabaseUrl = process.env.SUPABASE_URL; 5 | const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY; 6 | 7 | if (!supabaseUrl || !supabaseServiceKey) { 8 | throw new Error("Supabase URL and Service Key are required. Please check your .env file."); 9 | } 10 | 11 | const supabase = createClient(supabaseUrl, supabaseServiceKey, { 12 | auth: { 13 | autoRefreshToken: false, 14 | persistSession: false 15 | } 16 | }); 17 | 18 | module.exports = supabase; -------------------------------------------------------------------------------- /views/partials/qr-container.ejs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whatsapp-webhook-api", 3 | "version": "1.0.0", 4 | "main": "whatsapp-service.js", 5 | "scripts": { 6 | "start": "node whatsapp-service.js", 7 | "dev": "node whatsapp-service.js", 8 | "test": "jest" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "description": "", 14 | "dependencies": { 15 | "@hapi/boom": "^10.0.1", 16 | "@supabase/supabase-js": "^2.44.4", 17 | "@whiskeysockets/baileys": "npm:@neoxr/baileys@7.6.15", 18 | "body-parser": "^1.20.2", 19 | "cookie-parser": "^1.4.6", 20 | "cors": "^2.8.5", 21 | "csv-parser": "^3.0.0", 22 | "dotenv": "^16.4.5", 23 | "ejs": "^3.1.10", 24 | "express": "^4.19.2", 25 | "express-session": "^1.18.0", 26 | "multer": "^1.4.5-lts.1", 27 | "nodemon": "^3.1.10", 28 | "pg": "^8.11.3", 29 | "qrcode": "^1.5.3", 30 | "qrcode-terminal": "^0.12.0", 31 | "socket.io": "^4.7.5", 32 | "vcf": "^2.1.2" 33 | }, 34 | "devDependencies": { 35 | "jest": "^29.7.0", 36 | "supertest": "^6.3.3" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /views/partials/actions.ejs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Actions

5 |

Control the WhatsApp service.

6 |
7 | 8 |
9 |
10 |
11 | 15 | 19 | 23 |
24 |
25 |
-------------------------------------------------------------------------------- /views/dashboard.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- include('partials/head') %> 5 | 6 | 7 |
8 | <%- include('partials/sidebar') %> 9 |
10 | <%- include('partials/navbar') %> 11 |
12 |
13 |
14 | <%- include('partials/status-card') %> 15 |
16 | <%- include('partials/qr-container') %> 17 | <%- include('partials/actions') %> 18 |
19 | <%- include('partials/messages') %> 20 |
21 |
22 | <%- include('partials/api-info') %> 23 |
24 |
25 |
26 |
27 |
28 | <%- include('partials/scripts') %> 29 | 30 | -------------------------------------------------------------------------------- /views/partials/api-info.ejs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

API Endpoints

5 |

Endpoints available for integration.

6 |
7 | 8 |
9 |
10 |
11 | POST 12 | /send-message 13 | - Kirim pesan 14 |
15 |
16 | GET 17 | /status 18 | - Status koneksi 19 |
20 |
21 | POST 22 | /logout 23 | - Logout WhatsApp 24 |
25 |
26 |
27 | 28 | -------------------------------------------------------------------------------- /stop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # WhatsApp Webhook API - Stop Script 4 | # Usage: ./stop.sh 5 | 6 | echo "🛑 Stopping WhatsApp Webhook API..." 7 | 8 | # Function to stop process by PID file 9 | stop_service() { 10 | local service_name=$1 11 | local pid_file=$2 12 | 13 | if [ -f "$pid_file" ]; then 14 | local pid=$(cat "$pid_file") 15 | if kill -0 "$pid" 2>/dev/null; then 16 | echo "🔴 Stopping $service_name (PID: $pid)..." 17 | kill "$pid" 18 | 19 | # Wait for process to stop 20 | local count=0 21 | while kill -0 "$pid" 2>/dev/null && [ $count -lt 10 ]; do 22 | sleep 1 23 | count=$((count + 1)) 24 | done 25 | 26 | # Force kill if still running 27 | if kill -0 "$pid" 2>/dev/null; then 28 | echo "⚠️ Force killing $service_name..." 29 | kill -9 "$pid" 2>/dev/null 30 | fi 31 | 32 | echo "✅ $service_name stopped" 33 | else 34 | echo "⚠️ $service_name was not running" 35 | fi 36 | rm -f "$pid_file" 37 | else 38 | echo "⚠️ No PID file found for $service_name" 39 | fi 40 | } 41 | 42 | # Stop services 43 | stop_service "Flask backend" ".flask.pid" 44 | stop_service "WhatsApp service" ".whatsapp.pid" 45 | 46 | # Kill any remaining processes on ports 3000 and 5000 47 | echo "🧹 Cleaning up remaining processes..." 48 | 49 | # Kill processes on port 3000 50 | if lsof -Pi :3000 -sTCP:LISTEN -t >/dev/null 2>&1; then 51 | echo "🔴 Killing remaining processes on port 3000..." 52 | kill $(lsof -ti:3000) 2>/dev/null || true 53 | fi 54 | 55 | # Kill processes on port 5000 56 | if lsof -Pi :5000 -sTCP:LISTEN -t >/dev/null 2>&1; then 57 | echo "🔴 Killing remaining processes on port 5000..." 58 | kill $(lsof -ti:5000) 2>/dev/null || true 59 | fi 60 | 61 | echo "" 62 | echo "✅ All services stopped successfully!" 63 | echo "📝 To start again, run: ./start.sh" 64 | 65 | -------------------------------------------------------------------------------- /views/partials/sidebar.ejs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/endpoints.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const { app } = require('../whatsapp-service'); 3 | 4 | jest.mock('../supabaseClient', () => { 5 | const builder = (data = null, error = null) => { 6 | const chain = {}; 7 | const r = Promise.resolve({ data, error }); 8 | ['select', 'single', 'eq', 'order', 'range', 'insert', 'update', 'delete', 'match'].forEach(fn => { 9 | chain[fn] = () => chain; 10 | }); 11 | chain.select = () => chain; 12 | chain.order = () => chain; 13 | chain.eq = () => chain; 14 | chain.range = () => chain; 15 | chain.single = () => r; 16 | chain.insert = () => r; 17 | chain.update = () => r; 18 | chain.delete = () => r; 19 | chain.match = () => r; 20 | return chain; 21 | }; 22 | 23 | const authUser = { id: 'user-id', email: 'test@example.com' }; 24 | return { 25 | auth: { 26 | signUp: jest.fn(), 27 | signInWithPassword: jest.fn(), 28 | signOut: jest.fn(), 29 | getUser: jest.fn(() => Promise.resolve({ data: { user: authUser }, error: null })), 30 | }, 31 | from: jest.fn(() => builder()), 32 | }; 33 | }); 34 | 35 | const supabase = require('../supabaseClient'); 36 | 37 | function authCookie() { 38 | const token = { access_token: 'token' }; 39 | return [`supabase-auth-token=${encodeURIComponent(JSON.stringify(token))}`]; 40 | } 41 | 42 | describe('All Endpoint Tests', () => { 43 | afterEach(() => jest.clearAllMocks()); 44 | 45 | it('GET /login', async () => { 46 | const res = await request(app).get('/login'); 47 | expect(res.statusCode).toBe(200); 48 | }); 49 | 50 | it('GET /register', async () => { 51 | const res = await request(app).get('/register'); 52 | expect(res.statusCode).toBe(200); 53 | }); 54 | 55 | it('GET / (root) redirects', async () => { 56 | const res = await request(app).get('/'); 57 | expect(res.statusCode).toBe(302); 58 | }); 59 | 60 | it('GET /status (authenticated)', async () => { 61 | const res = await request(app).get('/status').set('Cookie', authCookie()); 62 | expect(res.statusCode).toBe(200); 63 | expect(res.body).toHaveProperty('connected'); 64 | }); 65 | 66 | it('POST /send-message', async () => { 67 | const res = await request(app) 68 | .post('/send-message') 69 | .set('Cookie', authCookie()) 70 | .send({ to: '62895395590009', message: 'Hello from test' }); 71 | expect(res.statusCode).toBe(200); 72 | expect(res.body.success).toBe(true); 73 | }); 74 | 75 | it('POST /send-bulk', async () => { 76 | const res = await request(app) 77 | .post('/send-bulk') 78 | .set('Cookie', authCookie()) 79 | .send({ numbers: '62895395590009\n628157695152', message: 'Hi from test' }); 80 | expect(res.statusCode).toBe(200); 81 | expect(res.body.success).toBe(true); 82 | }); 83 | 84 | it('GET /api/contacts returns array', async () => { 85 | supabase.from.mockImplementationOnce(() => ({ 86 | select: () => ({ order: () => Promise.resolve({ data: [], error: null }) }), 87 | })); 88 | 89 | const res = await request(app).get('/api/contacts').set('Cookie', authCookie()); 90 | expect(res.statusCode).toBe(200); 91 | expect(Array.isArray(res.body)).toBe(true); 92 | }); 93 | }); -------------------------------------------------------------------------------- /tests/auth.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | 3 | jest.mock('../supabaseClient', () => { 4 | const signUpMock = jest.fn(); 5 | const signInWithPasswordMock = jest.fn(); 6 | const signOutMock = jest.fn(); 7 | const getUserMock = jest.fn(); 8 | 9 | return { 10 | auth: { 11 | signUp: signUpMock, 12 | signInWithPassword: signInWithPasswordMock, 13 | signOut: signOutMock, 14 | getUser: getUserMock, 15 | }, 16 | from: () => ({ select: () => ({}) }), 17 | }; 18 | }); 19 | 20 | const supabase = require('../supabaseClient'); 21 | const { app } = require('../whatsapp-service'); 22 | 23 | describe('Authentication Routes', () => { 24 | afterEach(() => jest.clearAllMocks()); 25 | 26 | describe('POST /register', () => { 27 | it('should validate missing fields', async () => { 28 | const res = await request(app).post('/register').send({}); 29 | expect(res.statusCode).toBe(200); 30 | expect(res.text).toContain('All fields are required'); 31 | expect(supabase.auth.signUp).not.toHaveBeenCalled(); 32 | }); 33 | 34 | it('should validate password mismatch', async () => { 35 | const res = await request(app).post('/register').send({ 36 | name: 'Test', 37 | email: 'test@example.com', 38 | password: 'pass123', 39 | confirmPassword: 'different', 40 | }); 41 | expect(res.statusCode).toBe(200); 42 | expect(res.text).toContain('Passwords do not match'); 43 | expect(supabase.auth.signUp).not.toHaveBeenCalled(); 44 | }); 45 | 46 | it('should register and redirect on success', async () => { 47 | supabase.auth.signUp.mockResolvedValue({ 48 | data: { session: { access_token: 'token', expires_in: 3600 } }, 49 | error: null, 50 | }); 51 | 52 | const res = await request(app) 53 | .post('/register') 54 | .send({ 55 | name: 'Test', 56 | email: 'success@example.com', 57 | password: 'pass123', 58 | confirmPassword: 'pass123', 59 | }); 60 | 61 | expect(supabase.auth.signUp).toHaveBeenCalled(); 62 | expect(res.statusCode).toBe(302); 63 | expect(res.headers.location).toBe('/dashboard'); 64 | expect(res.headers['set-cookie']).toBeDefined(); 65 | }); 66 | }); 67 | 68 | describe('POST /login', () => { 69 | it('should login and redirect to dashboard', async () => { 70 | supabase.auth.signInWithPassword.mockResolvedValue({ 71 | data: { session: { access_token: 'token', expires_in: 3600 } }, 72 | error: null, 73 | }); 74 | 75 | const res = await request(app) 76 | .post('/login') 77 | .send({ email: 'login@example.com', password: 'pass123' }); 78 | 79 | expect(supabase.auth.signInWithPassword).toHaveBeenCalled(); 80 | expect(res.statusCode).toBe(302); 81 | expect(res.headers.location).toBe('/dashboard'); 82 | }); 83 | 84 | it('should return error on invalid credentials', async () => { 85 | supabase.auth.signInWithPassword.mockResolvedValue({ 86 | data: null, 87 | error: { message: 'Invalid login' }, 88 | }); 89 | 90 | const res = await request(app) 91 | .post('/login') 92 | .send({ email: 'fail@example.com', password: 'wrong' }); 93 | 94 | expect(res.statusCode).toBe(200); 95 | expect(res.text).toContain('Invalid login'); 96 | }); 97 | }); 98 | }); -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # WhatsApp Webhook API - Start Script 4 | # Usage: ./start.sh 5 | 6 | echo "🚀 Starting WhatsApp Webhook API..." 7 | 8 | # Check if virtual environment exists 9 | if [ ! -d "venv" ]; then 10 | echo "❌ Virtual environment not found. Please run setup first." 11 | exit 1 12 | fi 13 | 14 | # Activate virtual environment 15 | source venv/bin/activate 16 | 17 | # Check if dependencies are installed 18 | if ! pip show flask > /dev/null 2>&1; then 19 | echo "📦 Installing Python dependencies..." 20 | pip install -r requirements.txt 21 | fi 22 | 23 | if [ ! -d "node_modules" ]; then 24 | echo "📦 Installing Node.js dependencies..." 25 | npm install 26 | fi 27 | 28 | # Function to check if port is available 29 | check_port() { 30 | if lsof -Pi :$1 -sTCP:LISTEN -t >/dev/null ; then 31 | echo "❌ Port $1 is already in use" 32 | echo " Kill process with: kill \$(lsof -ti:$1)" 33 | return 1 34 | fi 35 | return 0 36 | } 37 | 38 | # Check ports 39 | echo "🔍 Checking ports..." 40 | check_port 3000 || exit 1 41 | check_port 5000 || exit 1 42 | 43 | echo "✅ Ports are available" 44 | 45 | # Start WhatsApp service in background 46 | echo "🟢 Starting WhatsApp service on port 3000..." 47 | node whatsapp-service.js & 48 | WHATSAPP_PID=$! 49 | 50 | # Wait for WhatsApp service to start 51 | sleep 3 52 | 53 | # Check if WhatsApp service is running 54 | if ! kill -0 $WHATSAPP_PID 2>/dev/null; then 55 | echo "❌ Failed to start WhatsApp service" 56 | exit 1 57 | fi 58 | 59 | echo "✅ WhatsApp service started (PID: $WHATSAPP_PID)" 60 | 61 | # Start Flask backend 62 | echo "🟢 Starting Flask backend on port 5000..." 63 | python src/main.py & 64 | FLASK_PID=$! 65 | 66 | # Wait for Flask to start 67 | sleep 3 68 | 69 | # Check if Flask is running 70 | if ! kill -0 $FLASK_PID 2>/dev/null; then 71 | echo "❌ Failed to start Flask backend" 72 | kill $WHATSAPP_PID 2>/dev/null 73 | exit 1 74 | fi 75 | 76 | echo "✅ Flask backend started (PID: $FLASK_PID)" 77 | 78 | # Save PIDs for stop script 79 | echo $WHATSAPP_PID > .whatsapp.pid 80 | echo $FLASK_PID > .flask.pid 81 | 82 | echo "" 83 | echo "🎉 WhatsApp Webhook API is running!" 84 | echo "" 85 | echo "📱 Web Interface: http://localhost:5000" 86 | echo "🔗 API Base URL: http://localhost:5000/api/whatsapp" 87 | echo "" 88 | echo "📋 Next steps:" 89 | echo " 1. Open http://localhost:5000 in your browser" 90 | echo " 2. Click 'Refresh Status' to generate QR code" 91 | echo " 3. Scan QR code with WhatsApp on your phone" 92 | echo " 4. Start sending messages via API!" 93 | echo "" 94 | echo "🛑 To stop services, run: ./stop.sh" 95 | echo "" 96 | 97 | # Keep script running and monitor processes 98 | trap 'echo ""; echo "🛑 Stopping services..."; kill $WHATSAPP_PID $FLASK_PID 2>/dev/null; rm -f .whatsapp.pid .flask.pid; exit 0' INT 99 | 100 | echo "📊 Monitoring services... (Press Ctrl+C to stop)" 101 | while true; do 102 | if ! kill -0 $WHATSAPP_PID 2>/dev/null; then 103 | echo "❌ WhatsApp service stopped unexpectedly" 104 | kill $FLASK_PID 2>/dev/null 105 | rm -f .whatsapp.pid .flask.pid 106 | exit 1 107 | fi 108 | 109 | if ! kill -0 $FLASK_PID 2>/dev/null; then 110 | echo "❌ Flask backend stopped unexpectedly" 111 | kill $WHATSAPP_PID 2>/dev/null 112 | rm -f .whatsapp.pid .flask.pid 113 | exit 1 114 | fi 115 | 116 | sleep 5 117 | done 118 | 119 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # WhatsApp Webhook API - Setup Script 4 | # Usage: ./setup.sh 5 | 6 | echo "🔧 Setting up WhatsApp Webhook API..." 7 | 8 | # Check system requirements 9 | echo "🔍 Checking system requirements..." 10 | 11 | # Check Python 12 | if ! command -v python3 &> /dev/null; then 13 | echo "❌ Python 3 is not installed" 14 | exit 1 15 | fi 16 | 17 | PYTHON_VERSION=$(python3 --version | cut -d' ' -f2 | cut -d'.' -f1,2) 18 | echo "✅ Python $PYTHON_VERSION found" 19 | 20 | # Check Node.js 21 | if ! command -v node &> /dev/null; then 22 | echo "❌ Node.js is not installed" 23 | exit 1 24 | fi 25 | 26 | NODE_VERSION=$(node --version) 27 | echo "✅ Node.js $NODE_VERSION found" 28 | 29 | # Check npm 30 | if ! command -v npm &> /dev/null; then 31 | echo "❌ npm is not installed" 32 | exit 1 33 | fi 34 | 35 | NPM_VERSION=$(npm --version) 36 | echo "✅ npm $NPM_VERSION found" 37 | 38 | # Create virtual environment if not exists 39 | if [ ! -d "venv" ]; then 40 | echo "🐍 Creating Python virtual environment..." 41 | python3 -m venv venv 42 | echo "✅ Virtual environment created" 43 | else 44 | echo "✅ Virtual environment already exists" 45 | fi 46 | 47 | # Activate virtual environment 48 | echo "🔄 Activating virtual environment..." 49 | source venv/bin/activate 50 | 51 | # Install Python dependencies 52 | echo "📦 Installing Python dependencies..." 53 | pip install --upgrade pip 54 | pip install -r requirements.txt 55 | echo "✅ Python dependencies installed" 56 | 57 | # Install Node.js dependencies 58 | echo "📦 Installing Node.js dependencies..." 59 | npm install 60 | echo "✅ Node.js dependencies installed" 61 | 62 | # Create necessary directories 63 | echo "📁 Creating necessary directories..." 64 | mkdir -p src/database 65 | mkdir -p logs 66 | mkdir -p backups 67 | 68 | # Set permissions for scripts 69 | echo "🔐 Setting script permissions..." 70 | chmod +x start.sh 71 | chmod +x stop.sh 72 | 73 | # Create .env template 74 | if [ ! -f ".env" ]; then 75 | echo "📝 Creating .env template..." 76 | cat > .env << EOF 77 | # WhatsApp Webhook API Configuration 78 | FLASK_SECRET_KEY=your-super-secret-key-here 79 | API_KEY=your-api-key-here 80 | WHATSAPP_SERVICE_URL=http://localhost:3000 81 | DATABASE_URL=sqlite:///src/database/app.db 82 | FLASK_ENV=development 83 | NODE_ENV=development 84 | EOF 85 | echo "✅ .env template created" 86 | else 87 | echo "✅ .env file already exists" 88 | fi 89 | 90 | # Create .gitignore 91 | if [ ! -f ".gitignore" ]; then 92 | echo "📝 Creating .gitignore..." 93 | cat > .gitignore << EOF 94 | # Python 95 | __pycache__/ 96 | *.py[cod] 97 | *$py.class 98 | *.so 99 | .Python 100 | venv/ 101 | env/ 102 | ENV/ 103 | 104 | # Node.js 105 | node_modules/ 106 | npm-debug.log* 107 | yarn-debug.log* 108 | yarn-error.log* 109 | 110 | # WhatsApp session data 111 | auth_info_baileys/ 112 | 113 | # Environment variables 114 | .env 115 | 116 | # Logs 117 | logs/ 118 | *.log 119 | 120 | # Database 121 | *.db 122 | *.sqlite 123 | 124 | # Backups 125 | backups/ 126 | 127 | # Process IDs 128 | *.pid 129 | 130 | # OS 131 | .DS_Store 132 | Thumbs.db 133 | EOF 134 | echo "✅ .gitignore created" 135 | else 136 | echo "✅ .gitignore already exists" 137 | fi 138 | 139 | echo "" 140 | echo "🎉 Setup completed successfully!" 141 | echo "" 142 | echo "📋 Next steps:" 143 | echo " 1. Review and update .env file with your configuration" 144 | echo " 2. Run: ./start.sh" 145 | echo " 3. Open http://localhost:5000 in your browser" 146 | echo " 4. Scan QR code with WhatsApp" 147 | echo "" 148 | echo "📚 Documentation:" 149 | echo " - README.md: Complete documentation" 150 | echo " - QUICKSTART.md: Quick start guide" 151 | echo "" 152 | echo "🚀 Ready to start? Run: ./start.sh" 153 | 154 | -------------------------------------------------------------------------------- /views/api-keys.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- include('partials/head', { title: 'API Keys' }) %> 5 | 6 | 7 |
8 | <%- include('partials/sidebar') %> 9 |
10 | <%- include('partials/navbar', { title: 'API Keys' }) %> 11 |
12 |
13 |
14 |
15 |

My API Keys

16 |

Use these keys to access the REST endpoints without session cookie.

17 |
18 |
19 | 23 |
24 |
25 |
26 | <% if (error) { %> 27 |

<%= error %>

28 | <% } %> 29 | <% if (success) { %> 30 |

<%= success %>

31 | <% } %> 32 | 33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | <% if (keys && keys.length > 0) { %> 45 | <% keys.forEach((k, index) => { %> 46 | 47 | 48 | 55 | 56 | 65 | 66 | <% }) %> 67 | <% } else { %> 68 | 69 | 70 | 71 | <% } %> 72 | 73 |
#KeyCreated AtActions
<%= index + 1 %> 49 | <% if (k.raw) { %> 50 | <%= k.raw %> 51 | <% } else { %> 52 | Hidden 53 | <% } %> 54 | <%= new Date(k.created_at).toLocaleString() %> 57 | <% if (k.id) { %> 58 |
59 | 62 |
63 | <% } %> 64 |
No keys yet.
74 |
75 | 76 |

Usage: add a header X-API-KEY: <your_key> or query parameter ?api_key=<your_key>.

77 |
78 |
79 |
80 |
81 |
82 | 83 | -------------------------------------------------------------------------------- /views/login.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Login • WhatsApp Webhook 7 | 8 | 9 | 10 | 11 | 12 | 101 | 102 | 103 |
104 | 105 |

Welcome Back

106 |

Please log in to continue

107 | 108 | <% if (error) { %> 109 |
<%= error %>
110 | <% } %> 111 | 112 |
113 |
114 | 115 | 116 |
117 |
118 | 119 | 120 | 121 |
122 | 123 |
124 | 125 | 128 |
129 | 142 | 143 | -------------------------------------------------------------------------------- /views/partials/head.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | WhatsApp Webhook API - Dashboard 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /views/auto-reply.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- include('partials/head', { title: 'WhatsApp Bot Settings' }) %> 5 | 6 | 7 | 8 |
9 | <%- include('partials/sidebar') %> 10 |
11 | <%- include('partials/navbar', { title: 'WhatsApp Bot Settings' }) %> 12 |
13 | 14 |
15 |
16 |
17 |

Bot Status

18 |

Enable or disable the entire auto-reply bot.

19 |
20 |
21 |
22 |
23 | 24 | 28 |
29 |
30 |
31 | 32 | 33 |
34 |
35 |
36 |

Add New Auto-Reply Rule

37 |

Create a new keyword and its corresponding reply.

38 |
39 | 40 |
41 |
42 |
43 |
44 |
45 | 46 | 47 |
48 |
49 | 50 | 51 |
52 |
53 |
54 | 58 |
59 |
60 |
61 |
62 | 63 | 64 |
65 |
66 |
67 |

Auto-Reply Rules

68 |

Manage your existing keyword replies.

69 |
70 | 71 |
72 |
73 |
74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | <% if (replies.length > 0) { %> 85 | <% replies.forEach(rule => { %> 86 | 87 | 92 | 93 | 94 | 101 | 102 | <% }); %> 103 | <% } else { %> 104 | 105 | 106 | 107 | <% } %> 108 | 109 |
StatusKeywordReplyActions
88 | 89 | <%= rule.enabled ? 'On' : 'Off' %> 90 | 91 | <%= rule.keyword %><%= rule.reply %> 95 |
96 | 99 |
100 |
No rules configured yet.
110 |
111 |
112 |
113 |
114 |
115 |
116 | 117 | -------------------------------------------------------------------------------- /views/documentation.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- include('partials/head', { title: 'API Documentation' }) %> 5 | 6 | 7 |
8 | <%- include('partials/sidebar') %> 9 |
10 | <%- include('partials/navbar', { title: 'API Documentation' }) %> 11 |
12 |
13 |
14 |

Introduction

15 |
16 |
17 |

This page describes how to interact programmatically with the WhatsApp Webhook service and gives an overview of the features available in the dashboard.

18 |

All API endpoints are protected. There are two ways to authenticate:

19 |
    20 |
  1. Session Cookie – Login via the web dashboard, then copy the supabase-auth-token cookie from your browser and attach it to each request.
  2. 21 |
  3. API Key – Generate a key in the API Keys page. Send it using an X-API-KEY header or ?api_key= query parameter.
  4. 22 |
23 |
24 |
25 | 26 |
27 |
28 |

REST API Endpoints

29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 54 | 55 | 56 | 57 | 58 | 64 | 65 | 66 | 67 | 68 | 71 | 72 | 73 |
MethodEndpointDescription
GET/statusReturns WhatsApp connection status and QR code (if required). (Auth: cookie or API key)
POST/send-messageSends a single WhatsApp message.
49 | Body parameters:
50 | to (string, phone number or JID)
51 | message (string, text content)
52 | Auth: cookie or API key 53 |
POST/send-bulkSends a message to multiple recipients with randomized delay.
59 | Body parameters:
60 | numbers (string, one phone number per line)
61 | message (string)
62 | Auth: cookie or API key 63 |
GET/api/contactsReturns the saved contacts list.
69 | Auth: cookie or API key 70 |
74 |

Example with API Key:

75 |
 76 | $ curl -X POST \
 77 |   -H "Content-Type: application/json" \
 78 |   -H "X-API-KEY: YOUR_API_KEY" \
 79 |   -d '{"to":"6281234567890","message":"Hello via API key!"}' \
 80 |   http://localhost:3000/send-message
 81 | 
82 |

Example with Cookie:

83 |
 84 | $ curl -X POST \
 85 |   -H "Content-Type: application/json" \
 86 |   -H "Cookie: supabase-auth-token=YOUR_COOKIE_HERE" \
 87 |   -d '{"to":"6281234567890","message":"Hello via cookie!"}' \
 88 |   http://localhost:3000/send-message
 89 | 
90 |
91 |
92 | 93 |
94 |
95 |

Dashboard Features

96 |
97 |
98 |
    99 |
  • Dashboard – Real-time connection status, QR code generation, and recent messages.
  • 100 |
  • Blaster – Send bulk messages with progress logs and randomized delays.
  • 101 |
  • Contacts – CRUD interface to manage phonebook entries used by the blaster.
  • 102 |
  • Auto Reply – Keyword-based bot with per-rule enable/disable and global master switch.
  • 103 |
  • API Keys – Generate/revoke keys for programmatic access without session cookies.
  • 104 |
105 |
106 |
107 | 108 |
109 |
110 |

Changelog & Resources

111 |
112 |
113 |

For a deeper dive, refer to the Markdown documents shipped with the project:

114 |
    115 |
  • Quick Start Guide - WhatsApp Webhook API.md
  • 116 |
  • WhatsApp Webhook API Documentation.md
  • 117 |
118 |

The project is continuously evolving—check these files and this page for updates.

119 |
120 |
121 |
122 |
123 |
124 | 125 | -------------------------------------------------------------------------------- /views/partials/scripts.ejs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /views/register.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Register • WhatsApp Webhook 7 | 8 | 9 | 10 | 11 | 12 | 106 | 107 | 108 |
109 | 110 |

Create Account

111 |

Join WhatsApp Webhook API

112 | 113 | <% if (error) { %> 114 |
<%= error %>
115 | <% } %> 116 | 117 | <% if (success) { %> 118 |
<%= success %>
119 | <% } %> 120 | 121 |
122 |
123 | 124 | 125 |
126 |
127 | 128 | 129 |
130 |
131 | 132 | 133 | 134 |
135 |
136 | 137 | 138 | 139 |
140 | 141 |
142 | 143 | 146 |
147 | 183 | 184 | -------------------------------------------------------------------------------- /public/css/dashboard.css: -------------------------------------------------------------------------------- 1 | /* General Styles */ 2 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); 3 | 4 | :root { 5 | --background: #f8f9fa; 6 | --foreground: #212529; 7 | --card: #ffffff; 8 | --card-foreground: #212529; 9 | --popover: #ffffff; 10 | --popover-foreground: #212529; 11 | --primary: #25D366; 12 | --primary-foreground: #ffffff; 13 | --secondary: #6c757d; 14 | --secondary-foreground: #ffffff; 15 | --muted: #f1f5f9; 16 | --muted-foreground: #64748b; 17 | --accent: #e9ecef; 18 | --accent-foreground: #495057; 19 | --destructive: #dc3545; 20 | --destructive-foreground: #ffffff; 21 | --border: #dee2e6; 22 | --input: #ced4da; 23 | --ring: #25D366; 24 | --radius: 0.5rem; 25 | } 26 | 27 | * { 28 | margin: 0; 29 | padding: 0; 30 | box-sizing: border-box; 31 | } 32 | 33 | body { 34 | font-family: 'Inter', sans-serif; 35 | background-color: var(--background); 36 | color: var(--foreground); 37 | display: flex; 38 | min-height: 100vh; 39 | } 40 | 41 | /* Layout */ 42 | .dashboard-layout { 43 | display: flex; 44 | width: 100%; 45 | } 46 | 47 | .sidebar { 48 | width: 250px; 49 | background-color: var(--card); 50 | border-right: 1px solid var(--border); 51 | padding: 1.5rem 1rem; 52 | display: flex; 53 | flex-direction: column; 54 | transition: width 0.3s ease; 55 | } 56 | 57 | .main-content { 58 | flex-grow: 1; 59 | display: flex; 60 | flex-direction: column; 61 | } 62 | 63 | .navbar { 64 | height: 60px; 65 | background-color: var(--card); 66 | border-bottom: 1px solid var(--border); 67 | display: flex; 68 | align-items: center; 69 | justify-content: space-between; 70 | padding: 0 1.5rem; 71 | } 72 | 73 | .content-wrapper { 74 | flex-grow: 1; 75 | padding: 2rem; 76 | overflow-y: auto; 77 | } 78 | 79 | /* Sidebar */ 80 | .sidebar-header { 81 | display: flex; 82 | align-items: center; 83 | gap: 0.75rem; 84 | padding: 0 0.5rem 1.5rem; 85 | border-bottom: 1px solid var(--border); 86 | margin-bottom: 1rem; 87 | } 88 | 89 | .sidebar-logo { 90 | font-size: 1.5rem; 91 | color: var(--primary); 92 | } 93 | 94 | .sidebar-title { 95 | font-size: 1.25rem; 96 | font-weight: 600; 97 | } 98 | 99 | .sidebar-nav { 100 | list-style: none; 101 | } 102 | 103 | .nav-item a { 104 | display: flex; 105 | align-items: center; 106 | gap: 0.75rem; 107 | padding: 0.75rem 1rem; 108 | border-radius: var(--radius); 109 | color: var(--muted-foreground); 110 | text-decoration: none; 111 | font-weight: 500; 112 | transition: background-color 0.2s, color 0.2s; 113 | } 114 | 115 | .nav-item a:hover { 116 | background-color: var(--accent); 117 | color: var(--accent-foreground); 118 | } 119 | 120 | .nav-item a.active { 121 | background-color: var(--primary); 122 | color: var(--primary-foreground); 123 | } 124 | 125 | /* Navbar */ 126 | .navbar-title { 127 | font-size: 1.25rem; 128 | font-weight: 600; 129 | } 130 | 131 | .user-menu form { 132 | display: inline; 133 | } 134 | 135 | .btn-logout { 136 | background: none; 137 | border: none; 138 | color: var(--muted-foreground); 139 | font-size: 1rem; 140 | font-weight: 500; 141 | padding: 0.5rem 1rem; 142 | border-radius: var(--radius); 143 | cursor: pointer; 144 | transition: background-color 0.2s, color 0.2s; 145 | } 146 | 147 | .btn-logout:hover { 148 | background-color: var(--accent); 149 | color: var(--accent-foreground); 150 | } 151 | 152 | /* Content Grid */ 153 | .grid-container { 154 | display: grid; 155 | grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); 156 | gap: 1.5rem; 157 | } 158 | 159 | /* Card */ 160 | .card { 161 | background-color: var(--card); 162 | border-radius: var(--radius); 163 | border: 1px solid var(--border); 164 | padding: 1.5rem; 165 | box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -2px rgba(0,0,0,0.05); 166 | } 167 | 168 | .card-header { 169 | display: flex; 170 | justify-content: space-between; 171 | align-items: flex-start; 172 | margin-bottom: 1rem; 173 | } 174 | 175 | .card-title { 176 | font-size: 1.125rem; 177 | font-weight: 600; 178 | } 179 | 180 | .card-description { 181 | color: var(--muted-foreground); 182 | font-size: 0.875rem; 183 | } 184 | 185 | .card-icon { 186 | font-size: 1.5rem; 187 | color: var(--muted-foreground); 188 | } 189 | 190 | .card-content { 191 | font-size: 0.875rem; 192 | } 193 | 194 | /* Components */ 195 | #connectionStatus { 196 | font-weight: 600; 197 | font-size: 1rem; 198 | display: flex; 199 | align-items: center; 200 | gap: 0.5rem; 201 | } 202 | #connectionStatus::before { 203 | content: ''; 204 | display: inline-block; 205 | width: 8px; 206 | height: 8px; 207 | border-radius: 50%; 208 | } 209 | .status.connected::before { background-color: #28a745; } 210 | .status.disconnected::before { background-color: var(--destructive); } 211 | .status.qr_ready::before { background-color: #ffc107; } 212 | 213 | #qrcode { 214 | margin: 1rem auto 0; 215 | display: flex; 216 | justify-content: center; 217 | } 218 | 219 | .actions { 220 | display: flex; 221 | flex-wrap: wrap; 222 | gap: 0.75rem; 223 | margin-top: 1rem; 224 | } 225 | 226 | .btn { 227 | display: inline-flex; 228 | align-items: center; 229 | justify-content: center; 230 | padding: 0.5rem 1rem; 231 | font-size: 0.875rem; 232 | font-weight: 500; 233 | border-radius: var(--radius); 234 | border: 1px solid transparent; 235 | cursor: pointer; 236 | transition: all 0.2s; 237 | } 238 | .btn-primary { 239 | background-color: var(--primary); 240 | color: var(--primary-foreground); 241 | } 242 | .btn-primary:hover { 243 | opacity: 0.9; 244 | } 245 | .btn-secondary { 246 | background-color: var(--secondary); 247 | color: var(--secondary-foreground); 248 | } 249 | .btn-secondary:hover { 250 | opacity: 0.9; 251 | } 252 | .btn-danger { 253 | background-color: var(--destructive); 254 | color: var(--destructive-foreground); 255 | } 256 | .btn-danger:hover { 257 | opacity: 0.9; 258 | } 259 | .btn:disabled { 260 | opacity: 0.6; 261 | cursor: not-allowed; 262 | } 263 | 264 | .message-item { 265 | padding: 0.75rem; 266 | border-radius: var(--radius); 267 | margin-bottom: 0.5rem; 268 | border: 1px solid var(--border); 269 | } 270 | .message-from { font-weight: 600; } 271 | .message-text { color: var(--muted-foreground); padding: 0.25rem 0; } 272 | .message-time { font-size: 0.75rem; color: var(--muted-foreground); opacity: 0.8; } 273 | 274 | .loading { 275 | display: inline-block; 276 | width: 1em; 277 | height: 1em; 278 | border: 2px solid currentColor; 279 | border-top-color: transparent; 280 | border-radius: 50%; 281 | animation: spin 1s linear infinite; 282 | } 283 | 284 | @keyframes spin { 285 | 0% { transform: rotate(0deg); } 286 | 100% { transform: rotate(360deg); } 287 | } 288 | 289 | @media (max-width: 768px) { 290 | .sidebar { 291 | width: 100%; 292 | position: fixed; 293 | left: -100%; 294 | height: 100%; 295 | z-index: 100; 296 | } 297 | .main-content { 298 | width: 100%; 299 | } 300 | } 301 | 302 | /* --- Utilities & components for Auto Reply page --- */ 303 | /* Spacing */ 304 | .mt-4 { margin-top: 1.5rem; } 305 | .mb-4 { margin-bottom: 1.5rem; } 306 | .my-4 { margin-top: 1.5rem; margin-bottom: 1.5rem; } 307 | 308 | /* Flex helpers */ 309 | .d-flex { display: flex; } 310 | .justify-content-between { justify-content: space-between; } 311 | .align-items-center { align-items: center; } 312 | 313 | /* Form control */ 314 | .form-control { 315 | width: 100%; 316 | padding: 0.5rem 0.75rem; 317 | font-size: 0.875rem; 318 | border: 1px solid var(--border); 319 | border-radius: var(--radius); 320 | background-color: var(--card); 321 | color: var(--foreground); 322 | transition: border-color 0.2s, box-shadow 0.2s; 323 | } 324 | .form-control:focus { 325 | outline: none; 326 | border-color: var(--primary); 327 | box-shadow: 0 0 0 2px rgba(37, 211, 102, 0.25); 328 | } 329 | 330 | /* Table */ 331 | .table { 332 | width: 100%; 333 | border-collapse: collapse; 334 | font-size: 0.875rem; 335 | } 336 | .table thead { 337 | background-color: var(--muted); 338 | } 339 | .table th, 340 | .table td { 341 | padding: 0.75rem 1rem; 342 | border: 1px solid var(--border); 343 | text-align: left; 344 | vertical-align: middle; 345 | } 346 | .table th { 347 | font-weight: 600; 348 | } 349 | .table-container { 350 | overflow-x: auto; 351 | } 352 | 353 | /* Toggle Switch */ 354 | .switch { 355 | position: relative; 356 | display: inline-block; 357 | width: 50px; 358 | height: 28px; 359 | } 360 | .switch input { 361 | opacity: 0; 362 | width: 0; 363 | height: 0; 364 | } 365 | .slider { 366 | position: absolute; 367 | cursor: pointer; 368 | top: 0; 369 | left: 0; 370 | right: 0; 371 | bottom: 0; 372 | background-color: #ccc; 373 | transition: .4s; 374 | border-radius: 28px; 375 | } 376 | .slider:before { 377 | position: absolute; 378 | content: ""; 379 | height: 20px; 380 | width: 20px; 381 | left: 4px; 382 | bottom: 4px; 383 | background-color: #ffffff; 384 | transition: .4s; 385 | border-radius: 50%; 386 | } 387 | .switch input:checked + .slider { 388 | background-color: var(--primary); 389 | } 390 | .switch input:checked + .slider:before { 391 | transform: translateX(22px); 392 | } 393 | 394 | /* Grid helpers */ 395 | .grid-2 { 396 | display: grid; 397 | grid-template-columns: 1fr 2fr; 398 | gap: 1rem; 399 | } 400 | 401 | .alert { 402 | padding: 1rem; 403 | border-radius: var(--radius); 404 | margin-bottom: 1rem; 405 | font-weight: 500; 406 | border: 1px solid transparent; 407 | } 408 | .alert-success { background-color: #d4edda; color: #155724; border-color: #c3e6cb; } 409 | .alert-danger { background-color: #f8d7da; color: #721c24; border-color: #f5c6cb; } 410 | 411 | .form-group { 412 | margin-bottom: 1rem; 413 | } 414 | 415 | .form-label { 416 | font-weight: 500; 417 | display: block; 418 | margin-bottom: 0.5rem; 419 | } 420 | 421 | .pagination { 422 | margin-top: 1.5rem; 423 | display: flex; 424 | justify-content: center; 425 | align-items: center; 426 | gap: 0.5rem; 427 | } 428 | 429 | .pagination .btn { 430 | padding: 0.25rem 0.75rem; 431 | } 432 | 433 | .pagination-ellipsis { 434 | display: inline-flex; 435 | align-items: center; 436 | justify-content: center; 437 | padding: 0.25rem 0.5rem; 438 | color: var(--muted-foreground); 439 | } 440 | 441 | .grid-2-cols { 442 | display: grid; 443 | grid-template-columns: 1fr 1fr; 444 | gap: 1rem; 445 | } 446 | 447 | .text-center { text-align: center; } 448 | .text-muted { color: var(--muted-foreground); } 449 | 450 | .btn:disabled { 451 | opacity: 0.65; 452 | cursor: not-allowed; 453 | } 454 | 455 | .btn-sm { 456 | padding: 0.25rem 0.5rem; 457 | font-size: 0.75rem; 458 | } -------------------------------------------------------------------------------- /views/contacts.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- include('partials/head', { title: 'Contact Management' }) %> 5 | 6 | 7 |
8 | <%- include('partials/sidebar') %> 9 |
10 | <%- include('partials/navbar', { title: 'Contact Management' }) %> 11 |
12 |
13 | 14 |
15 |
16 |
17 |

Add New Contact

18 |

Save a new contact to your list.

19 |
20 | 21 |
22 |
23 |
24 |
25 |
26 | 27 | 28 |
29 |
30 | 31 | 32 |
33 |
34 |
35 | 39 |
40 |
41 |
42 |
43 | 44 | 45 |
46 |
47 |
48 |

Import Contacts from VCF

49 |

Upload a .vcf file to add multiple contacts at once.

50 |
51 | 52 |
53 |
54 |
55 |
56 | 57 | 58 |
59 |
60 | 64 |
65 |
66 |
67 |
68 | 69 | 70 |
71 |
72 |
73 |

Import Contacts from CSV

74 |

Upload a .csv file. Columns: Name, Phone

75 |
76 | 77 |
78 |
79 |
80 |
81 | 82 | 83 |
84 |
85 | 89 |
90 |
91 |
92 |
93 | 94 | 95 |
96 |
97 |
98 |

Contact List

99 |

All your saved contacts.

100 |
101 | 102 |
103 |
104 | <% if (locals.error) { %> 105 |
<%= error %>
106 | <% } %> 107 | <% if (locals.success) { %> 108 |
<%= success %>
109 | <% } %> 110 |
111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | <% if (contacts.length > 0) { %> 121 | <% contacts.forEach(contact => { %> 122 | 123 | 124 | 125 | 132 | 133 | <% }); %> 134 | <% } else { %> 135 | 136 | 137 | 138 | <% } %> 139 | 140 |
NamePhone NumberActions
<%= contact.name %><%= contact.phone %> 126 |
127 | 130 |
131 |
No contacts found.
141 |
142 | 143 | <% if (totalPages > 1) { %> 144 | 182 | <% } %> 183 |
184 |
185 |
186 |
187 |
188 |
189 | 190 | -------------------------------------------------------------------------------- /views/chat.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%- include('partials/head') %> 6 | 118 | 119 | 120 | 121 |
122 | <%- include('partials/sidebar', { page: 'chat' }) %> 123 |
124 | <%- include('partials/navbar', { title: 'Live Chat' }) %> 125 |
126 |
127 |
128 |
129 |
130 |
131 | 132 |
133 |

Replying to

134 |

135 |
136 |
137 | 138 |
139 |
140 |
141 | 142 | 143 | 144 |
145 |
146 |
147 |
148 |
149 |
150 | 151 | 152 | 269 | 270 | 271 | -------------------------------------------------------------------------------- /views/blaster.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- include('partials/head', { title: 'WhatsApp Blaster' }) %> 5 | 81 | 82 | 83 |
84 | <%- include('partials/sidebar') %> 85 |
86 | <%- include('partials/navbar', { title: 'WhatsApp Blaster' }) %> 87 |
88 |
89 |
90 |
91 |
92 |

Bulk Message Sender

93 |

Send a message to multiple numbers at once.

94 |
95 | 96 |
97 |
98 |
99 | 103 |
104 |
105 |
106 | 107 | 108 |
109 |
110 | 111 | 112 |
113 |
114 |
115 | 119 |
120 |
121 |
122 | 123 |
124 |
125 |
126 |

Sending Log

127 |

Real-time status of the bulk sending process.

128 |
129 | 130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 | 140 | 141 | 155 | 156 | 157 | 283 | 284 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | WhatsApp Webhook API - QR Login 7 | 8 | 9 | 262 | 263 | 264 |
265 |
266 | 267 |

WhatsApp Webhook API

268 |

Scan QR code untuk menghubungkan WhatsApp Anda

269 |
270 | 271 |
272 |
Menghubungkan...
273 |
Memuat status koneksi...
274 |
275 | 276 |
277 |
278 |
279 | Cara scan QR code:
280 | 1. Buka WhatsApp di ponsel Anda
281 | 2. Tap Menu (⋮) > Linked Devices
282 | 3. Tap "Link a Device"
283 | 4. Scan QR code di atas 284 |
285 |
286 | 287 |
288 | 291 | 294 | 297 |
298 | 299 | 303 | 304 |
305 |

API Endpoints:

306 |
POST /api/whatsapp/send - Kirim pesan
307 |
GET /api/whatsapp/status - Status koneksi
308 |
POST /api/webhook - Webhook untuk pesan masuk
309 |
POST /api/whatsapp/logout - Logout WhatsApp
310 |
311 |
312 | 313 | 528 | 529 | 530 | 531 | -------------------------------------------------------------------------------- /whatsapp-service.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { default: makeWASocket, DisconnectReason, useMultiFileAuthState } = require('@whiskeysockets/baileys'); 3 | const { Boom } = require('@hapi/boom'); 4 | const express = require('express'); 5 | const http = require('http'); 6 | const socketIo = require('socket.io'); 7 | const cors = require('cors'); 8 | const fs = require('fs'); 9 | const path = require('path'); 10 | const cookieParser = require('cookie-parser'); 11 | const supabase = require('./supabaseClient'); 12 | const crypto = require('crypto'); 13 | const multer = require('multer'); 14 | const vCard = require('vcf'); 15 | const csv = require('csv-parser'); 16 | const { Readable } = require('stream'); 17 | 18 | const app = express(); 19 | const server = http.createServer(app); 20 | const io = socketIo(server, { 21 | cors: { 22 | origin: "*", 23 | methods: ["GET", "POST"] 24 | } 25 | }); 26 | 27 | // Setup view engine 28 | app.set('view engine', 'ejs'); 29 | app.set('views', path.join(__dirname, 'views')); 30 | 31 | // Middleware 32 | app.use(express.static(path.join(__dirname, 'public'))); 33 | app.use(cors()); 34 | app.use(express.json()); 35 | app.use(express.urlencoded({ extended: true })); 36 | app.use(cookieParser()); 37 | 38 | // Multer setup for file uploads 39 | const upload = multer({ 40 | storage: multer.memoryStorage(), 41 | fileFilter: (req, file, cb) => { 42 | const fileExt = path.extname(file.originalname).toLowerCase(); 43 | if (fileExt === '.vcf' || file.mimetype === 'text/vcard' || file.mimetype === 'text/x-vcard' || fileExt === '.csv' || file.mimetype === 'text/csv' || file.mimetype === 'application/vnd.ms-excel') { 44 | cb(null, true); 45 | } else { 46 | cb(new Error('Only .vcf and .csv files are allowed!'), false); 47 | } 48 | } 49 | }); 50 | 51 | // Authentication Middleware 52 | async function isAuthenticated(req, res, next) { 53 | const token = req.cookies['supabase-auth-token']; 54 | if (!token) { 55 | return res.redirect('/login'); 56 | } 57 | 58 | const { data: { user }, error } = await supabase.auth.getUser(JSON.parse(token).access_token); 59 | 60 | if (error || !user) { 61 | // Clear the invalid cookie 62 | res.clearCookie('supabase-auth-token'); 63 | return res.redirect('/login'); 64 | } 65 | 66 | req.user = user; 67 | next(); 68 | } 69 | 70 | // --- API KEY SYSTEM --- 71 | async function verifyApiKey(key) { 72 | try { 73 | const keyHash = crypto.createHash('sha256').update(key).digest('hex'); 74 | const { data, error } = await supabase 75 | .from('api_keys') 76 | .select('user_id') 77 | .eq('key_hash', keyHash) 78 | .single(); 79 | if (error || !data) return null; 80 | return data.user_id; 81 | } catch (err) { 82 | console.error('API key verification failed:', err); 83 | return null; 84 | } 85 | } 86 | 87 | async function isAuthenticatedOrApiKey(req, res, next) { 88 | const apiKey = req.headers['x-api-key'] || req.query.api_key; 89 | if (apiKey) { 90 | const userId = await verifyApiKey(apiKey); 91 | if (userId) { 92 | req.apiUserId = userId; 93 | return next(); 94 | } 95 | } 96 | return isAuthenticated(req, res, next); 97 | } 98 | 99 | // WhatsApp Service Globals (multi-tenant) 100 | const sessions = new Map(); // userId => { sock, isConnected, state, qr, keepAliveTimer } 101 | 102 | let appSettings = {}; 103 | let autoReplies = []; 104 | 105 | // Load settings and auto-replies from Supabase 106 | async function loadSettings() { 107 | try { 108 | const { data: settingsData, error: settingsError } = await supabase.from('settings').select('*'); 109 | if (settingsError) throw settingsError; 110 | appSettings = settingsData.reduce((acc, row) => { 111 | acc[row.key] = row.value; 112 | return acc; 113 | }, {}); 114 | 115 | const { data: repliesData, error: repliesError } = await supabase.from('auto_replies').select('*'); 116 | if (repliesError) throw repliesError; 117 | autoReplies = repliesData; 118 | 119 | console.log('App settings and auto-replies loaded from Supabase.'); 120 | } catch (error) { 121 | console.error('Failed to load settings from Supabase:', error); 122 | } 123 | } 124 | 125 | // ------------------ Multi-Tenant Session Helpers ------------------ 126 | 127 | function getEffectiveUserId(req) { 128 | return req.user?.id || req.apiUserId; 129 | } 130 | 131 | /** 132 | * Ensure WhatsApp session exists for given user ID. Returns session object. 133 | */ 134 | async function ensureSession(userId) { 135 | if (sessions.has(userId)) return sessions.get(userId); 136 | 137 | const authDir = path.join(__dirname, 'auth_info_baileys', userId); 138 | const { state, saveCreds } = await useMultiFileAuthState(authDir); 139 | 140 | const session = { 141 | sock: null, 142 | isConnected: false, 143 | state: 'disconnected', 144 | qr: null, 145 | keepAliveTimer: null 146 | }; 147 | 148 | const sock = makeWASocket({ 149 | auth: state, 150 | printQRInTerminal: false, 151 | browser: ['WhatsApp API', 'Chrome', '1.0.0'], 152 | keepAliveIntervalMs: 20_000, 153 | markOnlineOnConnect: false 154 | }); 155 | 156 | session.sock = sock; 157 | 158 | // keep-alive presence 159 | session.keepAliveTimer = setInterval(async () => { 160 | if (session.isConnected) { 161 | try { await sock.sendPresenceUpdate('available'); } catch {} 162 | } 163 | }, 25_000); 164 | 165 | sock.ev.on('creds.update', saveCreds); 166 | 167 | // -------- connection handling -------- 168 | sock.ev.on('connection.update', (update) => { 169 | const { connection, qr, lastDisconnect } = update; 170 | 171 | if (qr) { 172 | session.qr = qr; 173 | session.state = 'qr_ready'; 174 | io.to(userId).emit('qr', qr); 175 | } 176 | 177 | if (connection === 'open') { 178 | session.isConnected = true; 179 | session.state = 'connected'; 180 | session.qr = null; 181 | io.to(userId).emit('connection_status', { status: 'connected' }); 182 | } 183 | 184 | if (connection === 'close') { 185 | session.isConnected = false; 186 | session.state = 'disconnected'; 187 | io.to(userId).emit('connection_status', { status: 'disconnected' }); 188 | 189 | if (session.keepAliveTimer) clearInterval(session.keepAliveTimer); 190 | sessions.delete(userId); 191 | 192 | const code = (lastDisconnect?.error)?.output?.statusCode; 193 | const loggedOut = code === DisconnectReason.loggedOut; 194 | if (loggedOut) { 195 | try { if (fs.existsSync(authDir)) fs.rmSync(authDir, { recursive:true, force:true }); } catch {} 196 | } 197 | 198 | setTimeout(() => ensureSession(userId), 1_000); 199 | } 200 | }); 201 | 202 | // -------- auto-reply -------- 203 | sock.ev.on('messages.upsert', async (m) => { 204 | const message = m.messages[0]; 205 | if (!message.key.fromMe && m.type === 'notify') { 206 | const messageText = message.message?.conversation || message.message?.extendedTextMessage?.text || ''; 207 | 208 | const contextInfo = message.message?.extendedTextMessage?.contextInfo; 209 | let quoted_text = null; 210 | let quoted_sender = null; 211 | let reply_to_id = null; 212 | 213 | if (contextInfo?.quotedMessage) { 214 | quoted_text = contextInfo.quotedMessage.conversation || contextInfo.quotedMessage.extendedTextMessage?.text || '...'; 215 | quoted_sender = contextInfo.participant; 216 | const { data: repliedToMsg } = await supabase.from('messages').select('id').eq('stanza_id', contextInfo.stanzaId).eq('user_id', userId).maybeSingle() || {}; 217 | if(repliedToMsg) reply_to_id = repliedToMsg.id; 218 | } 219 | 220 | const recordedMessage = await recordMessage({ 221 | userId, 222 | chatJid: message.key.remoteJid, 223 | sender: message.pushName || message.key.participant || message.key.remoteJid, 224 | senderJid: message.key.participant || message.key.remoteJid, 225 | text: messageText, 226 | direction: 'in', 227 | timestamp: (message.messageTimestamp || Date.now()) * 1000, 228 | stanzaId: message.key.id, 229 | rawMessage: message.message, 230 | replyToId: reply_to_id, 231 | quoted_text: quoted_text, 232 | quoted_sender: quoted_sender 233 | }); 234 | 235 | if(recordedMessage) io.to(userId).emit('new_message', recordedMessage); 236 | 237 | const autoReplyEnabled = appSettings.auto_reply_enabled !== 'false'; 238 | if (autoReplyEnabled && !message.key.remoteJid.endsWith('@g.us') && messageText) { 239 | const lower = messageText.trim().toLowerCase(); 240 | const rule = autoReplies.find(r => r.enabled && lower.includes(String(r.keyword || '').toLowerCase().trim())); 241 | if (rule) { 242 | try { 243 | await sock.sendMessage(message.key.remoteJid, { text: rule.reply }); 244 | const recordedReply = await recordMessage({ 245 | userId, 246 | chatJid: message.key.remoteJid, 247 | sender: 'auto-reply', 248 | text: rule.reply, 249 | direction: 'out', 250 | timestamp: Date.now(), 251 | stanzaId: result.key.id, 252 | rawMessage: result.message, 253 | replyToId: reply_to_id, 254 | quotedText: quoted_text, 255 | quotedSender: quoted_sender, 256 | senderJid: s.sock.user.id.replace(/:.*$/,'@s.whatsapp.net') 257 | }); 258 | if(recordedReply) io.to(userId).emit('new_message', recordedReply); 259 | } catch {} 260 | } 261 | } 262 | } 263 | }); 264 | 265 | sessions.set(userId, session); 266 | return session; 267 | } 268 | 269 | // Note: legacy single-tenant startWhatsApp removed after multi-tenant refactor 270 | 271 | // --- ROUTES --- 272 | 273 | // Auth Routes 274 | app.get('/login', (req, res) => { 275 | res.render('login', { error: null }); 276 | }); 277 | 278 | app.get('/register', (req, res) => { 279 | res.render('register', { error: null, success: null }); 280 | }); 281 | 282 | app.post('/register', async (req, res) => { 283 | const { name, email, password, confirmPassword } = req.body; 284 | 285 | // Validation 286 | if (!name || !email || !password || !confirmPassword) { 287 | return res.render('register', { 288 | error: 'All fields are required', 289 | success: null 290 | }); 291 | } 292 | 293 | if (password !== confirmPassword) { 294 | return res.render('register', { 295 | error: 'Passwords do not match', 296 | success: null 297 | }); 298 | } 299 | 300 | if (password.length < 6) { 301 | return res.render('register', { 302 | error: 'Password must be at least 6 characters long', 303 | success: null 304 | }); 305 | } 306 | 307 | try { 308 | // 1) Attempt to sign up the user 309 | const { data: signUpData, error: signUpError } = await supabase.auth.signUp({ 310 | email, 311 | password, 312 | options: { 313 | data: { full_name: name } 314 | } 315 | }); 316 | if (signUpError) { 317 | return res.render('register', { error: signUpError.message, success: null }); 318 | } 319 | 320 | // 2) If Supabase did NOT return a session (common when using service key), 321 | // attempt to sign the user in to retrieve a session token. 322 | let session = signUpData.session; 323 | if (!session) { 324 | const { data: signInData, error: signInError } = await supabase.auth.signInWithPassword({ email, password }); 325 | if (!signInError) { 326 | session = signInData.session; 327 | } 328 | } 329 | 330 | // 3) If we managed to obtain a session, log the user in immediately. 331 | if (session) { 332 | res.cookie('supabase-auth-token', JSON.stringify(session), { 333 | httpOnly: true, 334 | secure: process.env.NODE_ENV === 'production', 335 | maxAge: session.expires_in * 1000, 336 | }); 337 | return res.redirect('/dashboard'); 338 | } 339 | 340 | // 4) Fallback: ask the user to confirm their email first. 341 | return res.render('register', { 342 | error: null, 343 | success: 'Registration successful! Please check your email to confirm your account before logging in.' 344 | }); 345 | 346 | } catch (err) { 347 | console.error('Registration error:', err); 348 | return res.render('register', { 349 | error: 'An unexpected error occurred. Please try again.', 350 | success: null 351 | }); 352 | } 353 | }); 354 | 355 | app.post('/login', async (req, res) => { 356 | const { email, password } = req.body; 357 | const { data, error } = await supabase.auth.signInWithPassword({ 358 | email, 359 | password, 360 | }); 361 | 362 | if (error) { 363 | return res.render('login', { error: error.message }); 364 | } 365 | 366 | res.cookie('supabase-auth-token', JSON.stringify(data.session), { 367 | httpOnly: true, 368 | secure: process.env.NODE_ENV === 'production', 369 | maxAge: data.session.expires_in * 1000, 370 | }); 371 | 372 | res.redirect('/dashboard'); 373 | }); 374 | 375 | app.post('/logout-user', async (req, res) => { 376 | await supabase.auth.signOut(); 377 | res.clearCookie('supabase-auth-token'); 378 | res.redirect('/login'); 379 | }); 380 | 381 | 382 | // Main App Routes 383 | app.get('/', (req, res) => { 384 | res.redirect('/dashboard'); 385 | }); 386 | 387 | app.get('/dashboard', isAuthenticated, async (req, res) => { 388 | await ensureSession(req.user.id); 389 | res.render('dashboard', { page: 'dashboard', userId: req.user.id }); 390 | }); 391 | 392 | app.get('/chat', isAuthenticated, (req, res) => { 393 | res.render('chat', { page: 'chat', userId: req.user.id }); 394 | }); 395 | 396 | app.get('/blaster', isAuthenticated, (req, res) => { 397 | res.render('blaster', { page: 'blaster', userId: req.user.id }); 398 | }); 399 | 400 | // Documentation Route 401 | app.get('/documentation', isAuthenticated, (req, res) => { 402 | res.render('documentation', { page: 'documentation' }); 403 | }); 404 | 405 | // WhatsApp Service Routes 406 | app.get('/status', isAuthenticatedOrApiKey, async (req, res) => { 407 | try { 408 | const uid = getEffectiveUserId(req); 409 | const s = await ensureSession(uid); 410 | return res.json({ status: s.state, connected: s.isConnected, qr: s.qr }); 411 | } catch (error) { 412 | console.error('Status route error:', error); 413 | return res.status(500).json({ error: 'internal_error', message: error.message }); 414 | } 415 | }); 416 | 417 | app.post('/send-message', isAuthenticatedOrApiKey, async (req, res) => { 418 | const { to, message, reply_to_id } = req.body; 419 | if (!to || !message) return res.status(400).json({ error: 'Missing "to" or "message"' }); 420 | 421 | const uid = getEffectiveUserId(req); 422 | const s = await ensureSession(uid); 423 | if (!s.isConnected) return res.status(400).json({ error: 'WhatsApp not connected' }); 424 | 425 | const phone = to.includes('@') ? to : `${to}@s.whatsapp.net`; 426 | try { 427 | let quotedInfo = undefined; 428 | let quotedDbRecord = null; 429 | if (reply_to_id) { 430 | const { data } = await supabase.from('messages').select('*').eq('id', reply_to_id).eq('user_id', uid).single(); 431 | if (data) { 432 | quotedDbRecord = data; 433 | quotedInfo = { 434 | key: { 435 | remoteJid: data.chat_jid, 436 | id: data.stanza_id, 437 | fromMe: data.direction === 'out', 438 | participant: data.sender_jid, 439 | }, 440 | message: data.raw_message 441 | }; 442 | } 443 | } 444 | 445 | const result = await s.sock.sendMessage(phone, { text: message }, { quoted: quotedInfo }); 446 | 447 | const recordedOutgoing = await recordMessage({ 448 | userId: uid, 449 | chatJid: phone, 450 | sender: 'me', 451 | text: message, 452 | direction: 'out', 453 | timestamp: Date.now(), 454 | stanzaId: result.key.id, 455 | rawMessage: result.message, 456 | replyToId: reply_to_id, 457 | quotedText: quotedDbRecord?.message, 458 | quotedSender: quotedDbRecord?.sender, 459 | senderJid: s.sock.user.id.replace(/:.*$/,'@s.whatsapp.net') 460 | }); 461 | 462 | if(recordedOutgoing) io.to(uid).emit('new_message', recordedOutgoing); 463 | 464 | res.json({ success: true, messageId: result.key.id, to: phone, message }); 465 | } catch (err) { 466 | console.error('Error sending message:', err); 467 | res.status(500).json({ error: 'Failed', details: err.message }); 468 | } 469 | }); 470 | 471 | app.post('/logout', isAuthenticated, async (req, res) => { 472 | const uid = req.user.id; 473 | const s = sessions.get(uid); 474 | if (!s) return res.json({ success: true }); 475 | 476 | try { 477 | await s.sock.logout(); 478 | } catch {} 479 | 480 | if (s.keepAliveTimer) clearInterval(s.keepAliveTimer); 481 | sessions.delete(uid); 482 | 483 | const dir = path.join(__dirname, 'auth_info_baileys', uid); 484 | try { if (fs.existsSync(dir)) fs.rmSync(dir, { recursive:true, force:true }); } catch {} 485 | 486 | res.json({ success: true, message: 'Logged out' }); 487 | }); 488 | 489 | // Blaster Route 490 | app.post('/send-bulk', isAuthenticatedOrApiKey, async (req, res) => { 491 | const { numbers, message } = req.body; 492 | if (!numbers || !message) return res.status(400).json({ success:false, error:'Numbers and message are required.' }); 493 | 494 | const uid = getEffectiveUserId(req); 495 | const s = await ensureSession(uid); 496 | if (!s.isConnected) return res.status(400).json({ success:false, error:'WhatsApp is not connected.' }); 497 | 498 | const numberList = numbers.split('\n').map(n=>n.trim()).filter(Boolean); 499 | res.json({ success:true, message:`Bulk sending started for ${numberList.length} numbers.` }); 500 | 501 | (async ()=>{ 502 | for(const n of numberList){ 503 | const jid = n.includes('@')? n : `${n}@s.whatsapp.net`; 504 | try{ 505 | await s.sock.sendMessage(jid,{ text: message }); 506 | io.to(uid).emit('bulk-log',{ status:'success', message:`Sent to ${n}`}); 507 | recordMessage({ userId: uid, chatJid: jid, sender: 'me', text: message, direction: 'out', timestamp: Date.now() }); 508 | }catch(err){ 509 | io.to(uid).emit('bulk-log',{ status:'error', message:`Failed to send to ${n}: ${err.message}`}); 510 | } 511 | await new Promise(r=>setTimeout(r, Math.floor(Math.random()*5000)+2000)); 512 | } 513 | io.to(uid).emit('bulk-log',{ status:'done', message:'Bulk sending finished.'}); 514 | })(); 515 | }); 516 | 517 | // Contacts Management Routes 518 | app.get('/contacts', isAuthenticated, async (req, res) => { 519 | try { 520 | const page = parseInt(req.query.page) || 1; 521 | const pageSize = 15; 522 | const offset = (page - 1) * pageSize; 523 | const userId = req.user.id; 524 | 525 | // Get total number of contacts for pagination 526 | const { count, error: countError } = await supabase 527 | .from('contacts') 528 | .select('*', { count: 'exact', head: true }) 529 | .eq('user_id', userId); 530 | 531 | if (countError) throw countError; 532 | 533 | // Get contacts for the current page 534 | const { data, error } = await supabase 535 | .from('contacts') 536 | .select('*') 537 | .eq('user_id', userId) 538 | .order('name', { ascending: true }) 539 | .range(offset, offset + pageSize - 1); 540 | 541 | if (error) throw error; 542 | 543 | res.render('contacts', { 544 | contacts: data || [], 545 | error: req.query.error, 546 | success: req.query.success, 547 | page: 'contacts', 548 | totalPages: Math.ceil(count / pageSize), 549 | currentPage: page 550 | }); 551 | } catch (error) { 552 | res.render('contacts', { 553 | contacts: [], 554 | error: 'Failed to load contacts.', 555 | success: null, 556 | page: 'contacts', 557 | totalPages: 0, 558 | currentPage: 1 559 | }); 560 | } 561 | }); 562 | 563 | app.post('/contacts/add', isAuthenticated, async (req, res) => { 564 | const { name, phone } = req.body; 565 | const { error } = await supabase.from('contacts').insert({ name, phone }); 566 | 567 | if (error) { 568 | return res.redirect('/contacts?error=' + encodeURIComponent(error.message)); 569 | } 570 | res.redirect('/contacts?success=Contact added successfully.'); 571 | }); 572 | 573 | app.post('/contacts/import', isAuthenticated, upload.single('vcfFile'), async (req, res) => { 574 | if (!req.file) { 575 | return res.redirect('/contacts?error=' + encodeURIComponent('No file uploaded.')); 576 | } 577 | 578 | try { 579 | const vcfContent = req.file.buffer.toString('utf8'); 580 | const cards = vCard.parse(vcfContent); 581 | 582 | if (!cards || cards.length === 0) { 583 | return res.redirect('/contacts?error=' + encodeURIComponent('VCF file is empty or invalid.')); 584 | } 585 | 586 | const parsedContacts = cards.map(card => { 587 | const name = card.data.fn; 588 | const phoneProp = card.data.tel?.[0]; 589 | const phone = phoneProp ? phoneProp.valueOf().replace(/\D/g, '') : null; 590 | if (!name || !phone) return null; 591 | return { user_id: req.user.id, name: name.valueOf(), phone: phone }; 592 | }).filter(Boolean); 593 | 594 | const { data: existingContacts, error: fetchError } = await supabase 595 | .from('contacts') 596 | .select('phone') 597 | .eq('user_id', req.user.id); 598 | 599 | if (fetchError) throw fetchError; 600 | 601 | const existingPhones = new Set(existingContacts.map(c => c.phone)); 602 | const uniqueNewContacts = []; 603 | const phonesInThisBatch = new Set(); 604 | 605 | for (const contact of parsedContacts) { 606 | if (!existingPhones.has(contact.phone) && !phonesInThisBatch.has(contact.phone)) { 607 | uniqueNewContacts.push(contact); 608 | phonesInThisBatch.add(contact.phone); 609 | } 610 | } 611 | 612 | const duplicateCount = parsedContacts.length - uniqueNewContacts.length; 613 | 614 | if (uniqueNewContacts.length > 0) { 615 | const { error } = await supabase.from('contacts').insert(uniqueNewContacts); 616 | if (error) throw error; 617 | } 618 | 619 | let successMessage = `${uniqueNewContacts.length} contacts imported successfully.`; 620 | if (duplicateCount > 0) { 621 | successMessage += ` ${duplicateCount} duplicates were ignored.`; 622 | } 623 | 624 | res.redirect('/contacts?success=' + encodeURIComponent(successMessage)); 625 | 626 | } catch (error) { 627 | console.error('Error importing VCF file:', error); 628 | res.redirect('/contacts?error=' + encodeURIComponent('Failed to import contacts from VCF file. ' + error.message)); 629 | } 630 | }); 631 | 632 | app.post('/contacts/import/csv', isAuthenticated, upload.single('csvFile'), async (req, res) => { 633 | if (!req.file) { 634 | return res.redirect('/contacts?error=' + encodeURIComponent('No file uploaded.')); 635 | } 636 | 637 | const parsedContacts = []; 638 | const buffer = req.file.buffer; 639 | const stream = Readable.from(buffer.toString()); 640 | 641 | stream.pipe(csv({ mapHeaders: ({ header }) => header.trim() })) 642 | .on('data', (row) => { 643 | const firstName = row['First Name'] || ''; 644 | const middleName = row['Middle Name'] || ''; 645 | const lastName = row['Last Name'] || ''; 646 | let name = `${firstName} ${middleName} ${lastName}`.replace(/\s+/g, ' ').trim(); 647 | if (!name) { 648 | name = row['File As'] || row['Nickname']; 649 | } 650 | const phone = row['Phone 1 - Value']; 651 | if (name && phone) { 652 | parsedContacts.push({ 653 | user_id: req.user.id, 654 | name: name, 655 | phone: phone.replace(/\D/g, '') 656 | }); 657 | } 658 | }) 659 | .on('end', async () => { 660 | if (parsedContacts.length === 0) { 661 | return res.redirect('/contacts?error=' + encodeURIComponent('No valid contacts found in the CSV file.')); 662 | } 663 | 664 | try { 665 | const { data: existingContacts, error: fetchError } = await supabase 666 | .from('contacts') 667 | .select('phone') 668 | .eq('user_id', req.user.id); 669 | 670 | if (fetchError) throw fetchError; 671 | 672 | const existingPhones = new Set(existingContacts.map(c => c.phone)); 673 | const uniqueNewContacts = []; 674 | const phonesInThisBatch = new Set(); 675 | 676 | for (const contact of parsedContacts) { 677 | if (!existingPhones.has(contact.phone) && !phonesInThisBatch.has(contact.phone)) { 678 | uniqueNewContacts.push(contact); 679 | phonesInThisBatch.add(contact.phone); 680 | } 681 | } 682 | 683 | const duplicateCount = parsedContacts.length - uniqueNewContacts.length; 684 | 685 | if (uniqueNewContacts.length > 0) { 686 | const { error } = await supabase.from('contacts').insert(uniqueNewContacts); 687 | if (error) throw error; 688 | } 689 | 690 | let successMessage = `${uniqueNewContacts.length} contacts imported successfully.`; 691 | if (duplicateCount > 0) { 692 | successMessage += ` ${duplicateCount} duplicates were ignored.`; 693 | } 694 | res.redirect('/contacts?success=' + encodeURIComponent(successMessage)); 695 | } catch (error) { 696 | console.error('Error importing CSV file:', error); 697 | res.redirect('/contacts?error=' + encodeURIComponent('Failed to import contacts from CSV file. ' + error.message)); 698 | } 699 | }) 700 | .on('error', (error) => { 701 | console.error('Error processing CSV file:', error); 702 | res.redirect('/contacts?error=' + encodeURIComponent('Failed to process CSV file. ' + error.message)); 703 | }); 704 | }); 705 | 706 | app.post('/contacts/delete/:id', isAuthenticated, async (req, res) => { 707 | const { id } = req.params; 708 | const userId = req.user.id; 709 | await supabase.from('contacts').delete().match({ id: id }); 710 | res.redirect('/contacts'); 711 | }); 712 | 713 | app.get('/api/contacts', isAuthenticatedOrApiKey, async (req, res) => { 714 | const uid = getEffectiveUserId(req); 715 | const { data, error } = await supabase 716 | .from('contacts') 717 | .select('*') 718 | .eq('user_id', uid) 719 | .order('name', { ascending: true }); 720 | if (error) return res.status(500).json({ error: error.message }); 721 | res.json(data); 722 | }); 723 | 724 | // Auto Reply Routes 725 | app.get('/auto-reply', isAuthenticated, async (req, res) => { 726 | const { data, error } = await supabase.from('auto_replies').select('*').order('keyword', { ascending: true }); 727 | res.render('auto-reply', { settings: appSettings, replies: data || [], error: error?.message, success: null, page: 'auto-reply' }); 728 | }); 729 | 730 | app.post('/auto-reply/settings', isAuthenticated, async (req, res) => { 731 | const { auto_reply_enabled } = req.body; 732 | const enabled = auto_reply_enabled ? 'true' : 'false'; 733 | await supabase.from('settings').update({ value: enabled }).match({ key: 'auto_reply_enabled' }); 734 | await loadSettings(); 735 | res.redirect('/auto-reply'); 736 | }); 737 | 738 | app.post('/auto-reply/add', isAuthenticated, async (req, res) => { 739 | const { keyword, reply } = req.body; 740 | await supabase.from('auto_replies').insert({ keyword, reply }); 741 | await loadSettings(); 742 | res.redirect('/auto-reply'); 743 | }); 744 | 745 | app.post('/auto-reply/delete/:id', isAuthenticated, async (req, res) => { 746 | await supabase.from('auto_replies').delete().match({ id: req.params.id }); 747 | await loadSettings(); 748 | res.redirect('/auto-reply'); 749 | }); 750 | 751 | app.get('/auto-reply/toggle/:id', isAuthenticated, async (req, res) => { 752 | const { data: rule } = await supabase.from('auto_replies').select('enabled').match({ id: req.params.id }).single(); 753 | if (rule) { 754 | await supabase.from('auto_replies').update({ enabled: !rule.enabled }).match({ id: req.params.id }); 755 | await loadSettings(); 756 | } 757 | res.redirect('/auto-reply'); 758 | }); 759 | 760 | // UI & REST routes to manage API keys (requires login) 761 | app.get('/api-keys', isAuthenticated, async (req, res) => { 762 | const { data, error } = await supabase 763 | .from('api_keys') 764 | .select('id, created_at') 765 | .eq('user_id', req.user.id) 766 | .order('created_at', { ascending: false }); 767 | res.render('api-keys', { keys: data || [], error: error?.message, success: null, page: 'api-keys' }); 768 | }); 769 | 770 | app.post('/api-keys/generate', isAuthenticated, async (req, res) => { 771 | try { 772 | const rawKey = crypto.randomBytes(32).toString('hex'); 773 | const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex'); 774 | await supabase.from('api_keys').insert({ user_id: req.user.id, key_hash: keyHash }); 775 | res.render('api-keys', { keys: [{ raw: rawKey, created_at: new Date() }], success: 'API key generated. Copy it now, it will not be shown again!', page: 'api-keys', error: null }); 776 | } catch (error) { 777 | console.error('Failed to generate API key:', error); 778 | res.render('api-keys', { keys: [], error: 'Failed to generate API key', page: 'api-keys' }); 779 | } 780 | }); 781 | 782 | app.post('/api-keys/delete/:id', isAuthenticated, async (req, res) => { 783 | await supabase.from('api_keys').delete().match({ id: req.params.id, user_id: req.user.id }); 784 | res.redirect('/api-keys'); 785 | }); 786 | 787 | // -------- Chat API -------- 788 | app.get('/api/chats', isAuthenticated, async (req, res) => { 789 | try { 790 | const { data, error } = await supabase 791 | .from('messages') 792 | .select('id, chat_jid, message, timestamp, sender') 793 | .eq('user_id', req.user.id) 794 | .order('timestamp', { ascending: false }); 795 | if (error) throw error; 796 | // aggregate last message per chat_jid 797 | const map = new Map(); 798 | for (const row of data) { 799 | if (!map.has(row.chat_jid)) map.set(row.chat_jid, row); 800 | } 801 | res.json(Array.from(map.values())); 802 | } catch (err) { 803 | console.error('Fetch chats error:', err); 804 | res.status(500).json({ error: err.message }); 805 | } 806 | }); 807 | 808 | app.get('/api/chats/:jid', isAuthenticated, async (req, res) => { 809 | try { 810 | const { data, error } = await supabase 811 | .from('messages') 812 | .select('*') 813 | .eq('user_id', req.user.id) 814 | .eq('chat_jid', req.params.jid) 815 | .order('timestamp', { ascending: true }); 816 | if (error) throw error; 817 | res.json(data); 818 | } catch (err) { 819 | console.error('Fetch chat messages error:', err); 820 | res.status(500).json({ error: err.message }); 821 | } 822 | }); 823 | 824 | // Socket.IO connection handling 825 | io.on('connection', (socket) => { 826 | const uid = socket.handshake.query.userId; 827 | if (uid) socket.join(uid); 828 | console.log('Socket connected for user:', uid); 829 | }); 830 | 831 | // Server Initialization 832 | const PORT = process.env.PORT || 3000; 833 | if (!process.env.JEST_WORKER_ID) { 834 | server.listen(PORT, '0.0.0.0', () => { 835 | console.log(`WhatsApp service running on port ${PORT}`); 836 | }); 837 | } 838 | 839 | // Initial Load and Start 840 | loadSettings(); 841 | 842 | // Preload WhatsApp sessions for all users that have auth files so they stay online even without an active WebSocket client 843 | (async function preloadWhatsAppSessions(){ 844 | try { 845 | const authRoot = path.join(__dirname, 'auth_info_baileys'); 846 | if (!fs.existsSync(authRoot)) return; 847 | const userDirs = fs.readdirSync(authRoot, { withFileTypes: true }) 848 | .filter(dirent => dirent.isDirectory()) 849 | .map(dirent => dirent.name); 850 | for (const uid of userDirs) { 851 | try { 852 | await ensureSession(uid); 853 | console.log('Preloaded WhatsApp session for user', uid); 854 | } catch (err) { 855 | console.error('Failed to preload session for', uid, err); 856 | } 857 | } 858 | } catch (err) { 859 | console.error('Error during WhatsApp session preload:', err); 860 | } 861 | })(); 862 | 863 | // ---------- Message Persistence Helper ---------- 864 | async function recordMessage(params) { 865 | const { 866 | userId, chatJid, sender, text, direction, timestamp, 867 | stanzaId, rawMessage, replyToId, quotedText, quotedSender, senderJid 868 | } = params; 869 | try { 870 | const { data, error } = await supabase.from('messages').insert({ 871 | user_id: userId, 872 | chat_jid: chatJid, 873 | sender: sender, 874 | message: text, 875 | direction: direction, 876 | timestamp: new Date(timestamp).toISOString(), 877 | stanza_id: stanzaId, 878 | raw_message: rawMessage, 879 | reply_to_id: replyToId, 880 | quoted_text: quotedText, 881 | quoted_sender: quotedSender, 882 | sender_jid: senderJid 883 | }).select().single(); 884 | if(error) throw error; 885 | return data; 886 | } catch (err) { 887 | console.error('Failed to record message:', err); 888 | return null; 889 | } 890 | } 891 | 892 | // For test environment, stub ensureSession to always return a connected dummy session 893 | if (process.env.JEST_WORKER_ID) { 894 | ensureSession = async () => ({ 895 | isConnected: true, 896 | state: 'connected', 897 | qr: null, 898 | sock: { 899 | sendMessage: async () => ({ key: { id: 'test-id' }, message: {} }), 900 | user: { id: 'bot@s.whatsapp.net' } 901 | } 902 | }); 903 | } 904 | 905 | module.exports = { app, server, ensureSession }; 906 | 907 | --------------------------------------------------------------------------------