├── .env.example ├── .gitignore ├── DEPLOYMENT.md ├── Procfile ├── README.md ├── app.js ├── deploy-heroku.sh ├── examples ├── test-batch-message.js └── webhook-receiver.js ├── package.json └── src ├── constants └── httpStatusCode.js ├── controllers ├── messageController.js ├── queueController.js ├── sessionController.js └── utilityController.js ├── middlewares ├── apikeyValidator.js └── jsonResponse.js ├── routes ├── index.js ├── messageRoute.js ├── queueRoute.js ├── sessionsRoute.js ├── swagger │ ├── index.js │ ├── swagger-config.js │ └── swagger-docs │ │ ├── messages.js │ │ ├── sessions.js │ │ ├── utility.js │ │ └── webhooks.js ├── utilityRoute.js └── webhookRoutes.js ├── services ├── queueService.js ├── webhookService.js └── whatsappService.js └── utils ├── general.js ├── logger.js ├── queue.js ├── redis.js └── response.js /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=local # production, local 2 | 3 | PORT=3000 4 | 5 | ENABLE_API_KEY=true 6 | API_KEY=kentut_nata 7 | BASE_URL=https://yourdomain.com # change to your production url 8 | 9 | # Logging levels: trace, debug, info, warn, error, fatal 10 | LOG_LEVEL=info 11 | 12 | # Redis Configuration 13 | REDIS_HOST=localhost 14 | REDIS_PORT=6379 15 | REDIS_PASSWORD= 16 | REDIS_DB=0 17 | 18 | # Upstash Configuration 19 | UPSTASH_REDIS_URL= 20 | UPSTASH_REDIS_HOST= 21 | UPSTASH_REDIS_PASSWORD= 22 | UPSTASH_REDIS_PORT=6379 23 | 24 | # Queue settings 25 | QUEUE_BATCH_SIZE=3 # jumlah pesan yang akan dikirim secara bersamaan 26 | QUEUE_BATCH_DELAY=2000 # delay antar batch 27 | QUEUE_MAX_RETRIES=2 # jumlah maksimal pengiriman pesan 28 | QUEUE_RETRY_DELAY=2000 # delay antar pengiriman pesan 29 | QUEUE_TIMEOUT=30000 # timeout pengiriman pesan 30 | 31 | # Group Metadata Cache Settings 32 | MAX_CACHED_GROUPS=100 33 | 34 | # WhatsApp API Configuration 35 | MAX_RECONNECTION_ATTEMPTS=3 36 | RECONNECTION_DELAY=2000 37 | 38 | # Webhook Configuration 39 | MAX_WEBHOOK_RETRIES=3 40 | WEBHOOK_DISABLED_ATTEMPTS=5 41 | WEBHOOK_BATCH_SIZE=10 42 | WEBHOOK_BATCH_TIMEOUT=5000 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | sessions 3 | yarn.lock 4 | .env 5 | package-lock.json 6 | .cursorrules 7 | data 8 | app.zip -------------------------------------------------------------------------------- /DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # 🚀 Panduan Deployment 2 | 3 | Dokumen ini berisi panduan lengkap untuk mendeploy WhatsApp API ke berbagai platform cloud. 4 | 5 | ## Heroku Deployment 6 | 7 | ### Prasyarat 8 | - Heroku CLI terinstall 9 | - Git terinstall 10 | - Akun Heroku aktif 11 | - Kartu kredit terdaftar di Heroku (diperlukan untuk add-on Redis, meski menggunakan free tier) 12 | 13 | ### Metode Quick Deploy 14 | 1. Install Heroku CLI: 15 | ```bash 16 | # MacOS 17 | brew tap heroku/brew && brew install heroku 18 | 19 | # Windows 20 | winget install --id=Heroku.HerokuCLI -e 21 | 22 | # Ubuntu 23 | curl https://cli-assets.heroku.com/install.sh | sh 24 | ``` 25 | 26 | 2. Clone repository dan masuk ke direktori: 27 | ```bash 28 | git clone 29 | cd 30 | ``` 31 | 32 | 3. Jalankan script deployment: 33 | ```bash 34 | ./deploy-heroku.sh 35 | ``` 36 | 37 | ### Manual Deploy 38 | 39 | 1. Login ke Heroku: 40 | ```bash 41 | heroku login 42 | ``` 43 | 44 | 2. Buat aplikasi baru dengan region terdekat: 45 | ```bash 46 | # List available regions 47 | heroku regions 48 | 49 | # Create app with specific region (recommended: eu) 50 | heroku create your-app-name --region eu 51 | ``` 52 | 53 | 3. Tambahkan Redis add-on: 54 | ```bash 55 | # Menambahkan Redis (free tier) 56 | heroku addons:create heroku-redis:hobby-dev 57 | 58 | # Atau untuk production (berbayar) 59 | heroku addons:create heroku-redis:premium-0 60 | ``` 61 | 62 | 4. Set environment variables: 63 | ```bash 64 | heroku config:set NODE_ENV=production 65 | heroku config:set ENABLE_API_KEY=true 66 | heroku config:set API_KEY=your_api_key 67 | # Redis URL akan otomatis ditambahkan sebagai REDIS_URL 68 | ``` 69 | 70 | 5. Deploy aplikasi: 71 | ```bash 72 | git push heroku main 73 | ``` 74 | 75 | 6. Pastikan minimal 1 dyno berjalan: 76 | ```bash 77 | heroku ps:scale web=1 78 | ``` 79 | 80 | ### Redis Configuration 81 | 82 | #### Free Tier (hobby-dev) 83 | - Memory: 25MB 84 | - Connections: 20 85 | - Tidak ada persistence 86 | - Cocok untuk development 87 | 88 | #### Premium Tier 89 | - Premium-0: 50MB RAM 90 | - Premium-2: 100MB RAM 91 | - Premium-3: 250MB RAM 92 | - Premium-4: 500MB RAM 93 | - Mendukung persistence 94 | - Backup otomatis 95 | - Monitoring dashboard 96 | 97 | ### Region Availability 98 | 99 | #### Common Runtime (Basic Plan $7/month) 100 | - Europe (eu) - Recommended untuk Asia 101 | - United States (us) 102 | 103 | #### Private Spaces (Enterprise Plan) 104 | - Dublin, Ireland (dublin) 105 | - Frankfurt, Germany (frankfurt) 106 | - London, UK (london) 107 | - Montreal, Canada (montreal) 108 | - Mumbai, India (mumbai) 109 | - Oregon, US (oregon) 110 | - Singapore (singapore) 111 | - Sydney, Australia (sydney) 112 | - Tokyo, Japan (tokyo) 113 | - Virginia, US (virginia) 114 | 115 | ### Estimasi Latency dari Indonesia 116 | - Europe (eu): ~250-350ms 117 | - United States (us): ~300-400ms 118 | 119 | ### Troubleshooting 120 | 121 | 1. Error R10 (Boot timeout): 122 | ```bash 123 | # Check logs 124 | heroku logs --tail 125 | 126 | # Restart dyno 127 | heroku restart 128 | ``` 129 | 130 | 2. Application Error (H10): 131 | ```bash 132 | # Check build logs 133 | heroku builds:output 134 | 135 | # Check release phase 136 | heroku releases 137 | ``` 138 | 139 | 3. Memory issues: 140 | ```bash 141 | # Check memory usage 142 | heroku metrics:web 143 | 144 | # Scale dyno if needed 145 | heroku ps:scale web=1:standard-1x 146 | ``` 147 | 148 | 4. Redis Connection Issues: 149 | ```bash 150 | # Check Redis status 151 | heroku redis:info 152 | 153 | # Reset Redis connection 154 | heroku redis:restart 155 | 156 | # Monitor Redis metrics 157 | heroku redis:metrics 158 | ``` 159 | 160 | ## Platform Alternatif 161 | 162 | ### 1. Railway 163 | - Region: Singapore available 164 | - Latency: ~50-100ms 165 | - Free tier: Available 166 | - Redis: Tersedia sebagai add-on 167 | - Quick Deploy: 168 | ```bash 169 | railway init 170 | railway up 171 | # Tambahkan Redis dari dashboard 172 | ``` 173 | 174 | ### 2. Render 175 | - Region: Singapore available 176 | - Latency: ~50-100ms 177 | - Free tier: Available (tidak termasuk Redis) 178 | - Redis: Berbayar, mulai dari $10/bulan 179 | - Auto deploy from GitHub 180 | - [Panduan Lengkap Render](https://render.com/docs) 181 | 182 | ### 3. Fly.io 183 | - Region: Singapore available 184 | - Latency: ~50-100ms 185 | - Free tier: 3 shared-cpu-1x VMs 186 | - Redis: Via Upstash atau self-hosted 187 | - Deploy: 188 | ```bash 189 | flyctl launch 190 | flyctl deploy 191 | ``` 192 | - [Panduan Lengkap Fly.io](https://fly.io/docs/getting-started/) 193 | 194 | ### 4. DigitalOcean App Platform 195 | - Region: Singapore (sgp1) 196 | - Latency: ~30-70ms 197 | - Starting: $5/month 198 | - Redis: Managed Database mulai $15/bulan 199 | - Managed SSL & custom domains 200 | - [Panduan DO App Platform](https://www.digitalocean.com/docs/app-platform/) -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | release: npm run db:prepare 2 | web: npm start -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WhatsApp API 2 | 3 | WhatsApp API menggunakan library Baileys dengan fitur multi-session dan webhook. 4 | 5 | ## Updates & Improvements 6 | 7 | ### Latest Updates (28 Mar 2025) 8 | - Peningkatan sistem antrian dengan dukungan delay dinamis 9 | - Implementasi webhook untuk pesan masuk dan status koneksi 10 | - Penambahan contoh implementasi batch messaging 11 | 12 | ## 🚀 Deployment 13 | 14 | Untuk panduan lengkap deployment ke berbagai platform (Heroku, Railway, Render, dll), silakan lihat [DEPLOYMENT.md](DEPLOYMENT.md). 15 | 16 | ## 🚀 Fitur Utama 17 | 18 | - Multi-session WhatsApp 19 | - Pengiriman pesan teks 20 | - Pengiriman pesan media (gambar, video, dokumen) 21 | - Pengiriman pesan massal (bulk messaging) dengan: 22 | - Sistem antrian 23 | - Retry otomatis untuk pesan gagal 24 | - Monitoring status pengiriman 25 | - Manajemen sesi (create, check status, logout) 26 | - Penanganan memory leak yang optimal 27 | - Graceful shutdown 28 | - Error handling yang robust 29 | - Sistem reconnection yang handal 30 | - Logging yang terstruktur 31 | - Webhook untuk pesan masuk dan status koneksi 32 | - Utilitas WhatsApp: 33 | - Cek status nomor 34 | - Daftar grup & peserta 35 | - Mention feature untuk grup & private chat 36 | 37 | ## 📋 Prasyarat 38 | 39 | - Node.js (versi 18 atau lebih baru) 40 | - NPM atau Yarn 41 | - API Key (untuk autentikasi) 42 | 43 | ## 🛠️ Instalasi & Penggunaan 44 | 45 | 1. Install dependensi: 46 | ```bash 47 | npm install 48 | ``` 49 | 50 | 2. Salin file .env.example ke .env dan sesuaikan: 51 | ```env 52 | NODE_ENV=local 53 | PORT=3000 54 | ENABLE_API_KEY=true 55 | API_KEY=your_api_key 56 | LOG_LEVEL=info 57 | GLOBAL_WEBHOOK_URL=https://your-webhook-url.com # Optional 58 | ``` 59 | 60 | 3. Jalankan server: 61 | ```bash 62 | npm run start 63 | ``` 64 | 65 | ## 📱 API Endpoints 66 | 67 | ### Sessions 68 | - `POST /sessions/:sessionId` - Membuat/menggunakan sesi 69 | - `GET /sessions/:sessionId` - Cek status sesi 70 | - `POST /sessions/:sessionId/logout` - Logout sesi 71 | 72 | ### Messages 73 | - `POST /messages/send` - Kirim pesan (teks/media) 74 | ```json 75 | { 76 | "sender": "session_id_1", 77 | "receiver": "6285123456789", 78 | "message": "Hello World!", 79 | "file": "https://example.com/image.jpg", // Optional 80 | "viewOnce": false, // Optional, default: false 81 | } 82 | ``` 83 | 84 | - `POST /messages/mention` - Kirim pesan dengan mention 85 | ```json 86 | { 87 | "sender": "session_id_1", 88 | "receiver": "6285123456789 or group_id@g.us", 89 | "message": "Hello @user!" 90 | } 91 | ``` 92 | 93 | - `POST /messages/batch` - Kirim pesan massal 94 | ```json 95 | { 96 | "sender": "session_id_1", 97 | "receivers": ["6285123456789", "6285987654321"], 98 | "message": "Hello World!", 99 | } 100 | ``` 101 | 102 | ### Webhook 103 | - `POST /webhook/set/:sessionId` - Set webhook untuk sesi tertentu 104 | ```json 105 | { 106 | "url": "https://your-webhook-url.com" 107 | } 108 | ``` 109 | - `GET /webhook/status` - Cek status webhook (global & per-sesi) 110 | 111 | ### Utility 112 | - `GET /utility/groups/:sessionId` - Daftar grup & peserta 113 | - `POST /utility/check-number` - Cek status nomor WhatsApp 114 | 115 | ## ⚙️ Konfigurasi 116 | 117 | ### Logging Levels 118 | ```env 119 | # Available levels: trace, debug, info, warn, error, fatal 120 | LOG_LEVEL=info 121 | ``` 122 | 123 | ### Queue Configuration 124 | ```env 125 | # Queue settings (default) 126 | QUEUE_BATCH_SIZE=5 127 | QUEUE_BATCH_DELAY=1000 128 | QUEUE_MAX_RETRIES=3 129 | QUEUE_RETRY_DELAY=2000 130 | QUEUE_TIMEOUT=30000 131 | 132 | # Redis Configuration 133 | REDIS_HOST=localhost 134 | REDIS_PORT=6379 135 | REDIS_PASSWORD= 136 | REDIS_DB=0 137 | ``` 138 | 139 | This API now uses Redis-based queue (Bull) to handle message processing. This provides several benefits: 140 | - Persistence: Messages will survive server restarts 141 | - Scalability: Multiple instances of the API can share the same queue 142 | - Reliability: Failed jobs can be retried automatically 143 | - Monitoring: Queue status can be easily monitored 144 | 145 | To use this feature, you need to have Redis server running. You can install Redis locally or use a cloud-based service. 146 | 147 | #### Local Redis Installation 148 | 149 | For development purposes, you can run Redis locally: 150 | 151 | **For Windows:** 152 | 1. Use WSL (Windows Subsystem for Linux) 153 | 2. Use Docker: `docker run -p 6379:6379 redis` 154 | 155 | **For Linux:** 156 | ```bash 157 | sudo apt update 158 | sudo apt install redis-server 159 | sudo systemctl start redis-server 160 | ``` 161 | 162 | **For macOS:** 163 | ```bash 164 | brew install redis 165 | brew services start redis 166 | ``` 167 | 168 | #### Queue Monitoring 169 | 170 | You can check the status of the queue by calling the API endpoint `/api/queue/status/:sessionId`. 171 | 172 | ### Webhook Configuration 173 | ```env 174 | # Global webhook URL (optional) 175 | GLOBAL_WEBHOOK_URL=https://your-webhook-url.com 176 | ``` 177 | 178 | ## 🔄 Update Terbaru 179 | 180 | ### 1. Sistem Antrian 181 | - Implementasi antrian untuk pengiriman pesan batch 182 | - Konfigurasi batch size dan delay yang fleksibel 183 | - Sistem retry otomatis untuk pesan gagal 184 | - Monitoring status antrian 185 | - Pemisahan antrian per sesi WhatsApp 186 | 187 | ### 2. Sistem Webhook 188 | - Webhook global dan per-sesi 189 | - Event untuk pesan masuk dan status koneksi 190 | - Penyimpanan konfigurasi webhook ke file 191 | - Format payload yang terstruktur 192 | - Logging untuk monitoring 193 | 194 | ### 3. Peningkatan Stabilitas 195 | - Perbaikan error handling untuk error 515 196 | - Sistem reconnection yang lebih handal 197 | - Timeout dan cleanup yang optimal 198 | - Logging yang lebih informatif dengan format [sessionId] 199 | 200 | ### 4. Fitur Utility 201 | - Endpoint groups dengan informasi lengkap (peserta, admin, pengaturan) 202 | - Verifikasi nomor WhatsApp yang lebih akurat 203 | - Format nomor otomatis (08/+62 -> 62) 204 | 205 | ### 5. Optimasi 206 | - Penyederhanaan path resolver 207 | - Cleanup sesi yang lebih menyeluruh 208 | - Penanganan memory leak yang lebih baik 209 | 210 | ## 📚 API Documentation 211 | 212 | Dokumentasi API tersedia melalui Swagger UI di: 213 | ``` 214 | http://localhost:3000/docs 215 | ``` 216 | 217 | ## Webhook 218 | 219 | Sistem webhook memungkinkan Anda untuk menerima notifikasi real-time tentang: 220 | - Pesan masuk 221 | - Status koneksi 222 | - QR Code generation 223 | - Perubahan status sesi 224 | 225 | ### Format Webhook 226 | 227 | 1. Pesan Masuk: 228 | ```json 229 | { 230 | "type": "message", 231 | "sessionId": "session1", 232 | "message": { 233 | "key": { 234 | "remoteJid": "6281234567890@s.whatsapp.net", 235 | "id": "1234567890" 236 | }, 237 | "message": { 238 | "conversation": "Hello World!" 239 | } 240 | } 241 | } 242 | ``` 243 | 244 | 2. Status Koneksi: 245 | ```json 246 | { 247 | "type": "connection", 248 | "sessionId": "session1", 249 | "status": "open", 250 | "qr": "base64_qr_code" // Jika ada 251 | } 252 | ``` 253 | 254 | ### Contoh Implementasi Webhook 255 | 256 | Silahkan lihat contoh implementasi webhook di [examples/webhook-receiver.js](examples/webhook-receiver.js) 257 | 258 | 259 | ### Contoh Test Batch Messaging 260 | 261 | Untuk test queue, silakan lihat [examples/test-batch-message.js](examples/test-batch-message.js) 262 | 263 | ### Menggunakan Webhook 264 | 265 | 1. Set webhook URL untuk sesi tertentu: 266 | ```bash 267 | curl -X POST http://localhost:3000/webhook/set/session1 \ 268 | -H "Content-Type: application/json" \ 269 | -H "X-API-Key: your-api-key" \ 270 | -d '{"url": "http://your-webhook-url/webhook"}' 271 | ``` 272 | 273 | 2. Cek status webhook: 274 | ```bash 275 | curl http://localhost:3000/webhook/status 276 | ``` 277 | 278 | 3. Jalankan webhook receiver (contoh menggunakan Node.js): 279 | ```bash 280 | cd examples 281 | npm install express body-parser 282 | node webhook-receiver.js 283 | ``` 284 | 285 | ## Sistem Antrian 286 | 287 | Sistem menggunakan antrian untuk mengatur pengiriman pesan dengan konfigurasi: 288 | - Batch size: 5 pesan 289 | - Delay: 1 detik antar batch 290 | - Max retries: 3x 291 | - Retry delay: 2 detik 292 | - Timeout: 30 detik 293 | 294 | ## 🤝 Kontribusi 295 | 296 | Kontribusi selalu diterima! Silakan buat pull request atau laporkan issues jika menemukan bug. 297 | 298 | ## 📜 Lisensi 299 | 300 | Proyek ini dilisensikan di bawah [MIT License](LICENSE). -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const express = require("express"); 3 | const nodeCleanup = require("node-cleanup"); 4 | const routes = require("./src/routes/index.js"); 5 | const whatsappService = require("./src/services/whatsappService.js"); 6 | const cors = require("cors"); 7 | const { cleanupAllSessions } = require("./src/services/whatsappService"); 8 | const logger = require("./src/utils/logger"); 9 | 10 | const app = express(); 11 | 12 | const PORT = process.env.PORT || 3000; 13 | const HOST = process.env.NODE_ENV === "production" ? "0.0.0.0" : "localhost"; 14 | 15 | app.use(cors()); 16 | app.use(express.urlencoded({ extended: true })); 17 | app.use(express.json()); 18 | app.use("/", routes); 19 | 20 | const listenerCallback = function () { 21 | whatsappService.init(); 22 | logger.info({ 23 | msg: "Server started", 24 | host: HOST, 25 | port: PORT, 26 | env: process.env.NODE_ENV, 27 | }); 28 | }; 29 | 30 | app.listen(PORT, HOST, listenerCallback); 31 | 32 | // Menambahkan handler untuk graceful shutdown 33 | nodeCleanup((exitCode, signal) => { 34 | logger.info({ 35 | msg: "Cleaning up before shutdown", 36 | exitCode, 37 | signal, 38 | }); 39 | 40 | cleanupAllSessions() 41 | .then(() => { 42 | logger.info({ 43 | msg: "All sessions cleaned up successfully", 44 | }); 45 | process.exit(exitCode); 46 | }) 47 | .catch((error) => { 48 | logger.error({ 49 | msg: "Error during cleanup", 50 | error: error.message, 51 | stack: error.stack, 52 | }); 53 | process.exit(1); 54 | }); 55 | 56 | // Mencegah exit langsung, menunggu cleanup selesai 57 | nodeCleanup.uninstall(); 58 | return false; 59 | }); 60 | 61 | module.exports = app; 62 | -------------------------------------------------------------------------------- /deploy-heroku.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "🚀 Memulai deployment ke Heroku..." 4 | 5 | # Memastikan Heroku CLI terinstall 6 | if ! command -v heroku &> /dev/null; then 7 | echo "❌ Heroku CLI tidak ditemukan. Silakan install terlebih dahulu." 8 | echo "💡 Kunjungi: https://devcenter.heroku.com/articles/heroku-cli" 9 | exit 1 10 | fi 11 | 12 | # Memastikan git terinstall 13 | if ! command -v git &> /dev/null; then 14 | echo "❌ Git tidak ditemukan. Silakan install terlebih dahulu." 15 | exit 1 16 | fi 17 | 18 | # Cek apakah sudah login ke Heroku 19 | if ! heroku auth:whoami &> /dev/null; then 20 | echo "🔑 Silakan login ke Heroku terlebih dahulu..." 21 | heroku login 22 | fi 23 | 24 | # Mendapatkan nama aplikasi Heroku 25 | echo "📝 Masukkan nama aplikasi Heroku Anda:" 26 | read APP_NAME 27 | 28 | # Pilihan region 29 | echo "📍 Pilih region server:" 30 | echo "=== Common Runtime (Basic Plan) ===" 31 | echo "1) Europe (eu) - Frankfurt/Ireland" 32 | echo "2) United States (us) - Default" 33 | 34 | echo -e "\n=== Private Spaces (Enterprise Plan) ===" 35 | echo "3) Dublin, Ireland (dublin)" 36 | echo "4) Frankfurt, Germany (frankfurt)" 37 | echo "5) London, UK (london)" 38 | echo "6) Montreal, Canada (montreal)" 39 | echo "7) Mumbai, India (mumbai)" 40 | echo "8) Oregon, US (oregon)" 41 | echo "9) Singapore (singapore)" 42 | echo "10) Sydney, Australia (sydney)" 43 | echo "11) Tokyo, Japan (tokyo)" 44 | echo "12) Virginia, US (virginia)" 45 | 46 | read -p "Pilih nomor region (1-12): " REGION_CHOICE 47 | 48 | case $REGION_CHOICE in 49 | 1) 50 | REGION="eu" 51 | echo "✅ Selected: Europe (Common Runtime)" 52 | ;; 53 | 2) 54 | REGION="us" 55 | echo "✅ Selected: United States (Common Runtime)" 56 | ;; 57 | 3|4|5|6|7|8|9|10|11|12) 58 | echo "⚠️ Region ini hanya tersedia untuk Private Spaces (Enterprise Plan)" 59 | echo "Menggunakan Europe sebagai alternatif..." 60 | REGION="eu" 61 | ;; 62 | *) 63 | echo "❌ Pilihan tidak valid. Menggunakan Europe sebagai default." 64 | REGION="eu" 65 | ;; 66 | esac 67 | 68 | # Estimasi latency berdasarkan region 69 | case $REGION in 70 | "eu") 71 | echo "📡 Estimasi latency dari Indonesia: ~250-350ms" 72 | ;; 73 | "us") 74 | echo "📡 Estimasi latency dari Indonesia: ~300-400ms" 75 | ;; 76 | esac 77 | 78 | # Cek apakah aplikasi sudah ada 79 | if ! heroku apps:info -a "$APP_NAME" &> /dev/null; then 80 | echo "🆕 Membuat aplikasi baru di Heroku di region $REGION..." 81 | heroku create "$APP_NAME" --region "$REGION" 82 | else 83 | echo "✅ Aplikasi '$APP_NAME' ditemukan." 84 | # Tampilkan region aplikasi yang sudah ada 85 | CURRENT_REGION=$(heroku info -a "$APP_NAME" | grep "Region" | awk '{print $2}') 86 | echo "📍 Region saat ini: $CURRENT_REGION" 87 | fi 88 | 89 | # Menambahkan Redis add-on 90 | echo "⚙️ Menambahkan Redis add-on..." 91 | echo "Pilih tier Redis:" 92 | echo "1) hobby-dev (Free - 25MB)" 93 | echo "2) premium-0 (Berbayar - 50MB)" 94 | read -p "Pilihan Anda (1/2): " REDIS_CHOICE 95 | 96 | case $REDIS_CHOICE in 97 | 1) 98 | echo "🔄 Menambahkan Redis hobby-dev..." 99 | heroku addons:create heroku-redis:hobby-dev -a "$APP_NAME" 100 | ;; 101 | 2) 102 | echo "🔄 Menambahkan Redis premium-0..." 103 | heroku addons:create heroku-redis:premium-0 -a "$APP_NAME" 104 | ;; 105 | *) 106 | echo "⚠️ Pilihan tidak valid. Menggunakan hobby-dev..." 107 | heroku addons:create heroku-redis:hobby-dev -a "$APP_NAME" 108 | ;; 109 | esac 110 | 111 | # Menunggu Redis siap 112 | echo "⏳ Menunggu Redis siap..." 113 | sleep 10 114 | 115 | # Set environment variables 116 | echo "⚙️ Mengatur environment variables..." 117 | heroku config:set NODE_ENV=production -a "$APP_NAME" 118 | echo "📝 Masukkan API Key Anda:" 119 | read API_KEY 120 | heroku config:set API_KEY="$API_KEY" -a "$APP_NAME" 121 | heroku config:set ENABLE_API_KEY=true -a "$APP_NAME" 122 | 123 | # Deploy ke Heroku 124 | echo "🚀 Mendeploy aplikasi ke Heroku..." 125 | git push heroku main 126 | 127 | # Memastikan minimal 1 dyno berjalan 128 | echo "⚙️ Memastikan aplikasi berjalan..." 129 | heroku ps:scale web=1 -a "$APP_NAME" 130 | 131 | # Cek status Redis 132 | echo "📊 Mengecek status Redis..." 133 | heroku redis:info -a "$APP_NAME" 134 | 135 | # Membuka aplikasi 136 | echo "🌐 Membuka aplikasi di browser..." 137 | heroku open -a "$APP_NAME" 138 | 139 | echo "✅ Deployment selesai!" 140 | echo "📱 Aplikasi Anda dapat diakses di: https://$APP_NAME.herokuapp.com" 141 | echo "📝 Dokumentasi API tersedia di: https://$APP_NAME.herokuapp.com/docs" 142 | echo "💡 Untuk memonitor Redis: heroku redis:metrics -a $APP_NAME" -------------------------------------------------------------------------------- /examples/test-batch-message.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const path = require("path"); 3 | 4 | require("dotenv").config({ path: path.resolve(__dirname, "../.env") }); 5 | 6 | const apiKey = process.env.API_KEY || "kentut_nata2"; 7 | 8 | // Array nama buah dan sayur 9 | const items = [ 10 | "Apel", 11 | "Pisang", 12 | "Mangga", 13 | "Jeruk", 14 | "Anggur", 15 | "Pepaya", 16 | "Nanas", 17 | "Semangka", 18 | "Melon", 19 | "Durian", 20 | "Wortel", 21 | "Bayam", 22 | "Kangkung", 23 | "Brokoli", 24 | "Kubis", 25 | "Tomat", 26 | "Timun", 27 | "Terong", 28 | "Labu", 29 | "Jagung", 30 | ]; 31 | 32 | // Fungsi untuk mendapatkan item random dari array 33 | const getRandomItem = () => { 34 | const randomIndex = Math.floor(Math.random() * items.length); 35 | return items[randomIndex]; 36 | }; 37 | 38 | // Fungsi untuk delay 39 | const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 40 | 41 | // Fungsi untuk mengirim pesan 42 | async function sendMessage(session, target, message, apiKey, index) { 43 | try { 44 | console.log(`Mengirim pesan ke-${index}: ${message}`); 45 | const response = await axios.post( 46 | "http://localhost:3000/messages/send", 47 | { 48 | sender: session, 49 | receiver: target, 50 | message: message, 51 | }, 52 | { 53 | headers: { 54 | "Content-Type": "application/json", 55 | "x-api-key": apiKey, 56 | accept: "application/json", 57 | }, 58 | } 59 | ); 60 | console.log(`Pesan ke-${index} terkirim!`); 61 | return { success: true, index, response: response.data }; 62 | } catch (error) { 63 | console.error(`Error mengirim pesan ke-${index}: ${error.message}`); 64 | return { success: false, index, error: error.message }; 65 | } 66 | } 67 | 68 | // Fungsi utama untuk mengirim batch pesan 69 | async function sendBatchMessages(count, target, session = "session_id_1") { 70 | console.log(`Akan mengirim ${count} pesan ke nomor ${target}...`); 71 | console.log(`Session ID: ${session}`); 72 | console.log("-----------------------------------"); 73 | 74 | // Buat array of promises untuk semua pesan 75 | const promises = Array.from({ length: count }, (_, i) => { 76 | const index = i + 1; 77 | const item = getRandomItem(); 78 | const message = `Test pesan ke-${index}: ${item}`; 79 | 80 | // Tambahkan sedikit delay random untuk menghindari rate limiting 81 | const initialDelay = Math.random() * 500; // delay 0-500ms 82 | 83 | return delay(initialDelay).then(() => 84 | sendMessage(session, target, message, apiKey, index) 85 | ); 86 | }); 87 | 88 | // Kirim semua pesan secara paralel 89 | const results = await Promise.all(promises); 90 | 91 | // Tampilkan ringkasan hasil 92 | const successful = results.filter((r) => r.success).length; 93 | console.log("\nRingkasan Pengiriman:"); 94 | console.log(`Total pesan terkirim: ${successful}/${count}`); 95 | console.log(`Total pesan gagal: ${count - successful}`); 96 | 97 | if (count - successful > 0) { 98 | console.log("\nDetail pesan gagal:"); 99 | results 100 | .filter((r) => !r.success) 101 | .forEach((r) => console.log(`Pesan ke-${r.index}: ${r.error}`)); 102 | } 103 | } 104 | 105 | // Mengambil argumen dari command line 106 | const args = process.argv.slice(2); 107 | const count = parseInt(args[0]); 108 | const target = args[1]; 109 | 110 | // Validasi input 111 | if (!count || !target) { 112 | console.log( 113 | "Usage: node test-batch-message.js " 114 | ); 115 | console.log("Example: node test-batch-message.js 5 6281234567890"); 116 | process.exit(1); 117 | } 118 | 119 | // Jalankan fungsi utama 120 | sendBatchMessages(count, target, "session_id_1").catch(console.error); 121 | sendBatchMessages(count, target, "session_id_2").catch(console.error); 122 | -------------------------------------------------------------------------------- /examples/webhook-receiver.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const bodyParser = require("body-parser"); 3 | const app = express(); 4 | const port = 3001; 5 | 6 | // Secret key yang didapat saat setup webhook 7 | const WEBHOOK_SECRET = "1c2rr1jn8fa"; 8 | 9 | // Middleware untuk parsing JSON 10 | app.use(bodyParser.json()); 11 | 12 | // Middleware untuk logging request 13 | app.use((req, res, next) => { 14 | console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`); 15 | if (req.method === "POST") { 16 | // console.log("Headers:", JSON.stringify(req.headers, null, 2)); 17 | } 18 | next(); 19 | }); 20 | 21 | // Endpoint untuk menerima webhook 22 | app.post("/webhook", (req, res) => { 23 | try { 24 | const secret = req.headers["x-webhook-secret"]; 25 | const data = req.body; 26 | 27 | // Verifikasi secret key 28 | if (!secret || secret !== WEBHOOK_SECRET) { 29 | throw new Error("Invalid webhook secret"); 30 | } 31 | 32 | // Log data webhook 33 | console.log("Webhook Data:", JSON.stringify(data, null, 2)); 34 | 35 | // Handle webhook data 36 | const { sessionId, type, message, status } = data; 37 | console.log(`[${sessionId}] Received ${type} webhook:`); 38 | 39 | if (type === "message") { 40 | console.log("Data:", JSON.stringify(message, null, 2)); 41 | } else if (type === "connection") { 42 | console.log("Data:", JSON.stringify(status, null, 2)); 43 | } 44 | 45 | // Kirim response sukses 46 | res.status(200).json({ 47 | status: true, 48 | message: "Webhook received successfully", 49 | timestamp: new Date().toISOString(), 50 | }); 51 | } catch (error) { 52 | console.error("Error processing webhook:", error); 53 | res.status(error.message === "Invalid webhook secret" ? 401 : 400).json({ 54 | status: false, 55 | message: "Error processing webhook", 56 | error: error.message, 57 | timestamp: new Date().toISOString(), 58 | }); 59 | } 60 | }); 61 | 62 | // Endpoint untuk mengecek status webhook 63 | app.get("/webhook/status", (req, res) => { 64 | res.json({ 65 | status: true, 66 | message: "Webhook server is running", 67 | timestamp: new Date().toISOString(), 68 | }); 69 | }); 70 | 71 | // Error handling middleware 72 | app.use((err, req, res, next) => { 73 | console.error("Error:", err); 74 | res.status(500).json({ 75 | status: false, 76 | message: "Internal server error", 77 | error: err.message, 78 | timestamp: new Date().toISOString(), 79 | }); 80 | }); 81 | 82 | // Start server 83 | app.listen(port, () => { 84 | console.log(`Webhook receiver running at http://localhost:${port}`); 85 | console.log("Available endpoints:"); 86 | console.log("- POST /webhook - Receive webhook data"); 87 | console.log("- GET /webhook/status - Check webhook status"); 88 | console.log("\nWebhook will verify using X-Webhook-Secret header"); 89 | console.log("Make sure to set WEBHOOK_SECRET environment variable"); 90 | }); 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wa-api", 3 | "version": "1.0.0", 4 | "description": "Implemented Baileys WhatsApp API", 5 | "main": "app.js", 6 | "private": true, 7 | "scripts": { 8 | "start": "node app.js", 9 | "dev": "nodemon src/app.js --ignore ./sessions/", 10 | "pm2": "pm2 start app.js --name $npm_package_name", 11 | "pm2:stop": "pm2 stop $npm_package_name", 12 | "pm2:restart": "pm2 restart $npm_package_name", 13 | "pm2:logs": "pm2 logs $npm_package_name" 14 | }, 15 | "engines": { 16 | "node": "18.x" 17 | }, 18 | "dependencies": { 19 | "@adiwajshing/keyed-db": "^0.2.4", 20 | "@whiskeysockets/baileys": "6.5.0", 21 | "axios": "^1.6.7", 22 | "bull": "^4.12.0", 23 | "cors": "^2.8.5", 24 | "dotenv": "^16.3.1", 25 | "express": "^4.18.2", 26 | "express-rate-limit": "^7.1.5", 27 | "ioredis": "^5.3.2", 28 | "joi": "^17.10.1", 29 | "link-preview-js": "^3.0.4", 30 | "node-cleanup": "^2.1.2", 31 | "opossum": "^8.1.3", 32 | "pino": "^8.16.2", 33 | "pino-pretty": "^10.2.3", 34 | "pm2": "^5.2.2", 35 | "qrcode": "^1.5.3", 36 | "qrcode-terminal": "^0.12.0", 37 | "sharp": "^0.33.1", 38 | "swagger-jsdoc": "^6.2.8", 39 | "swagger-ui-express": "^4.6.3", 40 | "ws": "^8.14.2", 41 | "prom-client": "^15.1.0" 42 | }, 43 | "devDependencies": { 44 | "eslint": "^8.5.0", 45 | "eslint-config-prettier": "^8.3.0", 46 | "eslint-config-xo": "^0.39.0", 47 | "eslint-plugin-prettier": "^4.0.0", 48 | "prettier": "^2.5.1", 49 | "nodemon": "^3.0.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/constants/httpStatusCode.js: -------------------------------------------------------------------------------- 1 | const OK = 200; 2 | const CREATED = 201; 3 | const ACCEPTED = 202; 4 | const NO_CONTENT = 204; 5 | 6 | const BAD_REQUEST = 400; 7 | const UNAUTHORIZED = 401; 8 | const NOT_FOUND = 404; 9 | const CONFLICT = 409; 10 | const FORBIDDEN = 403; 11 | 12 | const INTERNAL_SERVER_ERROR = 500; 13 | const SERVICE_UNAVAILABLE = 503; 14 | 15 | module.exports = { 16 | BAD_REQUEST, 17 | UNAUTHORIZED, 18 | NOT_FOUND, 19 | CONFLICT, 20 | OK, 21 | CREATED, 22 | ACCEPTED, 23 | NO_CONTENT, 24 | INTERNAL_SERVER_ERROR, 25 | FORBIDDEN, 26 | SERVICE_UNAVAILABLE 27 | }; -------------------------------------------------------------------------------- /src/controllers/messageController.js: -------------------------------------------------------------------------------- 1 | const Joi = require("joi"); 2 | const whatsappService = require("../services/whatsappService"); 3 | const { categorizeFile } = require("../utils/general"); 4 | const { sendResponse } = require("../utils/response"); 5 | const httpStatusCode = require("../constants/httpStatusCode"); 6 | const logger = require("../utils/logger"); 7 | 8 | const checkFormatMedia = async (file, message, viewOnce) => { 9 | try { 10 | if (!file) { 11 | return null; 12 | } 13 | 14 | // Validasi URL terlebih dahulu 15 | const fileResponse = await fetch(file, { method: "HEAD" }); 16 | if (!fileResponse.ok) { 17 | console.error("Invalid file URL:", file); 18 | return null; 19 | } 20 | 21 | // Kategorisasi file 22 | const categoryFile = categorizeFile(fileResponse); 23 | if (!categoryFile) { 24 | console.error("Failed to categorize file:", file); 25 | return null; 26 | } 27 | 28 | return { 29 | ...categoryFile, 30 | caption: message, 31 | viewOnce: viewOnce || false, 32 | }; 33 | } catch (error) { 34 | console.error("Error checking media format:", error); 35 | return null; 36 | } 37 | }; 38 | 39 | module.exports = { 40 | async sendMessage(req, res) { 41 | const schema = Joi.object({ 42 | sender: Joi.string().required(), 43 | receiver: Joi.string().required(), 44 | message: Joi.string().required(), 45 | file: Joi.string(), 46 | viewOnce: Joi.boolean().default(false), 47 | showTyping: Joi.boolean().default(true), 48 | }); 49 | 50 | const { error } = schema.validate(req.body); 51 | if (error) { 52 | return sendResponse( 53 | res, 54 | httpStatusCode.BAD_REQUEST, 55 | error.details[0].message 56 | ); 57 | } 58 | 59 | const { sender, receiver, message, file, viewOnce, showTyping } = req.body; 60 | 61 | try { 62 | const client = whatsappService.getSession(sender); 63 | if (!client) { 64 | return sendResponse(res, httpStatusCode.NOT_FOUND, "Session not found"); 65 | } 66 | 67 | // Format pesan dasar 68 | let formattedMessage = { text: message }; 69 | 70 | // Cek dan format media jika ada 71 | if (file && file !== "") { 72 | const formattedMedia = await checkFormatMedia(file, message, viewOnce); 73 | if (!formattedMedia) { 74 | return sendResponse( 75 | res, 76 | httpStatusCode.BAD_REQUEST, 77 | "Invalid media file or URL" 78 | ); 79 | } 80 | formattedMessage = formattedMedia; 81 | } 82 | 83 | // Format dan validasi penerima 84 | const receiverParts = receiver.split(/[,|]/); 85 | const formattedReceivers = receiverParts 86 | .map((part) => part.trim()) 87 | .filter(Boolean) // Hapus string kosong 88 | .map(whatsappService.formatPhone); 89 | 90 | if (formattedReceivers.length === 0) { 91 | return sendResponse( 92 | res, 93 | httpStatusCode.BAD_REQUEST, 94 | "No valid receivers provided" 95 | ); 96 | } 97 | 98 | let result; 99 | // Kirim ke satu penerima 100 | if (formattedReceivers.length === 1) { 101 | // Periksa apakah nomor valid, kecuali untuk grup 102 | try { 103 | // Skip pengecekan jika tujuannya adalah grup (ditandai dengan @g.us di akhir) 104 | if (!formattedReceivers[0].endsWith("@g.us")) { 105 | const isValid = await whatsappService.isExists( 106 | client, 107 | formattedReceivers[0] 108 | ); 109 | if (!isValid) { 110 | return sendResponse( 111 | res, 112 | httpStatusCode.BAD_REQUEST, 113 | "Invalid phone number" 114 | ); 115 | } 116 | } 117 | } catch (error) { 118 | logger.warn({ 119 | msg: `[${sender}] Error checking phone number`, 120 | error: error.message, 121 | }); 122 | } 123 | 124 | logger.info({ 125 | msg: `[${sender}] Sending message to ${formattedReceivers[0]}`, 126 | }); 127 | 128 | // Kirim pesan (sudah menggunakan antrian di whatsappService) 129 | try { 130 | logger.info({ 131 | msg: `[${sender}] About to call whatsappService.sendMessage`, 132 | receiver: formattedReceivers[0], 133 | }); 134 | 135 | const sendResult = await whatsappService.sendMessage( 136 | client, 137 | formattedReceivers[0], 138 | formattedMessage, 139 | 5, 140 | showTyping 141 | ); 142 | 143 | logger.info({ 144 | msg: `[${sender}] sendMessage result received`, 145 | receiver: formattedReceivers[0], 146 | resultType: typeof sendResult, 147 | resultKeys: sendResult ? Object.keys(sendResult) : [], 148 | messageId: sendResult?.key?.id || null, 149 | }); 150 | 151 | result = { 152 | sender, 153 | receiver: formattedReceivers[0], 154 | message: formattedMessage.text || message, 155 | file: file || null, 156 | viewOnce: viewOnce || false, 157 | messageId: sendResult?.key?.id || null, 158 | }; 159 | } catch (sendError) { 160 | logger.error({ 161 | msg: `[${sender}] Error calling sendMessage`, 162 | receiver: formattedReceivers[0], 163 | error: sendError.message, 164 | stack: sendError.stack, 165 | }); 166 | throw sendError; 167 | } 168 | } 169 | // Kirim ke banyak penerima 170 | else { 171 | logger.info({ 172 | msg: `[${sender}] Sending message to ${formattedReceivers.length} receivers`, 173 | }); 174 | 175 | const results = []; 176 | const invalidNumbers = []; 177 | 178 | // Gunakan Promise.all untuk pengecekan dan pengiriman asinkron 179 | const sendPromises = formattedReceivers.map(async (receiver) => { 180 | try { 181 | // Periksa nomor telepon kecuali untuk grup 182 | if (!receiver.endsWith("@g.us")) { 183 | const isValid = await whatsappService.isExists(client, receiver); 184 | if (!isValid) { 185 | invalidNumbers.push(receiver); 186 | return; 187 | } 188 | } 189 | 190 | // Kirim pesan 191 | const sendResult = await whatsappService.sendMessage( 192 | client, 193 | receiver, 194 | formattedMessage, 195 | 5, 196 | showTyping 197 | ); 198 | 199 | results.push({ 200 | receiver, 201 | message: formattedMessage.text || message, 202 | file: file || null, 203 | viewOnce: viewOnce || false, 204 | messageId: sendResult?.key?.id || null, 205 | status: "sent", 206 | }); 207 | } catch (error) { 208 | results.push({ 209 | receiver, 210 | message: formattedMessage.text || message, 211 | file: file || null, 212 | viewOnce: viewOnce || false, 213 | error: error.message, 214 | status: "failed", 215 | }); 216 | } 217 | }); 218 | 219 | await Promise.all(sendPromises); 220 | 221 | result = { 222 | total: formattedReceivers.length, 223 | sent: results.filter((r) => r.status === "sent").length, 224 | failed: results.filter((r) => r.status === "failed").length, 225 | invalid: invalidNumbers.length, 226 | details: { 227 | results, 228 | invalidNumbers, 229 | }, 230 | }; 231 | } 232 | 233 | return sendResponse( 234 | res, 235 | httpStatusCode.OK, 236 | "Message sent successfully", 237 | result 238 | ); 239 | } catch (error) { 240 | logger.error({ 241 | msg: `[${sender}] Error queueing message`, 242 | error: error.message, 243 | stack: error.stack, 244 | }); 245 | return sendResponse( 246 | res, 247 | httpStatusCode.INTERNAL_SERVER_ERROR, 248 | "Failed to queue message", 249 | null, 250 | error 251 | ); 252 | } 253 | }, 254 | 255 | // Tambahkan fungsi untuk handle mention message 256 | async sendMentionMessage(req, res) { 257 | const schema = Joi.object({ 258 | sender: Joi.string().required(), 259 | receiver: Joi.string().required(), 260 | message: Joi.string().allow(""), // Message bisa kosong 261 | showTyping: Joi.boolean().default(true), 262 | }); 263 | 264 | const { error } = schema.validate(req.body); 265 | if (error) { 266 | return sendResponse( 267 | res, 268 | httpStatusCode.BAD_REQUEST, 269 | error.details[0].message 270 | ); 271 | } 272 | 273 | const { sender, receiver, message, showTyping } = req.body; 274 | 275 | try { 276 | const client = whatsappService.getSession(sender); 277 | if (!client) { 278 | return sendResponse(res, httpStatusCode.NOT_FOUND, "Session not found"); 279 | } 280 | 281 | const formattedReceiver = whatsappService.formatPhone(receiver); 282 | logger.info({ 283 | msg: `[MENTION][${sender}] Formatting receiver ${receiver} -> ${formattedReceiver}`, 284 | sender, 285 | originalReceiver: receiver, 286 | formattedReceiver, 287 | }); 288 | 289 | const result = await whatsappService.sendMentionMessage( 290 | client, 291 | formattedReceiver, 292 | message, 293 | showTyping 294 | ); 295 | 296 | logger.info({ 297 | msg: `[MENTION][${sender}] Controller: Message sent successfully`, 298 | sender, 299 | receiver: formattedReceiver, 300 | messageId: result?.key?.id, 301 | }); 302 | 303 | return sendResponse( 304 | res, 305 | httpStatusCode.OK, 306 | "Mention message sent successfully", 307 | { 308 | sender, 309 | receiver: formattedReceiver, 310 | message: message || "Hello!", 311 | messageId: result?.key?.id, 312 | mentions: result.mentions, 313 | file: null, 314 | viewOnce: false, 315 | } 316 | ); 317 | } catch (error) { 318 | logger.error({ 319 | msg: `[MENTION][${sender}] Controller Error`, 320 | sender, 321 | error: error.message, 322 | stack: error.stack, 323 | }); 324 | return sendResponse( 325 | res, 326 | httpStatusCode.INTERNAL_SERVER_ERROR, 327 | "Failed to send mention message", 328 | null, 329 | error 330 | ); 331 | } 332 | }, 333 | }; 334 | -------------------------------------------------------------------------------- /src/controllers/queueController.js: -------------------------------------------------------------------------------- 1 | const queueService = require('../services/queueService'); 2 | const { sendResponse } = require('../utils/response'); 3 | const httpStatusCode = require('../constants/httpStatusCode'); 4 | const logger = require('../utils/logger'); 5 | 6 | /** 7 | * Get queue status for a session 8 | * @param {Object} req - Express request object 9 | * @param {Object} res - Express response object 10 | */ 11 | const getQueueStatus = async (req, res) => { 12 | const { sessionId } = req.params; 13 | 14 | try { 15 | logger.info({ 16 | msg: 'Getting queue status', 17 | sessionId, 18 | }); 19 | 20 | const status = await queueService.getQueueStatus(sessionId); 21 | 22 | return sendResponse( 23 | res, 24 | httpStatusCode.OK, 25 | 'Queue status retrieved successfully', 26 | status 27 | ); 28 | } catch (error) { 29 | logger.error({ 30 | msg: 'Error getting queue status', 31 | sessionId, 32 | error: error.message, 33 | stack: error.stack, 34 | }); 35 | 36 | return sendResponse( 37 | res, 38 | httpStatusCode.INTERNAL_SERVER_ERROR, 39 | 'Failed to get queue status', 40 | null, 41 | error 42 | ); 43 | } 44 | }; 45 | 46 | /** 47 | * Clear queue for a session 48 | * @param {Object} req - Express request object 49 | * @param {Object} res - Express response object 50 | */ 51 | const clearSessionQueue = async (req, res) => { 52 | const { sessionId } = req.params; 53 | 54 | try { 55 | logger.info({ 56 | msg: 'Clearing queue for session', 57 | sessionId, 58 | }); 59 | 60 | const result = await queueService.clearSessionQueue(sessionId); 61 | 62 | if (!result) { 63 | return sendResponse( 64 | res, 65 | httpStatusCode.NOT_FOUND, 66 | 'No queue found for session or failed to clear', 67 | { success: false } 68 | ); 69 | } 70 | 71 | return sendResponse( 72 | res, 73 | httpStatusCode.OK, 74 | 'Queue cleared successfully', 75 | { success: true } 76 | ); 77 | } catch (error) { 78 | logger.error({ 79 | msg: 'Error clearing queue', 80 | sessionId, 81 | error: error.message, 82 | stack: error.stack, 83 | }); 84 | 85 | return sendResponse( 86 | res, 87 | httpStatusCode.INTERNAL_SERVER_ERROR, 88 | 'Failed to clear queue', 89 | null, 90 | error 91 | ); 92 | } 93 | }; 94 | 95 | module.exports = { 96 | getQueueStatus, 97 | clearSessionQueue, 98 | }; -------------------------------------------------------------------------------- /src/controllers/sessionController.js: -------------------------------------------------------------------------------- 1 | const Joi = require("joi"); 2 | const { 3 | createSession, 4 | getSessionStatus, 5 | deleteSession, 6 | checkAndCleanSessionFolder, 7 | } = require("../services/whatsappService"); 8 | const whatsappService = require("../services/whatsappService"); 9 | const { sendResponse } = require("../utils/response"); 10 | const httpStatusCode = require("../constants/httpStatusCode"); 11 | const logger = require("../utils/logger"); 12 | 13 | module.exports = { 14 | async status(req, res) { 15 | const schema = Joi.object({ 16 | sessionId: Joi.string().required(), 17 | }); 18 | 19 | const { error } = schema.validate(req.params); 20 | 21 | if (error) { 22 | return sendResponse( 23 | res, 24 | httpStatusCode.BAD_REQUEST, 25 | error.details[0].message 26 | ); 27 | } 28 | 29 | const { sessionId } = req.params; 30 | try { 31 | const session = await getSessionStatus(sessionId); 32 | logger.info({ 33 | msg: "Session status retrieved", 34 | sessionId, 35 | status: session.status, 36 | }); 37 | 38 | return sendResponse( 39 | res, 40 | httpStatusCode.OK, 41 | "Session status retrieved successfully", 42 | { session } 43 | ); 44 | } catch (error) { 45 | logger.error({ 46 | msg: "Error getting session status", 47 | sessionId, 48 | error: error.message, 49 | stack: error.stack, 50 | }); 51 | return sendResponse( 52 | res, 53 | httpStatusCode.INTERNAL_SERVER_ERROR, 54 | "Internal server error" 55 | ); 56 | } 57 | }, 58 | 59 | async create(req, res) { 60 | const schema = Joi.object({ 61 | sessionId: Joi.required(), 62 | }); 63 | const { error } = schema.validate(req.params); 64 | 65 | if (error) { 66 | return sendResponse( 67 | res, 68 | httpStatusCode.BAD_REQUEST, 69 | error.details[0].message 70 | ); 71 | } 72 | const { sessionId } = req.params; 73 | 74 | try { 75 | logger.info({ 76 | msg: "Creating new session", 77 | sessionId, 78 | }); 79 | await createSession(sessionId, false, res); 80 | } catch (error) { 81 | logger.error({ 82 | msg: "Error creating session", 83 | sessionId, 84 | error: error.message, 85 | stack: error.stack, 86 | }); 87 | return sendResponse( 88 | res, 89 | httpStatusCode.INTERNAL_SERVER_ERROR, 90 | "Failed to create session", 91 | null, 92 | error 93 | ); 94 | } 95 | }, 96 | 97 | async logout(req, res) { 98 | const schema = Joi.object({ 99 | sessionId: Joi.required(), 100 | }); 101 | const { error } = schema.validate(req.params); 102 | 103 | if (error) { 104 | return sendResponse( 105 | res, 106 | httpStatusCode.BAD_REQUEST, 107 | error.details[0].message 108 | ); 109 | } 110 | 111 | const { sessionId } = req.params; 112 | try { 113 | logger.info({ 114 | msg: "Logging out session", 115 | sessionId, 116 | }); 117 | 118 | const checkDir = await checkAndCleanSessionFolder(sessionId); 119 | if (!checkDir) { 120 | return sendResponse(res, httpStatusCode.NOT_FOUND, "Session not found"); 121 | } 122 | 123 | await deleteSession(sessionId, false); 124 | return sendResponse( 125 | res, 126 | httpStatusCode.OK, 127 | "Session deleted successfully" 128 | ); 129 | } catch (error) { 130 | logger.error({ 131 | msg: "Error logging out session", 132 | sessionId, 133 | error: error.message, 134 | stack: error.stack, 135 | }); 136 | return sendResponse( 137 | res, 138 | httpStatusCode.INTERNAL_SERVER_ERROR, 139 | "Failed to logout session", 140 | null, 141 | error 142 | ); 143 | } 144 | }, 145 | 146 | async getGroups(req, res) { 147 | const { sessionId } = req.params; 148 | 149 | try { 150 | const client = whatsappService.getSession(sessionId); 151 | if (!client) { 152 | return sendResponse(res, httpStatusCode.NOT_FOUND, "Session not found"); 153 | } 154 | 155 | // Ambil daftar grup 156 | const groups = whatsappService.getChatList(sessionId, true); 157 | 158 | // Format response 159 | const formattedGroups = groups.map((group) => ({ 160 | id: group.id, 161 | name: group.name, 162 | participant_count: group.participant_count || 0, 163 | creation_time: group.creation_time, 164 | })); 165 | 166 | return sendResponse( 167 | res, 168 | httpStatusCode.OK, 169 | "Groups retrieved successfully", 170 | { 171 | groups: formattedGroups, 172 | } 173 | ); 174 | } catch (error) { 175 | logger.error({ 176 | msg: "Error getting groups", 177 | sessionId, 178 | error: error.message, 179 | stack: error.stack, 180 | }); 181 | return sendResponse( 182 | res, 183 | httpStatusCode.INTERNAL_SERVER_ERROR, 184 | "Failed to get groups", 185 | null, 186 | error 187 | ); 188 | } 189 | }, 190 | }; 191 | -------------------------------------------------------------------------------- /src/controllers/utilityController.js: -------------------------------------------------------------------------------- 1 | const whatsappService = require("../services/whatsappService"); 2 | const { sendResponse } = require("../utils/response"); 3 | const httpStatusCode = require("../constants/httpStatusCode"); 4 | const Joi = require("joi"); 5 | 6 | module.exports = { 7 | async getGroups(req, res) { 8 | const { sessionId } = req.params; 9 | 10 | try { 11 | const client = whatsappService.getSession(sessionId); 12 | if (!client) { 13 | return sendResponse(res, httpStatusCode.NOT_FOUND, "Session not found"); 14 | } 15 | 16 | console.log(`[${sessionId}] Fetching group list...`); 17 | const groups = await whatsappService.groupFetchAllParticipating(client); 18 | if (!groups) { 19 | return sendResponse(res, httpStatusCode.NOT_FOUND, "Groups not found"); 20 | } 21 | 22 | // Format data grup 23 | const formattedGroups = Object.entries(groups).map(([id, group]) => ({ 24 | id: id, 25 | subject: group.subject, 26 | creation: group.creation, 27 | owner: group.owner, 28 | desc: group.desc, 29 | participants: group.participants.map((participant) => ({ 30 | id: participant.id, 31 | admin: participant.admin || null, 32 | })), 33 | participant_count: group.participants.length, 34 | ephemeral: group.ephemeral || null, 35 | announce: group.announce || false, 36 | restrict: group.restrict || false, 37 | })); 38 | 39 | return sendResponse( 40 | res, 41 | httpStatusCode.OK, 42 | "Groups retrieved successfully", 43 | { 44 | groups: formattedGroups, 45 | } 46 | ); 47 | } catch (error) { 48 | console.error(`[${sessionId}] Error getting groups:`, error); 49 | return sendResponse( 50 | res, 51 | httpStatusCode.INTERNAL_SERVER_ERROR, 52 | "Failed to get groups", 53 | error.message 54 | ); 55 | } 56 | }, 57 | 58 | async checkNumber(req, res) { 59 | const schema = Joi.object({ 60 | sessionId: Joi.string().required(), 61 | phone: Joi.string().required(), 62 | }); 63 | 64 | const { error } = schema.validate(req.body); 65 | if (error) { 66 | return sendResponse( 67 | res, 68 | httpStatusCode.BAD_REQUEST, 69 | error.details[0].message 70 | ); 71 | } 72 | 73 | const { sessionId, phone } = req.body; 74 | const formattedPhone = whatsappService.formatPhone(phone); 75 | 76 | try { 77 | const client = whatsappService.getSession(sessionId); 78 | if (!client) { 79 | return sendResponse(res, httpStatusCode.NOT_FOUND, "Session not found"); 80 | } 81 | 82 | console.log(`[${sessionId}] Checking number ${formattedPhone}`); 83 | const exists = await whatsappService.isExists(client, formattedPhone); 84 | const phoneNumber = formattedPhone.replace(/[^0-9]/g, ""); 85 | 86 | return sendResponse( 87 | res, 88 | httpStatusCode.OK, 89 | "Number checked successfully", 90 | { 91 | phone: phoneNumber, 92 | exists: exists, 93 | whatsapp_id: formattedPhone, 94 | } 95 | ); 96 | } catch (error) { 97 | console.error(`[${sessionId}] Error checking number:`, error); 98 | return sendResponse( 99 | res, 100 | httpStatusCode.INTERNAL_SERVER_ERROR, 101 | "Failed to check number", 102 | error.message 103 | ); 104 | } 105 | }, 106 | }; 107 | -------------------------------------------------------------------------------- /src/middlewares/apikeyValidator.js: -------------------------------------------------------------------------------- 1 | const validate = function (req, res, next) { 2 | try { 3 | const authHeader = (req.headers.authorization || "").split(" ").pop(); 4 | const apikey = req.header("x-api-key"); 5 | 6 | const response = { 7 | success: false, 8 | message: "UNAUTHORIZED", 9 | error: { 10 | message: "UNAUTHORIZED", 11 | code: 401, 12 | ip: req.ip, 13 | timestamp: new Date().toISOString({ timeZone: "Asia/Jakarta" }), 14 | }, 15 | }; 16 | if (!authHeader && !apikey) { 17 | return res.status(401).json(response); 18 | } 19 | 20 | if (apikey && apikey !== process.env.API_KEY) { 21 | return res.status(401).json(response); 22 | } 23 | 24 | next(); 25 | } catch (error) { 26 | next(error); 27 | } 28 | }; 29 | 30 | module.exports = validate; 31 | -------------------------------------------------------------------------------- /src/middlewares/jsonResponse.js: -------------------------------------------------------------------------------- 1 | module.exports = (req, res, next) => { 2 | res.setHeader("Content-Type", "application/json"); 3 | next(); 4 | }; 5 | -------------------------------------------------------------------------------- /src/routes/index.js: -------------------------------------------------------------------------------- 1 | const router = require("express").Router(); 2 | const sessionsRoute = require("./sessionsRoute.js"); 3 | const messageRoute = require("./messageRoute.js"); 4 | const utilityRoute = require("./utilityRoute.js"); 5 | const webhookRoutes = require("./webhookRoutes.js"); 6 | const queueRoute = require("./queueRoute.js"); 7 | const swaggerRoute = require("./swagger"); 8 | 9 | // Health check 10 | router.get("/health", (req, res) => 11 | res.status(200).json({ 12 | success: true, 13 | message: "The server is running", 14 | date: new Date().toISOString({ timeZone: "Asia/Jakarta" }), 15 | version: process.env.npm_package_version, 16 | uptime: process.uptime(), 17 | memoryUsage: process.memoryUsage(), 18 | platform: process.platform, 19 | architecture: process.arch, 20 | nodeVersion: process.version, 21 | environment: process.env.NODE_ENV, 22 | }) 23 | ); 24 | 25 | // API routes 26 | router.use("/sessions", sessionsRoute); // Manajemen Sesi 27 | router.use("/messages", messageRoute); // Pengiriman Pesan 28 | router.use("/utility", utilityRoute); // Utilitas & Helper 29 | router.use("/webhook", webhookRoutes); // Webhook Management 30 | router.use("/queue", queueRoute); // Queue Management 31 | 32 | // Swagger documentation - pindah ke /docs 33 | router.use("/", swaggerRoute); // Dokumentasi API 34 | 35 | // Wildcard route untuk 404 36 | router.all("*", function (req, res) { 37 | res.status(404).json({ 38 | success: false, 39 | message: "Not Found", 40 | }); 41 | }); 42 | 43 | module.exports = router; 44 | -------------------------------------------------------------------------------- /src/routes/messageRoute.js: -------------------------------------------------------------------------------- 1 | const router = require("express").Router(); 2 | const messageController = require("../controllers/messageController"); 3 | const apikeyValidator = require("../middlewares/apikeyValidator"); 4 | const jsonResponse = require("../middlewares/jsonResponse"); 5 | 6 | // Endpoint untuk mengirim pesan (teks & media) 7 | router.post("/send", [apikeyValidator], messageController.sendMessage); 8 | 9 | // New mention route 10 | router.post( 11 | "/mention", 12 | [apikeyValidator], 13 | messageController.sendMentionMessage 14 | ); 15 | 16 | module.exports = router; 17 | -------------------------------------------------------------------------------- /src/routes/queueRoute.js: -------------------------------------------------------------------------------- 1 | const router = require("express").Router(); 2 | const queueController = require("../controllers/queueController.js"); 3 | const apiKeyValidation = require("../middlewares/apikeyValidator.js"); 4 | 5 | /** 6 | * @swagger 7 | * /queue/status/{sessionId}: 8 | * get: 9 | * summary: Get queue status for a session 10 | * tags: [Queue] 11 | * parameters: 12 | * - name: sessionId 13 | * in: path 14 | * required: true 15 | * schema: 16 | * type: string 17 | * responses: 18 | * 200: 19 | * description: Queue status retrieved 20 | * 401: 21 | * description: Unauthorized 22 | * 404: 23 | * description: Session not found 24 | * 500: 25 | * description: Server error 26 | */ 27 | router.get("/status/:sessionId", apiKeyValidation, queueController.getQueueStatus); 28 | 29 | /** 30 | * @swagger 31 | * /queue/clear/{sessionId}: 32 | * post: 33 | * summary: Clear queue for a session 34 | * tags: [Queue] 35 | * parameters: 36 | * - name: sessionId 37 | * in: path 38 | * required: true 39 | * schema: 40 | * type: string 41 | * responses: 42 | * 200: 43 | * description: Queue cleared successfully 44 | * 401: 45 | * description: Unauthorized 46 | * 404: 47 | * description: Session not found 48 | * 500: 49 | * description: Server error 50 | */ 51 | router.post("/clear/:sessionId", apiKeyValidation, queueController.clearSessionQueue); 52 | 53 | module.exports = router; -------------------------------------------------------------------------------- /src/routes/sessionsRoute.js: -------------------------------------------------------------------------------- 1 | const router = require("express").Router(); 2 | const apikeyValidator = require("./../middlewares/apikeyValidator.js"); 3 | const sessionController = require("../controllers/sessionController.js"); 4 | 5 | router.get("/:sessionId", apikeyValidator, sessionController.status); 6 | router.post("/:sessionId", apikeyValidator, sessionController.create); 7 | router.post("/:sessionId/logout", apikeyValidator, sessionController.logout); 8 | router.get("/:sessionId/groups", apikeyValidator, sessionController.getGroups); 9 | 10 | module.exports = router; 11 | -------------------------------------------------------------------------------- /src/routes/swagger/index.js: -------------------------------------------------------------------------------- 1 | const router = require("express").Router(); 2 | const swaggerUi = require("swagger-ui-express"); 3 | const specs = require("./swagger-config"); 4 | 5 | const swaggerOptions = { 6 | explorer: true, 7 | swaggerOptions: { 8 | persistAuthorization: true, 9 | }, 10 | customCss: ".swagger-ui .topbar { display: none }", 11 | customSiteTitle: "WhatsApp API Documentation", 12 | }; 13 | 14 | router.use("/docs", swaggerUi.serve); 15 | router.get("/docs", swaggerUi.setup(specs, swaggerOptions)); 16 | 17 | module.exports = router; 18 | -------------------------------------------------------------------------------- /src/routes/swagger/swagger-config.js: -------------------------------------------------------------------------------- 1 | const swaggerJsdoc = require("swagger-jsdoc"); 2 | 3 | const env = process.env.NODE_ENV || "local"; 4 | const port = process.env.PORT || 3000; 5 | const url = env === "local" ? "http://localhost:" + port : process.env.BASE_URL; 6 | const desc = env === "local" ? "Development" : "Production"; 7 | 8 | const options = { 9 | definition: { 10 | openapi: "3.0.0", 11 | info: { 12 | title: "WhatsApp API Documentation", 13 | version: "1.0.0", 14 | description: "Dokumentasi API untuk WhatsApp API menggunakan Baileys", 15 | license: { 16 | name: "MIT", 17 | url: "https://spdx.org/licenses/MIT.html", 18 | }, 19 | contact: { 20 | name: "WhatsApp API", 21 | url: "https://github.com/yourusername/whatsapp-api", 22 | }, 23 | }, 24 | servers: [ 25 | { 26 | url, 27 | description: desc, 28 | }, 29 | ], 30 | components: { 31 | securitySchemes: { 32 | ApiKeyAuth: { 33 | type: "apiKey", 34 | in: "header", 35 | name: "x-api-key", 36 | description: "API Key Authentication", 37 | }, 38 | }, 39 | }, 40 | security: [ 41 | { 42 | ApiKeyAuth: [], 43 | }, 44 | ], 45 | }, 46 | apis: ["./src/routes/swagger/swagger-docs/*.js", "./src/routes/*.js"], 47 | }; 48 | 49 | module.exports = swaggerJsdoc(options); 50 | -------------------------------------------------------------------------------- /src/routes/swagger/swagger-docs/messages.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @swagger 3 | * tags: 4 | * name: Messages 5 | * description: API Endpoint untuk pengiriman pesan 6 | */ 7 | 8 | /** 9 | * @swagger 10 | * /messages/send: 11 | * post: 12 | * summary: Mengirim pesan teks atau media 13 | * tags: [Messages] 14 | * requestBody: 15 | * required: true 16 | * content: 17 | * application/json: 18 | * schema: 19 | * type: object 20 | * required: 21 | * - sender 22 | * - receiver 23 | * - message 24 | * properties: 25 | * sender: 26 | * type: string 27 | * example: "session_id_1" 28 | * description: "ID sesi pengirim" 29 | * receiver: 30 | * type: string 31 | * example: "6285123456789" 32 | * description: "Nomor penerima dengan kode negara" 33 | * message: 34 | * type: string 35 | * example: "Hello World!" 36 | * description: "Isi pesan atau caption untuk media" 37 | * file: 38 | * type: string 39 | * example: "https://example.com/image.jpg" 40 | * description: "URL publik file media (opsional)" 41 | * viewOnce: 42 | * type: boolean 43 | * example: false 44 | * description: "Pesan sekali lihat (opsional, default: false)" 45 | * responses: 46 | * 200: 47 | * description: Pesan berhasil dikirim 48 | * content: 49 | * application/json: 50 | * schema: 51 | * type: object 52 | * properties: 53 | * success: 54 | * type: boolean 55 | * example: true 56 | * message: 57 | * type: string 58 | * example: "Message sent successfully" 59 | * data: 60 | * type: object 61 | * properties: 62 | * sender: 63 | * type: string 64 | * example: "session_id_1" 65 | * receiver: 66 | * type: string 67 | * example: "6285123456789@s.whatsapp.net" 68 | * message: 69 | * type: string 70 | * example: "Hello World!" 71 | * file: 72 | * type: string 73 | * example: "https://example.com/image.jpg" 74 | * nullable: true 75 | * viewOnce: 76 | * type: boolean 77 | * example: false 78 | * messageId: 79 | * type: string 80 | * example: "ABCD1234" 81 | * nullable: true 82 | */ 83 | 84 | /** 85 | * @swagger 86 | * /messages/mention: 87 | * post: 88 | * summary: Mengirim pesan dengan mention 89 | * tags: [Messages] 90 | * requestBody: 91 | * required: true 92 | * content: 93 | * application/json: 94 | * schema: 95 | * type: object 96 | * required: 97 | * - sender 98 | * - receiver 99 | * properties: 100 | * sender: 101 | * type: string 102 | * example: "session_id_1" 103 | * receiver: 104 | * type: string 105 | * description: "Nomor WhatsApp atau ID Grup" 106 | * example: "6285123456789 atau 123456789@g.us" 107 | * message: 108 | * type: string 109 | * example: "Hello everyone!" 110 | * responses: 111 | * 200: 112 | * description: Pesan mention berhasil dikirim 113 | * content: 114 | * application/json: 115 | * schema: 116 | * type: object 117 | * properties: 118 | * success: 119 | * type: boolean 120 | * example: true 121 | * message: 122 | * type: string 123 | * example: "Mention message sent successfully" 124 | * data: 125 | * type: object 126 | * properties: 127 | * messageId: 128 | * type: string 129 | * example: "123456789" 130 | * receiver: 131 | * type: string 132 | * example: "6285123456789@s.whatsapp.net" 133 | * message: 134 | * type: string 135 | * example: "Hello everyone!" 136 | * mentions: 137 | * type: array 138 | * items: 139 | * type: string 140 | * example: ["6285123456789@s.whatsapp.net"] 141 | * 404: 142 | * description: Session not found 143 | * 400: 144 | * description: Invalid request parameters 145 | * 500: 146 | * description: Internal server error 147 | */ 148 | -------------------------------------------------------------------------------- /src/routes/swagger/swagger-docs/sessions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @swagger 3 | * tags: 4 | * name: Sessions 5 | * description: API Endpoint untuk manajemen sesi WhatsApp 6 | */ 7 | 8 | /** 9 | * @swagger 10 | * /sessions/{sessionId}: 11 | * post: 12 | * summary: Membuat sesi WhatsApp baru 13 | * tags: [Sessions] 14 | * parameters: 15 | * - in: path 16 | * name: sessionId 17 | * required: true 18 | * schema: 19 | * type: string 20 | * example: "session_id_1" 21 | * description: "ID sesi yang unik" 22 | * responses: 23 | * 200: 24 | * description: Sesi berhasil dibuat atau sudah ada 25 | * content: 26 | * application/json: 27 | * schema: 28 | * type: object 29 | * properties: 30 | * success: 31 | * type: boolean 32 | * example: true 33 | * message: 34 | * type: string 35 | * example: "Session already exists and connected" 36 | * data: 37 | * type: object 38 | * properties: 39 | * status: 40 | * type: string 41 | * enum: [connected, exists, not_found] 42 | * example: "connected" 43 | * user: 44 | * type: object 45 | * description: "Data pengguna WhatsApp" 46 | * connectionState: 47 | * type: string 48 | * enum: [open, closed, null] 49 | * example: "open" 50 | * qr: 51 | * type: string 52 | * description: "QR code dalam format base64 (hanya untuk sesi baru)" 53 | * 401: 54 | * description: Unauthorized 55 | * 500: 56 | * description: Internal server error 57 | * content: 58 | * application/json: 59 | * schema: 60 | * type: object 61 | * properties: 62 | * success: 63 | * type: boolean 64 | * example: false 65 | * message: 66 | * type: string 67 | * example: "Failed to create session" 68 | * error: 69 | * type: string 70 | * example: "Connection timeout" 71 | * 72 | * get: 73 | * summary: Mengecek status sesi 74 | * tags: [Sessions] 75 | * parameters: 76 | * - in: path 77 | * name: sessionId 78 | * required: true 79 | * schema: 80 | * type: string 81 | * example: "session_id_1" 82 | * description: "ID sesi yang ingin dicek" 83 | * responses: 84 | * 200: 85 | * description: Status sesi berhasil diambil 86 | * content: 87 | * application/json: 88 | * schema: 89 | * type: object 90 | * properties: 91 | * success: 92 | * type: boolean 93 | * example: true 94 | * data: 95 | * type: object 96 | * properties: 97 | * status: 98 | * type: string 99 | * enum: [connected, exists, not_found, error] 100 | * example: "connected" 101 | * user: 102 | * type: object 103 | * description: "Data pengguna WhatsApp" 104 | * connectionState: 105 | * type: string 106 | * enum: [open, closed, null] 107 | * example: "open" 108 | * 401: 109 | * description: Unauthorized 110 | * 404: 111 | * description: Sesi tidak ditemukan 112 | */ 113 | 114 | /** 115 | * @swagger 116 | * /sessions/{sessionId}/logout: 117 | * post: 118 | * summary: Logout dari sesi WhatsApp 119 | * tags: [Sessions] 120 | * parameters: 121 | * - in: path 122 | * name: sessionId 123 | * required: true 124 | * schema: 125 | * type: string 126 | * example: "session_id_1" 127 | * description: "ID sesi yang ingin dilogout" 128 | * responses: 129 | * 200: 130 | * description: Berhasil logout 131 | * content: 132 | * application/json: 133 | * schema: 134 | * type: object 135 | * properties: 136 | * success: 137 | * type: boolean 138 | * example: true 139 | * message: 140 | * type: string 141 | * example: "Session logged out successfully" 142 | * data: 143 | * type: object 144 | * properties: 145 | * sessionId: 146 | * type: string 147 | * example: "session_id_1" 148 | * status: 149 | * type: string 150 | * example: "logged_out" 151 | * 401: 152 | * description: Unauthorized 153 | * 404: 154 | * description: Sesi tidak ditemukan 155 | * content: 156 | * application/json: 157 | * schema: 158 | * type: object 159 | * properties: 160 | * success: 161 | * type: boolean 162 | * example: false 163 | * message: 164 | * type: string 165 | * example: "Session not found" 166 | * 500: 167 | * description: Internal server error 168 | * content: 169 | * application/json: 170 | * schema: 171 | * type: object 172 | * properties: 173 | * success: 174 | * type: boolean 175 | * example: false 176 | * message: 177 | * type: string 178 | * example: "Failed to logout session" 179 | * error: 180 | * type: string 181 | * example: "Error message details" 182 | */ 183 | -------------------------------------------------------------------------------- /src/routes/swagger/swagger-docs/utility.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @swagger 3 | * tags: 4 | * name: Utility 5 | * description: API Endpoint untuk utilitas dan helper 6 | */ 7 | 8 | /** 9 | * @swagger 10 | * /utility/groups/{sessionId}: 11 | * get: 12 | * summary: Mendapatkan daftar grup 13 | * tags: [Utility] 14 | * parameters: 15 | * - in: path 16 | * name: sessionId 17 | * required: true 18 | * schema: 19 | * type: string 20 | * example: "session_id_1" 21 | * description: "ID sesi yang ingin dicek grupnya" 22 | * responses: 23 | * 200: 24 | * description: Daftar grup berhasil diambil 25 | * content: 26 | * application/json: 27 | * schema: 28 | * type: object 29 | * properties: 30 | * success: 31 | * type: boolean 32 | * example: true 33 | * message: 34 | * type: string 35 | * example: "Groups retrieved successfully" 36 | * data: 37 | * type: object 38 | * properties: 39 | * groups: 40 | * type: array 41 | * items: 42 | * type: object 43 | * properties: 44 | * id: 45 | * type: string 46 | * example: "123456789@g.us" 47 | * subject: 48 | * type: string 49 | * example: "My Group" 50 | * creation: 51 | * type: number 52 | * example: 1625097600 53 | * owner: 54 | * type: string 55 | * example: "6285123456789@s.whatsapp.net" 56 | * desc: 57 | * type: string 58 | * example: "Group Description" 59 | * participants: 60 | * type: array 61 | * items: 62 | * type: object 63 | * properties: 64 | * id: 65 | * type: string 66 | * example: "6285123456789@s.whatsapp.net" 67 | * admin: 68 | * type: string 69 | * enum: [null, "admin", "superadmin"] 70 | * example: "admin" 71 | * participant_count: 72 | * type: integer 73 | * example: 50 74 | * ephemeral: 75 | * type: number 76 | * nullable: true 77 | * example: null 78 | * announce: 79 | * type: boolean 80 | * example: false 81 | * restrict: 82 | * type: boolean 83 | * example: false 84 | * 85 | * /utility/check-number: 86 | * post: 87 | * summary: Mengecek apakah nomor terdaftar di WhatsApp 88 | * tags: [Utility] 89 | * requestBody: 90 | * required: true 91 | * content: 92 | * application/json: 93 | * schema: 94 | * type: object 95 | * required: 96 | * - sessionId 97 | * - phone 98 | * properties: 99 | * sessionId: 100 | * type: string 101 | * example: "session_id_1" 102 | * phone: 103 | * type: string 104 | * example: "6285123456789" 105 | * responses: 106 | * 200: 107 | * description: Pengecekan nomor berhasil 108 | * content: 109 | * application/json: 110 | * schema: 111 | * type: object 112 | * properties: 113 | * success: 114 | * type: boolean 115 | * example: true 116 | * message: 117 | * type: string 118 | * example: "Number checked successfully" 119 | * data: 120 | * type: object 121 | * properties: 122 | * phone: 123 | * type: string 124 | * example: "6285123456789" 125 | * exists: 126 | * type: boolean 127 | * example: true 128 | * whatsapp_id: 129 | * type: string 130 | * example: "6285123456789@s.whatsapp.net" 131 | */ 132 | -------------------------------------------------------------------------------- /src/routes/swagger/swagger-docs/webhooks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @swagger 3 | * tags: 4 | * name: Webhook 5 | * description: Endpoint untuk manajemen webhook 6 | */ 7 | 8 | /** 9 | * @swagger 10 | * /webhook/set/{sessionId}: 11 | * post: 12 | * summary: Mengatur webhook untuk session tertentu 13 | * description: | 14 | * Mengatur webhook URL untuk session tertentu dengan konfigurasi keamanan. 15 | * Webhook akan menerima notifikasi dengan secret key untuk verifikasi. 16 | * tags: [Webhook] 17 | * parameters: 18 | * - in: path 19 | * name: sessionId 20 | * required: true 21 | * schema: 22 | * type: string 23 | * example: session_id_1 24 | * description: ID session WhatsApp 25 | * requestBody: 26 | * required: true 27 | * content: 28 | * application/json: 29 | * schema: 30 | * type: object 31 | * required: 32 | * - url 33 | * properties: 34 | * url: 35 | * type: string 36 | * format: uri 37 | * description: URL webhook yang akan menerima notifikasi 38 | * secretKey: 39 | * type: string 40 | * description: Secret key untuk signing payload (opsional, akan digenerate jika tidak disediakan) 41 | * enabled: 42 | * type: boolean 43 | * description: Status aktif webhook (default true) 44 | * example: 45 | * url: "https://example.com/webhook" 46 | * secretKey: "your-secret-key" 47 | * enabled: true 48 | * responses: 49 | * 200: 50 | * description: Webhook berhasil diatur 51 | * content: 52 | * application/json: 53 | * schema: 54 | * type: object 55 | * properties: 56 | * status: 57 | * type: boolean 58 | * example: true 59 | * message: 60 | * type: string 61 | * example: "Webhook berhasil diatur" 62 | * data: 63 | * type: object 64 | * properties: 65 | * url: 66 | * type: string 67 | * example: "https://example.com/webhook" 68 | * secretKey: 69 | * type: string 70 | * example: "generated-or-provided-secret-key" 71 | * enabled: 72 | * type: boolean 73 | * example: true 74 | * retryCount: 75 | * type: integer 76 | * example: 0 77 | * lastFailedAt: 78 | * type: string 79 | * format: date-time 80 | * nullable: true 81 | * example: null 82 | * createdAt: 83 | * type: string 84 | * format: date-time 85 | * example: "2024-03-28T00:00:00Z" 86 | * updatedAt: 87 | * type: string 88 | * format: date-time 89 | * example: "2024-03-28T00:00:00Z" 90 | * 400: 91 | * description: URL webhook tidak valid atau parameter tidak lengkap 92 | * 401: 93 | * description: API key tidak valid 94 | * 500: 95 | * description: Server error 96 | * 97 | * /webhook/status/{sessionId}: 98 | * get: 99 | * summary: Cek status webhook 100 | * description: Mendapatkan status dan konfigurasi webhook untuk session tertentu 101 | * tags: [Webhook] 102 | * parameters: 103 | * - in: path 104 | * name: sessionId 105 | * required: true 106 | * schema: 107 | * type: string 108 | * example: session_id_1 109 | * description: ID session WhatsApp 110 | * responses: 111 | * 200: 112 | * description: Status webhook berhasil didapatkan 113 | * content: 114 | * application/json: 115 | * schema: 116 | * type: object 117 | * properties: 118 | * status: 119 | * type: boolean 120 | * example: true 121 | * data: 122 | * type: object 123 | * properties: 124 | * status: 125 | * type: string 126 | * enum: [healthy, unhealthy, not_configured] 127 | * example: healthy 128 | * url: 129 | * type: string 130 | * example: "https://example.com/webhook" 131 | * enabled: 132 | * type: boolean 133 | * example: true 134 | * retryCount: 135 | * type: integer 136 | * example: 0 137 | * lastFailedAt: 138 | * type: string 139 | * format: date-time 140 | * nullable: true 141 | * example: null 142 | * createdAt: 143 | * type: string 144 | * format: date-time 145 | * example: "2024-03-28T00:00:00Z" 146 | * updatedAt: 147 | * type: string 148 | * format: date-time 149 | * example: "2024-03-28T00:00:00Z" 150 | * error: 151 | * type: string 152 | * example: null 153 | * 401: 154 | * description: API key tidak valid 155 | * 500: 156 | * description: Server error 157 | */ 158 | -------------------------------------------------------------------------------- /src/routes/utilityRoute.js: -------------------------------------------------------------------------------- 1 | const router = require("express").Router(); 2 | const apikeyValidator = require("../middlewares/apikeyValidator.js"); 3 | const utilityController = require("../controllers/utilityController.js"); 4 | 5 | router.get("/groups/:sessionId", apikeyValidator, utilityController.getGroups); 6 | router.post("/check-number", apikeyValidator, utilityController.checkNumber); 7 | 8 | // TODO: Endpoint lain untuk utility/helper 9 | // router.get("/contacts/:sessionId", apikeyValidator, utilityController.getContacts); 10 | // router.get("/profile/:sessionId", apikeyValidator, utilityController.getProfile); 11 | 12 | module.exports = router; 13 | -------------------------------------------------------------------------------- /src/routes/webhookRoutes.js: -------------------------------------------------------------------------------- 1 | const router = require("express").Router(); 2 | const webhookService = require("../services/webhookService"); 3 | const apikeyValidator = require("../middlewares/apikeyValidator"); 4 | const logger = require("../utils/logger"); 5 | 6 | router.post("/set/:sessionId", [apikeyValidator], async (req, res) => { 7 | try { 8 | const { sessionId } = req.params; 9 | const { url, secretKey, enabled = true } = req.body; 10 | 11 | if (!url) { 12 | return res.status(400).json({ 13 | status: false, 14 | message: "URL webhook diperlukan", 15 | }); 16 | } 17 | 18 | const result = await webhookService.setWebhook(sessionId, url, { 19 | secretKey, 20 | enabled: enabled, 21 | retryCount: 0, 22 | lastFailedAt: null, 23 | createdAt: new Date().toISOString(), 24 | updatedAt: new Date().toISOString(), 25 | }); 26 | 27 | return res.status(200).json({ 28 | status: true, 29 | message: "Webhook berhasil diatur", 30 | data: result, 31 | }); 32 | } catch (error) { 33 | logger.error({ 34 | msg: "Error setting webhook", 35 | error: error.message, 36 | stack: error.stack, 37 | }); 38 | 39 | return res.status(500).json({ 40 | status: false, 41 | message: "Gagal mengatur webhook", 42 | error: error.message, 43 | }); 44 | } 45 | }); 46 | 47 | router.get("/status/:sessionId", [apikeyValidator], async (req, res) => { 48 | try { 49 | const { sessionId } = req.params; 50 | const status = await webhookService.checkHealth(sessionId); 51 | 52 | return res.status(200).json({ 53 | status: true, 54 | data: status, 55 | }); 56 | } catch (error) { 57 | logger.error({ 58 | msg: "Error getting webhook status", 59 | error: error.message, 60 | stack: error.stack, 61 | }); 62 | 63 | return res.status(500).json({ 64 | status: false, 65 | message: "Gagal mendapatkan status webhook", 66 | error: error.message, 67 | }); 68 | } 69 | }); 70 | 71 | module.exports = router; 72 | -------------------------------------------------------------------------------- /src/services/queueService.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Queue Service untuk menangani antrian pesan WhatsApp 3 | * Menggunakan Bull dan Redis untuk implementasi yang reliable 4 | */ 5 | 6 | const logger = require("../utils/logger"); 7 | const redisQueue = require("../utils/queue"); 8 | 9 | /** 10 | * Fungsi pemrosesan pesan dalam queue 11 | * @param {Object} job - Job yang akan diproses 12 | * @returns {Promise} - Hasil proses 13 | */ 14 | async function processMessage(job) { 15 | const { data: task } = job; 16 | let { sessionId, chatId, message, type } = task; 17 | 18 | logger.info({ 19 | msg: `Memproses pesan dalam queue`, 20 | sessionId, 21 | chatId, 22 | type, 23 | jobId: job.id, 24 | messageReceived: message, 25 | }); 26 | 27 | try { 28 | // Mendapatkan client session WhatsApp 29 | const whatsappService = require("./whatsappService"); 30 | const client = whatsappService.getSession(sessionId); 31 | 32 | if (!client) { 33 | const error = new Error(`Session ${sessionId} tidak ditemukan`); 34 | logger.error({ 35 | msg: "Session tidak ditemukan saat memproses pesan", 36 | sessionId, 37 | error: error.message, 38 | }); 39 | throw error; 40 | } 41 | 42 | // Format pesan sesuai dengan tipe data 43 | let messageToSend; 44 | 45 | // Jika message adalah string JSON, parse terlebih dahulu 46 | if ( 47 | typeof message === "string" && 48 | (message.startsWith("{") || message.startsWith("[")) 49 | ) { 50 | try { 51 | message = JSON.parse(message); 52 | } catch (e) { 53 | logger.warn({ 54 | msg: "Failed to parse JSON message, using as text", 55 | error: e.message, 56 | }); 57 | } 58 | } 59 | 60 | // Cek tipe pesan dan format sesuai kebutuhan 61 | if (typeof message === "object") { 62 | if (message.image || message.video || message.document || message.audio) { 63 | // Pesan media, gunakan langsung 64 | messageToSend = message; 65 | logger.info({ 66 | msg: "Menggunakan format media yang sudah ada", 67 | mediaType: type, 68 | hasCaption: message.caption ? "yes" : "no", 69 | }); 70 | } else if (message.text) { 71 | // Pesan teks dalam format objek 72 | messageToSend = message; 73 | } else { 74 | // Objek lain, konversi ke teks 75 | messageToSend = { text: JSON.stringify(message) }; 76 | } 77 | } else { 78 | // String biasa atau tipe data lain 79 | messageToSend = { text: String(message) }; 80 | } 81 | 82 | // Log detail pesan sebelum pengiriman 83 | logger.info({ 84 | msg: `Struktur pesan yang akan dikirim`, 85 | messageType: type, 86 | messageStructure: messageToSend, 87 | sessionId, 88 | chatId, 89 | }); 90 | 91 | // Validasi pesan 92 | if (!messageToSend) { 93 | throw new Error("Invalid message format: Message is empty or invalid"); 94 | } 95 | 96 | // Mengirim pesan 97 | const result = await client.sendMessage(chatId, messageToSend); 98 | 99 | logger.info({ 100 | msg: `Pesan berhasil dikirim`, 101 | sessionId, 102 | chatId, 103 | messageId: result?.key?.id || null, 104 | messageType: type, 105 | }); 106 | 107 | // Format hasil 108 | const enhancedResult = { 109 | ...result, 110 | sender: sessionId, 111 | receiver: chatId, 112 | message: type === "text" ? messageToSend.text : `${type} message sent`, 113 | }; 114 | 115 | return enhancedResult; 116 | } catch (error) { 117 | logger.error({ 118 | msg: `Error saat memproses pesan`, 119 | error: error.message, 120 | stack: error.stack, 121 | sessionId, 122 | chatId, 123 | }); 124 | throw error; 125 | } 126 | } 127 | 128 | /** 129 | * Menambahkan pesan ke dalam queue 130 | * @param {string} sessionId - ID session WhatsApp 131 | * @param {Object} task - Tugas yang akan ditambahkan ke queue 132 | * @returns {Promise} Promise dengan hasil penambahan ke queue 133 | */ 134 | async function addToQueue(sessionId, task) { 135 | try { 136 | // Membuat copy task untuk menghindari modifikasi langsung parameter asli 137 | let processedTask = { ...task }; 138 | 139 | // Konversi sender dan receiver jika perlu 140 | if (!processedTask.sessionId && processedTask.sender) { 141 | processedTask.sessionId = processedTask.sender; 142 | } else if (!processedTask.sessionId) { 143 | processedTask.sessionId = sessionId; 144 | } 145 | 146 | if (!processedTask.chatId && processedTask.receiver) { 147 | // Import whatsappService untuk format nomor telepon 148 | const whatsappService = require("./whatsappService"); 149 | processedTask.chatId = whatsappService.formatPhone( 150 | processedTask.receiver 151 | ); 152 | } 153 | 154 | // Tentukan tipe message berdasarkan konten 155 | if (!processedTask.type) { 156 | if (processedTask.file) { 157 | // Deteksi jenis file berdasarkan ekstensi URL 158 | const fileUrl = processedTask.file.toLowerCase(); 159 | const caption = processedTask.message || ""; 160 | let fileType = "document"; 161 | let mimeType = "application/octet-stream"; 162 | 163 | // Deteksi tipe file berdasarkan ekstensi atau URL 164 | if ( 165 | fileUrl.endsWith(".jpg") || 166 | fileUrl.endsWith(".jpeg") || 167 | fileUrl.endsWith(".png") || 168 | fileUrl.includes("image") 169 | ) { 170 | fileType = "image"; 171 | mimeType = fileUrl.endsWith(".png") ? "image/png" : "image/jpeg"; 172 | } else if (fileUrl.endsWith(".pdf")) { 173 | fileType = "document"; 174 | mimeType = "application/pdf"; 175 | } else if (fileUrl.endsWith(".mp4") || fileUrl.endsWith(".mov")) { 176 | fileType = "video"; 177 | mimeType = "video/mp4"; 178 | } else if (fileUrl.endsWith(".mp3") || fileUrl.endsWith(".ogg")) { 179 | fileType = "audio"; 180 | mimeType = fileUrl.endsWith(".mp3") ? "audio/mp3" : "audio/ogg"; 181 | } 182 | 183 | // Set jenis file yang terdeteksi 184 | processedTask.type = fileType; 185 | 186 | // Buat objek media sesuai dengan standar Baileys 187 | processedTask.message = { 188 | [fileType]: { url: processedTask.file }, 189 | caption: caption, 190 | mimetype: mimeType, 191 | }; 192 | 193 | if (processedTask.viewOnce === true) { 194 | processedTask.message.viewOnce = true; 195 | } 196 | 197 | logger.info({ 198 | msg: `File terdeteksi: ${fileType}`, 199 | sessionId: processedTask.sessionId, 200 | chatId: processedTask.chatId, 201 | fileUrl: processedTask.file, 202 | messageStructure: processedTask.message, 203 | }); 204 | } else { 205 | // Jika tidak ada file, set type menjadi text 206 | processedTask.type = "text"; 207 | // Pastikan message adalah string untuk pesan teks 208 | if (typeof processedTask.message !== "string") { 209 | processedTask.message = { text: String(processedTask.message) }; 210 | } else { 211 | processedTask.message = { text: processedTask.message }; 212 | } 213 | } 214 | } 215 | 216 | logger.info({ 217 | msg: `Menambahkan pesan ke queue`, 218 | sessionId: processedTask.sessionId, 219 | chatId: processedTask.chatId, 220 | type: processedTask.type, 221 | messageStructure: processedTask.message, 222 | }); 223 | 224 | // Buat queue jika belum ada dengan processor function 225 | const queue = redisQueue.getOrCreateQueue( 226 | processedTask.sessionId, 227 | processMessage 228 | ); 229 | 230 | // Tambahkan task ke queue 231 | const result = await redisQueue.addToQueue( 232 | processedTask.sessionId, 233 | processedTask 234 | ); 235 | 236 | logger.info({ 237 | msg: `Pesan berhasil ditambahkan ke queue`, 238 | sessionId: processedTask.sessionId, 239 | chatId: processedTask.chatId, 240 | }); 241 | 242 | return result; 243 | } catch (error) { 244 | logger.error({ 245 | msg: `Gagal menambahkan pesan ke queue`, 246 | sessionId, 247 | chatId: task?.chatId, 248 | error: error.message, 249 | stack: error.stack, 250 | }); 251 | throw error; 252 | } 253 | } 254 | 255 | /** 256 | * Menghapus queue untuk sesi tertentu 257 | * @param {string} sessionId - ID sesi yang queuenya akan dihapus 258 | * @returns {Promise} - Status keberhasilan 259 | */ 260 | async function clearSessionQueue(sessionId) { 261 | return await redisQueue.clearSessionQueue(sessionId); 262 | } 263 | 264 | /** 265 | * Menghapus semua queue yang ada 266 | * Berguna saat melakukan shutdown aplikasi 267 | */ 268 | async function clearAllQueues() { 269 | await redisQueue.clearAllQueues(); 270 | } 271 | 272 | /** 273 | * Mengecek status queue untuk session tertentu 274 | * @param {string} sessionId - ID session WhatsApp 275 | * @returns {Promise} Informasi status queue 276 | */ 277 | async function getQueueStatus(sessionId) { 278 | return await redisQueue.getQueueStatus(sessionId); 279 | } 280 | 281 | module.exports = { 282 | addToQueue, 283 | clearAllQueues, 284 | getQueueStatus, 285 | clearSessionQueue, 286 | }; 287 | -------------------------------------------------------------------------------- /src/services/webhookService.js: -------------------------------------------------------------------------------- 1 | const logger = require("../utils/logger"); 2 | const fs = require("fs").promises; 3 | const path = require("path"); 4 | const axios = require("axios"); 5 | 6 | const WEBHOOK_FILE = path.join(process.cwd(), "data", "webhook.json"); 7 | 8 | // Webhook URLs per session 9 | let webhookData = { 10 | globalWebhook: null, 11 | sessionWebhooks: {}, 12 | }; 13 | 14 | // Inisialisasi data webhook dari file 15 | const initializeWebhookData = async () => { 16 | try { 17 | const data = await fs.readFile(WEBHOOK_FILE, "utf8"); 18 | webhookData = JSON.parse(data); 19 | logger.info({ 20 | msg: "Webhook data loaded from file", 21 | }); 22 | } catch (error) { 23 | logger.warn({ 24 | msg: "Failed to load webhook data, using default", 25 | error: error.message, 26 | }); 27 | // Buat file baru jika tidak ada 28 | await saveWebhookData(); 29 | } 30 | }; 31 | 32 | // Simpan data webhook ke file 33 | const saveWebhookData = async () => { 34 | try { 35 | await fs.writeFile(WEBHOOK_FILE, JSON.stringify(webhookData, null, 2)); 36 | logger.info({ 37 | msg: "Webhook data has been updated and saved to file", 38 | }); 39 | return true; 40 | } catch (error) { 41 | logger.error({ 42 | msg: "Failed to update and save webhook data", 43 | error: error.message, 44 | }); 45 | return false; 46 | } 47 | }; 48 | 49 | // Set webhook URL untuk session 50 | const setWebhook = async (sessionId, url, options = {}) => { 51 | if (!url) { 52 | return false; 53 | } 54 | 55 | try { 56 | const webhookUrl = new URL(url); 57 | const existingWebhook = webhookData.sessionWebhooks[sessionId]; 58 | 59 | // Gunakan secret key yang sudah ada jika ada dan tidak di-override 60 | const secretKey = 61 | options.secretKey || 62 | (existingWebhook 63 | ? existingWebhook.secretKey 64 | : Math.random().toString(36).substring(2)); 65 | 66 | // Set enabled status, default true jika tidak disebutkan 67 | const enabled = options.hasOwnProperty("enabled") ? options.enabled : true; 68 | 69 | // Determine retryCount and lastFailedAt 70 | let retryCount = 0; 71 | let lastFailedAt = null; 72 | 73 | if (existingWebhook) { 74 | if (enabled) { 75 | // Reset counters if enabling 76 | retryCount = 0; 77 | lastFailedAt = null; 78 | } else { 79 | // Preserve existing values if disabling 80 | retryCount = existingWebhook.retryCount || 0; 81 | lastFailedAt = existingWebhook.lastFailedAt || null; 82 | } 83 | } 84 | 85 | webhookData.sessionWebhooks[sessionId] = { 86 | url: webhookUrl.toString(), 87 | secretKey, 88 | enabled: enabled, 89 | retryCount: 0, 90 | lastFailedAt: null, 91 | createdAt: existingWebhook?.createdAt || new Date().toISOString(), 92 | updatedAt: new Date().toISOString(), 93 | }; 94 | 95 | await saveWebhookData(); 96 | 97 | logger.info({ 98 | msg: "Webhook URL set successfully", 99 | sessionId, 100 | url: webhookUrl.toString(), 101 | enabled, 102 | }); 103 | 104 | // Return webhook configuration 105 | return { 106 | url: webhookUrl.toString(), 107 | secretKey, 108 | enabled, 109 | retryCount, 110 | lastFailedAt, 111 | createdAt: webhookData.sessionWebhooks[sessionId].createdAt, 112 | updatedAt: webhookData.sessionWebhooks[sessionId].updatedAt, 113 | }; 114 | } catch (error) { 115 | logger.error({ 116 | msg: "Invalid webhook URL", 117 | sessionId, 118 | url, 119 | error: error.message, 120 | }); 121 | return false; 122 | } 123 | }; 124 | 125 | // Hapus webhook untuk session 126 | const clearSessionWebhook = async (sessionId) => { 127 | delete webhookData.sessionWebhooks[sessionId]; 128 | await saveWebhookData(); 129 | 130 | logger.info({ 131 | msg: "Webhook cleared for session", 132 | sessionId, 133 | }); 134 | }; 135 | 136 | // Kirim data ke webhook langsung 137 | const sendToWebhook = async (sessionId, data) => { 138 | const webhookConfig = webhookData.sessionWebhooks[sessionId]; 139 | 140 | if (!webhookConfig || !webhookConfig.enabled) { 141 | logger.debug({ 142 | msg: "No webhook configured for session or webhook disabled", 143 | sessionId, 144 | }); 145 | return false; 146 | } 147 | 148 | try { 149 | const response = await axios.post(webhookConfig.url, data, { 150 | headers: { 151 | "Content-Type": "application/json", 152 | "X-Webhook-Secret": webhookConfig.secretKey, 153 | }, 154 | timeout: 5000, 155 | }); 156 | 157 | // Reset retry count jika sukses 158 | webhookConfig.retryCount = 0; 159 | webhookConfig.lastFailedAt = null; 160 | await saveWebhookData(); 161 | 162 | logger.info({ 163 | msg: "Webhook sent successfully", 164 | sessionId, 165 | statusCode: response.status, 166 | }); 167 | 168 | return true; 169 | } catch (error) { 170 | // Update retry counter 171 | webhookConfig.retryCount = (webhookConfig.retryCount || 0) + 1; 172 | webhookConfig.lastFailedAt = new Date().toISOString(); 173 | 174 | // Nonaktifkan jika webhook offline 175 | if (error.response && error.response.status === 404) { 176 | webhookConfig.enabled = false; 177 | logger.warn({ 178 | msg: `Webhook disabled due to target being offline`, 179 | sessionId, 180 | url: webhookConfig.url, 181 | }); 182 | } else if ( 183 | webhookConfig.retryCount >= process.env.WEBHOOK_DISABLED_ATTEMPTS 184 | ) { 185 | // Nonaktifkan jika gagal 5x 186 | webhookConfig.enabled = false; 187 | logger.warn({ 188 | msg: `Webhook disabled after ${process.env.WEBHOOK_DISABLED_ATTEMPTS} consecutive failures`, 189 | sessionId, 190 | url: webhookConfig.url, 191 | }); 192 | } 193 | 194 | await saveWebhookData(); 195 | 196 | logger.error({ 197 | msg: "Failed to send webhook", 198 | sessionId, 199 | error: error.message, 200 | retryCount: webhookConfig.retryCount, 201 | enabled: webhookConfig.enabled, 202 | }); 203 | 204 | return false; 205 | } 206 | }; 207 | 208 | // Check webhook health 209 | const checkHealth = async (sessionId) => { 210 | const webhookConfig = webhookData.sessionWebhooks[sessionId]; 211 | if (!webhookConfig) { 212 | return { 213 | status: "not_configured", 214 | }; 215 | } 216 | 217 | try { 218 | const response = await axios.get(webhookConfig.url, { 219 | timeout: 5000, 220 | }); 221 | return { 222 | status: response.status === 200 ? "healthy" : "unhealthy", 223 | url: webhookConfig.url, 224 | enabled: webhookConfig.enabled, 225 | retryCount: webhookConfig.retryCount, 226 | lastFailedAt: webhookConfig.lastFailedAt, 227 | createdAt: webhookConfig.createdAt, 228 | updatedAt: webhookConfig.updatedAt, 229 | }; 230 | } catch (error) { 231 | return { 232 | status: "unhealthy", 233 | url: webhookConfig.url, 234 | enabled: webhookConfig.enabled, 235 | retryCount: webhookConfig.retryCount, 236 | lastFailedAt: webhookConfig.lastFailedAt, 237 | createdAt: webhookConfig.createdAt, 238 | updatedAt: webhookConfig.updatedAt, 239 | error: error.message, 240 | }; 241 | } 242 | }; 243 | 244 | // reset webhook session 245 | const resetWebhookSession = async (sessionId) => { 246 | const currentTime = new Date().toISOString(); 247 | 248 | webhookData.sessionWebhooks[sessionId] = { 249 | enabled: true, 250 | retryCount: 0, 251 | lastFailedAt: null, 252 | createdAt: currentTime, 253 | updatedAt: currentTime, 254 | }; 255 | 256 | await saveWebhookData(); 257 | }; 258 | 259 | // Initialize webhook data when module loads 260 | initializeWebhookData(); 261 | 262 | module.exports = { 263 | setWebhook, 264 | clearSessionWebhook, 265 | sendToWebhook, 266 | checkHealth, 267 | }; 268 | -------------------------------------------------------------------------------- /src/services/whatsappService.js: -------------------------------------------------------------------------------- 1 | const { webcrypto } = require("crypto"); 2 | global.crypto = webcrypto; 3 | 4 | const fs = require("fs"); 5 | const path = require("path"); 6 | 7 | const pino = require("pino"); 8 | const { toDataURL } = require("qrcode"); 9 | const { sendResponse } = require("../utils/response"); 10 | const httpStatusCode = require("../constants/httpStatusCode"); 11 | const util = require("util"); 12 | const logger = require("../utils/logger"); 13 | const webhookService = require("./webhookService"); 14 | 15 | const readFileAsync = util.promisify(fs.readFile); 16 | const { 17 | makeWASocket, 18 | DisconnectReason, 19 | makeInMemoryStore, 20 | useMultiFileAuthState, 21 | delay, 22 | Browsers, 23 | fetchLatestBaileysVersion, 24 | } = require("@whiskeysockets/baileys"); 25 | 26 | const WebSocket = require("ws"); 27 | 28 | const sessions = new Map(); 29 | const retries = new Map(); 30 | 31 | const sessionsDir = (sessionId = "") => 32 | path.join(__dirname, "../../sessions", sessionId || ""); 33 | 34 | const isSessionExists = (sessionId) => sessions.has(sessionId); 35 | 36 | const getSessionStatus = async (sessionId) => { 37 | try { 38 | // Cek sesi di memory terlebih dahulu 39 | const session = sessions.get(sessionId); 40 | if (session) { 41 | try { 42 | const connectionState = await session.ws.readyState; 43 | if (connectionState === WebSocket.OPEN) { 44 | // Baca data credentials 45 | const data = await readFileAsync( 46 | `sessions/md_${sessionId}/creds.json` 47 | ); 48 | const userdata = JSON.parse(data); 49 | console.log(`[${sessionId}] Connected successfully`); 50 | 51 | return { 52 | status: "connected", 53 | user: userdata, 54 | connectionState: "open", 55 | }; 56 | } 57 | } catch (error) { 58 | logger.error({ 59 | msg: `Error checking session ${sessionId} status`, 60 | sessionId, 61 | error: error.message, 62 | stack: error.stack, 63 | }); 64 | } 65 | } 66 | 67 | // Jika tidak ada di memory, cek file credentials 68 | try { 69 | const data = await readFileAsync(`sessions/md_${sessionId}/creds.json`); 70 | const userdata = JSON.parse(data); 71 | 72 | return { 73 | status: "exists", 74 | user: userdata, 75 | connectionState: "closed", 76 | }; 77 | } catch { 78 | return { 79 | status: "not_found", 80 | connectionState: null, 81 | }; 82 | } 83 | } catch (error) { 84 | logger.error({ 85 | msg: `Error getting session status`, 86 | sessionId, 87 | error: error.message, 88 | stack: error.stack, 89 | }); 90 | return { 91 | status: "error", 92 | error: error.message, 93 | }; 94 | } 95 | }; 96 | 97 | const cleanupSession = async (sessionId) => { 98 | try { 99 | const session = sessions.get(sessionId); 100 | if (session) { 101 | // Clear interval 102 | if (session.storeInterval) { 103 | clearInterval(session.storeInterval); 104 | } 105 | 106 | // Simpan metadata terakhir kali 107 | if (session.store) { 108 | try { 109 | const storePath = sessionsDir(`${sessionId}_store.json`); 110 | await session.store.writeToFile(storePath); 111 | logger.info({ 112 | msg: "Final group metadata store saved", 113 | sessionId, 114 | path: storePath, 115 | }); 116 | } catch (error) { 117 | logger.error({ 118 | msg: "Failed to save final group metadata", 119 | sessionId, 120 | error: error.message, 121 | stack: error.stack, 122 | }); 123 | } 124 | } 125 | 126 | // Cleanup lainnya 127 | session.ev.removeAllListeners(); 128 | await session.ws.close(); 129 | sessions.delete(sessionId); 130 | 131 | logger.info({ 132 | msg: "Session cleaned up successfully", 133 | sessionId, 134 | }); 135 | return true; 136 | } 137 | return false; 138 | } catch (error) { 139 | logger.error({ 140 | msg: "Session cleanup failed", 141 | sessionId, 142 | error: error.message, 143 | stack: error.stack, 144 | }); 145 | return false; 146 | } 147 | }; 148 | 149 | // Tambahkan fungsi helper untuk cek folder 150 | const checkAndCleanSessionFolder = (sessionId) => { 151 | try { 152 | const sessionDir = path.join(sessionsDir(), `md_${sessionId}`); 153 | const storeFile = path.join(sessionsDir(), `${sessionId}_store.json`); 154 | 155 | // Cek apakah folder ada 156 | if (fs.existsSync(sessionDir)) { 157 | const files = fs.readdirSync(sessionDir); 158 | 159 | // Jika folder kosong atau tidak ada creds.json 160 | if (files.length === 0 || !files.includes("creds.json")) { 161 | logger.info({ 162 | msg: `Invalid session folder found, cleaning up...`, 163 | sessionId, 164 | action: "cleanup", 165 | }); 166 | 167 | // Hapus folder sesi 168 | fs.rmSync(sessionDir, { recursive: true, force: true }); 169 | 170 | // Hapus file store jika ada 171 | if (fs.existsSync(storeFile)) { 172 | fs.unlinkSync(storeFile); 173 | } 174 | 175 | return false; 176 | } 177 | return true; 178 | } 179 | return false; 180 | } catch (error) { 181 | logger.error({ 182 | msg: `Error checking session folder`, 183 | sessionId, 184 | error: error.message, 185 | stack: error.stack, 186 | }); 187 | return false; 188 | } 189 | }; 190 | 191 | const createSession = async (sessionId, isLegacy = false, res = null) => { 192 | try { 193 | logger.info({ 194 | msg: `Starting session creation`, 195 | sessionId, 196 | isLegacy, 197 | }); 198 | 199 | await checkAndCleanSessionFolder(sessionId); 200 | await cleanupSession(sessionId); 201 | 202 | // Optimasi store hanya untuk group metadata 203 | const store = makeInMemoryStore({ 204 | logger: pino({ level: "silent" }), 205 | path: sessionsDir(`${sessionId}_store.json`), 206 | maxCachedMessages: 0, 207 | maxCachedGroups: parseInt(process.env.MAX_CACHED_GROUPS || "100", 10), 208 | }); 209 | 210 | // Baca existing store jika ada 211 | store.readFromFile(sessionsDir(`${sessionId}_store.json`)); 212 | 213 | const { state, saveCreds } = await useMultiFileAuthState( 214 | sessionsDir(`md_${sessionId}`) 215 | ); 216 | 217 | const client = makeWASocket({ 218 | printQRInTerminal: true, 219 | browser: Browsers.ubuntu("Chrome"), 220 | auth: state, 221 | logger: pino({ level: "silent" }), 222 | cachedGroupMetadata: async (jid) => { 223 | try { 224 | const metadata = await client.groupMetadata(jid); 225 | return metadata; 226 | } catch (error) { 227 | logger.error({ 228 | msg: "Error getting group metadata", 229 | jid, 230 | error: error.message, 231 | stack: error.stack, 232 | }); 233 | return null; 234 | } 235 | }, 236 | }); 237 | 238 | // Bind store ke client 239 | store.bind(client.ev); 240 | 241 | // Optimize store saving interval (setiap 5 menit) 242 | const storeInterval = setInterval(() => { 243 | try { 244 | store.writeToFile(sessionsDir(`${sessionId}_store.json`)); 245 | logger.info({ 246 | msg: "Store saved", 247 | sessionId, 248 | }); 249 | } catch (error) { 250 | logger.error({ 251 | msg: "Failed to save store", 252 | sessionId, 253 | error: error.message, 254 | }); 255 | } 256 | }, 5 * 60 * 1000); 257 | 258 | // Simpan session dengan store 259 | sessions.set(sessionId, { 260 | ...client, 261 | store, 262 | storeInterval, 263 | }); 264 | 265 | // Cleanup handler yang lebih efisien 266 | const cleanupHandler = async () => { 267 | const session = sessions.get(sessionId); 268 | if (session) { 269 | clearInterval(session.storeInterval); 270 | if (session.store) { 271 | await session.store.writeToFile(); 272 | } 273 | } 274 | }; 275 | 276 | // Tambahkan cleanup handler 277 | process.on("SIGTERM", cleanupHandler); 278 | process.on("SIGINT", cleanupHandler); 279 | 280 | let connectionTimeout; 281 | let hasResponded = false; 282 | 283 | // Tambahkan event listener untuk pesan masuk 284 | client.ev.on("messages.upsert", async (m) => { 285 | if (m.type === "notify") { 286 | for (const msg of m.messages) { 287 | const isFromMe = msg.key.fromMe; 288 | const isBroadcast = msg.broadcast; 289 | const hasNoStickerMessage = msg.stickerMessage?.url === ""; 290 | if ((!isFromMe && !isBroadcast) || hasNoStickerMessage) { 291 | logger.info({ 292 | msg: `Pesan baru diterima`, 293 | sessionId, 294 | from: msg.key.remoteJid, 295 | messageId: msg.key.id, 296 | }); 297 | 298 | // Kirim ke webhook dengan format yang sesuai dengan webhookService lama 299 | const isGroup = msg.key.remoteJid.endsWith("@g.us"); 300 | const sender = !isGroup ? msg.key.remoteJid : msg.key.participant; 301 | const text = 302 | msg.message?.conversation || 303 | msg.message?.extendedTextMessage?.text; 304 | const quotedText = 305 | msg.message?.extendedTextMessage?.contextInfo?.quotedMessage 306 | ?.conversation; 307 | 308 | webhookService.sendToWebhook(sessionId, { 309 | sessionId, 310 | timestamp: Date.now(), 311 | type: "message", 312 | message: { 313 | id: msg.key.id, 314 | isGroup: isGroup, 315 | remoteJid: msg.key.remoteJid, 316 | sender: sender, 317 | text, 318 | quotedText, 319 | }, 320 | }); 321 | } 322 | } 323 | } 324 | }); 325 | 326 | // Tambahkan event listener untuk status koneksi 327 | client.ev.on("connection.update", async (update) => { 328 | const { connection, lastDisconnect, qr } = update; 329 | 330 | if (connectionTimeout) clearTimeout(connectionTimeout); 331 | 332 | // Log connection states 333 | if (connection) { 334 | logger.info({ 335 | msg: `Connection update`, 336 | sessionId, 337 | state: connection, 338 | code: lastDisconnect?.error?.output?.statusCode, 339 | }); 340 | 341 | // Kirim status koneksi ke webhook 342 | webhookService.sendToWebhook(sessionId, { 343 | sessionId, 344 | type: "connection", 345 | status: connection, 346 | qr: qr, 347 | }); 348 | } 349 | 350 | if (connection === "open") { 351 | retries.delete(sessionId); 352 | logger.info({ 353 | msg: `Session connected successfully`, 354 | sessionId, 355 | type: "connection", 356 | state: "connected", 357 | }); 358 | hasResponded = true; 359 | 360 | if (res && !res.headersSent) { 361 | return sendResponse( 362 | res, 363 | httpStatusCode.OK, 364 | "Session connected successfully", 365 | { 366 | status: "connected", 367 | } 368 | ); 369 | } 370 | } 371 | 372 | if (connection === "close") { 373 | const statusCode = lastDisconnect?.error?.output?.statusCode; 374 | logger.warn({ 375 | msg: `Connection closed`, 376 | sessionId, 377 | statusCode, 378 | error: lastDisconnect?.error?.message, 379 | }); 380 | 381 | // Gunakan shouldReconnect untuk menentukan apakah perlu reconnect 382 | if (shouldReconnect(statusCode, sessionId)) { 383 | const retriesCount = retries.get(sessionId) || 0; 384 | if (retriesCount < 3) { 385 | logger.info({ 386 | msg: `[${sessionId}] Attempting to reconnect... (Attempt ${ 387 | retriesCount + 1 388 | }/3)`, 389 | sessionId, 390 | retriesCount, 391 | }); 392 | retries.set(sessionId, retriesCount + 1); 393 | 394 | // Cleanup tanpa menghapus file sesi 395 | await cleanupSession(sessionId, false); 396 | 397 | // Delay sebelum reconnect 398 | await new Promise((resolve) => setTimeout(resolve, 3000)); 399 | 400 | // Reconnect 401 | await createSession(sessionId, isLegacy, res); 402 | return; 403 | } else { 404 | logger.info({ 405 | msg: `[${sessionId}] Max reconnection attempts reached`, 406 | sessionId, 407 | }); 408 | hasResponded = true; 409 | if (res && !res.headersSent) { 410 | return sendResponse( 411 | res, 412 | httpStatusCode.INTERNAL_SERVER_ERROR, 413 | "Failed to reconnect after maximum attempts", 414 | null 415 | ); 416 | } 417 | } 418 | } else { 419 | logger.info({ 420 | msg: `[${sessionId}] No reconnection needed, cleaning up session`, 421 | sessionId, 422 | }); 423 | hasResponded = true; 424 | await cleanupSession(sessionId); 425 | if (res && !res.headersSent) { 426 | return sendResponse( 427 | res, 428 | httpStatusCode.INTERNAL_SERVER_ERROR, 429 | "Session ended", 430 | null 431 | ); 432 | } 433 | } 434 | } 435 | 436 | // Log QR generation 437 | if (qr) { 438 | logger.info({ 439 | msg: `QR Code generated`, 440 | sessionId, 441 | type: "qr", 442 | }); 443 | hasResponded = true; 444 | if (res && !res.headersSent) { 445 | try { 446 | const qrImage = await toDataURL(qr); 447 | return sendResponse( 448 | res, 449 | httpStatusCode.OK, 450 | "QR Code generated successfully", 451 | { 452 | qr: qrImage, 453 | } 454 | ); 455 | } catch (error) { 456 | console.error("QR Generation Error:", error); 457 | return sendResponse( 458 | res, 459 | httpStatusCode.INTERNAL_SERVER_ERROR, 460 | "Failed to generate QR code", 461 | null, 462 | error 463 | ); 464 | } 465 | } 466 | } 467 | 468 | // Set connection timeout 469 | connectionTimeout = setTimeout(async () => { 470 | if (!hasResponded && res && !res.headersSent) { 471 | console.log(`Connection timeout for session ${sessionId}`); 472 | hasResponded = true; 473 | await cleanupSession(sessionId); 474 | return sendResponse( 475 | res, 476 | httpStatusCode.REQUEST_TIMEOUT, 477 | "Connection timeout", 478 | null 479 | ); 480 | } 481 | }, 60000); 482 | }); 483 | 484 | // Log creds updates 485 | client.ev.on("creds.update", () => { 486 | logger.info({ 487 | msg: `Credentials updated`, 488 | sessionId, 489 | type: "creds", 490 | }); 491 | saveCreds(); 492 | }); 493 | 494 | // Tambahkan event listener untuk chats 495 | client.ev.on("chats.set", async () => { 496 | if (store) { 497 | // Force update store 498 | store.writeToFile(sessionsDir(`${sessionId}_store.json`)); 499 | } 500 | }); 501 | 502 | return client; 503 | } catch (error) { 504 | logger.error({ 505 | msg: `Session creation failed`, 506 | sessionId, 507 | error: error.message, 508 | stack: error.stack, 509 | }); 510 | await cleanupSession(sessionId); 511 | 512 | if (res && !res.headersSent) { 513 | return sendResponse( 514 | res, 515 | httpStatusCode.INTERNAL_SERVER_ERROR, 516 | "Failed to create session", 517 | null, 518 | error 519 | ); 520 | } 521 | throw error; 522 | } 523 | }; 524 | 525 | const getSession = (sessionId) => sessions.get(sessionId) || null; 526 | 527 | const deleteSession = async (sessionId, isLegacy = false, res = null) => { 528 | await checkAndCleanSessionFolder(sessionId); 529 | 530 | try { 531 | console.log(`[${sessionId}] Attempting to logout session...`); 532 | 533 | // Hapus webhook untuk session ini 534 | const webhookService = require("./webhookService"); 535 | webhookService.clearSessionWebhook(sessionId); 536 | logger.info({ 537 | msg: `Webhook untuk session ${sessionId} dihapus saat logout`, 538 | sessionId, 539 | }); 540 | 541 | // Hapus antrian pesan untuk session ini 542 | const queueService = require("./queueService"); 543 | queueService.clearSessionQueue(sessionId); 544 | logger.info({ 545 | msg: `Antrian pesan untuk session ${sessionId} dihapus saat logout`, 546 | sessionId, 547 | }); 548 | 549 | // Cek apakah sesi ada di memory atau file system 550 | const session = sessions.get(sessionId); 551 | const sessionFileName = 552 | (isLegacy ? "legacy_" : "md_") + sessionId + (isLegacy ? ".json" : ""); 553 | const sessionPath = sessionsDir(sessionFileName); 554 | const sessionExists = fs.existsSync(sessionPath); 555 | 556 | if (!session && !sessionExists) { 557 | console.log(`[${sessionId}] Session not found in memory or filesystem`); 558 | if (res) { 559 | return sendResponse( 560 | res, 561 | httpStatusCode.NOT_FOUND, 562 | "Session not found", 563 | null 564 | ); 565 | } 566 | return false; 567 | } 568 | 569 | // Jika sesi ada di memory, lakukan logout 570 | if (session) { 571 | try { 572 | console.log(`[${sessionId}] Logging out session...`); 573 | await session.logout(); 574 | console.log(`[${sessionId}] Successfully logged out from WhatsApp`); 575 | } catch (error) { 576 | console.log( 577 | `[${sessionId}] Error during WhatsApp logout:`, 578 | error.message 579 | ); 580 | } 581 | } 582 | 583 | // Hapus semua file dan folder terkait sesi 584 | try { 585 | console.log(`[${sessionId}] Removing session files and directories...`); 586 | 587 | // Hapus folder sesi utama jika ada 588 | if (fs.existsSync(sessionPath)) { 589 | fs.rmSync(sessionPath, { force: true, recursive: true }); 590 | } 591 | 592 | // Hapus file store 593 | const storeFile = sessionsDir(sessionId + "_store.json"); 594 | if (fs.existsSync(storeFile)) { 595 | fs.rmSync(storeFile, { force: true }); 596 | } 597 | 598 | // Hapus folder pre-keys jika ada 599 | const preKeysPath = path.join(sessionPath, "pre-keys"); 600 | if (fs.existsSync(preKeysPath)) { 601 | fs.rmSync(preKeysPath, { force: true, recursive: true }); 602 | } 603 | 604 | console.log(`[${sessionId}] Successfully removed all session files`); 605 | } catch (error) { 606 | console.log( 607 | `[${sessionId}] Error removing session files:`, 608 | error.message 609 | ); 610 | } 611 | 612 | // Cleanup dari memory 613 | try { 614 | console.log(`[${sessionId}] Cleaning up session from memory...`); 615 | if (session) { 616 | // Membersihkan event listeners 617 | session.ev.removeAllListeners("connection.update"); 618 | session.ev.removeAllListeners("creds.update"); 619 | session.ev.removeAllListeners("chats.set"); 620 | session.ev.removeAllListeners("messages.update"); 621 | 622 | // Menutup koneksi 623 | if (session.ws) { 624 | await session.ws.close(); 625 | } 626 | 627 | // Membersihkan store 628 | if (session.store) { 629 | session.store.writeToFile(sessionsDir(sessionId + "_store.json")); 630 | } 631 | } 632 | console.log(`[${sessionId}] Successfully cleaned up from memory`); 633 | } catch (error) { 634 | console.log( 635 | `[${sessionId}] Error cleaning up from memory:`, 636 | error.message 637 | ); 638 | } 639 | 640 | // Hapus dari maps 641 | sessions.delete(sessionId); 642 | retries.delete(sessionId); 643 | 644 | // Double check untuk memastikan folder benar-benar terhapus 645 | setTimeout(() => { 646 | try { 647 | if (fs.existsSync(sessionPath)) { 648 | fs.rmSync(sessionPath, { force: true, recursive: true }); 649 | } 650 | } catch (error) { 651 | console.log(`[${sessionId}] Final cleanup error:`, error.message); 652 | } 653 | }, 1000); 654 | 655 | if (res) { 656 | return sendResponse( 657 | res, 658 | httpStatusCode.OK, 659 | "Session logged out successfully", 660 | { 661 | sessionId, 662 | status: "logged_out", 663 | } 664 | ); 665 | } 666 | return true; 667 | } catch (error) { 668 | console.error(`[${sessionId}] Failed to logout session:`, error); 669 | if (res) { 670 | return sendResponse( 671 | res, 672 | httpStatusCode.INTERNAL_SERVER_ERROR, 673 | "Failed to logout session", 674 | null, 675 | error 676 | ); 677 | } 678 | throw error; 679 | } 680 | }; 681 | 682 | const getChatList = (sessionId, isGroup = false) => { 683 | try { 684 | const session = sessions.get(sessionId); 685 | if (!session) { 686 | throw new Error("Session not found"); 687 | } 688 | 689 | const chatType = isGroup ? "@g.us" : "@s.whatsapp.net"; 690 | const chats = session.store.chats.filter((chat) => { 691 | return chat.id.endsWith(chatType); 692 | }); 693 | 694 | // Format data untuk grup 695 | if (isGroup) { 696 | return chats.map((chat) => ({ 697 | id: chat.id, 698 | name: chat.name || "Unknown Group", 699 | participant_count: chat.participantCount || 0, 700 | creation_time: chat.creationTime || new Date().toISOString(), 701 | })); 702 | } 703 | 704 | return chats; 705 | } catch (error) { 706 | console.error(`[${sessionId}] Error getting chat list:`, error); 707 | throw error; 708 | } 709 | }; 710 | 711 | const isExists = async (client, jid, isGroup = false) => { 712 | try { 713 | let chatInfo; 714 | 715 | if (isGroup) { 716 | chatInfo = await client.groupMetadata(jid); 717 | return Boolean(chatInfo.id); 718 | } 719 | 720 | chatInfo = await client.onWhatsApp(jid); 721 | return ( 722 | Array.isArray(chatInfo) && 723 | chatInfo.length > 0 && 724 | chatInfo[0].exists === true 725 | ); 726 | } catch { 727 | return false; 728 | } 729 | }; 730 | 731 | const groupFetchAllParticipating = (client) => { 732 | try { 733 | return client.groupFetchAllParticipating(); 734 | } catch (err) { 735 | return false; 736 | } 737 | }; 738 | 739 | const queueService = require("./queueService"); 740 | 741 | const sendMessage = async (client, chatId, message, showTyping = true) => { 742 | try { 743 | // Perbaikan: Gunakan Array.from(sessions.entries()) untuk mencari sessionId dari Map 744 | const sessionEntry = Array.from(sessions.entries()).find( 745 | ([_, clientObj]) => clientObj === client 746 | ); 747 | 748 | const sessionId = sessionEntry ? sessionEntry[0] : null; 749 | if (!sessionId) { 750 | throw new Error("Session not found"); 751 | } 752 | 753 | // Tampilkan status "sedang mengetik" sebelum mengirim pesan 754 | if (showTyping) { 755 | try { 756 | // Kirim status "composing" (sedang mengetik) 757 | await client.sendPresenceUpdate("composing", chatId); 758 | 759 | // Tunggu beberapa detik agar tampilan "sedang mengetik" terlihat natural 760 | // Waktu tunggu proporsional dengan panjang pesan 761 | const typingDelay = 762 | typeof message === "object" && message.text 763 | ? Math.min(Math.max(message.text.length * 40, 1000), 3000) 764 | : typeof message === "string" 765 | ? Math.min(Math.max(message.length * 40, 1000), 3000) 766 | : 1500; 767 | 768 | logger.info({ 769 | msg: `Showing typing indicator for ${typingDelay}ms`, 770 | sessionId, 771 | chatId, 772 | }); 773 | 774 | await delay(typingDelay); 775 | } catch (typingError) { 776 | // Lanjutkan proses pengiriman meskipun ada kesalahan saat menampilkan status mengetik 777 | logger.warn({ 778 | msg: `Failed to show typing indicator, proceeding with sending`, 779 | sessionId, 780 | chatId, 781 | error: typingError.message, 782 | }); 783 | } 784 | } 785 | 786 | // Log pesan untuk debugging 787 | logger.info({ 788 | msg: `Adding message to queue`, 789 | sessionId, 790 | chatId, 791 | messageType: typeof message, 792 | messageStructure: message, 793 | }); 794 | 795 | // Setelah menunjukkan typing, kirim status "paused" untuk menghentikan indikator typing 796 | if (showTyping) { 797 | try { 798 | await client.sendPresenceUpdate("paused", chatId); 799 | } catch (typingError) { 800 | logger.warn({ 801 | msg: `Failed to reset typing indicator`, 802 | sessionId, 803 | chatId, 804 | error: typingError.message, 805 | }); 806 | } 807 | } 808 | 809 | // Tambahkan ke antrian 810 | try { 811 | // Jika pesan adalah string, konversi ke format yang benar 812 | let processedMessage = message; 813 | if (typeof message === "string") { 814 | processedMessage = { text: message }; 815 | } 816 | 817 | // Pastikan pesan media memiliki format yang benar 818 | if (typeof message === "object") { 819 | // Jika ada properti media (image, video, document, audio), gunakan langsung 820 | if ( 821 | message.image || 822 | message.video || 823 | message.document || 824 | message.audio 825 | ) { 826 | processedMessage = message; 827 | } 828 | // Jika ada properti text, pastikan dalam format yang benar 829 | else if (message.text) { 830 | processedMessage = { text: message.text }; 831 | } 832 | } 833 | 834 | const result = await queueService.addToQueue(sessionId, { 835 | sessionId, 836 | chatId, 837 | message: processedMessage, 838 | type: processedMessage.image 839 | ? "image" 840 | : processedMessage.video 841 | ? "video" 842 | : processedMessage.document 843 | ? "document" 844 | : processedMessage.audio 845 | ? "audio" 846 | : "text", 847 | }); 848 | 849 | logger.info({ 850 | msg: `Message added to queue successfully`, 851 | sessionId, 852 | chatId, 853 | messageId: result?.key?.id || null, 854 | messageType: processedMessage.image 855 | ? "image" 856 | : processedMessage.video 857 | ? "video" 858 | : processedMessage.document 859 | ? "document" 860 | : processedMessage.audio 861 | ? "audio" 862 | : "text", 863 | }); 864 | 865 | return result; 866 | } catch (queueError) { 867 | logger.error({ 868 | msg: `Failed to add message to queue`, 869 | sessionId, 870 | chatId, 871 | error: queueError.message, 872 | stack: queueError.stack, 873 | }); 874 | throw queueError; 875 | } 876 | } catch (err) { 877 | logger.error({ 878 | msg: `Error in sendMessage`, 879 | error: err.message, 880 | stack: err.stack, 881 | }); 882 | return Promise.reject(err); 883 | } 884 | }; 885 | 886 | const formatPhone = (phoneNumber) => { 887 | // Hapus semua karakter non-digit (spasi, -, +, dll) 888 | let cleanedNumber = phoneNumber.replace(/[^0-9]/g, ""); 889 | 890 | // Jika panjang nomor lebih dari 15, anggap sebagai grup 891 | if (cleanedNumber.length > 15) { 892 | return formatGroup(phoneNumber); 893 | } 894 | 895 | // Format nomor ke format 628xxx 896 | if (cleanedNumber.startsWith("08")) { 897 | cleanedNumber = cleanedNumber.replace("08", "628"); 898 | } else if (cleanedNumber.startsWith("628")) { 899 | // Sudah dalam format yang benar 900 | cleanedNumber = cleanedNumber; 901 | } else if (cleanedNumber.startsWith("62")) { 902 | // Sudah dalam format yang benar 903 | cleanedNumber = cleanedNumber; 904 | } else if (cleanedNumber.startsWith("+62")) { 905 | // Hapus + di awal 906 | cleanedNumber = cleanedNumber.substring(1); 907 | } else if (!cleanedNumber.startsWith("62")) { 908 | // Tambahkan 62 di awal jika belum ada 909 | cleanedNumber = "62" + cleanedNumber; 910 | } 911 | 912 | return `${cleanedNumber}@s.whatsapp.net`; 913 | }; 914 | 915 | const formatGroup = (groupId) => groupId.replace(/[^\d-]/g, "") + "@g.us"; 916 | 917 | const backupStore = async (sessionId) => { 918 | const storePath = sessionsDir(`${sessionId}_store.json`); 919 | const backupPath = sessionsDir(`${sessionId}_store.backup.json`); 920 | 921 | if (fs.existsSync(storePath)) { 922 | fs.copyFileSync(storePath, backupPath); 923 | logger.info({ 924 | msg: "Store backup created", 925 | sessionId, 926 | }); 927 | } 928 | }; 929 | 930 | const cleanup = async () => { 931 | logger.info({ 932 | msg: "Running cleanup before exit", 933 | sessions: sessions.size, 934 | }); 935 | 936 | // Bersihkan semua queue 937 | await queueService.clearAllQueues(); 938 | 939 | for (const [sessionId, session] of sessions.entries()) { 940 | await backupStore(sessionId); 941 | if (!session.isLegacy) { 942 | session.store.writeToFile(sessionsDir(sessionId + "_store.json")); 943 | logger.info({ 944 | msg: "Store written to file", 945 | sessionId, 946 | }); 947 | } 948 | } 949 | }; 950 | 951 | const init = () => { 952 | logger.info({ 953 | msg: "Initializing WhatsApp service", 954 | type: "startup", 955 | }); 956 | 957 | if (!fs.existsSync(sessionsDir())) { 958 | fs.mkdirSync(sessionsDir()); 959 | logger.info({ 960 | msg: "Created sessions directory", 961 | path: sessionsDir(), 962 | }); 963 | } 964 | 965 | const sessions = fs.readdirSync(sessionsDir()); 966 | logger.info({ 967 | msg: "Found existing sessions", 968 | count: sessions.length, 969 | }); 970 | 971 | sessions.forEach((fileName) => { 972 | if ( 973 | !(fileName.startsWith("md_") || fileName.startsWith("legacy_")) || 974 | fileName.endsWith("_store") 975 | ) { 976 | return; 977 | } 978 | 979 | const parts = fileName.replace(".json", "").split("_"); 980 | const isLegacy = parts[0] !== "md"; 981 | const sessionId = isLegacy 982 | ? parts.slice(2).join("_") 983 | : parts.slice(1).join("_"); 984 | 985 | logger.info({ 986 | msg: "Restoring session", 987 | sessionId, 988 | type: isLegacy ? "legacy" : "md", 989 | }); 990 | 991 | createSession(sessionId, isLegacy); 992 | }); 993 | }; 994 | 995 | // Menambahkan fungsi untuk membersihkan semua sesi 996 | const cleanupAllSessions = async () => { 997 | const sessionIds = Array.from(sessions.keys()); 998 | for (const sessionId of sessionIds) { 999 | await cleanupSession(sessionId); 1000 | } 1001 | }; 1002 | 1003 | // Tambahkan fungsi shouldReconnect 1004 | const shouldReconnect = (statusCode, sessionId) => { 1005 | try { 1006 | // Cek validitas sesi terlebih dahulu 1007 | const sessionDir = path.join(sessionsDir(), `md_${sessionId}`); 1008 | if ( 1009 | !fs.existsSync(sessionDir) || 1010 | !fs.existsSync(path.join(sessionDir, "creds.json")) 1011 | ) { 1012 | logger.info({ 1013 | msg: `Session files not found, no reconnection needed`, 1014 | sessionId, 1015 | statusCode, 1016 | }); 1017 | return false; 1018 | } 1019 | 1020 | // Status code yang perlu reconnect 1021 | const reconnectCodes = { 1022 | 503: "Service Unavailable", 1023 | 515: "Stream Error", 1024 | 500: "Internal Server Error", 1025 | 408: "Request Timeout", 1026 | 428: "Unknown 1", 1027 | undefined: "Unknown Error", 1028 | }; 1029 | 1030 | // Jangan reconnect jika logout atau invalid auth 1031 | if ( 1032 | statusCode === DisconnectReason.loggedOut || 1033 | statusCode === DisconnectReason.invalidSession || 1034 | statusCode === 401 1035 | ) { 1036 | logger.info({ 1037 | msg: `No reconnection needed for status code: ${statusCode}`, 1038 | sessionId, 1039 | statusCode, 1040 | }); 1041 | return false; 1042 | } 1043 | 1044 | // Log reconnection attempt 1045 | if (reconnectCodes[statusCode]) { 1046 | logger.info({ 1047 | msg: `Reconnection needed`, 1048 | sessionId, 1049 | statusCode, 1050 | reason: reconnectCodes[statusCode], 1051 | }); 1052 | return true; 1053 | } 1054 | 1055 | return false; 1056 | } catch (error) { 1057 | logger.error({ 1058 | msg: `Error in shouldReconnect`, 1059 | sessionId, 1060 | error: error.message, 1061 | stack: error.stack, 1062 | }); 1063 | return false; 1064 | } 1065 | }; 1066 | 1067 | // Tambahkan fungsi helper untuk mention 1068 | const createMentionedMessage = async ( 1069 | client, 1070 | receiver, 1071 | message = "", 1072 | mentions = [] 1073 | ) => { 1074 | try { 1075 | // Format pesan dengan mention 1076 | const formattedMessage = { 1077 | text: message || "Hello!", // Default message jika kosong 1078 | mentions: mentions, // Array of JID yang akan di-mention 1079 | }; 1080 | 1081 | return formattedMessage; 1082 | } catch (error) { 1083 | console.error("Error creating mentioned message:", error); 1084 | return null; 1085 | } 1086 | }; 1087 | 1088 | // Tambahkan fungsi untuk mendapatkan participants dari grup 1089 | const getGroupParticipants = async (client, groupId) => { 1090 | try { 1091 | const metadata = await client.groupMetadata(groupId); 1092 | return metadata.participants.map((participant) => participant.id); 1093 | } catch (error) { 1094 | console.error("Error getting group participants:", error); 1095 | return []; 1096 | } 1097 | }; 1098 | 1099 | // Tambahkan fungsi untuk send mention 1100 | const sendMentionMessage = async ( 1101 | client, 1102 | receiver, 1103 | message = "", 1104 | showTyping = true 1105 | ) => { 1106 | // Perbaikan: Gunakan Array.from(sessions.entries()) untuk mencari sessionId dari Map 1107 | const sessionEntry = Array.from(sessions.entries()).find( 1108 | ([_, clientObj]) => clientObj === client 1109 | ); 1110 | const sessionId = sessionEntry ? sessionEntry[0] : "unknown"; 1111 | 1112 | try { 1113 | const isGroup = receiver.endsWith("@g.us"); 1114 | let mentions = []; 1115 | 1116 | if (isGroup) { 1117 | mentions = await getGroupParticipants(client, receiver); 1118 | console.log(`[${sessionId}] Mentioning to Groups ${receiver}`); 1119 | } else { 1120 | mentions = [receiver]; 1121 | console.log(`[${sessionId}] Mentioning to private ${receiver}`); 1122 | } 1123 | 1124 | // Tampilkan status "sedang mengetik" sebelum mengirim pesan 1125 | if (showTyping) { 1126 | try { 1127 | // Kirim status "composing" (sedang mengetik) 1128 | await client.sendPresenceUpdate("composing", receiver); 1129 | 1130 | // Tunggu beberapa detik agar tampilan "sedang mengetik" terlihat natural 1131 | const typingDelay = message 1132 | ? Math.min(Math.max(message.length * 40, 1000), 5000) 1133 | : 2000; 1134 | 1135 | logger.info({ 1136 | msg: `[MENTION] Showing typing indicator for ${typingDelay}ms`, 1137 | sessionId, 1138 | receiver, 1139 | }); 1140 | 1141 | await delay(typingDelay); 1142 | } catch (typingError) { 1143 | // Lanjutkan proses pengiriman meskipun ada kesalahan saat menampilkan status mengetik 1144 | logger.warn({ 1145 | msg: `[MENTION] Failed to show typing indicator, proceeding with sending`, 1146 | sessionId, 1147 | receiver, 1148 | error: typingError.message, 1149 | }); 1150 | } 1151 | } 1152 | 1153 | const mentionedMessage = await createMentionedMessage( 1154 | client, 1155 | receiver, 1156 | message, 1157 | mentions 1158 | ); 1159 | if (!mentionedMessage) { 1160 | throw new Error("Failed to create mentioned message"); 1161 | } 1162 | 1163 | // Setelah menunjukkan typing, kirim status "paused" untuk menghentikan indikator typing 1164 | if (showTyping) { 1165 | try { 1166 | await client.sendPresenceUpdate("paused", receiver); 1167 | } catch (typingError) { 1168 | logger.warn({ 1169 | msg: `[MENTION] Failed to reset typing indicator`, 1170 | sessionId, 1171 | receiver, 1172 | error: typingError.message, 1173 | }); 1174 | } 1175 | } 1176 | 1177 | const result = await client.sendMessage(receiver, mentionedMessage); 1178 | 1179 | logger.info({ 1180 | msg: `[MENTION][${sessionId}] Success: Message sent to ${receiver}`, 1181 | sessionId, 1182 | messageId: result?.key?.id, 1183 | mentions: mentions.length, 1184 | message: message || "Hello!", 1185 | receiver, 1186 | }); 1187 | 1188 | return result; 1189 | } catch (error) { 1190 | logger.error({ 1191 | msg: `[MENTION][${sessionId}] Error sending mention message`, 1192 | sessionId, 1193 | error: error.message, 1194 | stack: error.stack, 1195 | }); 1196 | throw error; 1197 | } 1198 | }; 1199 | 1200 | module.exports = { 1201 | isSessionExists, 1202 | createSession, 1203 | getSession, 1204 | deleteSession, 1205 | getChatList, 1206 | isExists, 1207 | groupFetchAllParticipating, 1208 | sendMessage, 1209 | formatPhone, 1210 | formatGroup, 1211 | cleanup, 1212 | init, 1213 | getSessionStatus, 1214 | cleanupSession, 1215 | cleanupAllSessions, 1216 | sendMentionMessage, 1217 | getGroupParticipants, 1218 | checkAndCleanSessionFolder, 1219 | }; 1220 | -------------------------------------------------------------------------------- /src/utils/general.js: -------------------------------------------------------------------------------- 1 | const { readFileSync } = require("fs"); 2 | const path = require("path"); 3 | 4 | function generateRandomString(length = 20) { 5 | const characters = 6 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 7 | let randomString = ""; 8 | 9 | for (let i = 0; i < length; i++) { 10 | const randomIndex = Math.floor(Math.random() * characters.length); 11 | randomString += characters.charAt(randomIndex); 12 | } 13 | 14 | return randomString; 15 | } 16 | 17 | function categorizeFile(fileResponse) { 18 | const contentType = fileResponse.headers.get("content-type"); 19 | let fileType = contentType.split("/")[0] || "document"; 20 | const fileExtension = contentType.split("/")[1] || "unknown"; 21 | 22 | if (fileType === "application") { 23 | return { 24 | document: { 25 | url: fileResponse.url, 26 | }, 27 | mimetype: contentType, 28 | }; 29 | } 30 | 31 | if (fileExtension === "gif") { 32 | return { 33 | video: { 34 | url: fileResponse.url, 35 | }, 36 | mimetype: contentType, 37 | gifPayback: true, 38 | }; 39 | } 40 | 41 | return { 42 | [fileType]: { 43 | url: fileResponse.url, 44 | }, 45 | mimetype: contentType, 46 | }; 47 | } 48 | 49 | module.exports = { 50 | generateRandomString, 51 | categorizeFile, 52 | }; 53 | -------------------------------------------------------------------------------- /src/utils/logger.js: -------------------------------------------------------------------------------- 1 | const pino = require("pino"); 2 | 3 | const logger = pino({ 4 | level: process.env.LOG_LEVEL || "info", 5 | transport: { 6 | target: "pino-pretty", 7 | options: { 8 | colorize: true, 9 | translateTime: "yyyy-mm-dd HH:MM:ss", 10 | ignore: "pid,hostname", 11 | messageFormat: "{msg}", 12 | }, 13 | }, 14 | formatters: { 15 | timestamp: () => `,"time":"${new Date(Date.now()).toLocaleString()}"`, 16 | level: (label) => { 17 | return { level: label.toUpperCase() }; 18 | }, 19 | }, 20 | }); 21 | 22 | module.exports = logger; 23 | -------------------------------------------------------------------------------- /src/utils/queue.js: -------------------------------------------------------------------------------- 1 | const Queue = require("bull"); 2 | const redisClient = require("./redis"); 3 | const logger = require("./logger"); 4 | // Store all queues in an object for easy access 5 | const queues = {}; 6 | 7 | // Default queue options 8 | const defaultQueueOptions = { 9 | redis: redisClient, 10 | defaultJobOptions: { 11 | attempts: parseInt(process.env.QUEUE_MAX_RETRIES || "2", 10), 12 | backoff: { 13 | type: "exponential", 14 | delay: parseInt(process.env.QUEUE_RETRY_DELAY || "2000", 10), 15 | }, 16 | removeOnComplete: true, 17 | removeOnFail: 50, // Keep last 50 failed jobs for inspection 18 | timeout: parseInt(process.env.QUEUE_TIMEOUT || "20000", 10), 19 | }, 20 | limiter: { 21 | max: parseInt(process.env.QUEUE_BATCH_SIZE || "3", 10), 22 | duration: parseInt(process.env.QUEUE_BATCH_DELAY || "2000", 10), 23 | }, 24 | }; 25 | 26 | /** 27 | * Creates a new queue for a session if it doesn't exist 28 | * @param {string} sessionId - WhatsApp session ID 29 | * @param {Function} processor - Function to process jobs 30 | * @returns {Object} Queue instance 31 | */ 32 | function getOrCreateQueue(sessionId, processor) { 33 | if (!queues[sessionId]) { 34 | logger.info({ 35 | msg: "Creating new queue for session", 36 | sessionId, 37 | }); 38 | 39 | // Create queue 40 | queues[sessionId] = new Queue(`whatsapp-${sessionId}`, defaultQueueOptions); 41 | 42 | // Increase max listeners to handle multiple concurrent jobs 43 | queues[sessionId].setMaxListeners(50); 44 | 45 | // Set up event handlers 46 | queues[sessionId].on("completed", (job, result) => { 47 | logger.info({ 48 | msg: "Job completed successfully", 49 | sessionId, 50 | jobId: job.id, 51 | data: { 52 | sender: result?.sender, 53 | receiver: result?.receiver, 54 | messageId: result?.key?.id || null, 55 | }, 56 | }); 57 | }); 58 | 59 | queues[sessionId].on("failed", (job, error) => { 60 | logger.error({ 61 | msg: "Job failed", 62 | sessionId, 63 | jobId: job.id, 64 | attempts: job.attemptsMade, 65 | error: error.message, 66 | }); 67 | }); 68 | 69 | queues[sessionId].on("stalled", (jobId) => { 70 | logger.warn({ 71 | msg: "Job stalled", 72 | sessionId, 73 | jobId, 74 | }); 75 | }); 76 | 77 | // Set processor if provided 78 | if (processor && typeof processor === "function") { 79 | queues[sessionId].process(processor); 80 | } 81 | 82 | // Tambahkan di queue.js 83 | queues[sessionId].on("active", (job) => { 84 | logger.info({ 85 | msg: "Memory Usage", 86 | usage: process.memoryUsage(), 87 | activeJobs: queues[sessionId].getActiveCount(), 88 | }); 89 | }); 90 | } 91 | 92 | return queues[sessionId]; 93 | } 94 | 95 | /** 96 | * Add a job to the queue 97 | * @param {string} sessionId - WhatsApp session ID 98 | * @param {Object} jobData - Data to be processed 99 | * @param {Object} options - Bull job options 100 | * @returns {Promise} Result of the job 101 | */ 102 | async function addToQueue(sessionId, jobData, options = {}) { 103 | try { 104 | // Make sure queue exists 105 | const queue = getOrCreateQueue(sessionId); 106 | 107 | // Optimize job data to reduce Redis memory usage 108 | const optimizedJobData = { 109 | sessionId: jobData.sessionId, 110 | chatId: jobData.chatId, 111 | type: jobData.type, 112 | // Only store text content for text messages 113 | message: 114 | typeof jobData.message === "string" 115 | ? jobData.message 116 | : jobData.message?.text || JSON.stringify(jobData.message), 117 | }; 118 | 119 | // Add job to queue with tracking info 120 | const job = await queue.add(optimizedJobData, { 121 | ...defaultQueueOptions.defaultJobOptions, 122 | ...options, 123 | timestamp: Date.now(), 124 | trackStatus: true, 125 | }); 126 | 127 | // Get initial queue status 128 | const queueStatus = await getQueueStatus(sessionId); 129 | 130 | logger.info({ 131 | msg: "Job added to queue", 132 | sessionId, 133 | jobId: job.id, 134 | queueStatus, 135 | jobData: { 136 | chatId: optimizedJobData.chatId, 137 | type: optimizedJobData.type, 138 | messagePreview: 139 | optimizedJobData.message.substring(0, 50) + 140 | (optimizedJobData.message.length > 50 ? "..." : ""), 141 | }, 142 | }); 143 | 144 | // Return a promise that resolves with enhanced job info 145 | return new Promise((resolve, reject) => { 146 | const completedListener = async (jobId, result) => { 147 | if (jobId === job.id.toString()) { 148 | queue.removeListener("global:completed", completedListener); 149 | queue.removeListener("global:failed", failedListener); 150 | 151 | // Get final queue status 152 | const finalQueueStatus = await getQueueStatus(sessionId); 153 | 154 | // Enhance result with queue info 155 | const enhancedResult = { 156 | success: true, 157 | jobId: job.id, 158 | queueInfo: { 159 | addedAt: job.timestamp, 160 | processedAt: Date.now(), 161 | queueStatus: finalQueueStatus, 162 | }, 163 | result: JSON.parse(result), 164 | }; 165 | 166 | resolve(enhancedResult); 167 | } 168 | }; 169 | 170 | const failedListener = async (jobId, err) => { 171 | if (jobId === job.id.toString()) { 172 | queue.removeListener("global:completed", completedListener); 173 | queue.removeListener("global:failed", failedListener); 174 | 175 | // Get queue status on failure 176 | const failureQueueStatus = await getQueueStatus(sessionId); 177 | 178 | // Enhance error with queue info 179 | const enhancedError = { 180 | success: false, 181 | jobId: job.id, 182 | queueInfo: { 183 | addedAt: job.timestamp, 184 | failedAt: Date.now(), 185 | queueStatus: failureQueueStatus, 186 | }, 187 | error: err.message, 188 | }; 189 | 190 | reject(enhancedError); 191 | } 192 | }; 193 | 194 | queue.on("global:completed", completedListener); 195 | queue.on("global:failed", failedListener); 196 | }); 197 | } catch (error) { 198 | logger.error({ 199 | msg: "Failed to add job to queue", 200 | sessionId, 201 | error: error.message, 202 | stack: error.stack, 203 | }); 204 | throw error; 205 | } 206 | } 207 | 208 | /** 209 | * Clear a specific session queue 210 | * @param {string} sessionId - WhatsApp session ID 211 | * @returns {Promise} Success status 212 | */ 213 | async function clearSessionQueue(sessionId) { 214 | if (!queues[sessionId]) { 215 | logger.info({ 216 | msg: "No queue exists for session", 217 | sessionId, 218 | }); 219 | return false; 220 | } 221 | 222 | try { 223 | // Empty the queue 224 | await queues[sessionId].empty(); 225 | // Close the queue 226 | await queues[sessionId].close(); 227 | // Remove from cache 228 | delete queues[sessionId]; 229 | 230 | logger.info({ 231 | msg: "Queue cleared and closed successfully", 232 | sessionId, 233 | }); 234 | return true; 235 | } catch (error) { 236 | logger.error({ 237 | msg: "Failed to clear queue", 238 | sessionId, 239 | error: error.message, 240 | stack: error.stack, 241 | }); 242 | return false; 243 | } 244 | } 245 | 246 | /** 247 | * Clear all active queues 248 | * @returns {Promise} 249 | */ 250 | async function clearAllQueues() { 251 | const sessionIds = Object.keys(queues); 252 | 253 | logger.info({ 254 | msg: "Clearing all queues", 255 | count: sessionIds.length, 256 | }); 257 | 258 | for (const sessionId of sessionIds) { 259 | try { 260 | await queues[sessionId].empty(); 261 | await queues[sessionId].close(); 262 | delete queues[sessionId]; 263 | 264 | logger.info({ 265 | msg: "Queue cleared and closed successfully", 266 | sessionId, 267 | }); 268 | } catch (error) { 269 | logger.error({ 270 | msg: "Failed to clear queue", 271 | sessionId, 272 | error: error.message, 273 | }); 274 | } 275 | } 276 | } 277 | 278 | /** 279 | * Get queue status with enhanced metrics 280 | * @param {string} sessionId - WhatsApp session ID 281 | * @returns {Promise} Enhanced queue status 282 | */ 283 | async function getQueueStatus(sessionId) { 284 | if (!queues[sessionId]) { 285 | return { 286 | exists: false, 287 | metrics: { 288 | waiting: 0, 289 | active: 0, 290 | completed: 0, 291 | failed: 0, 292 | delayed: 0, 293 | }, 294 | performance: { 295 | avgProcessingTime: 0, 296 | throughput: 0, 297 | }, 298 | }; 299 | } 300 | 301 | try { 302 | const queue = queues[sessionId]; 303 | const [waiting, active, completed, failed, delayed] = await Promise.all([ 304 | queue.getWaitingCount(), 305 | queue.getActiveCount(), 306 | queue.getCompletedCount(), 307 | queue.getFailedCount(), 308 | queue.getDelayedCount(), 309 | ]); 310 | 311 | // Calculate basic metrics 312 | const totalProcessed = completed + failed; 313 | const avgProcessingTime = await calculateAverageProcessingTime(queue); 314 | const throughput = await calculateThroughput(queue); 315 | 316 | return { 317 | exists: true, 318 | metrics: { 319 | waiting, 320 | active, 321 | completed, 322 | failed, 323 | delayed, 324 | total: waiting + active + delayed, 325 | }, 326 | performance: { 327 | avgProcessingTime, 328 | throughput, 329 | successRate: 330 | totalProcessed > 0 ? (completed / totalProcessed) * 100 : 100, 331 | }, 332 | status: active > 0 ? "processing" : waiting > 0 ? "pending" : "idle", 333 | }; 334 | } catch (error) { 335 | logger.error({ 336 | msg: "Failed to get queue status", 337 | sessionId, 338 | error: error.message, 339 | }); 340 | 341 | return { 342 | exists: true, 343 | error: error.message, 344 | metrics: { 345 | waiting: 0, 346 | active: 0, 347 | completed: 0, 348 | failed: 0, 349 | delayed: 0, 350 | }, 351 | performance: { 352 | avgProcessingTime: 0, 353 | throughput: 0, 354 | successRate: 0, 355 | }, 356 | status: "error", 357 | }; 358 | } 359 | } 360 | 361 | /** 362 | * Calculate average processing time for completed jobs 363 | * @param {Object} queue - Bull queue instance 364 | * @returns {Promise} Average processing time in ms 365 | */ 366 | async function calculateAverageProcessingTime(queue) { 367 | try { 368 | const jobs = await queue.getCompleted(0, 10); // Get last 10 completed jobs 369 | if (jobs.length === 0) return 0; 370 | 371 | const processingTimes = jobs.map((job) => { 372 | const finished = job.finishedOn || Date.now(); 373 | const started = job.processedOn || job.timestamp; 374 | return finished - started; 375 | }); 376 | 377 | return Math.round(processingTimes.reduce((a, b) => a + b, 0) / jobs.length); 378 | } catch (error) { 379 | logger.warn({ 380 | msg: "Failed to calculate average processing time", 381 | error: error.message, 382 | }); 383 | return 0; 384 | } 385 | } 386 | 387 | /** 388 | * Calculate queue throughput (jobs/minute) 389 | * @param {Object} queue - Bull queue instance 390 | * @returns {Promise} Jobs per minute 391 | */ 392 | async function calculateThroughput(queue) { 393 | try { 394 | const jobs = await queue.getCompleted(0, 60); // Get up to last 60 completed jobs 395 | if (jobs.length < 2) return 0; 396 | 397 | const newest = jobs[0].finishedOn; 398 | const oldest = jobs[jobs.length - 1].finishedOn; 399 | const minutes = (newest - oldest) / 1000 / 60; 400 | 401 | return minutes > 0 ? Math.round(jobs.length / minutes) : 0; 402 | } catch (error) { 403 | logger.warn({ 404 | msg: "Failed to calculate throughput", 405 | error: error.message, 406 | }); 407 | return 0; 408 | } 409 | } 410 | 411 | module.exports = { 412 | getOrCreateQueue, 413 | addToQueue, 414 | clearSessionQueue, 415 | clearAllQueues, 416 | getQueueStatus, 417 | }; 418 | -------------------------------------------------------------------------------- /src/utils/redis.js: -------------------------------------------------------------------------------- 1 | const Redis = require("ioredis"); 2 | const logger = require("./logger"); 3 | 4 | // Configure Redis client 5 | const redisConfig = { 6 | host: process.env.REDIS_HOST || "localhost", 7 | port: process.env.REDIS_PORT || 6379, 8 | password: process.env.REDIS_PASSWORD || undefined, 9 | db: parseInt(process.env.REDIS_DB || "0", 10), 10 | maxRetriesPerRequest: null, 11 | enableReadyCheck: false, 12 | retryStrategy(times) { 13 | const delay = Math.min(times * 200, 2000); 14 | return delay; 15 | }, 16 | }; 17 | 18 | // Config Upstash dengan konfigurasi yang kompatibel dengan Bull 19 | const upstashConfig = { 20 | url: 21 | process.env.UPSTASH_REDIS_URL || 22 | `rediss://:${process.env.UPSTASH_REDIS_PASSWORD}@${process.env.UPSTASH_REDIS_ENDPOINT}:${process.env.UPSTASH_REDIS_PORT}`, 23 | maxRetriesPerRequest: null, 24 | enableReadyCheck: false, 25 | }; 26 | 27 | let redisClient; 28 | 29 | // if upstash config is not empty, use upstash config 30 | if (process.env.UPSTASH_REDIS_URL && process.env.UPSTASH_REDIS_PASSWORD) { 31 | redisClient = new Redis(upstashConfig.url, { 32 | maxRetriesPerRequest: null, 33 | enableReadyCheck: false, 34 | }); 35 | logger.info({ 36 | msg: "Using Upstash Redis", 37 | url: upstashConfig.url.replace(/\/\/.*@/, "//***@"), 38 | }); 39 | } else { 40 | redisClient = new Redis(redisConfig); 41 | logger.info({ 42 | msg: "Using Local Redis", 43 | host: redisConfig.host, 44 | port: redisConfig.port, 45 | }); 46 | } 47 | 48 | // Redis Event Handlers 49 | redisClient.on("connect", () => { 50 | logger.info({ 51 | msg: "Redis client connected", 52 | type: process.env.UPSTASH_REDIS_URL ? "upstash" : "local", 53 | }); 54 | }); 55 | 56 | redisClient.on("error", (err) => { 57 | logger.error({ 58 | msg: "Redis client error", 59 | error: err.message, 60 | stack: err.stack, 61 | }); 62 | }); 63 | 64 | redisClient.on("reconnecting", () => { 65 | logger.warn({ 66 | msg: "Redis client reconnecting", 67 | }); 68 | }); 69 | 70 | redisClient.on("close", () => { 71 | logger.warn({ 72 | msg: "Redis connection closed", 73 | }); 74 | }); 75 | 76 | // Fungsi helper untuk membuat koneksi baru dengan konfigurasi yang sama 77 | redisClient.duplicate = function () { 78 | if (process.env.UPSTASH_REDIS_URL || process.env.UPSTASH_REDIS_PASSWORD) { 79 | return new Redis(upstashConfig.url, { 80 | maxRetriesPerRequest: null, 81 | enableReadyCheck: false, 82 | }); 83 | } 84 | return new Redis(redisConfig); 85 | }; 86 | 87 | module.exports = redisClient; 88 | -------------------------------------------------------------------------------- /src/utils/response.js: -------------------------------------------------------------------------------- 1 | const httpStatusCode = require("../constants/httpStatusCode"); 2 | 3 | function formatServiceReturn(status, code, data = null, message = null) { 4 | return { status, code, data, message }; 5 | } 6 | 7 | function isClientErrorCategory(code) { 8 | return code >= 400 && code <= 500; 9 | } 10 | 11 | function sendResponse(res, code, message, data, error) { 12 | const result = { 13 | message, 14 | success: true, 15 | }; 16 | 17 | if (data) { 18 | result.data = data; 19 | } 20 | 21 | if (isClientErrorCategory(code)) { 22 | result.success = false; 23 | } 24 | 25 | if (error) { 26 | result.success = false; 27 | result.error = process.env.NODE_ENV == "local" ? error : null; 28 | console.error({ ...result, error }); 29 | } 30 | 31 | res.status(code); 32 | res.json(result); 33 | } 34 | 35 | function buildError(code, message, referenceId) { 36 | const result = {}; 37 | result.code = code; 38 | if (message instanceof Error) { 39 | result.message = message.message; 40 | console.error(message.message); 41 | console.error(message.stack); 42 | } else { 43 | result.message = message; 44 | console.error(message); 45 | } 46 | result.referenceId = referenceId; 47 | return result; 48 | } 49 | 50 | function buildFileResponse(res, code, mimeType, fileName, data) { 51 | res.status(code); 52 | 53 | if (fileName) { 54 | res.setHeader( 55 | "Content-Disposition", 56 | attachment, 57 | (filename = "${fileName}") 58 | ); 59 | } 60 | res.setHeader("Content-type", mimeType); 61 | if (mimeType.includes("csv")) { 62 | res.end(data); 63 | } else { 64 | res.end(Buffer.from(data), "binary"); 65 | } 66 | } 67 | 68 | class ResponseUtil { 69 | static ok({ res, message = "Success", data = null }) { 70 | res.setHeader("Content-Type", "application/json"); 71 | return res.status(200).json({ 72 | success: true, 73 | message, 74 | data, 75 | }); 76 | } 77 | 78 | static badRequest({ res, message = "Bad Request", error = null }) { 79 | res.setHeader("Content-Type", "application/json"); 80 | return res.status(400).json({ 81 | success: false, 82 | message, 83 | error, 84 | }); 85 | } 86 | 87 | static notFound({ res, message = "Not Found", error = null }) { 88 | res.setHeader("Content-Type", "application/json"); 89 | return res.status(404).json({ 90 | success: false, 91 | message, 92 | error, 93 | }); 94 | } 95 | 96 | static internalError({ 97 | res, 98 | message = "Internal Server Error", 99 | error = null, 100 | }) { 101 | res.setHeader("Content-Type", "application/json"); 102 | return res.status(500).json({ 103 | success: false, 104 | message, 105 | error, 106 | }); 107 | } 108 | } 109 | 110 | module.exports = { 111 | formatServiceReturn, 112 | sendResponse, 113 | buildError, 114 | prepareListResponse: function (page, total, array, limit) { 115 | const result = { 116 | page, 117 | count: array.length, 118 | limit, 119 | total, 120 | result: array, 121 | }; 122 | return result; 123 | }, 124 | prepareListResponseCustom: function ( 125 | currentPage, 126 | total, 127 | array, 128 | perPage, 129 | sort, 130 | filter 131 | ) { 132 | const result = { 133 | previousPage: currentPage > 1 ? currentPage - 1 : null, 134 | nextPage: total / perPage > currentPage ? currentPage + 1 : null, 135 | currentPage, 136 | perPage, 137 | total, 138 | sort, 139 | filter, 140 | data: array, 141 | }; 142 | return result; 143 | }, 144 | created: function ({ res, message, data }) { 145 | sendResponse(res, httpStatusCode.CREATED, message, data); 146 | }, 147 | accepted: function ({ res, message, data }) { 148 | sendResponse(res, httpStatusCode.ACCEPTED, message, data); 149 | }, 150 | conflict: function ({ res, message, err }) { 151 | sendResponse(res, httpStatusCode.CONFLICT, message, null, err); 152 | }, 153 | unauthorized: function ({ res, message, err }) { 154 | sendResponse(res, httpStatusCode.UNAUTHORIZED, message, null, err); 155 | }, 156 | conflict: function ({ res, message, err }) { 157 | sendResponse(res, httpStatusCode.CONFLICT, message, null, err); 158 | }, 159 | internalError: function ({ res, message = "Internal Server Error", err }) { 160 | sendResponse(res, httpStatusCode.INTERNAL_SERVER_ERROR, message, null, err); 161 | }, 162 | csvFile: function ({ res, fileName, data }) { 163 | buildFileResponse(res, 200, "text/csv", fileName, data); 164 | }, 165 | formatClientErrorResponse(res, data, err) { 166 | const message = data?.message; 167 | 168 | if (data.code === httpStatusCode.CONFLICT) { 169 | this.conflict({ res, message, err }); 170 | } else if (data.code === httpStatusCode.BAD_REQUEST) { 171 | this.badRequest({ res, message, err }); 172 | } else if (data.code === httpStatusCode.INTERNAL_SERVER_ERROR) { 173 | let error = err; 174 | 175 | if (!err) { 176 | error = new Error(message); 177 | } 178 | 179 | this.internalError({ res, message, err: error }); 180 | } else { 181 | this.notFound({ res, message, err }); 182 | } 183 | }, 184 | }; 185 | --------------------------------------------------------------------------------