diff --git a/.gitignore b/.gitignore index 807d8031..1111b272 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.sessions +.*sessions tokens files venv diff --git a/Dockerfile b/Dockerfile index 45c22d62..53787670 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,15 @@ FROM node:18 as build ENV PUPPETEER_SKIP_DOWNLOAD=True + +# Install https://github.com/WhiskeySockets/Baileys +WORKDIR / +RUN git clone https://github.com/WhiskeySockets/Baileys WhiskeySockets-Baileys && \ + cd WhiskeySockets-Baileys && \ + git checkout b7878e531144133b48d9e5970787e7b256d21063 && \ + yarn install && \ + yarn build:all + # npm packages WORKDIR /src COPY package.json . diff --git a/docs/site/config/_default/params.toml b/docs/site/config/_default/params.toml index 2bc862f6..b9b1a318 100644 --- a/docs/site/config/_default/params.toml +++ b/docs/site/config/_default/params.toml @@ -74,7 +74,7 @@ copyRight = "Copyright (c) 2021-2023 Dev Like A Pro" alert = true alertDismissable = false # alertText = "Introducing the Doks child theme, several DX + UX updates, and more! Check out Doks v0.2" -alertText = "Enjoy Core version or want to get WAHA Plus verison? You can support the project ->" +alertText = "Enjoy Core version or want to get WAHA Plus verison? Support the project ->" # Edit Page # repoHost [Github | Gitea | GitLab | Bitbucket | BitbucketServer ] is used for building the edit link based on git hoster diff --git a/docs/site/content/en/docs/help/changelog.md b/docs/site/content/en/docs/help/changelog.md index 04a4c0bb..064c3da6 100644 --- a/docs/site/content/en/docs/help/changelog.md +++ b/docs/site/content/en/docs/help/changelog.md @@ -5,18 +5,31 @@ lead: "Changelog" date: 2020-10-06T08:49:31+00:00 lastmod: 2020-10-06T08:49:31+00:00 draft: false -images: [] +images: [ ] menu: docs: parent: "help" weight: 600 toc: true --- -## 2021.3 + +## 2021.5 - May 2023 + +- Added new [NOWEB engine]({{< relref "/docs/how-to/engines" >}}). **NOWEB** engine does not require a browser to work + with + WhatsApp Web, it does so directly using a WebSocket. + - Less CPU and RAM usage! + - Send Locations API works! + - Send Link Preview API works! + - ⚠ Read the article before using it [How to avoid blocking ->]({{< relref "/docs/help/how-to-avoid-blocking" >}}). + +## 2021.4 - March 2023 + - Add [Groups API]({{< relref "/docs/how-to/groups" >}}) - Use Chromium by default instead of Chrome -## 2021.1 +## 2021.1 - January 2023 + - Added [Contacts API]({{< relref "/docs/how-to/contacts" >}}) - Get all contacts - Get a contact @@ -25,33 +38,42 @@ toc: true - Check number exists (is registered in WhatsApp) - works even if the number is not in the contact list - Block and unblock contact -## 2022.12 +## 2022.12 - December 2023 + - Added `GET /messages/` endpoint to get chat messages [#31](https://github.com/devlikeapro/whatsapp-http-api/issues/31) ### Security ![](/images/versions/plus.png) + - Added `WHATSAPP_SWAGGER_USERNAME` and `WHATSAPP_SWAGGER_PASSWORD` to hide and protect swagger panel. ## 2022.11 + **Please test changes in test environment before update production!!** ### Engine ![](/images/versions/core.png) -1. WAHA has changed its underlying engine from Venom to Whatsapp Web.JS. It might change the response and webhook's payloads. + +1. WAHA has changed its underlying engine from Venom to Whatsapp Web.JS. It might change the response and webhook's + payloads. 2. Optimize CPU and memory consumption. - ### Requests ![](/images/versions/core.png) + - For all `/api/session/` requests use `name` field instead of `sessionName`. - For all "chatting" requests use `session` field instead of `sessionName`. ### Sessions ![](/images/versions/plus.png) + Now you don't have to scan QR code each time you run WAHA, WAHA saves it for you! Available only in Plus version. ### Authentication ![](/images/versions/plus.png) + Now you can authenticate all requests for WAHA - use `WHATSAPP_API_KEY=secret` environment variable to set "secret key". -If `WHATSAPP_API_KEY` is set - requests must have `X-Api-Key` header with `secret` value, where `secret` - any random secret key. +If `WHATSAPP_API_KEY` is set - requests must have `X-Api-Key` header with `secret` value, where `secret` - any random +secret key. ### Webhooks ![](/images/versions/core.png) + #### Configuration Instead of setting each webhook via environment variables - we use two environments variables: diff --git a/docs/site/content/en/docs/help/faq.md b/docs/site/content/en/docs/help/faq.md index a355163d..55219fee 100644 --- a/docs/site/content/en/docs/help/faq.md +++ b/docs/site/content/en/docs/help/faq.md @@ -5,7 +5,7 @@ lead: "Answers to frequently asked questions." date: 2020-10-06T08:49:31+00:00 lastmod: 2020-10-06T08:49:31+00:00 draft: false -images: [] +images: [ ] menu: docs: parent: "help" @@ -13,7 +13,28 @@ weight: 630 toc: true --- +## How to update? + +{{< alert icon="πŸ’‘" text="Please test all new versions in the development environment before updating production!" />}} + +![](/images/versions/core.png) For **Core** version the command is + +```bash +docker pull devlikeapro/whatsapp-http-api +``` + +![](/images/versions/plus.png) For **Plus** version, we use login to get the image before: + +```bash +docker login -u devlikeapro -p {PASSWORD} +docker pull devlikeapro/whatsapp-http-api-plus +docker logout +``` + +Read more about how to get `PASSWORD` for [Plus Version β†’]({{< relref "plus-version" >}}) + ## How much CPU and Memory does WhatsApp HTTP API consume? + The benchmark has been made on **Intel(R) Core(TM) i7-10510U CPU @ 1.80GHz**. It may differ from case to case, it depends on usage pattern - how many messages you get, how many send, etc. @@ -22,6 +43,7 @@ It may differ from case to case, it depends on usage pattern - how many messages | 1 | 30% | 400MB | | 10 | 270% | 1.5GB | - The nature of WhatsApp HTTP API and its underlying engines - is to run **real** WhatsApp Web version in Chrome and communicate with it to prevent blocking from WhatsApp. It's the reason why it's so demanding on resources. + +If you're looking for less resource demanded engine - [have a look at **NOWEB** engine ->]({{< relref "/docs/how-to/engines" >}}) diff --git a/docs/site/content/en/docs/help/how-to-avoid-blocking.md b/docs/site/content/en/docs/help/how-to-avoid-blocking.md new file mode 100644 index 00000000..ec0d2ff5 --- /dev/null +++ b/docs/site/content/en/docs/help/how-to-avoid-blocking.md @@ -0,0 +1,58 @@ +--- +title: "How to Avoid Blocking" +description: "How to Avoid Blocking From Whatsapp" +date: 2020-11-12T13:26:54+01:00 +lastmod: 2020-11-12T13:26:54+01:00 +draft: false +images: [ ] +menu: + docs: + parent: "help" +weight: 610 +toc: true +--- + +How to Avoid Blocking in WhatsApp when developing bots. + +It's important to keep in mind that WhatsApp has strict policies in place to prevent spamming and abuse of their +platform. + +If you're developing a bot for WhatsApp, it's crucial to follow these guidelines to avoid getting blocked. + +## Guidelines to Follow + +### 1. Only Reply to Messages + +When developing a bot for WhatsApp, you **should never initiate a conversation**. +Instead, your bot **should only reply** to messages that it receives. +This will prevent your bot from being flagged as spam by WhatsApp's users and algorithms. + +### 2. Avoid Spamming and Sending Unnecessary Content + +Sending too many messages or sending content that the user did not request can also lead to your bot being blocked. Make +sure to only send relevant and useful information to the user. +Additionally, do not send too many messages at once, as this can also trigger spam filters. + +### 3. Other Considerations + +There are other guidelines to follow when developing a bot for WhatsApp, such as avoiding the use of banned words and +not sharing sensitive or inappropriate content. +Make sure to read WhatsApp's policies thoroughly to ensure that your bot complies with all of their rules. + +## How to Process Messages + +When processing messages in your bot, it's important to follow certain steps to avoid being flagged as spam. +Here's a recommended process to follow: + +1. **Send seen** before processing the message. This can be done by sending a `POST /api/sendSeen/` request to + the WAHA API. + +2. **Start typing** before sending a message and wait for a random interval depending on the size of the message. This can + be done by sending a `POST /api/startTyping/` request. + +3. **Stop typing** before sending the message. This can be done by sending a `POST /api/stopTyping/` request. + +4. **Send the text message** using the `POST /api/sendText` request. + +By following these steps, you can ensure that your bot processes messages in a way that's compliant with WhatsApp's +guidelines and reduces the risk of being blocked. diff --git a/docs/site/content/en/docs/help/how-to-update.md b/docs/site/content/en/docs/help/how-to-update.md deleted file mode 100644 index 2fe198da..00000000 --- a/docs/site/content/en/docs/help/how-to-update.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: "How to Update" -description: "Regularly update the installed WAHA to keep your bot stable, usable, and secure." -lead: "Regularly update the installed WAHA to keep your bot stable, usable, and secure." -date: 2020-11-12T13:26:54+01:00 -lastmod: 2020-11-12T13:26:54+01:00 -draft: false -images: [] -menu: - docs: - parent: "help" -weight: 610 -toc: true ---- - -{{< alert icon="πŸ’‘" text="Please test all new versions in the development environment before updating production!" />}} - -## Core -![](/images/versions/core.png) For Core version the command is - -```bash -docker pull devlikeapro/whatsapp-http-api -``` - -## Plus -![](/images/versions/plus.png) For Plus version, we use login to get the image before: - -```bash -docker login -u devlikeapro -p {PASSWORD} -docker pull devlikeapro/whatsapp-http-api-plus -docker logout -``` - -Read more about how to get `PASSWORD` for [Plus Version β†’]({{< relref "plus-version" >}}) diff --git a/docs/site/content/en/docs/how-to/config/index.md b/docs/site/content/en/docs/how-to/config/index.md index 37b6c732..36c2cf85 100644 --- a/docs/site/content/en/docs/how-to/config/index.md +++ b/docs/site/content/en/docs/how-to/config/index.md @@ -34,25 +34,3 @@ docker run -it -e "WHATSAPP_HOOK_EVENTS=*" -e WHATSAPP_HOOK_URL=https://httpbin. - `WHATSAPP_FILES_LIFETIME`- to keep free space files will be removed after this time (default: `180`, in seconds) - `WHATSAPP_FILES_FOLDER` - folder where will be stored files from chats (images, voice messages) ( default: `/tmp/whatsapp-files`) - -## Engines - -Under the hood **WAHA** allows you to use two different engines. You can control what you want to run by settings -`WHATSAPP_DEFAULT_ENGINE` environment variables. - -If you have any problems with one engine - try another! - -{{< alert icon="πŸ‘‰" text="API responses and webhook payloads may differ significantly! We do our best to keep requests body stable between engines." />}} - -### WhatsApp WebJS ![](/images/versions/core.png) - -**WAHA** uses **WhatsApp WebJS** engine by default - -- https://github.com/pedroslopez/whatsapp-web.js -- `WHATSAPP_DEFAULT_ENGINE=WEBJS` - -### Venom ![](/images/versions/core.png) - -- https://github.com/orkestral/venom -- `WHATSAPP_DEFAULT_ENGINE=VENOM` - diff --git a/docs/site/content/en/docs/how-to/engines/index.md b/docs/site/content/en/docs/how-to/engines/index.md new file mode 100644 index 00000000..d9b3673c --- /dev/null +++ b/docs/site/content/en/docs/how-to/engines/index.md @@ -0,0 +1,50 @@ +--- +title: "Engines" +description: "Engines" +lead: "" +date: 2020-10-06T08:48:45+00:00 +lastmod: 2020-10-06T08:48:45+00:00 +draft: false +images: [ ] +weight: 800 +--- + +Under the hood **WAHA** allows you to use two different engines. You can control what you want to run by settings +`WHATSAPP_DEFAULT_ENGINE` environment variables. + +```bash +docker run -it -e "WHATSAPP_DEFAULT_ENGINE=WEBJS" devlikeapro/whatsapp-http-api +``` + +If you have any problems with one engine - try another! + +{{< alert icon="πŸ‘‰" text="API responses and webhook payloads may differ significantly! We do our best to keep requests body stable between engines." />}} + +## WEBJS ![](/images/versions/core.png) + +A WhatsApp API client that connects through the WhatsApp Web browser app. +It uses Puppeteer to run a real instance of Whatsapp Web to avoid getting blocked. + +**WAHA** uses **WhatsApp WebJS** engine by default. + +- `WHATSAPP_DEFAULT_ENGINE=WEBJS` +- https://github.com/pedroslopez/whatsapp-web.js + +## NOWEB ![](/images/versions/core.png) + +**NOWEB** engine does not require a browser to work with WhatsApp Web, it does so directly using a WebSocket. +Not running Chromium saves you CPU and Memory, so you can run more instances on a single server! + +️Working with the engine requires more attention, because it's easy to be blocked with it! + +⚠ Read the article before using **NOWEB** engine [How to avoid blocking ->]({{< relref "/docs/help/how-to-avoid-blocking" >}}). + +- `WHATSAPP_DEFAULT_ENGINE=NOWEB` +- https://github.com/WhiskeySockets/Baileys + +## VENOM ![](/images/versions/core.png) + +It's a high-performance system developed with JavaScript to create a bot for WhatsApp + +- `WHATSAPP_DEFAULT_ENGINE=VENOM` +- https://github.com/orkestral/venom diff --git a/docs/site/content/en/docs/how-to/plus-version/index.md b/docs/site/content/en/docs/how-to/plus-version/index.md index 5aa4b746..d091136b 100644 --- a/docs/site/content/en/docs/how-to/plus-version/index.md +++ b/docs/site/content/en/docs/how-to/plus-version/index.md @@ -32,8 +32,7 @@ The Plus additional features are: 4. [**Security** β†’]({{< relref "/docs/how-to/security" >}}) 5. [**Saving sessions** β†’]({{< relref "/docs/how-to/sessions" >}}) 6. [**Support multiple sessions** β†’]({{< relref "/docs/how-to/sessions" >}}) -7. **Instant updates** - no one month delay in getting bugfixes or new features -8. **Priority in support** - on both bugs and features +7. **Priority in support** - on both bugs and features ## Differences diff --git a/examples/python/whatsapp_download_files_bot.py b/examples/python/whatsapp_download_files_bot.py index 792d4d13..4c045828 100644 --- a/examples/python/whatsapp_download_files_bot.py +++ b/examples/python/whatsapp_download_files_bot.py @@ -25,6 +25,17 @@ def send_message(chat_id, text): ) response.raise_for_status() +def send_seen(chat_id, message_id, participant): + response = requests.post( + "http://localhost:3000/api/sendSeen", + json={ + "session": "default", + "chatId": chat_id, + "messageId": message_id, + "participant": participant, + }, + ) + response.raise_for_status() @app.route("/") def whatsapp_echo(): @@ -48,7 +59,13 @@ def whatsapp_webhook(): return "No files in the message" # Number in format 791111111@c.us - from_ = payload["from"] + chat_id = payload["from"] + # Message ID - false_11111111111@c.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + message_id = payload['id'] + # For groups - who sent the message + participant = payload.get('participant') + # IMPORTANT - Always send seen before sending new message + send_seen(chat_id=chat_id, message_id=message_id, participant=participant) # Download the file and download it to the current folder client_url = payload["mediaUrl"] @@ -61,7 +78,7 @@ def whatsapp_webhook(): # Send a text back via WhatsApp HTTP API text = f"We have downloaded file here: {path}" print(text) - send_message(chat_id=from_, text=text) + send_message(chat_id=chat_id, text=text) # Send OK back return "OK" diff --git a/examples/python/whatsapp_echo_bot.py b/examples/python/whatsapp_echo_bot.py index 907dca33..dbdaed96 100644 --- a/examples/python/whatsapp_echo_bot.py +++ b/examples/python/whatsapp_echo_bot.py @@ -24,6 +24,31 @@ def send_message(chat_id, text): ) response.raise_for_status() +def reply(chat_id, message_id, text): + response = requests.post( + "http://localhost:3000/api/reply", + json={ + "chatId": chat_id, + "text": text, + "reply_to": message_id, + "session": "default", + }, + ) + response.raise_for_status() + + +def send_seen(chat_id, message_id, participant): + response = requests.post( + "http://localhost:3000/api/sendSeen", + json={ + "session": "default", + "chatId": chat_id, + "messageId": message_id, + "participant": participant, + }, + ) + response.raise_for_status() + @app.route("/") def whatsapp_echo(): @@ -45,9 +70,19 @@ def whatsapp_webhook(): payload = data["payload"] # The text text = payload["body"] - # Number in format 1231231231@c.us - from_ = payload["from"] + # Number in format 1231231231@c.us or @g.us for group + chat_id = payload["from"] + # Message ID - false_11111111111@c.us_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + message_id = payload['id'] + # For groups - who sent the message + participant = payload.get('participant') + # IMPORTANT - Always send seen before sending new message + send_seen(chat_id=chat_id, message_id=message_id, participant=participant) + # Send a text back via WhatsApp HTTP API - send_message(chat_id=from_, text=text) + send_message(chat_id=chat_id, text=text) + # OR reply on the message + # reply(chat_id=chat_id, message_id=message_id, text=text) + # Send OK back return "OK" diff --git a/package.json b/package.json index 50d52cf2..930ab009 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@adiwajshing/baileys": "file:../WhiskeySockets-Baileys", + "@adiwajshing/keyed-db": "^0.2.4", "@nestjs/common": "^9.0.9", "@nestjs/config": "^0.5.0", "@nestjs/core": "^9.0.9", @@ -29,12 +31,16 @@ "@nestjs/platform-express": "^9.0.9", "@nestjs/serve-static": "^2.1.3", "@nestjs/swagger": "^6.0.5", + "@types/ws": "^8.5.4", "class-validator": "^0.12.2", "del": "^6.0.0", "express-basic-auth": "^1.2.1", + "file-type": "16.5.4", + "link-preview-js": "^3.0.4", "mime-types": "^2.1.27", "passport": "^0.6.0", "passport-headerapikey": "^1.2.2", + "qrcode": "^1.5.1", "qrcode-terminal": "^0.12.0", "reflect-metadata": "^0.1.13", "request": "^2.88.2", diff --git a/src/api/chatting.controller.ts b/src/api/chatting.controller.ts index def5f72e..1901f7c7 100644 --- a/src/api/chatting.controller.ts +++ b/src/api/chatting.controller.ts @@ -13,7 +13,7 @@ import { MessageTextButtonsRequest, MessageTextQuery, MessageTextRequest, - MessageVoiceRequest + MessageVoiceRequest, SendSeenRequest } from "../structures/chatting.dto"; import {WAMessage, WANumberExistResult} from "../structures/responses.dto"; import {SessionManager} from "../core/abc/manager.abc"; @@ -105,7 +105,7 @@ export class ChattingController { } @Post('/sendSeen') - sendSeen(@Body() chat: ChatRequest) { + sendSeen(@Body() chat: SendSeenRequest) { const whatsapp = this.manager.getSession(chat.session) return whatsapp.sendSeen(chat) } diff --git a/src/core/QR.ts b/src/core/QR.ts new file mode 100644 index 00000000..4bcaf020 --- /dev/null +++ b/src/core/QR.ts @@ -0,0 +1,11 @@ +export class QR { + private base64: string; + + save(base64) { + this.base64 = base64.replace(/^data:image\/png;base64,/, ''); + } + + get(): Buffer { + return Buffer.from(this.base64, "base64") + } +} diff --git a/src/core/abc/session.abc.ts b/src/core/abc/session.abc.ts index 90fe02f3..11de8605 100644 --- a/src/core/abc/session.abc.ts +++ b/src/core/abc/session.abc.ts @@ -23,6 +23,8 @@ import * as fs from "fs"; const CHROME_PATH = "/usr/bin/google-chrome-stable" const CHROMIUM_PATH = "/usr/bin/chromium" +import { EventEmitter } from 'events' + export function getBrowserExecutablePath() { if (fs.existsSync(CHROME_PATH)) { @@ -30,14 +32,27 @@ export function getBrowserExecutablePath() { } return CHROMIUM_PATH } +export function ensureSuffix(phone) { + const suffix = "@c.us" + if (phone.includes("@")) { + return phone + } + return phone + suffix +} + +export enum WAHAInternalEvent { + engine_start = "engine.start", +} export abstract class WhatsappSession { public status: WhatsappStatus; + public events: EventEmitter public constructor(public name: string, protected storage: MediaStorage, protected log: ConsoleLogger) { this.name = name this.status = WhatsappStatus.STARTING this.log = log + this.events = new EventEmitter() } getBrowserExecutablePath() { @@ -216,11 +231,7 @@ export abstract class WhatsappSession { * @param phone */ protected ensureSuffix(phone) { - const suffix = "@c.us" - if (phone.includes("@")) { - return phone - } - return phone + suffix + return ensureSuffix(phone) } protected deserializeId(messageId: string): MessageId { diff --git a/src/core/manager.core.ts b/src/core/manager.core.ts index 7cc44ebd..0259a0cd 100644 --- a/src/core/manager.core.ts +++ b/src/core/manager.core.ts @@ -1,6 +1,6 @@ import {ConsoleLogger, Injectable, NotFoundException, UnprocessableEntityException} from "@nestjs/common"; import {SessionManager} from "./abc/manager.abc"; -import {WhatsappSession} from "./abc/session.abc"; +import {WAHAInternalEvent, WhatsappSession} from "./abc/session.abc"; import {WhatsappEngine} from "../structures/enums.dto"; import {SessionDTO, SessionStartRequest, SessionStopRequest} from "../structures/sessions.dto"; import {WhatsappConfigService} from "../config.service"; @@ -9,6 +9,7 @@ import {WhatsappSessionWebJSCore} from "./session.webjs.core"; import {DOCS_URL} from "./exceptions"; import {WebhookConductorCore} from "./webhooks.core"; import {MediaStorageCore} from "./storage.core"; +import {WhatsappSessionNoWebCore} from "./session.noweb.core"; export class OnlyDefaultSessionIsAllowed extends UnprocessableEntityException { constructor() { @@ -49,6 +50,8 @@ export class SessionManagerCore extends SessionManager { return WhatsappSessionWebJSCore } else if (engine === WhatsappEngine.VENOM) { return WhatsappSessionVenomCore + } else if (engine === WhatsappEngine.NOWEB) { + return WhatsappSessionNoWebCore } else { throw new NotFoundException(`Unknown whatsapp engine '${engine}'.`) } @@ -100,7 +103,8 @@ export class SessionManagerCore extends SessionManager { const session = new this.EngineClass(name, storage, log) this.session = session - session.start().then(() => webhook.configure(session)) + session.events.on(WAHAInternalEvent.engine_start, () => webhook.configure(session)) + session.start() return {name: session.name, status: session.status} } diff --git a/src/core/session.noweb.core.ts b/src/core/session.noweb.core.ts new file mode 100644 index 00000000..7a8d2f4f --- /dev/null +++ b/src/core/session.noweb.core.ts @@ -0,0 +1,468 @@ +import {SECOND, WAEvents, WhatsappStatus} from "../structures/enums.dto"; +import makeWASocket, {DisconnectReason, makeInMemoryStore, isJidGroup, useMultiFileAuthState} from '@adiwajshing/baileys' + +import {ensureSuffix, WAHAInternalEvent, WhatsappSession} from "./abc/session.abc"; +import {WAMessage, WANumberExistResult} from "../structures/responses.dto"; +import { + Button, + ChatRequest, + CheckNumberStatusQuery, + GetMessageQuery, + MessageContactVcardRequest, + MessageFileRequest, + MessageImageRequest, + MessageLinkPreviewRequest, + MessageLocationRequest, + MessageReactionRequest, + MessageReplyRequest, + MessageTextButtonsRequest, + MessageTextRequest, + MessageVoiceRequest, + SendSeenRequest +} from "../structures/chatting.dto"; +import {AvailableInPlusVersion, NotImplementedByEngineError} from "./exceptions"; +import {ContactQuery, ContactRequest} from "../structures/contacts.dto"; +import {CreateGroupRequest, ParticipantsRequest} from "../structures/groups.dto"; +import {QR} from "./QR"; +import {MediaStorage} from "./abc/storage.abc"; +import {ConsoleLogger, UnprocessableEntityException} from "@nestjs/common"; +import {Message} from "whatsapp-web.js"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const QRCode = require('qrcode') +// eslint-disable-next-line @typescript-eslint/no-var-requires +const logger = require('pino')() + + +export const BaileysEvents = { + CONNECTION_UPDATE: "connection.update", + CREDS_UPDATE: "creds.update", + MESSAGES_UPSERT: "messages.upsert", + GROUPS_UPSERT: 'groups.upsert', +} + +export class WhatsappSessionNoWebCore extends WhatsappSession { + sock: any + store: any + private qr: QR + authFolder: string + + public constructor(public name: string, protected storage: MediaStorage, protected log: ConsoleLogger) { + super(name, storage, log); + this.qr = new QR() + this.authFolder = this.getAuthFolder() + } + + start() { + return this.buildClient() + } + + protected getAuthFolder() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'noweb-')) + } + + async makeSocket() { + const {state, saveCreds} = await useMultiFileAuthState(this.authFolder) + const sock: any = makeWASocket({ + auth: state, + printQRInTerminal: true, + browser: ['Linux', 'Chrome', '111.0.5563.64'], + logger: logger, + }) + sock.ev.on(BaileysEvents.CREDS_UPDATE, saveCreds) + return sock + } + connectStore(){ + if (!this.store){ + this.store = makeInMemoryStore({ }) + } + this.store.bind(this.sock.ev) + } + + async buildClient() { + this.sock = await this.makeSocket() + this.connectStore() + this.sock.ev.on(BaileysEvents.CONNECTION_UPDATE, async (update) => { + const {connection, lastDisconnect, qr} = update + if (connection === 'connecting') { + this.qr.save("") + this.status = WhatsappStatus.WORKING + return + } else if (connection === 'close') { + const shouldReconnect = (lastDisconnect.error)?.output?.statusCode !== DisconnectReason.loggedOut + this.log.error('connection closed due to ', lastDisconnect.error, ', reconnecting ', shouldReconnect) + this.qr.save("") + this.status = WhatsappStatus.FAILED + // reconnect if not logged out + if (shouldReconnect) { + setTimeout(() => this.buildClient(), 2 * SECOND) + } + } + + // Save QR + if (qr) { + this.status = WhatsappStatus.SCAN_QR_CODE + QRCode.toDataURL(qr).then((url) => { + this.qr.save(url) + }) + } + }) + this.events.emit(WAHAInternalEvent.engine_start) + } + + stop() { + this.sock.ws.removeAllListeners() + this.log.log('socket connection terminated') + return + } + + /** + * START - Methods for API + */ + async getScreenshot(): Promise { + if (this.status === WhatsappStatus.STARTING) { + throw new UnprocessableEntityException(`The session is starting, please try again after few seconds`); + } else if (this.status === WhatsappStatus.SCAN_QR_CODE) { + return Promise.resolve(this.qr.get()) + } else if (this.status === WhatsappStatus.WORKING) { + throw new UnprocessableEntityException(`Can not get screenshot for non chrome based engine.`); + } else { + throw new UnprocessableEntityException(`Unknown status - ${this.status}`); + } + } + + async checkNumberStatus(request: CheckNumberStatusQuery): Promise { + const phone = request.phone.split("@")[0] + const [result] = await this.sock.onWhatsApp(phone) + if (!result) { + return {numberExists: false} + } + return {numberExists: result.exists} + } + + sendText(request: MessageTextRequest) { + const chatId = this.ensureSuffix(request.chatId) + return this.sock.sendMessage(chatId, {text: request.text}) + } + + sendTextButtons(request: MessageTextButtonsRequest) { + const buttons = request.buttons.map((button: Button) => { + return { + buttonId: button.id, buttonText: {displayText: button.text}, type: 1 + } + }) + + const buttonMessage = { + text: request.title, + buttons: buttons, + headerType: 1 + } + + return this.sock.sendMessage(request.chatId, buttonMessage) + } + + sendContactVCard(request: MessageContactVcardRequest) { + throw new NotImplementedByEngineError() + } + + async reply(request: MessageReplyRequest) { + const {id} = parseMessageId(request.reply_to) + const message = await this.store.loadMessage(toJID(request.chatId), id) + return await this.sock.sendMessage(request.chatId, {text: request.text}, {quoted: message}) + } + + sendImage(request: MessageImageRequest) { + throw new AvailableInPlusVersion() + } + + sendFile(request: MessageFileRequest) { + throw new AvailableInPlusVersion() + } + + sendVoice(request: MessageVoiceRequest) { + throw new AvailableInPlusVersion() + } + + sendLocation(request: MessageLocationRequest) { + return this.sock.sendMessage( + request.chatId, + {location: {degreesLatitude: request.latitude, degreesLongitude: request.longitude}} + ) + } + + sendLinkPreview(request: MessageLinkPreviewRequest) { + const text = `${request.title}\n${request.url}` + const chatId = this.ensureSuffix(request.chatId) + return this.sock.sendMessage(chatId, {text: text}) + } + + + async sendSeen(request: SendSeenRequest) { + const key = parseMessageId(request.messageId) + const data = { + remoteJid: key.remoteJid, + id: key.id, + participant: request.participant + } + return this.sock.readMessages([data]) + } + + async startTyping(request: ChatRequest) { + return this.sock.sendPresenceUpdate('composing', request.chatId) + } + + async stopTyping(request: ChatRequest) { + return this.sock.sendPresenceUpdate("paused", request.chatId) + } + + async getMessages(query: GetMessageQuery) { + throw new NotImplementedByEngineError() + } + + async setReaction(request: MessageReactionRequest) { + const key = parseMessageId(request.messageId) + const reactionMessage = { + react: { + text: request.reaction, + key: key, + } + } + return this.sock.sendMessage(key.remoteJid, reactionMessage) + } + + + /** + * Contacts methods + */ + getContact(query: ContactQuery) { + throw new NotImplementedByEngineError() + } + + getContacts() { + throw new NotImplementedByEngineError() + } + + public async getContactAbout(query: ContactQuery) { + throw new NotImplementedByEngineError() + } + + public async getContactProfilePicture(query: ContactQuery) { + throw new NotImplementedByEngineError() + } + + public async blockContact(request: ContactRequest) { + throw new NotImplementedByEngineError() + } + + public async unblockContact(request: ContactRequest) { + throw new NotImplementedByEngineError() + } + + /** + * Group methods + */ + public createGroup(request: CreateGroupRequest) { + const participants = request.participants.map(getId) + return this.sock.groupCreate(request.name, participants) + } + + public async getGroups() { + return await this.sock.groupFetchAllParticipating() + } + + public async getGroup(id) { + const groups = await this.sock.groupFetchAllParticipating() + return groups[id] + } + + public async deleteGroup(id) { + throw new NotImplementedByEngineError() + } + + public async leaveGroup(id) { + return this.sock.groupLeave(id) + } + + public async setDescription(id, description) { + return this.sock.groupUpdateDescription(id, description) + } + + public async setSubject(id, subject) { + return this.sock.groupUpdateSubject(id, subject) + } + + public async getInviteCode(id): Promise { + return this.sock.groupInviteCode(id) + } + + public async revokeInviteCode(id): Promise { + await this.sock.groupRevokeInvite(id) + return this.sock.groupInviteCode(id) + } + + public async getParticipants(id) { + const groups = await this.sock.groupFetchAllParticipating() + return groups[id].participants + } + + public async addParticipants(id, request: ParticipantsRequest) { + const participants = request.participants.map(getId) + return this.sock.groupParticipantsUpdate(id, participants, "add") + } + + public async removeParticipants(id, request: ParticipantsRequest) { + const participants = request.participants.map(getId) + return this.sock.groupParticipantsUpdate(id, participants, "remove") + } + + public async promoteParticipantsToAdmin(id, request: ParticipantsRequest) { + const participants = request.participants.map(getId) + return this.sock.groupParticipantsUpdate(id, participants, "promote") + } + + public async demoteParticipantsToUser(id, request: ParticipantsRequest) { + const participants = request.participants.map(getId) + return this.sock.groupParticipantsUpdate(id, participants, "demote") + } + + /** + * END - Methods for API + */ + + subscribe(event, handler) { + if (event === WAEvents.MESSAGE) { + return this.sock.ev.on(BaileysEvents.MESSAGES_UPSERT, + ({messages}) => this.handleIncomingMessages(messages, handler, false) + ) + } else if (event === WAEvents.MESSAGE_ANY) { + return this.sock.ev.on(BaileysEvents.MESSAGES_UPSERT, + ({messages}) => this.handleIncomingMessages(messages, handler, true) + ) + } else if (event === WAEvents.STATE_CHANGE) { + return this.sock.ev.on(BaileysEvents.CONNECTION_UPDATE, handler) + } else if (event === WAEvents.GROUP_JOIN) { + return this.sock.ev.on(BaileysEvents.GROUPS_UPSERT, handler) + } else { + throw new NotImplementedByEngineError(`Engine does not support webhook event: ${event}`) + } + } + + private handleIncomingMessages(messages, handler, includeFromMe) { + for (const message of messages) { + // if there is no text or media message + if (!message) return; + if (!message.message) return + // Do not include my messages + if (!includeFromMe && message.key.fromMe) { + continue + } + this.processIncomingMessage(message).then(handler) + } + } + + private processIncomingMessage(message) { + return this.downloadMedia(message).then(this.toWAMessage) + } + + protected toWAMessage(message): Promise { + const isGroupMessage = Boolean(message.key.participant) + let participant: string + let to: string + if (isGroupMessage) { + participant = message.key.participant + to = message.key.remoteJid + } + const from = message.key.remoteJid + const id = buildMessageId(message.key) + return Promise.resolve({ + id: id, + timestamp: message.messageTimestamp, + from: toCusFormat(from), + fromMe: message.key.fromMe, + body: message.message.conversation, + to: toCusFormat(to), + participant: toCusFormat(participant), + // @ts-ignore + hasMedia: Boolean(message.mediaUrl), + // @ts-ignore + mediaUrl: message.mediaUrl, + // @ts-ignore + ack: message.ack, + location: message.location, + vCards: message.vCards, + _data: message, + }) + } + + protected async downloadMedia(message: Message) { + if (!message.hasMedia) { + return message + } + + // @ts-ignore + message.mediaUrl = await this.storage.save(message.key.id, "", undefined) + return message + } + +} + +/** + * Convert from 11111111111@s.whatsapp.net to 11111111111@c.us + */ +function toCusFormat(remoteJid) { + if (isJidGroup(remoteJid)) { + return remoteJid + } + if (!remoteJid) { + return + } + const number = remoteJid.split("@")[0] + return ensureSuffix(number) +} + +/** + * Convert from 11111111111@c.us to 11111111111@s.whatsapp.net + * @param chatId + */ +function toJID(chatId) { + if (isJidGroup(chatId)) { + return chatId + } + const number = chatId.split("@")[0] + return number + "@s.whatsapp.net" +} + +/** + * Build WAHA message id from engine one + * {id: "AAA", remoteJid: "11111111111@s.whatsapp.net", "fromMe": false} + * false_11111111111@c.us_AA + */ +function buildMessageId({id, remoteJid, fromMe}) { + const chatId = toCusFormat(remoteJid) + return `${fromMe || false}_${chatId}_${id}` +} + +/** + * Parse message id from WAHA to engine + * false_11111111111@c.us_AAA + * {id: "AAA", remoteJid: "11111111111@s.whatsapp.net", "fromMe": false} + */ +function parseMessageId(messageId) { + const parts = messageId.split("_") + if (parts.length != 3) { + throw new Error("Message id be in format false_11111111111@c.us_AAAAAAAAAAAAAAAAAAAA") + } + const fromMe = parts[0] == "true" + const chatId = parts[1] + const remoteJid = toJID(chatId) + const id = parts[2] + return {fromMe: fromMe, id: id, remoteJid: remoteJid} +} + + +function getId(object) { + return object.id +} diff --git a/src/core/session.venom.core.ts b/src/core/session.venom.core.ts index 451b1e32..d784d853 100644 --- a/src/core/session.venom.core.ts +++ b/src/core/session.venom.core.ts @@ -1,8 +1,9 @@ -import {WhatsappSession} from "./abc/session.abc"; +import {WAHAInternalEvent, WhatsappSession} from "./abc/session.abc"; import { Button, ChatRequest, - CheckNumberStatusQuery, GetMessageQuery, + CheckNumberStatusQuery, + GetMessageQuery, MessageContactVcardRequest, MessageFileRequest, MessageImageRequest, @@ -20,20 +21,7 @@ import {NotImplementedByEngineError} from "./exceptions"; import {MediaStorage} from "./abc/storage.abc"; import {UnprocessableEntityException} from "@nestjs/common/exceptions/unprocessable-entity.exception"; import {ConsoleLogger} from "@nestjs/common"; -import {ContactQuery} from "../structures/contacts.dto"; - -class QR { - private base64: string; - - save(base64) { - this.base64 = base64.replace(/^data:image\/png;base64,/, ''); - } - - get(): Buffer { - return Buffer.from(this.base64, "base64") - } - -} +import {QR} from "./QR"; export class WhatsappSessionVenomCore extends WhatsappSession { whatsapp: Whatsapp; @@ -84,6 +72,7 @@ export class WhatsappSessionVenomCore extends WhatsappSession { } this.status = WhatsappStatus.WORKING + this.events.emit(WAHAInternalEvent.engine_start) return this } @@ -126,10 +115,10 @@ export class WhatsappSessionVenomCore extends WhatsappSession { async checkNumberStatus(request: CheckNumberStatusQuery): Promise { try { - return await this.whatsapp.checkNumberStatus(this.ensureSuffix(request.phone)) + return await this.whatsapp.checkNumberStatus(this.ensureSuffix(request.phone)) } catch (error) { // Catch number doesn't exist error and return it as is - if (error.status === 404 && !error.numberExists){ + if (error.status === 404 && !error.numberExists) { return error } throw error @@ -177,11 +166,11 @@ export class WhatsappSessionVenomCore extends WhatsappSession { return { buttonId: button.id, buttonText: { - displayText: button.body + displayText: button.text } } }) - return this.whatsapp.sendButtons(this.ensureSuffix(request.chatId), request.title, buttons, request.text) + return this.whatsapp.sendButtons(this.ensureSuffix(request.chatId), request.title, buttons, request.footer) } startTyping(chat: ChatRequest) { @@ -192,12 +181,12 @@ export class WhatsappSessionVenomCore extends WhatsappSession { return this.whatsapp.stopTyping(chat.chatId) } - async getMessages(query: GetMessageQuery){ + async getMessages(query: GetMessageQuery) { const messages = await this.whatsapp.getAllMessagesInChat(query.chatId, true, false) // Go over messages, download media, and convert to right format. const result = [] for (const [count, message] of messages.entries()) { - if (count > query.limit){ + if (count > query.limit) { // Have enough in the list, stop processing break } diff --git a/src/core/session.webjs.core.ts b/src/core/session.webjs.core.ts index 2b9818b6..236bec8c 100644 --- a/src/core/session.webjs.core.ts +++ b/src/core/session.webjs.core.ts @@ -2,9 +2,10 @@ import {UnprocessableEntityException} from "@nestjs/common/exceptions/unprocessa import {Buttons, Chat, Client, Contact, Events, GroupChat, Location, Message} from "whatsapp-web.js"; import {Message as MessageInstance} from "whatsapp-web.js/src/structures" import {WAEvents, WhatsappStatus} from "../structures/enums.dto"; -import {WhatsappSession} from "./abc/session.abc"; +import {WAHAInternalEvent, WhatsappSession} from "./abc/session.abc"; import {WAMessage, WANumberExistResult} from "../structures/responses.dto"; import { + Button, ChatRequest, CheckNumberStatusQuery, GetMessageQuery, @@ -17,7 +18,7 @@ import { MessageReplyRequest, MessageTextButtonsRequest, MessageTextRequest, - MessageVoiceRequest + MessageVoiceRequest, SendSeenRequest } from "../structures/chatting.dto"; import {AvailableInPlusVersion, NotImplementedByEngineError} from "./exceptions"; import {ContactQuery, ContactRequest} from "../structures/contacts.dto"; @@ -59,6 +60,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { this.status = WhatsappStatus.WORKING this.log.log(`Session '${this.name}' has been authenticated!`) }); + this.events.emit(WAHAInternalEvent.engine_start) return this } @@ -86,7 +88,13 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { } sendTextButtons(request: MessageTextButtonsRequest) { - const message = new Buttons("", request.buttons, request.title, request.text) + const buttons = request.buttons.map((button: Button) => { + return { + id: button.id, + body: button.text, + } + }) + const message = new Buttons("", buttons, request.title, request.footer) return this.whatsapp.sendMessage(this.ensureSuffix(request.chatId), message) } @@ -127,7 +135,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { } - async sendSeen(request: ChatRequest) { + async sendSeen(request: SendSeenRequest) { const chat: Chat = await this.whatsapp.getChatById(request.chatId) await chat.sendSeen() } @@ -157,8 +165,6 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { async setReaction(request: MessageReactionRequest) { const messageId = this.deserializeId(request.messageId) - console.log(messageId) - // Recreate instance to react on it const message = new MessageInstance(this.whatsapp) message.id = messageId diff --git a/src/core/webhooks.core.ts b/src/core/webhooks.core.ts index 0e267f10..45b651bc 100644 --- a/src/core/webhooks.core.ts +++ b/src/core/webhooks.core.ts @@ -1,4 +1,4 @@ -import {WhatsappSession} from "./abc/session.abc"; +import {WAHAInternalEvent, WhatsappSession} from "./abc/session.abc"; import {ConsoleLogger} from "@nestjs/common"; import {WAEvents} from "../structures/enums.dto"; import {WAWebhook} from "../structures/responses.dto"; diff --git a/src/structures/chatting.dto.ts b/src/structures/chatting.dto.ts index df1043b5..35155b00 100644 --- a/src/structures/chatting.dto.ts +++ b/src/structures/chatting.dto.ts @@ -41,6 +41,22 @@ export class ChatRequest extends SessionBaseRequest { chatId: string } +export class SendSeenRequest extends ChatRequest { + @ApiProperty({ + example: 'false_11111111111@c.us_AAAAAAAAAAAAAAAAAAAA', + required: false, + description: "NOWEB engine only - it's important to mark ALL messages as seen" + }) + messageId?: string + + @ApiProperty({ + example: '11111111111@c.us', + required: false, + description: "NOWEB engine only - the ID of the user that sent the message (undefined for individual chats)" + }) + participant?: string +} + export class MessageRequest extends SessionBaseRequest { @ApiProperty({ example: "false_11111111111@c.us_AAAAAAAAAAAAAAAAAAAA", @@ -59,12 +75,12 @@ export class MessageTextRequest extends ChatRequest { export class Button { id: string - body: string + text: string } export class MessageTextButtonsRequest extends ChatRequest { title: string; - text: string; + footer?: string buttons?: Array