diff --git a/Makefile b/Makefile index fd47e1d0..e3658481 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,9 @@ up-noweb-libsignal: up-webjs: yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18 +up-wpp: + yarn up @wppconnect-team/wppconnect + up-rust-bridge: yarn up -R whatsapp-rust-bridge diff --git a/package.json b/package.json index abb5de6c..b055deaf 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@types/passport": "^1.0.17", "@types/sqlite3": "^5.1.0", "@types/ws": "^8.5.4", + "@wppconnect-team/wppconnect": "^1.40.1", "adm-zip": "0.5.10", "agentkeepalive": "^4.5.0", "async-lock": "^1.4.1", diff --git a/src/api/screenshot.controller.ts b/src/api/screenshot.controller.ts index f660f647..f09792a0 100644 --- a/src/api/screenshot.controller.ts +++ b/src/api/screenshot.controller.ts @@ -31,7 +31,7 @@ export class ScreenshotController { @Get('/screenshot') @ApiOperation({ summary: - 'Get a screenshot of the current WhatsApp session (**WEBJS** only)', + 'Get a screenshot of the current WhatsApp session (**WEBJS/WPP** only)', }) @UseInterceptors(new BufferResponseInterceptor('image/jpeg')) @ApiFileAcceptHeader('image/jpeg') diff --git a/src/apps/chatwoot/consumers/waha/message.reaction.ts b/src/apps/chatwoot/consumers/waha/message.reaction.ts index 44d2b0fe..35c8c7ec 100644 --- a/src/apps/chatwoot/consumers/waha/message.reaction.ts +++ b/src/apps/chatwoot/consumers/waha/message.reaction.ts @@ -32,7 +32,10 @@ export class WAHAMessageReactionConsumer extends ChatWootWAHABaseConsumer { } GetChatId(event: WAHAWebhookMessageReaction): string { - if (event.environment?.engine == WAHAEngine.WEBJS) { + if ( + event.environment?.engine == WAHAEngine.WEBJS || + event.environment?.engine == WAHAEngine.WPP + ) { // chat in "to" field for WEBJS for message.reaction // probably we need to set it to "from" // but for backward compatability we do this diff --git a/src/apps/chatwoot/waha/engines.ts b/src/apps/chatwoot/waha/engines.ts index 78defca9..15029772 100644 --- a/src/apps/chatwoot/waha/engines.ts +++ b/src/apps/chatwoot/waha/engines.ts @@ -4,6 +4,7 @@ import { WAHAEngine } from '@waha/structures/enums.dto'; import { getEngineName } from '@waha/version'; import { Message as MessageInstance } from 'whatsapp-web.js/src/structures'; import { isLidUser, isPnUser, toCusFormat } from '@waha/core/utils/jids'; +import { parseMessageIdSerialized } from '@waha/core/utils/ids'; import { WAMessage } from '@waha/structures/responses.dto'; import { CallData } from '@waha/structures/calls.dto'; @@ -162,6 +163,62 @@ class WEBJSHelper implements IEngineHelper { } } +class WPPHelper implements IEngineHelper { + ChatID(message: WAMessage): string { + return toCusFormat(parseMessageIdSerialized(message.id as any).remoteJid); + } + + CallChatID(call: CallData): string { + return call.from; + } + + /** + * Parse API response and get the data for WPP engine. + * WPP returns message.id as a composite string "fromMe_chatId_msgId". + */ + WhatsAppMessageKeys(message: any): WhatsAppMessage { + const parsed = parseMessageIdSerialized(message.id); + return { + timestamp: new Date(message.timestamp * 1000), + from_me: parsed.fromMe, + chat_id: toCusFormat(parsed.remoteJid), + message_id: parsed.id, + participant: message.author || null, + }; + } + + /** + * WPP API lacks server-side sorting hooks, so we buffer and sort by the unix timestamp in memory. + */ + async *IterateMessages( + messages: AsyncGenerator, + ): AsyncGenerator { + const buffer: T[] = []; + + for await (const message of messages) { + buffer.push(message); + } + + const sorted = lodash.sortBy(buffer, (item) => item.timestamp); + + for (const message of sorted) { + yield message; + } + } + + FilterChatIdsForMessages(chats: string[]): string[] { + return preferPnChats(chats); + } + + SupportsAllChatForMessage(): boolean { + return false; + } + + ContactIsMy(contact) { + return contact.isMyContact; + } +} + // Choose the right EngineHelper based on getEngineName() function let engineHelper: IEngineHelper; @@ -175,6 +232,9 @@ switch (getEngineName()) { case WAHAEngine.WEBJS: engineHelper = new WEBJSHelper(); break; + case WAHAEngine.WPP: + engineHelper = new WPPHelper(); + break; default: engineHelper = new WEBJSHelper(); // Default to WEBJS as it's the default engine } diff --git a/src/core/app.module.core.ts b/src/core/app.module.core.ts index 81af8c7c..25c32f63 100644 --- a/src/core/app.module.core.ts +++ b/src/core/app.module.core.ts @@ -22,6 +22,7 @@ import { ApiKeyAuthMiddleware } from '@waha/core/auth/api-key-auth.middleware'; import { BasicAuthFunction } from '@waha/core/auth/basicAuth'; import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth'; import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService'; +import { WPPEngineConfigService } from '@waha/core/config/WPPEngineConfigService'; import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService'; import { MediaLocalStorageModule } from '@waha/core/media/local/media.local.storage.module'; import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig'; @@ -176,6 +177,7 @@ export const PROVIDERS_BASE: Provider[] = [ DashboardConfigServiceCore, SwaggerConfigServiceCore, WebJSEngineConfigService, + WPPEngineConfigService, GowsEngineConfigService, WhatsappConfigService, EngineConfigService, diff --git a/src/core/config/WPPEngineConfigService.ts b/src/core/config/WPPEngineConfigService.ts new file mode 100644 index 00000000..47f5f3a6 --- /dev/null +++ b/src/core/config/WPPEngineConfigService.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { WPPConfig } from '@waha/core/engines/wpp/WppConfig'; + +@Injectable() +export class WPPEngineConfigService { + constructor(protected configService: ConfigService) {} + + getConfig(): WPPConfig { + let webVersion = this.configService.get( + 'WAHA_WPP_WEB_VERSION', + undefined, + ); + if (!webVersion) { + webVersion = this.configService.get( + 'WAHA_WEBJS_WEB_VERSION', + undefined, + ); + } + return { + webVersion: webVersion, + puppeteerArgs: this.getPuppeterArgs(), + }; + } + + getPuppeterArgs(): string[] { + let args = this.configService.get('WAHA_WPP_PUPPETER_ARGS', ''); + if (!args) { + args = this.configService.get('WAHA_WEBJS_PUPPETER_ARGS', ''); + } + return args + .split(' ') + .map((arg) => arg.trim()) + .filter(Boolean); + } +} diff --git a/src/core/engines/wpp/IWPPAuthManager.ts b/src/core/engines/wpp/IWPPAuthManager.ts new file mode 100644 index 00000000..82dd3dfc --- /dev/null +++ b/src/core/engines/wpp/IWPPAuthManager.ts @@ -0,0 +1,18 @@ +export interface IWPPAuthManager { + /** + * Called BEFORE WPP browser starts. + * Downloads and extracts any existing remote session to userDataDir. + */ + beforeStart(): Promise; + + /** + * Called AFTER the session reaches WORKING status. + * Waits for stabilization, saves first snapshot, then starts periodic backup. + */ + afterConnected(): Promise; + + /** + * Stops the periodic backup runner. + */ + stop(): Promise; +} diff --git a/src/core/engines/wpp/WppConfig.ts b/src/core/engines/wpp/WppConfig.ts new file mode 100644 index 00000000..b9ea7cba --- /dev/null +++ b/src/core/engines/wpp/WppConfig.ts @@ -0,0 +1,4 @@ +export interface WPPConfig { + webVersion?: string; + puppeteerArgs: string[]; +} diff --git a/src/core/engines/wpp/WppTypes.ts b/src/core/engines/wpp/WppTypes.ts new file mode 100644 index 00000000..98c74275 --- /dev/null +++ b/src/core/engines/wpp/WppTypes.ts @@ -0,0 +1,82 @@ +import { Whatsapp as WPPWhatsapp } from '@wppconnect-team/wppconnect'; +import type { + PoolMessageOptions, + TextMessageOptions, +} from '@wppconnect/wa-js/dist/chat'; +import type { EditMessageOptions } from '@wppconnect/wa-js/dist/chat/functions/editMessage'; +import type { TextStatusOptions } from '@wppconnect/wa-js/dist/status/functions/sendTextStatus'; + +// +// Event data types extracted directly from wppconnect on* method signatures. +// Using Parameters<> keeps these automatically in sync with wppconnect: +// if an inline type changes upstream, TypeScript will catch mismatches here. +// + +// Extracts the last parameter of a function (handles overloaded on* methods +// that accept an optional id filter as the first arg before the callback). +type LastParam any> = Parameters extends [ + ...any[], + infer Last, +] + ? Last + : never; + +// Extracts the event data type: first arg of the (last) callback parameter. +type WppEventData any> = + LastParam extends (...args: any[]) => any + ? Parameters>[0] + : never; + +type WppTextMessageOptions = TextMessageOptions; +type WppMentionedList = WppTextMessageOptions extends { + mentionedList?: infer Value; +} + ? Value + : string[]; + +export type WppPresenceEvent = WppEventData; +export type WppParticipantEvent = WppEventData< + WPPWhatsapp['onParticipantsChanged'] +>; + +export enum WppParticipantAction { + ADD = 'add', + JOIN = 'join', + REMOVE = 'remove', + LEAVE = 'leave', + LEAVER = 'leaver', + PROMOTE = 'promote', + DEMOTE = 'demote', +} + +export enum WppParticipantOperation { + ADD = 'add', + REMOVE = 'remove', + PROMOTE = 'promote', + DEMOTE = 'demote', +} + +export type WppReactionEvent = WppEventData; +export type WppRevokedMessageEvent = WppEventData< + WPPWhatsapp['onRevokedMessage'] +>; +export type WppPollResponseEvent = WppEventData; +export type WppUpdateLabelEvent = WppEventData; +export type WppIncomingCallEvent = WppEventData; +export type WppSendPollOptions = PoolMessageOptions; +export type WppSendTextOptions = WppTextMessageOptions; +export type WppSendTextStatusOptions = Omit< + TextStatusOptions, + 'backgroundColor' +> & { + backgroundColor?: string; +}; +export type WppEditMessageOptions = EditMessageOptions & { + mentions?: WppMentionedList; +}; + +// onMessageEdit callback has three separate args: (chat: Wid, id: string, msg: Message). +// fromWppCallback packs them into a tuple since args.length > 1. +export type WppMessageEditArgs = Parameters< + LastParam +>; diff --git a/src/core/engines/wpp/events.wpp.ts b/src/core/engines/wpp/events.wpp.ts new file mode 100644 index 00000000..17eb3092 --- /dev/null +++ b/src/core/engines/wpp/events.wpp.ts @@ -0,0 +1,216 @@ +import { toCusFormat } from '@waha/core/utils/jids'; +import { SerializeMsgKey } from '@waha/core/utils/ids'; +import { WAMessageReaction } from '@waha/structures/responses.dto'; +import { WAHAPresenceStatus } from '@waha/structures/enums.dto'; +import { + GroupId, + GroupParticipant, + GroupParticipantRole, +} from '@waha/structures/groups.dto'; +import { + GroupParticipantType, + GroupV2LeaveEvent, + GroupV2ParticipantsEvent, + GroupV2UpdateEvent, +} from '@waha/structures/groups.events.dto'; +import { MeInfo } from '@waha/structures/sessions.dto'; +import { LabelChatAssociation } from '@waha/structures/labels.dto'; +import { WAHAChatPresences } from '@waha/structures/presence.dto'; +import { + WppParticipantAction, + WppParticipantEvent, + WppPresenceEvent, + WppReactionEvent, + WppUpdateLabelEvent, +} from '@waha/core/engines/wpp/WppTypes'; + +// +// Converters +// + +function presenceStateToWAHA( + state: string, + isOnline?: boolean, +): WAHAPresenceStatus { + switch (state) { + case 'available': + return WAHAPresenceStatus.ONLINE; + case 'composing': + case 'typing': + return WAHAPresenceStatus.TYPING; + case 'recording': + return WAHAPresenceStatus.RECORDING; + case 'unavailable': + return WAHAPresenceStatus.OFFLINE; + default: + return isOnline ? WAHAPresenceStatus.ONLINE : WAHAPresenceStatus.OFFLINE; + } +} + +export function WppPresenceToPresence( + data: WppPresenceEvent, +): WAHAChatPresences { + const chatId = toCusFormat(data.id); + + // Group: iterate over per-participant states when available + if (data.isGroup && data.participants?.length) { + return { + id: chatId, + presences: data.participants.map((p) => ({ + participant: toCusFormat(p.id), + lastKnownPresence: presenceStateToWAHA(p.state), + lastSeen: null, + })), + }; + } + + return { + id: chatId, + presences: [ + { + participant: chatId, + lastKnownPresence: presenceStateToWAHA(data.state, data.isOnline), + lastSeen: null, + }, + ], + }; +} + +export function WppParticipantsToGroupV2Participants( + data: WppParticipantEvent, +): GroupV2ParticipantsEvent | null { + let type: GroupParticipantType; + let role: GroupParticipantRole; + + switch (data.action) { + case WppParticipantAction.ADD: + case WppParticipantAction.JOIN: + type = GroupParticipantType.JOIN; + role = GroupParticipantRole.PARTICIPANT; + break; + case WppParticipantAction.REMOVE: + case WppParticipantAction.LEAVE as any: + case WppParticipantAction.LEAVER: + type = GroupParticipantType.LEAVE; + role = GroupParticipantRole.LEFT; + break; + case WppParticipantAction.PROMOTE: + type = GroupParticipantType.PROMOTE; + role = GroupParticipantRole.ADMIN; + break; + case WppParticipantAction.DEMOTE: + type = GroupParticipantType.DEMOTE; + role = GroupParticipantRole.PARTICIPANT; + break; + default: + return null; + } + + const group: GroupId = { id: toCusFormat(data.groupId) }; + const participants: GroupParticipant[] = data.who.map((id) => ({ + id: id, + pn: null, + role: role, + })); + + return { + group: group, + type: type, + timestamp: Math.floor(Date.now() / 1000), + participants: participants, + _data: data, + }; +} + +export function WppParticipantsIsMyJoin( + data: WppParticipantEvent, + me: MeInfo | null | undefined, +): boolean { + const joinActions = [WppParticipantAction.ADD, WppParticipantAction.JOIN]; + if (!joinActions.includes(data.action as WppParticipantAction)) return false; + return data.who.some((id) => id === me?.id || id === me?.lid); +} + +export function WppParticipantsIsMyLeave( + data: WppParticipantEvent, + me: MeInfo | null | undefined, +): boolean { + const leaveActions = [ + WppParticipantAction.REMOVE, + WppParticipantAction.LEAVE, + WppParticipantAction.LEAVER, + ]; + if (!leaveActions.includes(data.action as WppParticipantAction)) return false; + return data.who.some((id) => id === me?.id || id === me?.lid); +} + +export function WppParticipantsToGroupV2Leave( + data: WppParticipantEvent, +): GroupV2LeaveEvent { + return { + timestamp: Math.floor(Date.now() / 1000), + group: { id: toCusFormat(data.groupId) }, + _data: data, + }; +} + +export function WppReactionToMessageReaction( + data: WppReactionEvent, +): WAMessageReaction { + // sender is present at runtime but absent from the installed package's type + const raw = data as any; + const sender: string = + raw.sender?._serialized ?? raw.sender?.toString?.() ?? raw.sender ?? ''; + // remote is the chat ID (peer in DM, group JID in group chats) + const remote = toCusFormat(raw.id?.remote ?? ''); + const isGroup = remote.includes('@g.us'); + // In DMs, the remote party IS the sender when sender field is missing + const from = sender ? toCusFormat(sender) : isGroup ? '' : remote; + const participant = sender ? toCusFormat(sender) : isGroup ? '' : remote; + const id = SerializeMsgKey(data.id); + const messageId = SerializeMsgKey(data.msgId); + return { + id: id, + from: from, + fromMe: raw.id?.fromMe ?? false, + source: null, + participant: participant, + to: remote, + timestamp: data.timestamp, + reaction: { + text: data.reactionText ?? '', + messageId: messageId, + }, + // @ts-ignore + _data: data, + }; +} + +export function WppGp2ToGroupV2Update(msg: any): GroupV2UpdateEvent { + const id = toCusFormat(msg.chatId); + const group: Partial> = { id: id }; + if (msg.subtype === 'subject') { + group.subject = msg.body; + } else if (msg.subtype === 'description') { + group.description = msg.body; + } else { + return null; + } + return { + timestamp: msg.timestamp, + group: group as any, + _data: msg, + }; +} + +export function WppUpdateLabelToAssociations( + data: WppUpdateLabelEvent, +): LabelChatAssociation[] { + const chat = data.chat as any; + const chatId: string = chat?.id?._serialized ?? chat?.id ?? chat ?? ''; + return data.ids.map((labelId, i) => ({ + labelId: labelId, + label: (data.labels?.[i] ?? null) as any, + chatId: toCusFormat(chatId), + })); +} diff --git a/src/core/engines/wpp/reactive/fromWppCallback.ts b/src/core/engines/wpp/reactive/fromWppCallback.ts new file mode 100644 index 00000000..3ee9614a --- /dev/null +++ b/src/core/engines/wpp/reactive/fromWppCallback.ts @@ -0,0 +1,24 @@ +import { Observable } from 'rxjs'; +import { EnginePayload } from '@waha/structures/webhooks.dto'; + +/** + * Wraps a WPPConnect `on*` listener method into a lazy RxJS Observable. + * The listener is registered only when subscribed and disposed on teardown. + * + * @param event Event name label stored in EnginePayload, e.g. `'onMessage'` + * @param fn Bound wppconnect listener invocation, e.g. `(cb) => wpp.onMessage(cb)` + */ +export function fromWppCallback( + event: string, + fn: (callback: (...args: any[]) => void) => { dispose: () => void }, +): Observable { + return new Observable((subscriber) => { + const { dispose } = fn((...args: any[]) => { + // Multi-arg callbacks (e.g. onMessageEdit receives chat, id, msg) are + // packed into an array so EnginePayload.data is always a single value. + const data = args.length === 1 ? args[0] : args; + subscriber.next({ event: event, data: data }); + }); + return () => dispose(); + }); +} diff --git a/src/core/engines/wpp/reactive/wppStreams.ts b/src/core/engines/wpp/reactive/wppStreams.ts new file mode 100644 index 00000000..d752b33f --- /dev/null +++ b/src/core/engines/wpp/reactive/wppStreams.ts @@ -0,0 +1,123 @@ +import { Observable, share } from 'rxjs'; +import { Whatsapp as WPPWhatsapp } from '@wppconnect-team/wppconnect'; +import { fromWppCallback } from '@waha/core/engines/wpp/reactive/fromWppCallback'; +import { EnginePayload } from '@waha/structures/webhooks.dto'; + +/** + * One Observable per wppconnect on* listener method we subscribe to. + * The compile-time guard below ensures every key exists on WPPWhatsapp: + * if wppconnect removes a method TypeScript will error on that assertion. + */ +export interface WppStreams { + onMessage: Observable; + onAnyMessage: Observable; + onAck: Observable; + onMessageEdit: Observable; + onNotificationMessage: Observable; + onParticipantsChanged: Observable; + onStateChange: Observable; + onStreamChange: Observable; + onIncomingCall: Observable; + onInterfaceChange: Observable; + onPresenceChanged: Observable; + onLiveLocation: Observable; + onAddedToGroup: Observable; + onRevokedMessage: Observable; + onReactionMessage: Observable; + onPollResponse: Observable; + onUpdateLabel: Observable; + onOrderStatusUpdate: Observable; +} + +// Compile-time guard: every key of WppStreams must be a real method on WPPWhatsapp. +// If wppconnect removes a listener method this line will produce a TypeScript error. +type _AssertWppStreams = keyof WppStreams extends keyof WPPWhatsapp + ? true + : never; +export const _assertWppStreams: _AssertWppStreams = true; +void _assertWppStreams; + +/** + * Builds a map of shared, lazy Observables — one per WPPConnect on* listener. + * + * Each Observable registers the underlying wppconnect callback only when + * subscribed and disposes it on teardown. Using direct method calls + * (rather than a generic loop) lets IDEs resolve types and navigate to + * wppconnect's own declarations. + * + * WppStreams is the exhaustiveness guard: if a field is missing here TypeScript + * will report a missing-property error on the object literal. + */ +export function buildWppStreams(wpp: WPPWhatsapp): WppStreams { + return { + onMessage: fromWppCallback('onMessage', (cb) => wpp.onMessage(cb)).pipe( + share(), + ), + + onAnyMessage: fromWppCallback('onAnyMessage', (cb) => + wpp.onAnyMessage(cb), + ).pipe(share()), + + onAck: fromWppCallback('onAck', (cb) => wpp.onAck(cb)).pipe(share()), + + onMessageEdit: fromWppCallback('onMessageEdit', (cb) => + wpp.onMessageEdit(cb), + ).pipe(share()), + + onNotificationMessage: fromWppCallback('onNotificationMessage', (cb) => + wpp.onNotificationMessage(cb), + ).pipe(share()), + + onParticipantsChanged: fromWppCallback('onParticipantsChanged', (cb) => + wpp.onParticipantsChanged(cb), + ).pipe(share()), + + onStateChange: fromWppCallback('onStateChange', (cb) => + wpp.onStateChange(cb), + ).pipe(share()), + + onStreamChange: fromWppCallback('onStreamChange', (cb) => + wpp.onStreamChange(cb), + ).pipe(share()), + + onIncomingCall: fromWppCallback('onIncomingCall', (cb) => + wpp.onIncomingCall(cb), + ).pipe(share()), + + onInterfaceChange: fromWppCallback('onInterfaceChange', (cb) => + wpp.onInterfaceChange(cb), + ).pipe(share()), + + onPresenceChanged: fromWppCallback('onPresenceChanged', (cb) => + wpp.onPresenceChanged(cb), + ).pipe(share()), + + onLiveLocation: fromWppCallback('onLiveLocation', (cb) => + wpp.onLiveLocation(cb), + ).pipe(share()), + + onAddedToGroup: fromWppCallback('onAddedToGroup', (cb) => + wpp.onAddedToGroup(cb), + ).pipe(share()), + + onRevokedMessage: fromWppCallback('onRevokedMessage', (cb) => + wpp.onRevokedMessage(cb), + ).pipe(share()), + + onReactionMessage: fromWppCallback('onReactionMessage', (cb) => + wpp.onReactionMessage(cb), + ).pipe(share()), + + onPollResponse: fromWppCallback('onPollResponse', (cb) => + wpp.onPollResponse(cb), + ).pipe(share()), + + onUpdateLabel: fromWppCallback('onUpdateLabel', (cb) => + wpp.onUpdateLabel(cb), + ).pipe(share()), + + onOrderStatusUpdate: fromWppCallback('onOrderStatusUpdate', (cb) => + wpp.onOrderStatusUpdate(cb), + ).pipe(share()), + }; +} diff --git a/src/core/engines/wpp/session.wpp.core.ts b/src/core/engines/wpp/session.wpp.core.ts new file mode 100644 index 00000000..cda80fe6 --- /dev/null +++ b/src/core/engines/wpp/session.wpp.core.ts @@ -0,0 +1,2576 @@ +import { + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { Activity } from '@waha/core/abc/activity'; +import { + getChannelInviteLink, + getPublicUrlFromDirectPath, + parseGroupInviteLink, + WhatsappSession, +} from '@waha/core/abc/session.abc'; +import { + Channel, + ChannelListResult, + ChannelMessage, + ChannelPublicInfo, + ChannelRole, + ChannelSearchByText, + ChannelSearchByView, + CreateChannelRequest, + ListChannelsQuery, + PreviewChannelMessages, +} from '@waha/structures/channels.dto'; +import { splitAt } from '@waha/helpers'; +import { PairingCodeResponse } from '@waha/structures/auth.dto'; +import { + ChatSortField, + GetChatMessageQuery, + ChatSummary, + GetChatMessagesFilter, + GetChatMessagesQuery, + OverviewFilter, + ReadChatMessagesQuery, + ReadChatMessagesResponse, +} from '@waha/structures/chats.dto'; +import { + ChatRequest, + CheckNumberStatusQuery, + EditMessageRequest, + MessageContactVcardRequest, + MessageForwardRequest, + MessageImageRequest, + MessageLocationRequest, + MessagePollRequest, + MessageReactionRequest, + MessageReplyRequest, + MessageFileRequest, + MessageStarRequest, + MessageTextRequest, + MessageVoiceRequest, + SendSeenRequest, + WANumberExistResult, + VCardContact, + Contact, +} from '@waha/structures/chatting.dto'; +import { + ACK_UNKNOWN, + SECOND, + WAHAEngine, + WAHAEvents, + WAHAPresenceStatus, + WAHASessionStatus, + WAMessageAck, +} from '@waha/structures/enums.dto'; +import { SingleDelayedJobRunner } from '@waha/utils/SingleDelayedJobRunner'; +import { WAHA_CLIENT_DEVICE_NAME } from '@waha/core/env'; +import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto'; +import { CallData } from '@waha/structures/calls.dto'; +import { + GroupV2JoinEvent, + GroupV2LeaveEvent, + GroupV2ParticipantsEvent, + GroupV2UpdateEvent, +} from '@waha/structures/groups.events.dto'; +import { + Label, + LabelChatAssociation, + LabelDTO, + LabelID, +} from '@waha/structures/labels.dto'; +import { WAHAChatPresences } from '@waha/structures/presence.dto'; +import { WAMessage } from '@waha/structures/responses.dto'; +import { MeInfo } from '@waha/structures/sessions.dto'; +import { TextStatus } from '@waha/structures/status.dto'; +import { ReplyToMessage } from '@waha/structures/message.dto'; +import { + PollVotePayload, + WAMessageAckBody, + WAMessageEditedBody, + WAMessageRevokedBody, +} from '@waha/structures/webhooks.dto'; +import { PaginatorInMemory } from '@waha/utils/Paginator'; +import { PinoWinstonAdapter } from '@waha/utils/logging/PinoWinstonAdapter'; +import { sleep } from '@waha/utils/promiseTimeout'; +import { + create as createWPPClient, + CreateOptions, + GroupProperty, + MessageType, + SocketState, + StatusFind, + Whatsapp as WPPWhatsapp, +} from '@wppconnect-team/wppconnect'; +import type { Logger as WinstonLogger } from 'winston'; +import { + WppGp2ToGroupV2Update, + WppParticipantsIsMyJoin, + WppParticipantsIsMyLeave, + WppParticipantsToGroupV2Leave, + WppParticipantsToGroupV2Participants, + WppPresenceToPresence, + WppReactionToMessageReaction, + WppUpdateLabelToAssociations, +} from '@waha/core/engines/wpp/events.wpp'; +import { WPPConfig } from '@waha/core/engines/wpp/WppConfig'; +import { buildWppStreams } from '@waha/core/engines/wpp/reactive/wppStreams'; +import { + WppEditMessageOptions, + WppMessageEditArgs, + WppSendPollOptions, + WppSendTextOptions, + WppSendTextStatusOptions, +} from '@waha/core/engines/wpp/WppTypes'; +import { + AvailableInPlusVersion, + NotImplementedByEngineError, +} from '@waha/core/exceptions'; +import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor'; +import { IWPPAuthManager } from '@waha/core/engines/wpp/IWPPAuthManager'; +import { QR } from '@waha/core/QR'; +import { removeSingletonFiles } from '@waha/core/utils/chrome'; +import { killProcessesByPatterns } from '@waha/core/utils/processes'; +import { DistinctAck } from '@waha/core/utils/reactive'; +import { isJidGroup, toCusFormat } from '@waha/core/utils/jids'; +import { WAMedia } from '@waha/structures/media.dto'; +import * as lodash from 'lodash'; +import { + distinct, + from, + interval, + merge, + mergeMap, + Observable, + retry, + share, +} from 'rxjs'; +import { filter, map, tap } from 'rxjs/operators'; +import { + Deserialized, + parseMessageIdSerialized, + SerializeMsgKey, +} from '@waha/core/utils/ids'; +import { normalizePN, parseVCardV3 } from '@waha/core/vcard'; +import { BinaryFile, RemoteFile } from '@waha/structures/files.dto'; +import { + CreateGroupRequest, + GroupInfo, + GroupParticipant, + GroupParticipantRole, + GroupSortField, + ParticipantsRequest, + SettingsSecurityChangeInfo, +} from '@waha/structures/groups.dto'; +import { ContactQuery, ContactRequest } from '@waha/structures/contacts.dto'; +import { LidToPhoneNumber } from '@waha/structures/lids.dto'; +import { evaluateAndReturn } from '@wppconnect-team/wppconnect/dist/api/helpers'; +import { getFromToParticipant } from '@waha/core/engines/noweb/session.noweb.core'; +import { IsChrome } from '@waha/version'; + +declare global { + interface Window { + WPP: any; + } + + const WPP: any; +} + +interface WppMessageEditPayload { + chatId: string; + editedMessageId: string | null; + editKey: string | null; + message: any; + raw: any; +} + +export class WhatsappSessionWPPCore extends WhatsappSession { + private START_ATTEMPT_DELAY_SECONDS = 2; + + engine = WAHAEngine.WPP; + protected engineConfig?: WPPConfig; + whatsapp: any; + protected qr: QR; + protected wpp?: WPPWhatsapp; + protected authManager: IWPPAuthManager | null = null; + private meInfo: MeInfo | null = null; + private pairingCode?: string; + private presencesByChatId = new Map(); + private startAttemptId = 0; + private shouldRestart: boolean; + private startDelayedJob: SingleDelayedJobRunner; + + constructor(config) { + super(config); + this.qr = new QR(); + this.shouldRestart = true; + this.startDelayedJob = new SingleDelayedJobRunner( + 'start-engine', + this.START_ATTEMPT_DELAY_SECONDS * SECOND, + this.logger, + ); + } + + protected getUserDataDir(): string { + const base = process.env.WAHA_LOCAL_STORE_BASE_DIR || './.sessions'; + return `${base}/wpp/default/${this.name}`; + } + + async start() { + this.shouldRestart = true; + this.status = WAHASessionStatus.STARTING; + this.pairingCode = null; + const startAttemptId = ++this.startAttemptId; + + const args = this.getBrowserArgsForPuppeteer(); + args.push(...(this.engineConfig?.puppeteerArgs || [])); + args.unshift(`--a-waha-timestamp=${new Date()}`); + args.unshift(`--a-waha-session=${this.name}`); + const deviceName = + this.sessionConfig?.client?.deviceName ?? WAHA_CLIENT_DEVICE_NAME; + + const userDataDir = this.getUserDataDir(); + const logger = this.logger.child({ + name: 'WPP', + session: this.name, + }); + // WPPConnect types logger as winston.Logger (extends EventEmitter), but + // only ever calls log-level methods on it — never EventEmitter methods. + const wppLogger = new PinoWinstonAdapter( + logger, + ) as unknown as WinstonLogger; + const options: CreateOptions = { + session: this.name, + disableWelcome: true, + updatesLog: false, + logQR: false, + waitForLogin: false, + autoClose: 0, + deviceSyncTimeout: 0, + deviceName: deviceName || false, + headless: true, + debug: this.isDebugEnabled(), + logger: wppLogger, + whatsappVersion: this.engineConfig?.webVersion, + browserArgs: args, + puppeteerOptions: { + protocolTimeout: 300_000, + headless: true, + executablePath: this.getBrowserExecutablePath(), + args: args, + dumpio: this.isDebugEnabled(), + userDataDir: userDataDir, + }, + catchQR: (base64Image, asciiQR, attempt, urlCode) => { + if (!this.isCurrentStartAttempt(startAttemptId)) { + return; + } + void base64Image; + void asciiQR; + void attempt; + // WPP callback first arg is base64 image, while WAHA QR store expects raw QR text. + this.qr.save(urlCode); + this.printQR(this.qr); + this.status = WAHASessionStatus.SCAN_QR_CODE; + }, + catchLinkCode: (code) => { + if (!this.isCurrentStartAttempt(startAttemptId)) { + return; + } + this.pairingCode = code; + }, + statusFind: (status) => { + if (!this.isCurrentStartAttempt(startAttemptId)) { + return; + } + this.applyStatusFind(status); + }, + }; + + if (this.proxyConfig?.server) { + options.proxy = { + url: this.proxyConfig.server, + username: this.proxyConfig.username, + password: this.proxyConfig.password, + }; + } + + void this.createClientAsync(options, startAttemptId).catch((error) => { + this.logger.error({ error: error }, 'Failed to create WPP client async'); + }); + + return this; + } + + async stop() { + this.shouldRestart = false; + this.startDelayedJob.cancel(); + this.status = WAHASessionStatus.STOPPED; + this.stopEvents(); + this.mediaManager.close(); + await this.authManager?.stop(); + await this.end(); + } + + 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 failed() { + this.status = WAHASessionStatus.FAILED; + this.restartClient(); + } + + private async end() { + ++this.startAttemptId; + this.cleanupPresenceTimeout(); + this.meInfo = null; + this.presencesByChatId.clear(); + this.qr.save(''); + const wpp = this.wpp; + this.wpp = null; + this.whatsapp = null; + wpp?.page?.removeAllListeners(); + wpp?.page?.browser()?.removeAllListeners(); + await wpp?.close().catch((error) => { + this.logger.warn({ error: error }, 'Failed to close WPP client'); + }); + } + + private isCurrentStartAttempt(startAttemptId: number): boolean { + return this.startAttemptId === startAttemptId; + } + + private async createClientAsync( + options: CreateOptions, + startAttemptId: number, + ): Promise { + if (!this.isCurrentStartAttempt(startAttemptId)) { + return; + } + + await killProcessesByPatterns( + [IsChrome ? 'chrome' : 'chromium', `--a-waha-session=${this.name}`], + 'SIGKILL', + this.logger, + ); + await removeSingletonFiles(this.getUserDataDir()); + + await this.authManager?.beforeStart(); + + let wpp: WPPWhatsapp; + try { + wpp = await createWPPClient(options); + } catch (error) { + if (!this.isCurrentStartAttempt(startAttemptId)) { + return; + } + this.logger.error('Failed to start WPP client'); + this.logger.error(error, (error as Error)?.stack); + this.status = WAHASessionStatus.FAILED; + return; + } + + if (!this.isCurrentStartAttempt(startAttemptId)) { + await wpp.close().catch((error) => { + this.logger.warn({ error: error }, 'Failed to close stale WPP client'); + }); + return; + } + + this.wpp = wpp; + // Keep the shared field assigned for inherited API methods. + this.whatsapp = this.wpp as any; + this.subscribeEngineEvents2(); + if (this.isDebugEnabled()) { + this.listenEngineEventsInDebugMode(); + } + + // Listen for browser disconnected event + wpp.page.browser().on('disconnected', () => { + if (this.wpp !== wpp) { + return; + } + 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 + wpp.page.on('close', () => { + if (this.wpp !== wpp) { + return; + } + this.logger.error('The WhatsApp Web page has been closed'); + this.failed(); + }); + + wpp.onStateChange((state) => { + if (this.wpp !== wpp) { + return; + } + this.applySocketState(state); + }); + + const state = await wpp.getConnectionState().catch(() => null); + if (!this.isCurrentStartAttempt(startAttemptId) || this.wpp !== wpp) { + return; + } + this.applySocketState(state); + } + + async unpair() { + this.unpairing = true; + this.shouldRestart = false; + await this.wpp?.logout(); + } + + getSessionMeInfo(): MeInfo | null { + return this.meInfo; + } + + public getQR(): QR { + return this.qr; + } + + async getScreenshot(): Promise { + if (!this.wpp?.page) { + throw new Error('WPP page is not ready'); + } + const screenshot = await this.wpp.page.screenshot({ + encoding: 'binary', + }); + return screenshot as Buffer; + } + + @Activity() + public async checkNumberStatus( + request: CheckNumberStatusQuery, + ): Promise { + let phone = request.phone.split('@')[0]; + phone = phone.replace(/\+/g, ''); + const profile = await this.wpp!.checkNumberStatus(this.ensureSuffix(phone)); + const chatId = Deserialized(profile?.id); + if (!chatId) { + return { + numberExists: false, + }; + } + return { + numberExists: true, + chatId: chatId, + }; + } + + @Activity() + public async setProfileName(name: string): Promise { + await this.wpp!.setProfileName(name); + return true; + } + + @Activity() + public async setProfileStatus(status: string): Promise { + await this.wpp!.setProfileStatus(status); + return true; + } + + @Activity() + protected async setProfilePicture( + file: BinaryFile | RemoteFile, + ): Promise { + const content = await this.fileToBuffer(file); + const mimetype = MimetypeForDataUrl(file.mimetype || 'image/jpeg'); + const base64 = content.toString('base64'); + const media = `data:${mimetype};base64,${base64}`; + await this.wpp!.setProfilePic(media); + return true; + } + + @Activity() + protected async deleteProfilePicture(): Promise { + await this.wpp!.removeMyProfilePicture(); + return true; + } + + @Activity() + public async rejectCall(from: string, id: string): Promise { + void from; + await this.wpp!.rejectCall(id); + } + + @Activity() + public async sendLocation(request: MessageLocationRequest) { + const quotedMessageId = this.getReplyToMessageId(request as any); + const options = { + lat: request.latitude, + lng: request.longitude, + name: request.title || '', + } as any; + if (quotedMessageId) { + options.quotedMsg = quotedMessageId; + } + const sent = await this.wpp!.sendLocation( + this.ensureSuffix(request.chatId), + options, + ); + return this.toWAMessage(sent); + } + + @Activity() + public async forwardMessage( + request: MessageForwardRequest, + ): Promise { + const sent = await this.wpp!.forwardMessage( + this.ensureSuffix(request.chatId), + request.messageId, + ); + return { + sent: Boolean(sent), + } as any; + } + + @Activity() + public async sendPoll(request: MessagePollRequest): Promise { + const quotedMessageId = this.getReplyToMessageId(request as any); + const options: WppSendPollOptions = { + selectableCount: request.poll.multipleAnswers + ? request.poll.options.length + : 1, + }; + if (quotedMessageId) { + options.quotedMsg = quotedMessageId; + } + const sent = await this.wpp!.sendPollMessage( + this.ensureSuffix(request.chatId), + request.poll.name, + request.poll.options, + options, + ); + const message = this.toWAMessage(sent); + const messageIdPart = this.getMessageIdPart(message.id); + this.saveSentMessageId(messageIdPart); + message.source = this.getMessageSource(messageIdPart); + return message; + } + + @Activity() + public async sendContactVCard( + request: MessageContactVcardRequest, + ): Promise { + const chatId = this.ensureSuffix(request.chatId); + const contacts: Array<{ id: string; name: string }> = []; + + // Raw vcard + const vcards: VCardContact[] = request.contacts.filter((c) => c.vcard); + for (const vcard of vcards) { + const contact = parseVCardV3(vcard.vcard); + const id = + contact.whatsappId || normalizePN(contact.phoneNumbers[0] || ''); + if (!id) { + continue; + } + contacts.push({ + id: this.ensureSuffix(id), + name: contact.fullName || null, + }); + } + // Formated contact + const contactsData: Contact[] = request.contacts.filter( + (c) => !c.vcard, + ) as any; + for (const contact of contactsData) { + const id = contact.whatsappId || normalizePN(contact.phoneNumber); + contacts.push({ + id: this.ensureSuffix(id), + name: contact.fullName || null, + }); + } + + if (contacts.length <= 1) { + const single = contacts[0]; + const sent = await this.wpp!.sendContactVcard( + chatId, + single.id, + single.name || undefined, + ); + return this.toWAMessage(sent); + } + + const sent = await this.wpp!.sendContactVcardList(chatId, contacts); + return this.toWAMessage(sent); + } + + public sendImage(request: MessageImageRequest) { + void request; + throw new AvailableInPlusVersion(); + } + + public sendFile(request: MessageFileRequest) { + void request; + throw new AvailableInPlusVersion(); + } + + public sendVoice(request: MessageVoiceRequest) { + void request; + throw new AvailableInPlusVersion(); + } + + @Activity() + public async reply(request: MessageReplyRequest) { + const quotedMessageId = this.getReplyToMessageId(request as any); + const options: WppSendTextOptions = { + mentionedList: request.mentions?.map((id) => this.ensureSuffix(id)), + quotedMsg: quotedMessageId, + waitForAck: false, + }; + const sent = await this.wpp!.sendText( + this.ensureSuffix(request.chatId), + request.text, + options, + ); + return this.toWAMessage(sent); + } + + @Activity() + public async startTyping(request: ChatRequest): Promise { + await this.wpp!.startTyping(this.ensureSuffix(request.chatId)); + } + + @Activity() + public async stopTyping(request: ChatRequest) { + const chatId = this.ensureSuffix(request.chatId); + await Promise.all([ + this.wpp!.stopTyping(chatId), + this.wpp!.stopRecording(chatId), + ]); + } + + @Activity() + public async setReaction(request: MessageReactionRequest) { + const reaction = request.reaction || false; + return this.wpp!.sendReactionToMessage(request.messageId, reaction); + } + + @Activity() + public async setStar(request: MessageStarRequest): Promise { + await this.wpp!.starMessage(request.messageId, request.star); + } + + @Activity() + async sendText(request: MessageTextRequest) { + const quotedMessageId = this.getReplyToMessageId(request as any); + const options: WppSendTextOptions = { + mentionedList: request.mentions?.map((id) => this.ensureSuffix(id)), + quotedMsg: quotedMessageId, + waitForAck: false, + }; + const sent = await this.wpp!.sendText( + this.ensureSuffix(request.chatId), + request.text, + options, + ); + const message = this.toWAMessage(sent); + const messageIdPart = this.getMessageIdPart(message.id); + this.saveSentMessageId(messageIdPart); + message.source = this.getMessageSource(messageIdPart); + return message as any; + } + + @Activity() + async sendSeen(request: SendSeenRequest) { + await this.wpp!.sendSeen(this.ensureSuffix(request.chatId)); + } + + @Activity() + public async setPresence(presence: WAHAPresenceStatus, chatId?: string) { + switch (presence) { + case WAHAPresenceStatus.ONLINE: + await this.wpp!.setOnlinePresence(true); + break; + case WAHAPresenceStatus.OFFLINE: + await this.wpp!.setOnlinePresence(false); + break; + case WAHAPresenceStatus.TYPING: { + await this.maintainPresenceOnline(); + const normalizedChatId = this.ensureSuffix(chatId); + await this.wpp!.startTyping(normalizedChatId); + break; + } + case WAHAPresenceStatus.RECORDING: { + await this.maintainPresenceOnline(); + const normalizedChatId = this.ensureSuffix(chatId); + await this.wpp!.startRecording(normalizedChatId); + break; + } + case WAHAPresenceStatus.PAUSED: { + await this.maintainPresenceOnline(); + const normalizedChatId = this.ensureSuffix(chatId); + await Promise.all([ + this.wpp!.stopTyping(normalizedChatId), + this.wpp!.stopRecording(normalizedChatId), + ]); + break; + } + default: + throw new NotImplementedByEngineError( + `WPP engine doesn't support '${presence}' presence.`, + ); + } + this.presence = presence; + } + + public async getPresences(): Promise { + return Array.from(this.presencesByChatId.values()); + } + + @Activity() + public async getPresence(id: string): Promise { + const chatId = this.ensureSuffix(id); + await this.subscribePresence(chatId); + const presence = this.presencesByChatId.get(chatId); + if (presence) { + return presence; + } + return { + id: chatId, + presences: [], + }; + } + + @Activity() + public async subscribePresence(id: string): Promise { + const chatId = this.ensureSuffix(id); + await this.wpp!.subscribePresence(chatId); + return null; + } + + @Activity() + public async sendTextStatus(status: TextStatus): Promise { + this.checkStatusRequest(status); + const options: WppSendTextStatusOptions = { + waitForAck: false, + }; + if (status.font != null) { + options.font = status.font; + } + if (status.backgroundColor != null) { + options.backgroundColor = status.backgroundColor; + } + if (status.id) { + options.messageId = status.id; + } + + const sent = await this.wpp!.sendTextStatus(status.text, options); + const sentId = extractWppMessageId(sent) || status.id || null; + if (!sentId) { + return { + id: null, + _data: sent, + }; + } + this.saveSentMessageId(this.getMessageIdPart(sentId)); + const sentMessage = await this.wpp!.getMessageById(sentId).catch( + () => null, + ); + if (!sentMessage) { + return { + id: sentId, + _data: sent, + }; + } + return this.toWAMessage(sentMessage); + } + + async getChats(pagination, filter: OverviewFilter | null = null) { + const chats = await this.wpp!.listChats(); + let rows = chats.map((chat) => { + return { + ...chat, + id: this.toChatId(chat), + }; + }); + if (filter?.ids?.length) { + const ids = new Set(filter.ids.map((id) => this.ensureSuffix(id))); + rows = rows.filter((chat) => ids.has(chat.id)); + } + + const sortBy = this.toChatSortBy(pagination?.sortBy); + const normalizedPagination = { + ...pagination, + sortBy: sortBy, + }; + return new PaginatorInMemory(normalizedPagination).apply(rows); + } + + public async getChatsOverview( + pagination, + 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: any): Promise { + const chatId = this.toChatId(chat); + const [picture, lastMessage] = await Promise.all([ + this.getContactProfilePicture(chatId, false), + this.getLastMessage(chatId), + ]); + return { + id: chatId, + name: chat.name || null, + picture: picture, + lastMessage: lastMessage, + _chat: chat, + }; + } + + public async getChatMessages( + chatId: string, + query: GetChatMessagesQuery, + filter: GetChatMessagesFilter, + ): Promise { + if (chatId === 'all') { + throw new NotImplementedByEngineError( + "Can not get messages from 'all' in WPP", + ); + } + const id = this.ensureSuffix(chatId); + + const offset = query?.offset || 0; + const limit = query?.limit || 10; + const fetchCount = offset + limit; + const downloadMedia = query.downloadMedia; + const rawMessages = await this.wpp!.getMessages(id, { + count: fetchCount, + direction: 'before', + }); + + const messagesById = new Map(); + for (const rawMessage of rawMessages) { + const rawMessageId = Deserialized(rawMessage?.id); + if (!rawMessageId) { + continue; + } + messagesById.set(rawMessageId, rawMessage); + } + + let messages = rawMessages.map((message) => this.toWAMessage(message)); + messages = this.filterMessages(messages, filter); + messages = new PaginatorInMemory({ + limit: limit, + offset: offset, + sortBy: query?.sortBy || 'timestamp', + sortOrder: query?.sortOrder || SortOrder.DESC, + }).apply(messages); + + if (!downloadMedia) { + return messages; + } + + const promises = []; + for (const message of messages) { + const rawMessage = messagesById.get(message.id); + if (!rawMessage) { + promises.push(Promise.resolve(message)); + continue; + } + promises.push(this.processIncomingMessage(rawMessage, true)); + } + let result = await Promise.all(promises); + result = result.filter(Boolean); + return result; + } + + public async getChatMessage( + chatId: string, + messageId: string, + query: GetChatMessageQuery, + ): Promise { + void chatId; + const message = await this.wpp!.getMessageById(messageId).catch(() => null); + if (!message) { + return null; + } + return this.processIncomingMessage(message, query.downloadMedia); + } + + @Activity() + public async deleteMessage( + chatId: string, + messageId: string, + ): Promise { + const normalizedChatId = this.ensureSuffix(chatId); + await this.wpp!.deleteMessage(normalizedChatId, messageId); + return true; + } + + @Activity() + public async editMessage( + chatId: string, + messageId: string, + request: EditMessageRequest, + ): Promise { + void chatId; + const options: WppEditMessageOptions = {}; + if (request.mentions?.length) { + options.mentions = request.mentions.map((id) => this.ensureSuffix(id)); + } + if (request.linkPreview != null) { + options.linkPreview = request.linkPreview; + } + const sent = await this.wpp!.editMessage(messageId, request.text, options); + return this.toWAMessage(sent); + } + + @Activity() + public deleteChat(chatId: string): Promise { + return this.wpp!.deleteChat(this.ensureSuffix(chatId)); + } + + @Activity() + public clearMessages(chatId: string): Promise { + return this.wpp!.clearChat(this.ensureSuffix(chatId), true); + } + + @Activity() + public chatsArchiveChat(chatId: string): Promise { + return this.wpp!.archiveChat(this.ensureSuffix(chatId), true); + } + + @Activity() + public chatsUnarchiveChat(chatId: string): Promise { + return this.wpp!.archiveChat(this.ensureSuffix(chatId), false); + } + + @Activity() + public chatsUnreadChat(chatId: string): Promise { + return this.wpp!.markUnseenMessage(this.ensureSuffix(chatId)); + } + + @Activity() + public async readChatMessages( + chatId: string, + request: ReadChatMessagesQuery, + ): Promise { + void request; + await this.wpp!.sendSeen(this.ensureSuffix(chatId)); + return { ids: null }; + } + + @Activity() + public async fetchContactProfilePicture(id: string): Promise { + const contactId = this.ensureSuffix(id); + const profilePicture = await this.wpp!.getProfilePicFromServer(contactId); + return ( + profilePicture?.eurl || + profilePicture?.imgFull || + profilePicture?.img || + null + ); + } + + // + // Contacts + // + + public async getContact(query: ContactQuery) { + const contact = await this.wpp!.getContact( + this.ensureSuffix(query.contactId), + ); + return this.toWAContact(contact); + } + + public async getContacts(pagination: PaginationParams) { + const contacts = await this.wpp!.getAllContacts(); + const rows = contacts.map((contact) => this.toWAContact(contact)); + return new PaginatorInMemory(pagination).apply(rows); + } + + public async getContactAbout( + query: ContactQuery, + ): Promise<{ about: string }> { + const result = await this.wpp!.getStatus( + this.ensureSuffix(query.contactId), + ).catch(() => null); + if (!result) { + return { + about: null, + }; + } + if (typeof result === 'string') { + return { + about: result, + }; + } + return { + about: result?.status || null, + }; + } + + @Activity() + public async blockContact(request: ContactRequest): Promise { + await this.wpp!.blockContact(this.ensureSuffix(request.contactId)); + } + + @Activity() + public async unblockContact(request: ContactRequest): Promise { + await this.wpp!.unblockContact(this.ensureSuffix(request.contactId)); + } + + // + // LID to Phone Number + // + + public async getAllLids( + pagination: PaginationParams, + ): Promise> { + const lids = await this.listAllKnownLids(); + const paginator = new PaginatorInMemory(pagination); + return paginator.apply(lids); + } + + public async getLidsCount(): Promise { + const lids = await this.listAllKnownLids(); + return lids.length; + } + + public async findPNByLid(lid: string): Promise { + const normalizedLid = lid.includes('@') ? lid : `${lid}@lid`; + const entry = await this.wpp!.getPnLidEntry(normalizedLid).catch( + () => null, + ); + return this.toLidMapping(entry, normalizedLid, null); + } + + public async findLIDByPhoneNumber( + phoneNumber: string, + ): Promise { + const normalizedPhoneNumber = this.ensureSuffix(phoneNumber); + const entry = await this.wpp!.getPnLidEntry(normalizedPhoneNumber).catch( + () => null, + ); + return this.toLidMapping(entry, null, normalizedPhoneNumber); + } + + // + // Groups + // + + @Activity() + public createGroup(request: CreateGroupRequest) { + const participants = request.participants.map((participant) => + this.ensureSuffix(participant.id), + ); + return this.wpp!.createGroup(request.name, participants); + } + + @Activity() + public async joinGroup(code: string): Promise { + const response = await this.wpp!.joinGroup(code); + const id = Deserialized(response?.id as any) || (response as any)?.id; + return toCusFormat(id); + } + + @Activity() + public joinInfoGroup(code: string): Promise { + return this.wpp!.getGroupInfoFromInviteLink(code); + } + + public async getGroups(pagination: PaginationParams): Promise { + const groups = await this.wpp!.listChats({ + onlyGroups: true, + }); + const rows = groups.map((group) => ({ + ...group, + id: this.toChatId(group), + })); + const normalizedPagination = { + ...pagination, + sortBy: this.toGroupSortBy(pagination?.sortBy), + }; + return new PaginatorInMemory(normalizedPagination).apply(rows); + } + + protected removeGroupsFieldParticipant(group: any) { + delete group.participants; + delete group.pendingParticipants; + delete group.pastParticipants; + delete group.membershipApprovalRequests; + delete group.groupMetadata?.participants; + delete group.groupMetadata?.pendingParticipants; + delete group.groupMetadata?.pastParticipants; + delete group.groupMetadata?.membershipApprovalRequests; + } + + @Activity() + public async refreshGroups(): Promise { + await this.wpp!.listChats({ + onlyGroups: true, + }); + return true; + } + + public getGroup(id: string): Promise { + return this.wpp!.getChatById(this.ensureSuffix(id)); + } + + public async getGroupParticipants(id: string): Promise { + const group = await this.wpp!.getChatById(this.ensureSuffix(id)); + const participants = group?.groupMetadata?.participants || []; + return this.toGroupParticipants(participants); + } + + public async getInfoAdminsOnly(id): Promise { + const group = await this.wpp!.getChatById(this.ensureSuffix(id)); + const adminsOnly = Boolean(group?.groupMetadata?.restrict); + return { + adminsOnly: adminsOnly, + }; + } + + @Activity() + public setInfoAdminsOnly(id: string, value: boolean): Promise { + return this.wpp!.setGroupProperty( + this.ensureSuffix(id), + GroupProperty.RESTRICT, + value, + ); + } + + public async getMessagesAdminsOnly( + id: string, + ): Promise { + const group = await this.wpp!.getChatById(this.ensureSuffix(id)); + const adminsOnly = Boolean(group?.groupMetadata?.announce); + return { + adminsOnly: adminsOnly, + }; + } + + @Activity() + public setMessagesAdminsOnly(id: string, value: boolean): Promise { + return this.wpp!.setGroupProperty( + this.ensureSuffix(id), + GroupProperty.ANNOUNCEMENT, + value, + ); + } + + @Activity() + public deleteGroup(id: string): Promise { + return this.wpp!.deleteChat(this.ensureSuffix(id)); + } + + @Activity() + public leaveGroup(id: string): Promise { + return this.wpp!.leaveGroup(this.ensureSuffix(id)); + } + + @Activity() + public setDescription(id: string, description: string): Promise { + return this.wpp!.setGroupDescription(this.ensureSuffix(id), description); + } + + protected async setGroupPicture( + id: string, + file: BinaryFile | RemoteFile, + ): Promise { + const content = await this.fileToBuffer(file); + const mimetype = MimetypeForDataUrl(file.mimetype || 'image/jpeg'); + const base64 = content.toString('base64'); + const media = `data:${mimetype};base64,${base64}`; + await this.wpp!.setGroupIcon(this.ensureSuffix(id), media); + return true; + } + + protected deleteGroupPicture(id: string): Promise { + return this.wpp!.removeGroupIcon(this.ensureSuffix(id)); + } + + @Activity() + public setSubject(id: string, subject: string): Promise { + return this.wpp!.setGroupSubject(this.ensureSuffix(id), subject); + } + + @Activity() + public async getInviteCode(id: string): Promise { + const inviteLink = await this.wpp!.getGroupInviteLink( + this.ensureSuffix(id), + ); + return this.unwrapGroupInviteCode(inviteLink); + } + + @Activity() + public async revokeInviteCode(id: string): Promise { + const inviteLink = await this.wpp!.revokeGroupInviteLink( + this.ensureSuffix(id), + ); + return this.unwrapGroupInviteCode(inviteLink); + } + + public async getParticipants(id: string): Promise { + const group = await this.wpp!.getChatById(this.ensureSuffix(id)); + return group?.groupMetadata?.participants || []; + } + + @Activity() + public addParticipants( + id: string, + request: ParticipantsRequest, + ): Promise { + const participants = request.participants.map((participant) => + this.ensureSuffix(participant.id), + ); + return this.wpp!.addParticipant(this.ensureSuffix(id), participants); + } + + @Activity() + public removeParticipants( + id: string, + request: ParticipantsRequest, + ): Promise { + const participants = request.participants.map((participant) => + this.ensureSuffix(participant.id), + ); + return this.wpp!.removeParticipant(this.ensureSuffix(id), participants); + } + + @Activity() + public promoteParticipantsToAdmin( + id: string, + request: ParticipantsRequest, + ): Promise { + const participants = request.participants.map((participant) => + this.ensureSuffix(participant.id), + ); + return this.wpp!.promoteParticipant(this.ensureSuffix(id), participants); + } + + @Activity() + public demoteParticipantsToUser( + id: string, + request: ParticipantsRequest, + ): Promise { + const participants = request.participants.map((participant) => + this.ensureSuffix(participant.id), + ); + return this.wpp!.demoteParticipant(this.ensureSuffix(id), participants); + } + + // + // Labels + // + + public async getLabels(): Promise { + const labels = await this.wpp!.getAllLabels(); + return labels.map((label) => this.toLabel(label)); + } + + @Activity() + public async createLabel(label: LabelDTO): Promise