const { Client, LocalAuth } = require('whatsapp-web.js') const fs = require('fs') const path = require('path') const sessions = new Map() const { baseWebhookURL, sessionFolderPath, maxAttachmentSize, setMessagesAsSeen, webVersion, webVersionCacheType, recoverSessions, chromeBin, headless, releaseBrowserLock } = require('./config') const { triggerWebhook, waitForNestedObject, checkIfEventisEnabled, sendMessageSeenStatus } = require('./utils') const { logger } = require('./logger') const { initWebSocketServer, terminateWebSocketServer, triggerWebSocket } = require('./websocket') // Function to validate if the session is ready const validateSession = async (sessionId) => { try { const returnData = { success: false, state: null, message: '' } // Session not Connected 😢 if (!sessions.has(sessionId) || !sessions.get(sessionId)) { returnData.message = 'session_not_found' return returnData } const client = sessions.get(sessionId) // wait until the client is created await waitForNestedObject(client, 'pupPage') .catch((err) => { return { success: false, state: null, message: err.message } }) // Wait for client.pupPage to be evaluable let maxRetry = 0 while (true) { try { if (client.pupPage.isClosed()) { return { success: false, state: null, message: 'browser tab closed' } } await Promise.race([ client.pupPage.evaluate('1'), new Promise(resolve => setTimeout(resolve, 1000)) ]) break } catch (error) { if (maxRetry === 2) { return { success: false, state: null, message: 'session closed' } } maxRetry++ } } const state = await client.getState() returnData.state = state if (state !== 'CONNECTED') { returnData.message = 'session_not_connected' return returnData } // Session Connected 🎉 returnData.success = true returnData.message = 'session_connected' return returnData } catch (error) { logger.error({ sessionId, err: error }, 'Failed to validate session') return { success: false, state: null, message: error.message } } } // Function to handle client session restoration const restoreSessions = () => { try { if (!fs.existsSync(sessionFolderPath)) { fs.mkdirSync(sessionFolderPath) // Create the session directory if it doesn't exist } // Read the contents of the folder fs.readdir(sessionFolderPath, async (_, files) => { // Iterate through the files in the parent folder for (const file of files) { // Use regular expression to extract the string from the folder name const match = file.match(/^session-(.+)$/) if (match) { const sessionId = match[1] logger.warn({ sessionId }, 'existing session detected') await setupSession(sessionId) } } }) } catch (error) { logger.error(error, 'Failed to restore sessions') } } // Setup Session const setupSession = async (sessionId) => { try { if (sessions.has(sessionId)) { return { success: false, message: `Session already exists for: ${sessionId}`, client: sessions.get(sessionId) } } // Disable the delete folder from the logout function (will be handled separately) const localAuth = new LocalAuth({ clientId: sessionId, dataPath: sessionFolderPath }) delete localAuth.logout localAuth.logout = () => { } const clientOptions = { puppeteer: { executablePath: chromeBin, headless, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] }, authStrategy: localAuth } if (webVersion) { clientOptions.webVersion = webVersion switch (webVersionCacheType.toLowerCase()) { case 'local': clientOptions.webVersionCache = { type: 'local' } break case 'remote': clientOptions.webVersionCache = { type: 'remote', remotePath: 'https://raw.githubusercontent.com/wppconnect-team/wa-version/main/html/' + webVersion + '.html' } break default: clientOptions.webVersionCache = { type: 'none' } } } const client = new Client(clientOptions) if (releaseBrowserLock) { // See https://github.com/puppeteer/puppeteer/issues/4860 const singletonLockPath = path.resolve(path.join(sessionFolderPath, `session-${sessionId}`, 'SingletonLock')) const singletonLockExists = await fs.promises.lstat(singletonLockPath).then(() => true).catch(() => false) if (singletonLockExists) { logger.warn({ sessionId }, 'Browser lock file exists, removing') await fs.promises.unlink(singletonLockPath) } } try { await client.initialize() } catch (error) { logger.error({ sessionId, err: error }, 'Initialize error') throw error } initWebSocketServer(sessionId) initializeEvents(client, sessionId) // Save the session to the Map sessions.set(sessionId, client) return { success: true, message: 'Session initiated successfully', client } } catch (error) { return { success: false, message: error.message, client: null } } } const initializeEvents = (client, sessionId) => { // check if the session webhook is overridden const sessionWebhook = process.env[sessionId.toUpperCase() + '_WEBHOOK_URL'] || baseWebhookURL if (recoverSessions) { waitForNestedObject(client, 'pupPage').then(() => { const restartSession = async (sessionId) => { sessions.delete(sessionId) await client.destroy().catch(e => { }) await setupSession(sessionId) } client.pupPage.once('close', function () { // emitted when the page closes logger.warn({ sessionId }, 'Browser page closed. Restoring') restartSession(sessionId) }) client.pupPage.once('error', function () { // emitted when the page crashes logger.warn({ sessionId }, 'Error occurred on browser page. Restoring') restartSession(sessionId) }) }).catch(e => { }) } checkIfEventisEnabled('auth_failure') .then(_ => { client.on('auth_failure', (msg) => { triggerWebhook(sessionWebhook, sessionId, 'status', { msg }) triggerWebSocket(sessionId, 'status', { msg }) }) }) checkIfEventisEnabled('authenticated') .then(_ => { client.qr = null client.on('authenticated', () => { triggerWebhook(sessionWebhook, sessionId, 'authenticated') triggerWebSocket(sessionId, 'authenticated') }) }) checkIfEventisEnabled('call') .then(_ => { client.on('call', async (call) => { triggerWebhook(sessionWebhook, sessionId, 'call', { call }) triggerWebSocket(sessionId, 'call', { call }) }) }) checkIfEventisEnabled('change_state') .then(_ => { client.on('change_state', state => { triggerWebhook(sessionWebhook, sessionId, 'change_state', { state }) triggerWebSocket(sessionId, 'change_state', { state }) }) }) checkIfEventisEnabled('disconnected') .then(_ => { client.on('disconnected', (reason) => { triggerWebhook(sessionWebhook, sessionId, 'disconnected', { reason }) triggerWebSocket(sessionId, 'disconnected', { reason }) }) }) checkIfEventisEnabled('group_join') .then(_ => { client.on('group_join', (notification) => { triggerWebhook(sessionWebhook, sessionId, 'group_join', { notification }) triggerWebSocket(sessionId, 'group_join', { notification }) }) }) checkIfEventisEnabled('group_leave') .then(_ => { client.on('group_leave', (notification) => { triggerWebhook(sessionWebhook, sessionId, 'group_leave', { notification }) triggerWebSocket(sessionId, 'group_leave', { notification }) }) }) checkIfEventisEnabled('group_admin_changed') .then(_ => { client.on('group_admin_changed', (notification) => { triggerWebhook(sessionWebhook, sessionId, 'group_admin_changed', { notification }) triggerWebSocket(sessionId, 'group_admin_changed', { notification }) }) }) checkIfEventisEnabled('group_membership_request') .then(_ => { client.on('group_membership_request', (notification) => { triggerWebhook(sessionWebhook, sessionId, 'group_membership_request', { notification }) triggerWebSocket(sessionId, 'group_membership_request', { notification }) }) }) checkIfEventisEnabled('group_update') .then(_ => { client.on('group_update', (notification) => { triggerWebhook(sessionWebhook, sessionId, 'group_update', { notification }) triggerWebSocket(sessionId, 'group_update', { notification }) }) }) checkIfEventisEnabled('loading_screen') .then(_ => { client.on('loading_screen', (percent, message) => { triggerWebhook(sessionWebhook, sessionId, 'loading_screen', { percent, message }) triggerWebSocket(sessionId, 'loading_screen', { percent, message }) }) }) checkIfEventisEnabled('media_uploaded') .then(_ => { client.on('media_uploaded', (message) => { triggerWebhook(sessionWebhook, sessionId, 'media_uploaded', { message }) triggerWebSocket(sessionId, 'media_uploaded', { message }) }) }) checkIfEventisEnabled('message') .then(_ => { client.on('message', async (message) => { triggerWebhook(sessionWebhook, sessionId, 'message', { message }) triggerWebSocket(sessionId, 'message', { message }) if (message.hasMedia && message._data?.size < maxAttachmentSize) { // custom service event checkIfEventisEnabled('media').then(_ => { message.downloadMedia().then(messageMedia => { triggerWebhook(sessionWebhook, sessionId, 'media', { messageMedia, message }) triggerWebSocket(sessionId, 'media', { messageMedia, message }) }).catch(error => { logger.error({ sessionId, err: error }, 'Failed to download media') }) }) } if (setMessagesAsSeen) { sendMessageSeenStatus(message) } }) }) checkIfEventisEnabled('message_ack') .then(_ => { client.on('message_ack', async (message, ack) => { triggerWebhook(sessionWebhook, sessionId, 'message_ack', { message, ack }) triggerWebSocket(sessionId, 'message_ack', { message, ack }) if (setMessagesAsSeen) { sendMessageSeenStatus(message) } }) }) checkIfEventisEnabled('message_create') .then(_ => { client.on('message_create', async (message) => { triggerWebhook(sessionWebhook, sessionId, 'message_create', { message }) triggerWebSocket(sessionId, 'message_create', { message }) if (setMessagesAsSeen) { sendMessageSeenStatus(message) } }) }) checkIfEventisEnabled('message_reaction') .then(_ => { client.on('message_reaction', (reaction) => { triggerWebhook(sessionWebhook, sessionId, 'message_reaction', { reaction }) triggerWebSocket(sessionId, 'message_reaction', { reaction }) }) }) checkIfEventisEnabled('message_edit') .then(_ => { client.on('message_edit', (message, newBody, prevBody) => { triggerWebhook(sessionWebhook, sessionId, 'message_edit', { message, newBody, prevBody }) triggerWebSocket(sessionId, 'message_edit', { message, newBody, prevBody }) }) }) checkIfEventisEnabled('message_ciphertext') .then(_ => { client.on('message_ciphertext', (message) => { triggerWebhook(sessionWebhook, sessionId, 'message_ciphertext', { message }) triggerWebSocket(sessionId, 'message_ciphertext', { message }) }) }) checkIfEventisEnabled('message_revoke_everyone') .then(_ => { client.on('message_revoke_everyone', async (message) => { triggerWebhook(sessionWebhook, sessionId, 'message_revoke_everyone', { message }) triggerWebSocket(sessionId, 'message_revoke_everyone', { message }) }) }) checkIfEventisEnabled('message_revoke_me') .then(_ => { client.on('message_revoke_me', async (message, revokedMsg) => { triggerWebhook(sessionWebhook, sessionId, 'message_revoke_me', { message, revokedMsg }) triggerWebSocket(sessionId, 'message_revoke_me', { message, revokedMsg }) }) }) client.on('qr', (qr) => { // by default QR code is being updated every 20 seconds if (client.qrClearTimeout) { clearTimeout(client.qrClearTimeout) } // inject qr code into session client.qr = qr client.qrClearTimeout = setTimeout(() => { if (client.qr) { logger.warn({ sessionId }, 'Removing expired QR code') client.qr = null } }, 30000) checkIfEventisEnabled('qr') .then(_ => { triggerWebhook(sessionWebhook, sessionId, 'qr', { qr }) triggerWebSocket(sessionId, 'qr', { qr }) }) }) checkIfEventisEnabled('ready') .then(_ => { client.on('ready', () => { triggerWebhook(sessionWebhook, sessionId, 'ready') triggerWebSocket(sessionId, 'ready') }) }) checkIfEventisEnabled('contact_changed') .then(_ => { client.on('contact_changed', async (message, oldId, newId, isContact) => { triggerWebhook(sessionWebhook, sessionId, 'contact_changed', { message, oldId, newId, isContact }) triggerWebSocket(sessionId, 'contact_changed', { message, oldId, newId, isContact }) }) }) checkIfEventisEnabled('chat_removed') .then(_ => { client.on('chat_removed', async (chat) => { triggerWebhook(sessionWebhook, sessionId, 'chat_removed', { chat }) triggerWebSocket(sessionId, 'chat_removed', { chat }) }) }) checkIfEventisEnabled('chat_archived') .then(_ => { client.on('chat_archived', async (chat, currState, prevState) => { triggerWebhook(sessionWebhook, sessionId, 'chat_archived', { chat, currState, prevState }) triggerWebSocket(sessionId, 'chat_archived', { chat, currState, prevState }) }) }) checkIfEventisEnabled('unread_count') .then(_ => { client.on('unread_count', async (chat) => { triggerWebhook(sessionWebhook, sessionId, 'unread_count', { chat }) triggerWebSocket(sessionId, 'unread_count', { chat }) }) }) checkIfEventisEnabled('vote_update') .then(_ => { client.on('vote_update', async (vote) => { triggerWebhook(sessionWebhook, sessionId, 'vote_update', { vote }) triggerWebSocket(sessionId, 'vote_update', { vote }) }) }) } // Function to delete client session folder const deleteSessionFolder = async (sessionId) => { try { const targetDirPath = path.join(sessionFolderPath, `session-${sessionId}`) const resolvedTargetDirPath = await fs.promises.realpath(targetDirPath) const resolvedSessionPath = await fs.promises.realpath(sessionFolderPath) // Ensure the target directory path ends with a path separator const safeSessionPath = `${resolvedSessionPath}${path.sep}` // Validate the resolved target directory path is a subdirectory of the session folder path if (!resolvedTargetDirPath.startsWith(safeSessionPath)) { throw new Error('Invalid path: Directory traversal detected') } await fs.promises.rm(resolvedTargetDirPath, { recursive: true, force: true }) } catch (error) { logger.error({ sessionId, err: error }, 'Folder deletion error') throw error } } // Function to reload client session without removing browser cache const reloadSession = async (sessionId) => { try { const client = sessions.get(sessionId) if (!client) { return } client.pupPage?.removeAllListeners('close') client.pupPage?.removeAllListeners('error') try { const pages = await client.pupBrowser.pages() await Promise.all(pages.map((page) => page.close())) await Promise.race([ client.pupBrowser.close(), new Promise(resolve => setTimeout(resolve, 5000)) ]) } catch (e) { const childProcess = client.pupBrowser.process() if (childProcess) { childProcess.kill(9) } } sessions.delete(sessionId) await setupSession(sessionId) } catch (error) { logger.error({ sessionId, err: error }, 'Failed to reload session') throw error } } const deleteSession = async (sessionId, validation) => { try { const client = sessions.get(sessionId) if (!client) { return } client.pupPage?.removeAllListeners('close') client.pupPage?.removeAllListeners('error') try { await terminateWebSocketServer(sessionId) } catch (error) { logger.error({ sessionId, err: error }, 'Failed to terminate WebSocket server') } if (validation.success) { // Client Connected, request logout logger.info({ sessionId }, 'Logging out session') await client.logout() } else if (validation.message === 'session_not_connected') { // Client not Connected, request destroy logger.info({ sessionId }, 'Destroying session') await client.destroy() } // Wait 10 secs for client.pupBrowser to be disconnected before deleting the folder let maxDelay = 0 while (client.pupBrowser.isConnected() && (maxDelay < 10)) { await new Promise(resolve => setTimeout(resolve, 1000)) maxDelay++ } sessions.delete(sessionId) await deleteSessionFolder(sessionId) } catch (error) { logger.error({ sessionId, err: error }, 'Failed to delete session') throw error } } // Function to handle session flush const flushSessions = async (deleteOnlyInactive) => { try { // Read the contents of the sessions folder const files = await fs.promises.readdir(sessionFolderPath) // Iterate through the files in the parent folder for (const file of files) { // Use regular expression to extract the string from the folder name const match = file.match(/^session-(.+)$/) if (match) { const sessionId = match[1] const validation = await validateSession(sessionId) if (!deleteOnlyInactive || !validation.success) { await deleteSession(sessionId, validation) } } } } catch (error) { logger.error(error, 'Failed to flush sessions') throw error } } module.exports = { sessions, setupSession, restoreSessions, validateSession, deleteSession, reloadSession, flushSessions }