import { isJidGroup, isJidStatusBroadcast } from '@adiwajshing/baileys'; import { UnprocessableEntityException } from '@nestjs/common'; import { getChannelInviteLink, WhatsappSession, } from '@waha/core/abc/session.abc'; import { getFromToParticipant, toCusFormat, } from '@waha/core/engines/noweb/session.noweb.core'; import { ReceiptEvent, TagReceiptNodeToReceiptEvent, } from '@waha/core/engines/webjs/ack.webjs'; import { ToGroupV2JoinEvent, ToGroupV2LeaveEvent, ToGroupV2ParticipantsEvent, ToGroupV2UpdateEvent, } from '@waha/core/engines/webjs/groups.webjs'; import { LocalAuth } from '@waha/core/engines/webjs/LocalAuth'; import { TagChatstateToPresence, TagPresenceToPresence, } from '@waha/core/engines/webjs/presence'; import { WebjsClientCore } from '@waha/core/engines/webjs/WebjsClientCore'; import { CallErrorEvent, PAGE_CALL_ERROR_EVENT, } from '@waha/core/engines/webjs/WPage'; import { AvailableInPlusVersion, NotImplementedByEngineError, } from '@waha/core/exceptions'; import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor'; import { QR } from '@waha/core/QR'; import { StatusToAck } from '@waha/core/utils/acks'; import { parseMessageIdSerialized, SerializeMessageKey, } from '@waha/core/utils/ids'; import { DistinctAck } from '@waha/core/utils/reactive'; import { splitAt } from '@waha/helpers'; import { PairingCodeResponse } from '@waha/structures/auth.dto'; import { Channel, ChannelListResult, ChannelMessage, ChannelRole, ChannelSearchByText, ChannelSearchByView, CreateChannelRequest, ListChannelsQuery, PreviewChannelMessages, } from '@waha/structures/channels.dto'; import { ChatSortField, ChatSummary, GetChatMessageQuery, GetChatMessagesFilter, GetChatMessagesQuery, OverviewFilter, ReadChatMessagesQuery, ReadChatMessagesResponse, } from '@waha/structures/chats.dto'; import { ChatRequest, CheckNumberStatusQuery, EditMessageRequest, MessageButtonReply, MessageFileRequest, MessageForwardRequest, MessageImageRequest, MessageLocationRequest, MessageReactionRequest, MessageReplyRequest, MessageStarRequest, MessageTextRequest, MessageVoiceRequest, SendSeenRequest, WANumberExistResult, } from '@waha/structures/chatting.dto'; import { ContactQuery, ContactRequest, ContactUpdateBody, } from '@waha/structures/contacts.dto'; import { ACK_UNKNOWN, SECOND, WAHAEngine, WAHAEvents, WAHAPresenceStatus, WAHASessionStatus, WAMessageAck, } from '@waha/structures/enums.dto'; import { BinaryFile, RemoteFile } from '@waha/structures/files.dto'; import { CreateGroupRequest, GroupSortField, ParticipantsRequest, SettingsSecurityChangeInfo, } from '@waha/structures/groups.dto'; import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto'; import { LidToPhoneNumber } from '@waha/structures/lids.dto'; import { WAMedia } from '@waha/structures/media.dto'; import { ReplyToMessage } from '@waha/structures/message.dto'; import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto'; import { WAHAChatPresences, WAHAPresenceData, } from '@waha/structures/presence.dto'; import { WAMessage, WAMessageReaction } from '@waha/structures/responses.dto'; import { BrowserTraceQuery } from '@waha/structures/server.debug.dto'; import { MeInfo } from '@waha/structures/sessions.dto'; import { StatusRequest, TextStatus } from '@waha/structures/status.dto'; import { EnginePayload, WAMessageAckBody, WAMessageEditedBody, WAMessageRevokedBody, } from '@waha/structures/webhooks.dto'; import { PaginatorInMemory } from '@waha/utils/Paginator'; import { sleep, waitUntil } from '@waha/utils/promiseTimeout'; import { SingleDelayedJobRunner } from '@waha/utils/SingleDelayedJobRunner'; import { TmpDir } from '@waha/utils/tmpdir'; import * as lodash from 'lodash'; import * as path from 'path'; import { ProtocolError } from 'puppeteer'; import { filter, fromEvent, merge, mergeMap, Observable, share } from 'rxjs'; import { map } from 'rxjs/operators'; import { AuthStrategy, Call, Channel as WEBJSChannel, Chat, ClientOptions, Contact, Events, GroupChat, GroupNotification, Label as WEBJSLabel, Location, Message, MessageMedia, Reaction, WAState, } from 'whatsapp-web.js'; import { Message as MessageInstance } from 'whatsapp-web.js/src/structures'; import { WAJSPresenceChatStateType, WebJSPresence } from './types'; export interface WebJSConfig { webVersion?: string; cacheType: 'local' | 'none'; } export class WhatsappSessionWebJSCore extends WhatsappSession { private START_ATTEMPT_DELAY_SECONDS = 2; engine = WAHAEngine.WEBJS; protected engineConfig?: WebJSConfig; private startDelayedJob: SingleDelayedJobRunner; private engineStateCheckDelayedJob: SingleDelayedJobRunner; private shouldRestart: boolean; private lastQRDate: Date = null; whatsapp: WebjsClientCore; protected qr: QR; public constructor(config) { super(config); this.qr = new QR(); this.shouldRestart = true; // Restart job if session failed this.startDelayedJob = new SingleDelayedJobRunner( 'start-engine', this.START_ATTEMPT_DELAY_SECONDS * SECOND, this.logger, ); this.engineStateCheckDelayedJob = new SingleDelayedJobRunner( 'engine-state-check', 2 * SECOND, this.logger, ); } /** * Folder with the current class */ protected getClassDirName() { return __dirname; } protected getClientOptions(): ClientOptions { const path = this.getClassDirName(); const webVersion = this.engineConfig?.webVersion || '2.3000.1018072227-alpha'; const cacheType = this.engineConfig?.cacheType || 'none'; this.logger.info(`Using cache type: '${cacheType}'`); if (cacheType === 'local') { this.logger.info(`Using web version: '${webVersion}'`); } const args = this.getBrowserArgsForPuppeteer(); // add at the start args.unshift(`--a-waha-timestamp=${new Date()}`); args.unshift(`--a-waha-session=${this.name}`); return { puppeteer: { protocolTimeout: 300_000, headless: true, executablePath: this.getBrowserExecutablePath(), args: args, dumpio: this.isDebugEnabled(), }, userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36', webVersion: webVersion, webVersionCache: { type: cacheType, path: path, strict: true, }, }; } protected async buildClient() { const clientOptions = this.getClientOptions(); const base = process.env.WAHA_LOCAL_STORE_BASE_DIR || './.sessions'; clientOptions.authStrategy = new LocalAuth({ clientId: this.name, dataPath: `${base}/webjs/default`, logger: this.logger, rmMaxRetries: undefined, }); this.addProxyConfig(clientOptions); return new WebjsClientCore(clientOptions, this.getWebjsTagsFlag()); } protected getWebjsTagsFlag() { // Emit 'tag:*' events only when explicitly enabled in session config. // This flag is required for presence.update and message.ack events. // Disabled by default for performance and stability reasons. return !!this.sessionConfig?.webjs?.tagsEventsOn; } private restartClient() { if (!this.shouldRestart) { this.logger.debug( 'Should not restart the client, ignoring restart request', ); this.end().catch((error) => { this.logger.error(error, 'Failed to end() the client'); }); return; } this.startDelayedJob.schedule(async () => { if (!this.shouldRestart) { this.logger.warn( 'Should not restart the client, ignoring restart request', ); return; } await this.end(); await this.start(); }); } protected addProxyConfig(clientOptions: ClientOptions) { if (this.proxyConfig?.server !== undefined) { // push the proxy server to the args clientOptions.puppeteer.args.push( `--proxy-server=${this.proxyConfig?.server}`, ); // Authenticate if (this.proxyConfig?.username && this.proxyConfig?.password) { clientOptions.proxyAuthentication = { username: this.proxyConfig?.username, password: this.proxyConfig?.password, }; } } } protected async init() { this.shouldRestart = true; this.whatsapp = await this.buildClient(); this.whatsapp .initialize() .then(() => { // Listen for browser disconnected event this.whatsapp.pupBrowser.on('disconnected', () => { if (this.shouldRestart) { this.logger.error('The browser has been disconnected'); } else { this.logger.info('The browser has been disconnected'); } this.failed(); }); // Listen for page close event this.whatsapp.pupPage.on('close', () => { this.logger.error('The WhatsApp Web page has been closed'); this.failed(); }); // Listen for function call errors this.whatsapp.events.on( PAGE_CALL_ERROR_EVENT, (event: CallErrorEvent) => { if (event.error instanceof ProtocolError) { this.logger.error( `ProtocolError when calling page method: ${String( event.method, )}, restarting client...`, ); this.logger.error(event.error); this.failed(); } }, ); // Listen for page error event if (this.isDebugEnabled()) { this.logger.debug("Logging 'console' event for web page"); this.whatsapp.pupPage.on('console', (msg) => this.logger.debug(`WEBJS page log: ${msg.text()}`), ); this.whatsapp.pupPage.evaluate(() => console.log(`url is ${location.href}`), ); } }) .catch((error) => { this.logger.error(error); this.failed(); return; }); if (this.isDebugEnabled()) { this.listenEngineEventsInDebugMode(); } this.listenConnectionEvents(); this.subscribeEngineEvents2(); } async start() { this.status = WAHASessionStatus.STARTING; await this.init().catch((err) => { this.logger.error('Failed to start the client'); this.logger.error(err, err.stack); this.failed(); }); return this; } async stop() { this.shouldRestart = false; this.status = WAHASessionStatus.STOPPED; this.stopEvents(); this.startDelayedJob.cancel(); this.mediaManager.close(); await this.end(); } protected failed() { // We'll restart the client if it's in the process of unpairing this.status = WAHASessionStatus.FAILED; this.restartClient(); } async unpair() { this.unpairing = true; this.shouldRestart = false; await this.whatsapp.unpair(); // Wait for unpairing to complete await sleep(2_000); } private async end() { this.engineStateCheckDelayedJob.cancel(); this.whatsapp?.removeAllListeners(); this.whatsapp?.pupBrowser?.removeAllListeners(); this.whatsapp?.pupPage?.removeAllListeners(); try { // It's possible that browser yet starting await waitUntil( async () => { const result = !!this.whatsapp.pupBrowser; this.logger.debug(`Browser is ready to be closed: ${result}`); return result; }, 1_000, 10_000, ); this.logger.debug( 'Successfully waited for browser to be ready for closing', ); } catch (error) { this.logger.error( error, 'Failed while waiting for browser to be ready for closing', ); } try { await this.whatsapp?.destroy(); this.logger.debug('Successfully destroyed whatsapp client'); } catch (error) { this.logger.error(error, 'Failed to destroy whatsapp client'); } try { // @ts-ignore const strategy: AuthStrategy = this.whatsapp?.authStrategy; await strategy?.destroy(); this.logger.debug('Successfully destroyed auth strategy'); } catch (error) { this.logger.error(error, 'Failed to destroy auth strategy'); } } getSessionMeInfo(): MeInfo | null { const clientInfo = this.whatsapp?.info; if (!clientInfo) { return null; } const wid = clientInfo.wid; return { id: wid?._serialized, pushName: clientInfo?.pushname, }; } protected listenEngineEventsInDebugMode() { // Iterate over Events enum and log with debug level all incoming events // This is useful for debugging for (const key in Events) { const event = Events[key]; this.whatsapp.on(event, (...data: any[]) => { const log = { event: event, data: data }; this.logger.debug({ event: log }, `WEBJS event`); }); } } protected listenConnectionEvents() { this.whatsapp.on(Events.QR_RECEIVED, async (qr) => { this.logger.debug('QR received'); // Convert to image and save this.qr.save(qr); this.printQR(this.qr); this.status = WAHASessionStatus.SCAN_QR_CODE; this.lastQRDate = new Date(); }); this.whatsapp.on(Events.READY, () => { this.status = WAHASessionStatus.WORKING; this.qr.save(''); this.logger.info(`Session '${this.name}' is ready!`); }); // // Temp fix for hiding "Fresh look" modal // https://github.com/devlikeapro/waha/issues/987 // this.whatsapp.on(Events.READY, async () => { try { const hidden = await this.whatsapp.hideUXFreshLook(); if (hidden) { this.logger.info('"Fresh look" modal has been hidden'); } } catch (err) { this.logger.warn('Failed to hide "Fresh look" modal'); this.logger.warn(err, err.stack); } }); this.whatsapp.on(Events.AUTHENTICATED, (args) => { this.qr.save(''); this.logger.info({ args: args }, `Session has been authenticated!`); }); this.whatsapp.on(Events.AUTHENTICATION_FAILURE, (args) => { this.qr.save(''); this.shouldRestart = false; this.logger.info({ args: args }, `Session has failed to authenticate!`); this.failed(); }); this.whatsapp.on(Events.DISCONNECTED, (args) => { if (args === 'LOGOUT') { this.logger.warn({ args: args }, `Session has been logged out!`); this.shouldRestart = false; } this.qr.save(''); this.logger.info({ args: args }, `Session has been disconnected!`); this.failed(); }); this.whatsapp.on(Events.STATE_CHANGED, (state: WAState) => { const badStates = [WAState.OPENING, WAState.TIMEOUT]; const log = this.logger.child({ state: state, event: 'change_state' }); log.info('Session engine state changed'); if (!badStates.includes(state)) { return; } log.info(`Session state changed to bad state, waiting for recovery...`); this.engineStateCheckDelayedJob.schedule(async () => { if (this.startDelayedJob.scheduled) { log.info('Session is restarting already, skip check.'); return; } if (!this.whatsapp) { log.warn('Session is not initialized, skip recovery.'); return; } const currentState = await this.whatsapp.getState().catch((error) => { log.error('Failed to get current state'); log.error(error, error.stack); return null; }); log.setBindings({ currentState: currentState }); if (!currentState) { log.warn('Session has no current state, restarting...'); this.restartClient(); return; } else if (badStates.includes(currentState)) { log.info('Session is still in bad state, restarting...'); this.restartClient(); return; } log.info('Session has recovered, no need to restart.'); }); }); } /** * START - Methods for API */ public async browserTrace(query: BrowserTraceQuery): Promise { const tmpdir = new TmpDir( this.logger, `waha-browser-trace-${this.name}-`, (10 * query.seconds + 120) * 1000, ); const page = this.whatsapp.pupPage; return await tmpdir.use(async (dir) => { this.logger.info({ query }, `Starting browser tracing...`); const filepath = path.join(dir, 'trace.json'); await page.tracing.start({ path: filepath }); await sleep(query.seconds * 1000); await page.tracing.stop(); this.logger.info(`Browser tracing finished, saved to ${filepath}`); return filepath; }); } /** * Auth methods */ public getQR(): QR { return this.qr; } public async requestCode( phoneNumber: string, method: string, params?: any, ): Promise { const code = await this.whatsapp.requestPairingCode(phoneNumber, true); // show it as ABCD-ABCD const parts = splitAt(code, 4); const codeRepr = parts.join('-'); this.logger.debug(`Your code: ${codeRepr}`); return { code: codeRepr }; } async getScreenshot(): Promise { const screenshot = await this.whatsapp.pupPage.screenshot({ encoding: 'binary', }); return screenshot as Buffer; } async checkNumberStatus( request: CheckNumberStatusQuery, ): Promise { const phone = request.phone.split('@')[0]; const result = await this.whatsapp.getNumberId(phone); if (!result) { return { numberExists: false, }; } return { numberExists: true, chatId: result._serialized, }; } /** * Profile methods */ public async setProfileName(name: string): Promise { await this.whatsapp.setPushName(name); return true; } public async setProfileStatus(status: string): Promise { await this.whatsapp.setStatus(status); return true; } protected setProfilePicture(file: BinaryFile | RemoteFile): Promise { throw new AvailableInPlusVersion(); } protected deleteProfilePicture(): Promise { throw new AvailableInPlusVersion(); } /** * Other methods */ sendText(request: MessageTextRequest) { const options = this.getMessageOptions(request); return this.whatsapp.sendMessage( this.ensureSuffix(request.chatId), request.text, options, ); } public deleteMessage(chatId: string, messageId: string) { const message = this.recreateMessage(messageId); return message.delete(true); } public editMessage( chatId: string, messageId: string, request: EditMessageRequest, ) { const message = this.recreateMessage(messageId); const options = { // It's fine to sent just ids instead of Contact object mentions: request.mentions as unknown as string[], linkPreview: request.linkPreview, }; return message.edit(request.text, options); } reply(request: MessageReplyRequest) { const options = this.getMessageOptions(request); return this.whatsapp.sendMessage( this.ensureSuffix(request.chatId), request.text, options, ); } sendImage(request: MessageImageRequest) { throw new AvailableInPlusVersion(); } sendFile(request: MessageFileRequest) { throw new AvailableInPlusVersion(); } sendVoice(request: MessageVoiceRequest) { throw new AvailableInPlusVersion(); } sendButtonsReply(request: MessageButtonReply) { throw new AvailableInPlusVersion(); } async sendLocation(request: MessageLocationRequest) { const location = new Location(request.latitude, request.longitude, { name: request.title, }); const options = this.getMessageOptions(request); return this.whatsapp.sendMessage( this.ensureSuffix(request.chatId), location, options, ); } async forwardMessage(request: MessageForwardRequest): Promise { const forwardMessage = this.recreateMessage(request.messageId); const msg = await forwardMessage.forward(this.ensureSuffix(request.chatId)); // Return "sent: true" for now // need to research how to get the data from WebJS // @ts-ignore return { sent: msg || false }; } async sendSeen(request: SendSeenRequest) { const chat: Chat = await this.whatsapp.getChatById( this.ensureSuffix(request.chatId), ); await chat.sendSeen(); } async startTyping(request: ChatRequest) { const chat: Chat = await this.whatsapp.getChatById( this.ensureSuffix(request.chatId), ); await chat.sendStateTyping(); } async stopTyping(request: ChatRequest) { const chat: Chat = await this.whatsapp.getChatById( this.ensureSuffix(request.chatId), ); await chat.clearState(); } async setReaction(request: MessageReactionRequest) { const message = this.recreateMessage(request.messageId); return message.react(request.reaction); } /** * Recreate message instance from id */ private recreateMessage(msgId: string): MessageInstance { const messageId = this.deserializeId(msgId); const data = { id: messageId, }; return new MessageInstance(this.whatsapp, data); } async setStar(request: MessageStarRequest) { const message = this.recreateMessage(request.messageId); if (request.star) { await message.star(); } else { await message.unstar(); } } /** * Chats methods */ getChats(pagination: PaginationParams, filter: OverviewFilter | null = null) { switch (pagination.sortBy) { case ChatSortField.ID: pagination.sortBy = 'id._serialized'; break; case ChatSortField.CONVERSATION_TIMESTAMP: pagination.sortBy = 't'; break; } return this.whatsapp.getChats(pagination, filter); } public async getChatsOverview( pagination: PaginationParams, filter?: OverviewFilter, ): Promise { pagination = { ...pagination, sortBy: ChatSortField.CONVERSATION_TIMESTAMP, sortOrder: SortOrder.DESC, }; const chats = await this.getChats(pagination, filter); const promises = []; for (const chat of chats) { promises.push(this.fetchChatSummary(chat)); } const result = await Promise.all(promises); return result; } protected async fetchChatSummary(chat: Chat): Promise { const picture = await this.getContactProfilePicture( chat.id._serialized, false, ); const lastMessage = chat.lastMessage ? this.toWAMessage(chat.lastMessage) : null; return { id: chat.id._serialized, name: chat.name || null, picture: picture, lastMessage: lastMessage, _chat: chat, }; } public async getChatMessages( chatId: string, query: GetChatMessagesQuery, filter: GetChatMessagesFilter, ) { if (chatId == 'all') { throw new NotImplementedByEngineError( "Can not get messages from 'all' in WEBJS", ); } const downloadMedia = query.downloadMedia; // Test there's chat with id await this.whatsapp.getChatById(this.ensureSuffix(chatId)); const pagination: PaginationParams = query; const messages = await this.whatsapp.getMessages( this.ensureSuffix(chatId), filter, pagination, ); const promises = []; for (const msg of messages) { promises.push(this.processIncomingMessage(msg, downloadMedia)); } let result = await Promise.all(promises); result = result.filter(Boolean); return result; } public async readChatMessages( chatId: string, request: ReadChatMessagesQuery, ): Promise { const chat: Chat = await this.whatsapp.getChatById( this.ensureSuffix(chatId), ); await chat.sendSeen(); return { ids: null }; } public async getChatMessage( chatId: string, messageId: string, query: GetChatMessageQuery, ): Promise { const message = await this.whatsapp.getMessageById(messageId); if (!message) return null; if ( isJidGroup(message.id.remote) || isJidStatusBroadcast(message.id.remote) ) { // @ts-ignore message.rawData.receipts = await message.getInfo().catch((error) => { this.logger.error( { error: error, msg: message.id._serialized }, 'Failed to get receipts', ); return null; }); } return await this.processIncomingMessage(message, query.downloadMedia); } public async pinMessage( chatId: string, messageId: string, duration: number, ): Promise { const message = await this.whatsapp.getMessageById(messageId); return message.pin(duration); } public async unpinMessage( chatId: string, messageId: string, ): Promise { const message = await this.whatsapp.getMessageById(messageId); return message.unpin(); } async deleteChat(chatId) { const chat = await this.whatsapp.getChatById(this.ensureSuffix(chatId)); return chat.delete(); } async clearMessages(chatId) { const chat = await this.whatsapp.getChatById(chatId); return chat.clearMessages(); } public chatsArchiveChat(chatId: string): Promise { const id = this.ensureSuffix(chatId); return this.whatsapp.archiveChat(id); } public chatsUnarchiveChat(chatId: string): Promise { const id = this.ensureSuffix(chatId); return this.whatsapp.unarchiveChat(id); } public chatsUnreadChat(chatId: string): Promise { const id = this.ensureSuffix(chatId); return this.whatsapp.markChatUnread(id); } /** * * Label methods */ public async getLabels(): Promise { const labels = await this.whatsapp.getLabels(); return labels.map(this.toLabel); } public async createLabel(label: LabelDTO): Promise