550 lines
18 KiB
JavaScript
550 lines
18 KiB
JavaScript
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
|
|
}
|