Files
waha/src/core/engines/webjs/WebjsClientCore.ts
T

794 lines
25 KiB
TypeScript

import { WebJSPresence } from '@waha/core/engines/webjs/types';
import { GetSerialized } from '@waha/core/utils/serialized';
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
import { Label } from '@waha/structures/labels.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { TextStatus } from '@waha/structures/status.dto';
import { sleep } from '@waha/utils/promiseTimeout';
import { EventEmitter } from 'events';
import * as lodash from 'lodash';
import { Logger } from 'pino';
import { Page } from 'puppeteer';
import { Client, Events, Message as WebjsMessage } from 'whatsapp-web.js';
import { Message } from 'whatsapp-web.js/src/structures';
import { Message as MessageInstance } from 'whatsapp-web.js/src/structures';
import { CallErrorEvent, PAGE_CALL_ERROR_EVENT, WPage } from './WPage';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { LoadLodash } = require('./_lodash.js');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { LoadPaginator } = require('./_Paginator.js');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ChatFactory = require('whatsapp-web.js/src/factories/ChatFactory');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {
exposeFunctionIfAbsent,
} = require('whatsapp-web.js/src/util/Puppeteer');
export interface WebjsChannelMessage {
message: WebjsMessage;
reactions: ChannelMessageReaction[];
viewCount: number;
}
class ChannelMessageReaction {
reaction: string;
count: number;
}
interface _Id {
id: string;
fromMe: boolean;
remote: string;
_serialized: string;
}
/**
* "parentMsgKey": {
* "fromMe": false,
* "remote": "111111111111111111@newsletter",
* "id": "AAAAAAAAAAAAAAAAAAAA",
* "_serialized": "false_111111111111111111@newsletter_AAAAAAAAAAAAAAAAAAAA"
* },
* "serverTimestamp": 1738536731,
* "emojiCountMap": {emoji=>count}
*/
interface _NewsletterReaction {
parentMsgKey: _Id;
serverTimestamp: number;
emojiCountMap: any;
}
interface _GetNewsletterPreviewDataResponse {
ids: any[];
newsletterMetadata: any;
newsletterMessages: any[];
newsletterReactions: _NewsletterReaction[];
timestamp: number;
}
function extractReactionsByMessageKey(
newsletterReactions: _NewsletterReaction[],
): Map<string, ChannelMessageReaction[]> {
const reactions = new Map();
for (const reaction of newsletterReactions) {
const key = GetSerialized(reaction.parentMsgKey);
const emojiCountMap = reaction.emojiCountMap;
const reactionList: ChannelMessageReaction[] = [];
for (const emoji in emojiCountMap) {
reactionList.push({
reaction: emoji,
count: emojiCountMap[emoji],
});
}
reactions.set(key, reactionList);
}
return reactions;
}
export class WebjsClientCore extends Client {
public events = new EventEmitter();
private wpage: WPage = null;
private injecting: Promise<void> = null;
constructor(
options,
protected tags: boolean,
protected logger: Logger,
) {
super(options);
// Wait until it's READY and inject more utils
// AUTHENTICATED and READY fire back to back - run one injection for both
this.on(Events.AUTHENTICATED, () => this.injectUtils());
this.on(Events.READY, () => this.injectUtils());
}
private injectUtils(): Promise<void> {
if (this.injecting) {
return this.injecting;
}
this.injecting = this.attachCustomEventListeners()
.then(() => this.injectWaha())
.catch((err) => this.logger.error(err, 'Failed to inject utils'))
.finally(() => {
this.injecting = null;
});
return this.injecting;
}
async initialize() {
const result = await super.initialize();
if (this.pupPage && !(this.pupPage instanceof WPage)) {
this.wpage = new WPage(this.pupPage);
this.wpage.on(PAGE_CALL_ERROR_EVENT as any, (event: CallErrorEvent) => {
this.events.emit(PAGE_CALL_ERROR_EVENT as any, event);
});
this.pupPage = this.wpage as any as Page;
}
return result;
}
async injectWaha() {
await this.pupPage.evaluate(LoadLodash);
await this.pupPage.evaluate(LoadPaginator);
}
/**
* @result indicating whether the UX fresh look was successfully hidden.
*/
hideUXFreshLook(): Promise<boolean> {
return this.pupPage.evaluate(() => {
const WAWebUserPrefsUiRefresh = window.require('WAWebUserPrefsUiRefresh');
if (!WAWebUserPrefsUiRefresh) {
return false;
}
if (WAWebUserPrefsUiRefresh.getUiRefreshNuxAcked()) {
return false;
}
WAWebUserPrefsUiRefresh.incrementNuxViewCount();
WAWebUserPrefsUiRefresh.setUiRefreshNuxAcked(true);
const WAWebModalManager = window.require('WAWebModalManager');
WAWebModalManager.ModalManager.close();
return true;
});
}
/**
* @result indicating whether the "What's New" auto-modal was prevented or dismissed.
*/
hideWhatsNewModal(): Promise<boolean> {
return this.pupPage.evaluate(() => {
// Module registry is not available until the app bundle has loaded
if (typeof window.require !== 'function') {
return false;
}
const WAWebWhatsNewNux = window.require('WAWebWhatsNewNux');
if (!WAWebWhatsNewNux) {
return false;
}
// user prefs are not writable before login
const WAWebUserPrefsMeUser = window.require('WAWebUserPrefsMeUser');
if (!WAWebUserPrefsMeUser.getMaybeMePnUser()) {
return false;
}
const nux = WAWebWhatsNewNux.createWhatsNewNux();
if (!nux.shouldShow()) {
return false;
}
// Bump the dismiss count and start the cool-off, so the app never auto-opens the modal
nux.dismiss();
// If the modal has already opened - close the topmost modal (no-op when nothing is open)
const WAWebModalManager = window.require('WAWebModalManager');
WAWebModalManager.ModalManager.close();
return true;
});
}
async attachCustomEventListeners() {
await exposeFunctionIfAbsent(
this.pupPage,
'onNewMessageId',
(messageId: string) => {
this.events.emit('message.id', { id: messageId });
return;
},
);
if (this.tags) {
await this.attachTagsEvents();
}
}
async attachTagsEvents() {
await this.pupPage.evaluate(() => {
// @ts-ignore
if (window.decodeStanzaBack) {
return;
}
const tags = ['receipt', 'presence', 'chatstate'];
const WAWap = window.require('WAWap');
// @ts-ignore
window.decodeStanzaBack = WAWap.decodeStanza;
WAWap.decodeStanza = async (...args) => {
// @ts-ignore
const result = await window.decodeStanzaBack(...args);
if (tags.includes(result?.tag)) {
// @ts-ignore
setTimeout(() => window.onTag(result), 0);
}
return result;
};
});
}
async destroy() {
this.events.removeAllListeners();
this.wpage?.removeAllListeners();
await super.destroy();
}
async setPushName(name: string) {
await this.ensureWahaInjected();
await this.pupPage.evaluate(async (pushName) => {
return await window
.require('WAWebSetPushnameConnAction')
.setPushname(pushName);
}, name);
if (this.info) {
this.info.pushname = name;
}
}
async unpair() {
await this.pupPage.evaluate(async () => {
const Socket = window.require('WAWebSocketModel')?.Socket;
if (Socket && typeof Socket.logout === 'function') {
await Socket.logout();
}
});
}
async createLabel(name: string, color: number): Promise<number> {
await this.ensureWahaInjected();
const labelId: number = (await this.pupPage.evaluate(
async (name, color) => {
return await window
.require('WAWebBizLabelEditingAction')
.labelAddAction(name, color);
},
name,
color,
)) as any;
return labelId;
}
async deleteLabel(label: Label) {
await this.ensureWahaInjected();
return await this.pupPage.evaluate(async (label) => {
return await window
.require('WAWebBizLabelEditingAction')
.labelDeleteAction(label.id, label.name, label.color);
}, label);
}
async updateLabel(label: Label) {
await this.ensureWahaInjected();
return await this.pupPage.evaluate(async (label) => {
return await window.require('WAWebBizLabelEditingAction').labelEditAction(
label.id,
label.name,
undefined, // predefinedId
label.color,
);
}, label);
}
async getChats(pagination?: PaginationParams, filter?: { ids?: string[] }) {
if (lodash.isEmpty(pagination)) {
return await super.getChats();
}
await this.ensureWahaInjected();
// Get paginated chats
pagination.limit ||= Infinity;
pagination.offset ||= 0;
const chats = await this.pupPage.evaluate(
async (pagination, filter) => {
let chats = window
.require('WAWebCollections')
.Chat.getModelsArray()
.slice();
// Filter chats by IDs if filter is provided
if (filter && filter.ids && filter.ids.length > 0) {
chats = chats.filter((chat) =>
// @ts-ignore
filter.ids.includes(window.WWebJS.GetSerialized(chat.id)),
);
}
// @ts-ignore
const paginator = new window.Paginator(pagination);
chats = paginator.apply(chats);
const chatPromises = chats.map((chat) =>
// @ts-ignore
window.WWebJS.getChatModel(chat),
);
return await Promise.all(chatPromises);
},
pagination,
filter,
);
return chats.map((chat) => ChatFactory.create(this, chat));
}
protected async ensureWahaInjected() {
const hasWaha = await this.pupPage.evaluate(() => {
// @ts-ignore
return Boolean(window.Paginator);
});
if (!hasWaha) {
await this.injectWaha();
}
}
async sendTextStatus(status: TextStatus) {
// Convert from hex to number
const waColor = 'FF' + status.backgroundColor.replace('#', '');
const color = parseInt(waColor, 16);
const textStatus = {
text: status.text,
color: color,
font: status.font,
};
const sentMsg = await this.pupPage.evaluate(async (status) => {
await window
.require('WAWebSendStatusMsgAction')
.sendStatusTextMsgAction(status);
const meUser = window.require('WAWebUserPrefsMeUser').getMaybeMePnUser();
const myStatus = window
.require('WAWebCollections')
.Status.getModelsArray()
.findLast((x) => x.id == meUser);
if (!myStatus) {
return undefined;
}
// @ts-ignore
const msg = myStatus.msgs.last();
// @ts-ignore
return msg ? window.WWebJS.getMessageModel(msg) : undefined;
}, textStatus);
return sentMsg ? new Message(this, sentMsg) : undefined;
}
async getMessages(
chatId: string,
filter: GetChatMessagesFilter,
pagination: PaginationParams,
) {
const messages = await this.pupPage.evaluate(
async (chatId, filter, pagination) => {
pagination.limit ||= Infinity;
pagination.offset ||= 0;
const msgFilter = (m) => {
if (m.isNotification) {
return false;
}
if (
filter['filter.fromMe'] != null &&
m.id.fromMe !== filter['filter.fromMe']
) {
return false;
}
if (
filter['filter.timestamp.gte'] != null &&
m.t < filter['filter.timestamp.gte']
) {
return false;
}
if (
filter['filter.timestamp.lte'] != null &&
m.t > filter['filter.timestamp.lte']
) {
return false;
}
if (filter['filter.ack'] != null && m.ack !== filter['filter.ack']) {
return false;
}
return true;
};
// @ts-ignore
const chat = await window.WWebJS.getChat(chatId, { getAsModel: false });
if (!chat) return [];
let msgs = [];
// Try new WhatsApp API (>= 2.3000.1034162388) which replaced loadEarlierMsgs
// @ts-ignore
const WAWebDBMessageFindLocal = window.require(
'WAWebDBMessageFindLocal',
);
if (WAWebDBMessageFindLocal?.msgFindByDirection) {
const BATCH_SIZE = 20;
// Construct the initial anchor the same way wa-js does:
// serialize to string then reconstruct via MsgKey.fromString so the
// object has the exact shape msgFindByDirection expects.
// @ts-ignore
const lastReceivedSerialized = window.WWebJS.GetSerialized(
chat.lastReceivedKey,
);
if (!lastReceivedSerialized) return [];
let currentAnchorKey = window
.require('WAWebMsgKey')
.fromString(lastReceivedSerialized);
// msgFindByDirection is exclusive of the anchor; include the anchor
// message itself (the most recent message in the chat) upfront
const anchorMsg = window
.require('WAWebCollections')
.Msg.get(lastReceivedSerialized);
if (anchorMsg) {
msgs.push(anchorMsg);
}
const neededFiltered = Number.isFinite(
pagination.limit + pagination.offset,
)
? pagination.limit + pagination.offset
: Infinity;
// @ts-ignore
const toModel = (m) => {
if (m && typeof m.serialize === 'function') return m;
// @ts-ignore
const serializedId = window.WWebJS.GetSerialized(m?.id);
const Msg = window.require('WAWebCollections').Msg;
if (serializedId) {
const stored = Msg.get(serializedId);
if (stored) return stored;
}
return new Msg.modelClass(m);
};
while (true) {
const result = await WAWebDBMessageFindLocal.msgFindByDirection({
anchor: currentAnchorKey,
count: BATCH_SIZE,
direction: 'before',
});
const batch = Array.isArray(result)
? result
: result?.messages || [];
if (!batch || batch.length === 0) break;
const batchModels = batch.map(toModel);
// msgFindByDirection returns newest-first (descending) so prepend
// to keep the accumulated list in approximate ascending order
msgs = [...batchModels, ...msgs];
// Deduplicate by serialized id — the same Backbone model object can
// appear in multiple batches when anchors overlap
const seenIds = new Set();
msgs = msgs.filter((m) => {
// @ts-ignore
const sid = window.WWebJS.GetSerialized(m?.id);
if (!sid || seenIds.has(sid)) return false;
seenIds.add(sid);
return true;
});
// Stop once we have enough messages that pass the filter
if (msgs.filter(msgFilter).length >= neededFiltered) break;
// Stop if the oldest message in this batch is already before the
// lower timestamp bound - no earlier messages can be in range
if (filter['filter.timestamp.gte'] != null) {
const batchMinT = batchModels.reduce(
(min, m) => Math.min(min, m.t ?? Infinity),
Infinity,
);
if (batchMinT < filter['filter.timestamp.gte']) break;
}
// No more messages available
if (batch.length < BATCH_SIZE) break;
// msgFindByDirection returns newest-first, so the LAST element is
// the oldest message in this batch — use it as the next anchor to
// walk further back in history without overlap
const oldestInBatch = batchModels[batchModels.length - 1];
// @ts-ignore
const oldestSerialized = window.WWebJS.GetSerialized(
oldestInBatch?.id,
);
if (!oldestSerialized) break;
currentAnchorKey = window
.require('WAWebMsgKey')
.fromString(oldestSerialized);
}
} else {
// Legacy fallback: loadEarlierMsgs loop
msgs = chat.msgs.getModelsArray();
while (msgs.length < pagination.limit + pagination.offset) {
const loadedMessages = await window
.require('WAWebChatLoadMessages')
.loadEarlierMsgs(chat, chat.msgs);
if (!loadedMessages || loadedMessages.length == 0) break;
msgs = [...loadedMessages, ...msgs];
msgs = msgs.sort((a, b) => b.t - a.t);
const earliest = msgs[msgs.length - 1];
if (earliest.t < (filter['filter.timestamp.gte'] || Infinity)) {
break;
}
}
}
msgs = msgs.filter(msgFilter);
// Always sort newest first before applying the pagination window.
msgs = msgs.sort((a, b) => b.t - a.t);
const offset = Math.max(0, pagination.offset);
const limit = pagination.limit;
if (Number.isFinite(limit)) {
const end = Math.min(offset + limit, msgs.length);
msgs = msgs.slice(offset, end);
} else if (offset > 0) {
// When the limit is unbounded we still need to respect the offset.
msgs = msgs.slice(offset);
}
// @ts-ignore
return msgs.map((m) => window.WWebJS.getMessageModel(m));
},
chatId,
filter,
pagination,
);
return messages.map((m) => new Message(this, m));
}
public async getAllLids(
pagination: PaginationParams,
): Promise<Array<LidToPhoneNumber>> {
const lids: Array<LidToPhoneNumber> = (await this.pupPage.evaluate(
async (pagination) => {
pagination.limit ||= Infinity;
pagination.offset ||= 0;
pagination.sortBy ||= 'lid';
const WAWebApiContact = window.require('WAWebApiContact');
await WAWebApiContact.warmUpAllLidPnMappings();
const lidMap = WAWebApiContact.lidPnCache['$1'];
const values = Array.from(lidMap.values());
const result = values.map((map) => {
return {
// @ts-ignore
lid: window.WWebJS.GetSerialized(map.lid),
// @ts-ignore
pn: window.WWebJS.GetSerialized(map.phoneNumber),
};
});
// @ts-ignore
const paginator = new window.Paginator(pagination);
const page = paginator.apply(result);
return page;
},
pagination,
)) as any;
return lids;
}
public async getLidsCount(): Promise<number> {
const count: number = (await this.pupPage.evaluate(async () => {
const WAWebApiContact = window.require('WAWebApiContact');
await WAWebApiContact.warmUpAllLidPnMappings();
const lidMap = WAWebApiContact.lidPnCache['$1'];
return lidMap.size;
})) as any;
return count;
}
public async findPNByLid(lid: string): Promise<string> {
const pn = await this.pupPage.evaluate(async (lid) => {
const WAWebApiContact = window.require('WAWebApiContact');
const WAWebWidFactory = window.require('WAWebWidFactory');
const wid = WAWebWidFactory.createWid(lid);
const result = WAWebApiContact.getPhoneNumber(wid);
// @ts-ignore
return window.WWebJS.GetSerialized(result);
}, lid);
return pn;
}
public async findLIDByPhoneNumber(phoneNumber: string): Promise<string> {
const lid: string = (await this.pupPage.evaluate(async (pn) => {
const WAWebApiContact = window.require('WAWebApiContact');
const WAWebWidFactory = window.require('WAWebWidFactory');
const wid = WAWebWidFactory.createWid(pn);
const result = WAWebApiContact.getCurrentLid(wid);
// @ts-ignore
return window.WWebJS.GetSerialized(result);
}, phoneNumber)) as any;
return lid;
}
/**
* Presences methods
*/
/**
* WhatsApp keys 1:1 presence by LID on LID-migrated accounts, so map @c.us to the current LID when known
*/
private async getPresenceKey(chatId: string): Promise<string> {
return await this.pupPage.evaluate((chatId) => {
const d = require;
const wid = d('WAWebWidFactory').createWidFromWidLike(chatId);
if (wid.isGroup() || wid.isLid()) {
return chatId;
}
const lid = d('WAWebApiContact').getCurrentLid(wid);
if (!lid) {
return chatId;
}
// @ts-ignore
return window.WWebJS.GetSerialized(lid);
}, chatId);
}
public async subscribePresence(chatId: string): Promise<void> {
const key = await this.getPresenceKey(chatId);
await this.pupPage.evaluate(async (chatId) => {
const d = require;
const wid = d('WAWebWidFactory').createWidFromWidLike(chatId);
// find() subscribes user or group presence the same way the app does
await d('WAWebPresenceCollection').PresenceCollection.find(wid);
}, key);
}
private async getCurrentPresence(chatId: string): Promise<WebJSPresence[]> {
const key = await this.getPresenceKey(chatId);
const result = await this.pupPage.evaluate(
async (chatId, key) => {
const d = require;
const WidFactory = d('WAWebWidFactory');
const PresenceCollection = d(
'WAWebPresenceCollection',
).PresenceCollection;
const wid = WidFactory.createWidFromWidLike(key);
const presence = PresenceCollection.get(wid);
if (!presence) {
return [];
}
if (wid.isGroup()) {
return presence.chatstates.getModelsArray().map((chatstate) => {
return {
// @ts-ignore
participant: window.WWebJS.GetSerialized(chatstate.id),
lastSeen: chatstate.t,
state: chatstate.type,
};
});
}
// Report the id the caller asked for, not the LID key
return [
{
participant: chatId,
lastSeen: presence.chatstate.t,
state: presence.chatstate.type,
},
];
},
chatId,
key,
);
return result;
}
public async getPresence(chatId: string): Promise<WebJSPresence[]> {
await this.sendPresenceAvailable();
await this.subscribePresence(chatId);
await sleep(3_000);
return await this.getCurrentPresence(chatId);
}
/**
* Channels methods
*/
async channelFetchMessageByInvite(
inviteCode: string,
limit: number,
): Promise<WebjsChannelMessage[]> {
const response: _GetNewsletterPreviewDataResponse =
await this.pupPage.evaluate(
async (code, limit) => {
// Overwrite the server-side message count so the preview fetches
// exactly `limit` messages
window.require(
'WAWebNewsletterGatingUtils',
).getMaxMsgCountFromServer = () => limit;
const result = await window
.require('WAWebNewsletterPreviewJob')
.getNewsletterPreviewData(code, 'guest');
for (const newsletterReaction of result.newsletterReactions) {
// puppeter doesn't support Map,
// so we need to convert it to object
newsletterReaction.emojiCountMap = Object.fromEntries(
newsletterReaction.emojiCountMap,
);
}
// Fetch one more time to save in database so we can fetch media later
await window
.require('WAWebLoadNewsletterPreviewChatAction')
.loadNewsletterPreviewChat(code);
return result;
},
inviteCode,
limit,
);
const messageInstances = response.newsletterMessages
.filter((msg) => msg.type != 'revoked')
.map((msg) => {
return new MessageInstance(this, msg);
});
const reactions = extractReactionsByMessageKey(
response.newsletterReactions,
);
const messages: WebjsChannelMessage[] = messageInstances.map((msg) => {
return {
message: msg,
reactions: reactions.get(GetSerialized(msg.id)) || [],
viewCount: msg.rawData.viewCount,
};
});
return messages;
}
/**
* Channels Search methods
*/
async searchChannelsView(params: any): Promise<any> {
const newsletters: any = await this.pupPage.evaluate(async (params) => {
return await window
.require('WAWebNewsletterDirectorySearchJob')
.getNewsletterDirectoryList(params);
}, params);
return newsletters;
}
async searchChannelsText(params: any): Promise<any> {
const newsletters: any = await this.pupPage.evaluate(async (params) => {
return await window
.require('WAWebNewsletterDirectorySearchJob')
.getNewsletterDirectorySearchResults(params);
}, params);
return newsletters;
}
}