Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bc3489060 | ||
|
|
6914cb171a | ||
|
|
176c2bbd4c | ||
|
|
7771954455 | ||
|
|
a66743bb20 | ||
|
|
851c7b75f9 | ||
|
|
ed0b406027 | ||
|
|
71985972a2 | ||
|
|
2dbeef9290 | ||
|
|
a79453270d | ||
|
|
ae71808c61 | ||
|
|
5c195ba37b | ||
|
|
607ee17a86 | ||
|
|
a15aacc5b3 | ||
|
|
78fb4b1a65 | ||
|
|
8e566cba72 | ||
|
|
36184288de | ||
|
|
6fc5bf38a7 | ||
|
|
e146ecde70 | ||
|
|
72b796019a | ||
|
|
0f66d15ba8 | ||
|
|
4695e933b8 | ||
|
|
5e1da6cd6f | ||
|
|
0c3bbc0980 | ||
|
|
d2bb5bea63 | ||
|
|
c76e191d4d | ||
|
|
171594d0ae | ||
|
|
4e5f499b48 | ||
|
|
bb75ad8b8e | ||
|
|
482cc7028b | ||
|
|
735bdb3854 | ||
|
|
1ee1bd78af | ||
|
|
00463c729d | ||
|
|
dac636b3bb | ||
|
|
ad87121506 | ||
|
|
55d802f005 | ||
|
|
f17164a3d9 | ||
|
|
0b9f9b2335 | ||
|
|
71d3e24241 | ||
|
|
ccd4512e8f | ||
|
|
fe6b77d67d | ||
|
|
58d12f4e70 | ||
|
|
17303039b8 | ||
|
|
f70d5203a3 | ||
|
|
efd6b1db65 | ||
|
|
dcca0fa921 | ||
|
|
e5bc447807 | ||
|
|
cd6b4b5ab1 | ||
|
|
fb79ae6138 | ||
|
|
17801b0b13 | ||
|
|
9010388de0 | ||
|
|
2da901f994 | ||
|
|
7832cc7a5d | ||
|
|
13ab2e8d0e | ||
|
|
868d23a278 | ||
|
|
eaf2bca44e | ||
|
|
af8f537f58 | ||
|
|
bc7ff472be | ||
|
|
9b9a2b5203 | ||
|
|
347259394b | ||
|
|
ead61b5e37 | ||
|
|
c9f7814f5d | ||
|
|
3ee3355e81 | ||
|
|
ce8f8b0ce9 | ||
|
|
cae35cf3b9 | ||
|
|
900e3c23bc | ||
|
|
aaeffeb983 | ||
|
|
15600324be | ||
|
|
da4913edb5 | ||
|
|
e8cc1e4906 | ||
|
|
801fc3f9fd | ||
|
|
703b0b4cde | ||
|
|
d55895389a | ||
|
|
56009e482b | ||
|
|
767d1bb3e3 | ||
|
|
05132ac0f5 | ||
|
|
fb372f8c28 | ||
|
|
a2dc863b7a | ||
|
|
15671c6781 | ||
|
|
6296115adb | ||
|
|
0a099a91d0 | ||
|
|
5f060fe022 | ||
|
|
a4bbe1cf7b | ||
|
|
dce74ae7d5 | ||
|
|
871c509493 | ||
|
|
9333470e3b |
No files matched your search
@@ -1,4 +1,6 @@
|
||||
Dockerfile
|
||||
src/core/engines/gows/grpc
|
||||
src/core/engines/gows/proto
|
||||
.*sessions
|
||||
sessions
|
||||
files
|
||||
|
||||
+32
-17
@@ -6,10 +6,12 @@
|
||||
# If you know the right URL, you can use it here.
|
||||
# Use any port in WHATSAPP_API_PORT and domain in WHATSAPP_API_HOSTNAME
|
||||
# https://waha.devlike.pro/docs/how-to/config/#common
|
||||
WAHA_BASE_URL=http://localhost:3000
|
||||
|
||||
#WHATSAPP_API_SCHEMA=http
|
||||
#WHATSAPP_API_PORT=3000
|
||||
#WHATSAPP_API_HOSTNAME=localhost
|
||||
#WAHA_BASE_URL=http://localhost:3000
|
||||
|
||||
#
|
||||
# Set your timezone to see the right time on screenshots
|
||||
# Find your timezone name in the list
|
||||
@@ -90,6 +92,17 @@ WHATSAPP_SWAGGER_PASSWORD=admin
|
||||
#WHATSAPP_SWAGGER_TITLE=WAHA API
|
||||
#
|
||||
|
||||
#
|
||||
# PostgresSQL Configuration
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#sessions---postgresql
|
||||
#WHATSAPP_SESSIONS_POSTGRESQL_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
#
|
||||
# Media - PostgresSQL Storage
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#media---postgresql
|
||||
#WAHA_MEDIA_STORAGE=POSTGRESQL
|
||||
#WAHA_MEDIA_POSTGRESQL_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
#
|
||||
|
||||
#
|
||||
# Global Proxy Settings
|
||||
# https://waha.devlike.pro/docs/how-to/config/#global-proxy-configuration
|
||||
@@ -101,22 +114,6 @@ WHATSAPP_SWAGGER_PASSWORD=admin
|
||||
#WHATSAPP_PROXY_SERVER_PASSWORD=pass
|
||||
#
|
||||
|
||||
#
|
||||
# HTTPS Configuration
|
||||
# https://waha.devlike.pro/docs/how-to/config/#https
|
||||
#
|
||||
# Consider using certbot for HTTPS
|
||||
# https://waha.devlike.pro/blog/setting-up-https-for-waha/#lets-encrypt-certbot--waha
|
||||
#
|
||||
#WAHA_HTTPS_ENABLED=true
|
||||
#WAHA_HTTPS_PATH_KEY=/etc/letsencrypt/live/waha.example.pro/privkey.pem
|
||||
#WAHA_HTTPS_PATH_CERT=/etc/letsencrypt/live/waha.example.pro/cert.pem \
|
||||
#WAHA_HTTPS_PATH_CA=/etc/letsencrypt/live/waha.example.pro/chain.pem
|
||||
#WHATSAPP_API_SCHEMA=https
|
||||
#WHATSAPP_API_PORT=3000
|
||||
#WHATSAPP_API_HOSTNAME=waha.example.pro
|
||||
#
|
||||
|
||||
#
|
||||
# Media Configuration
|
||||
#
|
||||
@@ -169,8 +166,26 @@ WHATSAPP_FILES_FOLDER=/app/.media
|
||||
# volumes:
|
||||
# - './.sessions:/app/sessions'
|
||||
|
||||
|
||||
#
|
||||
# MongoDB Configuration
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#sessions---mongodb
|
||||
#WHATSAPP_SESSIONS_MONGO_URL=mongodb://mongouser:mongopassword@mongodb:27017
|
||||
#
|
||||
|
||||
#
|
||||
# HTTPS Configuration
|
||||
# https://waha.devlike.pro/docs/how-to/config/#https
|
||||
#
|
||||
# Consider using certbot for HTTPS
|
||||
# https://waha.devlike.pro/blog/setting-up-https-for-waha/#lets-encrypt-certbot--waha
|
||||
#
|
||||
#WAHA_HTTPS_ENABLED=true
|
||||
#WAHA_HTTPS_PATH_KEY=/etc/letsencrypt/live/waha.example.pro/privkey.pem
|
||||
#WAHA_HTTPS_PATH_CERT=/etc/letsencrypt/live/waha.example.pro/cert.pem \
|
||||
#WAHA_HTTPS_PATH_CA=/etc/letsencrypt/live/waha.example.pro/chain.pem
|
||||
#WHATSAPP_API_SCHEMA=https
|
||||
#WHATSAPP_API_PORT=3000
|
||||
#WHATSAPP_API_HOSTNAME=waha.example.pro
|
||||
#
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
src/core/engines/gows/grpc/*
|
||||
src/core/engines/gows/proto/*
|
||||
@@ -8,7 +8,9 @@ on:
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
name: ${{ matrix.platform }} - ${{ matrix.browser }} - ${{ matrix.tag }}
|
||||
name:
|
||||
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
|
||||
${{ matrix.tag }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -17,6 +19,7 @@ jobs:
|
||||
tag: 'latest'
|
||||
platform: 'amd64'
|
||||
browser: 'chromium'
|
||||
engine: 'WEBJS'
|
||||
goss: 'goss-linux-amd64'
|
||||
|
||||
# Chromium - ARM
|
||||
@@ -24,6 +27,7 @@ jobs:
|
||||
tag: 'arm'
|
||||
platform: 'linux/arm64'
|
||||
browser: 'chromium'
|
||||
engine: 'WEBJS'
|
||||
goss: 'goss-linux-arm'
|
||||
|
||||
# Chrome - x86
|
||||
@@ -31,6 +35,7 @@ jobs:
|
||||
tag: 'chrome'
|
||||
platform: 'amd64'
|
||||
browser: 'chrome'
|
||||
engine: 'WEBJS'
|
||||
goss: 'goss-linux-amd64'
|
||||
|
||||
# Chrome - ARM (Chrome is not available for ARM)
|
||||
@@ -40,18 +45,36 @@ jobs:
|
||||
# browser: "chrome"
|
||||
# goss: 'goss-linux-arm'
|
||||
|
||||
# No browser - x86
|
||||
# NOWEB - x86
|
||||
- runner: 'buildjet-4vcpu-ubuntu-2204'
|
||||
tag: 'noweb'
|
||||
platform: 'amd64'
|
||||
browser: 'none'
|
||||
engine: 'NOWEB'
|
||||
goss: 'goss-linux-amd64'
|
||||
|
||||
# No browser - ARM
|
||||
# NOWEB - ARM
|
||||
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
|
||||
tag: 'noweb-arm'
|
||||
platform: 'linux/arm64'
|
||||
browser: 'none'
|
||||
engine: 'NOWEB'
|
||||
goss: 'goss-linux-arm'
|
||||
|
||||
# GOWS - x86
|
||||
- runner: 'buildjet-4vcpu-ubuntu-2204'
|
||||
tag: 'gows'
|
||||
platform: 'amd64'
|
||||
browser: 'none'
|
||||
engine: 'GOWS'
|
||||
goss: 'goss-linux-amd64'
|
||||
|
||||
# No browser - ARM
|
||||
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
|
||||
tag: 'gows-arm'
|
||||
platform: 'linux/arm64'
|
||||
browser: 'none'
|
||||
engine: 'GOWS'
|
||||
goss: 'goss-linux-arm'
|
||||
|
||||
steps:
|
||||
@@ -85,6 +108,7 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
USE_BROWSER=${{ matrix.browser }}
|
||||
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -116,6 +140,7 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
USE_BROWSER=${{ matrix.browser }}
|
||||
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -1,3 +1,5 @@
|
||||
src/core/engines/gows/grpc
|
||||
src/core/engines/gows/proto
|
||||
.env
|
||||
*.heapsnapshot
|
||||
.yarn
|
||||
|
||||
@@ -2,6 +2,8 @@ exclude: |
|
||||
(?x)^(
|
||||
^src/dashboard/.*|
|
||||
^src/core/engines/webjs/.*html|
|
||||
^src/core/engines/gows/grpc/.*|
|
||||
^src/core/engines/gows/proto/.*|
|
||||
^src/plus/engines/webjs/.*html|
|
||||
^.yarn/.*
|
||||
)$
|
||||
@@ -73,4 +75,6 @@ repos:
|
||||
.pre-commit-config.yaml|
|
||||
^src/core/engines/webjs/session.webjs.core.ts|
|
||||
^src/core/engines/webjs/_lodash.js|
|
||||
^tests/perf/.*|
|
||||
^scripts/.*|
|
||||
)$
|
||||
+63
-8
@@ -6,7 +6,7 @@ FROM node:${NODE_VERSION} AS build
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD=True
|
||||
|
||||
# npm packages
|
||||
WORKDIR /src
|
||||
WORKDIR /git
|
||||
COPY package.json .
|
||||
COPY yarn.lock .
|
||||
ENV YARN_CHECKSUM_BEHAVIOR=update
|
||||
@@ -15,9 +15,10 @@ RUN yarn set version 3.6.3
|
||||
RUN yarn install
|
||||
|
||||
# App
|
||||
WORKDIR /src
|
||||
ADD . /src
|
||||
WORKDIR /git
|
||||
ADD . /git
|
||||
RUN yarn install
|
||||
RUN yarn gows:proto
|
||||
RUN yarn build && find ./dist -name "*.d.ts" -delete
|
||||
|
||||
#
|
||||
@@ -25,16 +26,51 @@ RUN yarn build && find ./dist -name "*.d.ts" -delete
|
||||
#
|
||||
FROM node:${NODE_VERSION} AS dashboard
|
||||
|
||||
# Download WAHA Dashboard
|
||||
ENV WAHA_DASHBOARD_SHA 45db5b46a944f4320136f2409d8b970483c8bd9d
|
||||
# jq to parse json
|
||||
RUN apt-get update && apt-get install -y jq && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY waha.config.json /tmp/waha.config.json
|
||||
RUN \
|
||||
wget https://github.com/devlikeapro/dashboard/archive/${WAHA_DASHBOARD_SHA}.zip \
|
||||
WAHA_DASHBOARD_GITHUB_REPO=$(jq -r '.waha.dashboard.repo' /tmp/waha.config.json) && \
|
||||
WAHA_DASHBOARD_SHA=$(jq -r '.waha.dashboard.ref' /tmp/waha.config.json) && \
|
||||
wget https://github.com/${WAHA_DASHBOARD_GITHUB_REPO}/archive/${WAHA_DASHBOARD_SHA}.zip \
|
||||
&& unzip ${WAHA_DASHBOARD_SHA}.zip -d /tmp/dashboard \
|
||||
&& mkdir -p /dashboard \
|
||||
&& mv /tmp/dashboard/dashboard-${WAHA_DASHBOARD_SHA}/* /dashboard/ \
|
||||
&& rm -rf ${WAHA_DASHBOARD_SHA}.zip \
|
||||
&& rm -rf /tmp/dashboard/dashboard-${WAHA_DASHBOARD_SHA}
|
||||
|
||||
#
|
||||
# GOWS
|
||||
#
|
||||
FROM golang:1.23-bullseye AS gows
|
||||
|
||||
# jq to parse json
|
||||
RUN apt-get update && apt-get install -y jq && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# install protoc
|
||||
RUN apt-get update && \
|
||||
apt-get install protobuf-compiler -y
|
||||
|
||||
# Image processing for thumbnails
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y libvips-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY waha.config.json /tmp/waha.config.json
|
||||
RUN \
|
||||
GOWS_GITHUB_REPO=$(jq -r '.waha.gows.repo' /tmp/waha.config.json) && \
|
||||
GOWS_SHA=$(jq -r '.waha.gows.ref' /tmp/waha.config.json) && \
|
||||
git clone https://github.com/${GOWS_GITHUB_REPO} gows && \
|
||||
cd gows && \
|
||||
git checkout ${GOWS_SHA}
|
||||
|
||||
WORKDIR /go/gows
|
||||
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
||||
RUN make all
|
||||
|
||||
|
||||
#
|
||||
# Final
|
||||
#
|
||||
@@ -44,12 +80,18 @@ ENV PUPPETEER_SKIP_DOWNLOAD=True
|
||||
# https://github.com/devlikeapro/waha/issues/347
|
||||
ENV NODE_OPTIONS="--max-old-space-size=16384"
|
||||
ARG USE_BROWSER=chromium
|
||||
ARG WHATSAPP_DEFAULT_ENGINE
|
||||
|
||||
RUN echo "USE_BROWSER=$USE_BROWSER"
|
||||
|
||||
# Install ffmpeg to generate previews for videos
|
||||
RUN apt-get update && apt-get install -y ffmpeg --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Image processing for thumbnails
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y libvips \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install zip and unzip - either for chromium or chrome
|
||||
RUN if [ "$USE_BROWSER" = "chromium" ] || [ "$USE_BROWSER" = "chrome" ]; then \
|
||||
apt-get update \
|
||||
@@ -99,12 +141,25 @@ RUN if [ "$USE_BROWSER" = "chrome" ]; then \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
# GOWS requirements
|
||||
# libc6
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y libc6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the ENV for docker image
|
||||
ENV WHATSAPP_DEFAULT_ENGINE=$WHATSAPP_DEFAULT_ENGINE
|
||||
|
||||
# Attach sources, install packages
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY --from=build /src/node_modules ./node_modules
|
||||
COPY --from=build /src/dist ./dist
|
||||
COPY --from=build /git/node_modules ./node_modules
|
||||
COPY --from=build /git/dist ./dist
|
||||
COPY --from=dashboard /dashboard ./dist/dashboard
|
||||
COPY --from=gows /go/gows/bin/gows /app/gows
|
||||
ENV WAHA_GOWS_PATH /app/gows
|
||||
ENV WAHA_GOWS_SOCKET /tmp/gows.sock
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Chokidar options to monitor file changes
|
||||
|
||||
@@ -5,7 +5,10 @@ build-chrome:
|
||||
docker build . -t devlikeapro/waha-plus:chrome --build-arg USE_BROWSER=chrome
|
||||
|
||||
build-noweb:
|
||||
docker build . -t devlikeapro/waha:noweb --build-arg USE_BROWSER=none
|
||||
docker build . -t devlikeapro/waha:noweb --build-arg USE_BROWSER=none --build-arg WHATSAPP_DEFAULT_ENGINE=NOWEB
|
||||
|
||||
build-plus-gows:
|
||||
docker build . -t devlikeapro/waha-plus:gows --build-arg USE_BROWSER=none --build-arg WHATSAPP_DEFAULT_ENGINE=GOWS
|
||||
|
||||
build-all: build build-chrome build-noweb
|
||||
|
||||
@@ -34,8 +37,24 @@ for-swagger:
|
||||
up-noweb:
|
||||
. ${NVM_DIR}/nvm.sh && nvm exec yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-channels-678
|
||||
|
||||
up-noweb-libsignal:
|
||||
. ${NVM_DIR}/nvm.sh && nvm exec yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
|
||||
|
||||
up-webjs:
|
||||
. ${NVM_DIR}/nvm.sh && nvm exec yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-channels
|
||||
|
||||
start-proxy:
|
||||
docker run --rm -d --name squid-container -e TZ=UTC -p 3128:3128 ubuntu/squid:5.2-22.04_beta
|
||||
|
||||
proto-gows:
|
||||
node scripts/gows-proto.js build --dir ../gows/proto
|
||||
|
||||
gows:
|
||||
cd ../gows && \
|
||||
export PATH=${HOME}/go/bin:${PATH} && \
|
||||
make all
|
||||
|
||||
copy-dashboard:
|
||||
cd ../waha-hub/ui && \
|
||||
make copy-waha
|
||||
|
||||
@@ -9,19 +9,13 @@
|
||||
[](https://hub.docker.com/r/devlikeapro/waha)
|
||||
|
||||
- Documentation: [https://waha.devlike.pro/](https://waha.devlike.pro/)
|
||||
- Dashboard Example: [https://waha.devlike.pro/dashboard](https://waha.devlike.pro/dashboard)
|
||||
- Swagger Example: [https://waha.devlike.pro/swagger](https://waha.devlike.pro/swagger)
|
||||
|
||||
# Tables of Contents
|
||||
|
||||
<!-- toc -->
|
||||
|
||||
- [Features](#features)
|
||||
* [Messages](#messages)
|
||||
* [Sessions](#sessions)
|
||||
* [Security](#security)
|
||||
* [Updates](#updates)
|
||||
* [Groups](#groups)
|
||||
* [Contacts](#contacts)
|
||||
- [Quick start](#quick-start)
|
||||
* [Requirements](#requirements)
|
||||
* [Send your first message](#send-your-first-message)
|
||||
@@ -32,52 +26,11 @@
|
||||
+ [5. Get the screenshot](#5-get-the-screenshot)
|
||||
+ [6. Send a text message](#6-send-a-text-message)
|
||||
* [What is next?](#what-is-next)
|
||||
- [Development](#development)
|
||||
* [Start the project](#start-the-project)
|
||||
|
||||
<!-- tocstop -->
|
||||
|
||||
# Features
|
||||
|
||||
## Messages
|
||||
- Send messages
|
||||
- Receive messages
|
||||
- Message replies
|
||||
- Send location
|
||||
- Receive location
|
||||
- React to messages
|
||||
- Send media (images/documents/files)
|
||||
- Send voice messages
|
||||
- Receive media (images/audio/video/documents)
|
||||
- Receive messages - webhook retries
|
||||
|
||||
## Sessions
|
||||
- Multi Device
|
||||
- Get the screenshot
|
||||
- Session saving (don't have to scan QR on every restart)
|
||||
- Single WhatsApp account running inside one container
|
||||
- Multiple WhatsApp account running inside one container
|
||||
|
||||
## Security
|
||||
- Swagger panel authentication
|
||||
- API authentication
|
||||
|
||||
## Updates
|
||||
- Bug fixes and updates come as soon as they are implemented.
|
||||
|
||||
## Groups
|
||||
- Create a group
|
||||
- Get invite for group
|
||||
- Modify group info (subject, description)
|
||||
- Add group participants
|
||||
- Kick group participants
|
||||
- Promote/demote group participants
|
||||
|
||||
## Contacts
|
||||
- Mute/unmute chats
|
||||
- Block/unblock contacts
|
||||
- Get contact info
|
||||
- Get profile pictures
|
||||
- Get presence (online\offline\typing) status
|
||||
|
||||
# Quick start
|
||||
|
||||
## Requirements
|
||||
@@ -87,13 +40,6 @@ instruction <a href="https://docs.docker.com/get-docker/" target="_blank" rel="n
|
||||
|
||||
When you are ready - come back and follows the below steps to send the first text message to WhatsApp via HTTP API!
|
||||
|
||||
{{< details "Why Docker?" >}}
|
||||
Docker makes it easy to ship all-in-one solution with the runtime and dependencies. You don't have to worry about
|
||||
language-specific libraries or chrome installation.
|
||||
|
||||
Also Docker makes installation and update processes so simple, just one command!
|
||||
{{< /details >}}
|
||||
|
||||
## Send your first message
|
||||
|
||||
Let's go over steps that allow you to send your first text message via WhatsApp HTTP API!
|
||||
@@ -114,7 +60,7 @@ docker pull devlikeapro/waha-plus
|
||||
docker logout
|
||||
```
|
||||
|
||||
Read more about how to get `PASSWORD` for [Plus Version →]({{< relref "plus-version" >}})
|
||||
Read more about how to get `PASSWORD` for [**➕ WAHA Plus**](https://waha.devlike.pro/docs/how-to/waha-plus/)
|
||||
|
||||
### 2. Run WhatsApp HTTP API
|
||||
|
||||
@@ -195,3 +141,19 @@ curl -d "{\"chatId\": \"${PHONE}@c.us\", \"text\": \"Hello from WhatsApp HTTP AP
|
||||
|
||||
## What is next?
|
||||
[Go and read the full documentation!](https://waha.devlike.pro/docs/overview/introduction/)
|
||||
|
||||
# Development
|
||||
|
||||
## Start the project
|
||||
1. Clone the repository
|
||||
2. Make sure you're using node>=22
|
||||
3. Run the following commands:
|
||||
```bash
|
||||
# Install dependencies
|
||||
yarn install
|
||||
# Fetch and compile proto files
|
||||
yarn gows:proto
|
||||
# Run
|
||||
yarn start
|
||||
# open http://localhost:3000
|
||||
```
|
||||
+22
-4
@@ -22,9 +22,27 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
# NOTE: Only if you're using MongoDB
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#sessions---mongodb
|
||||
# Uncomment this block if you're using MongoDB
|
||||
# NOTE: Only if you're using PostgreSQL to save sessions
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#media---postgresql
|
||||
# postgres:
|
||||
# image: postgres:17
|
||||
# restart: always
|
||||
# environment:
|
||||
# POSTGRES_USER: postgres
|
||||
# POSTGRES_PASSWORD: postgres
|
||||
# POSTGRES_DB: postgres
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
# volumes:
|
||||
# - pg_data:/var/lib/postgresql/data
|
||||
# command:
|
||||
# - postgres
|
||||
# - "-c"
|
||||
# - "max_connections=1000"
|
||||
|
||||
# NOTE: Only if you're using MongoDB
|
||||
# https://waha.devlike.pro/docs/how-to/storages/#sessions---mongodb
|
||||
# Uncomment this block if you're using MongoDB
|
||||
# mongodb:
|
||||
# image: mongo
|
||||
# container_name: mongodb
|
||||
@@ -55,6 +73,6 @@ services:
|
||||
# command: server /data --console-address ":9001"
|
||||
|
||||
volumes:
|
||||
# NOTE: Only if you're using MongoDB
|
||||
mongodb_data: {}
|
||||
minio_data: {}
|
||||
pg_data: {}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
# Calculate UV_THREADPOOL_SIZE based on number of CPUs
|
||||
cpus=$(node -e "const os = require('os'); console.log(os.cpus().length);")
|
||||
uv_threadpool_size=$(($cpus * 2))
|
||||
uv_threadpool_size=$(($cpus * 1))
|
||||
|
||||
# Set UV_THREADPOOL_SIZE as an environment variable
|
||||
export UV_THREADPOOL_SIZE="${UV_THREADPOOL_SIZE:-$uv_threadpool_size}"
|
||||
|
||||
+17
-4
@@ -20,7 +20,10 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"gows:proto:fetch": "node scripts/gows-proto.js fetch",
|
||||
"gows:proto:build": "node scripts/gows-proto.js build",
|
||||
"gows:proto": "yarn gows:proto:fetch && yarn gows:proto:build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-channels-678",
|
||||
@@ -55,10 +58,12 @@
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"file-type": "16.5.4",
|
||||
"fs-extra": "^11.2.0",
|
||||
"google-protobuf": "^3.21.4",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"joi": "^17.13.3",
|
||||
"knex": "^3.1.0",
|
||||
"libphonenumber-js": "^1.10.36",
|
||||
"libsignal": "github:devlikeapro/libsignal-node#fork-master",
|
||||
"link-preview-js": "^3.0.4",
|
||||
"lodash": "^4.17.21",
|
||||
"mime-types": "^2.1.27",
|
||||
@@ -66,6 +71,7 @@
|
||||
"nestjs-pino": "^4.1.0",
|
||||
"passport": "^0.6.0",
|
||||
"passport-headerapikey": "^1.2.2",
|
||||
"pg": "^8.13.1",
|
||||
"pino-http": "^10.2.0",
|
||||
"pino-pretty": "^11.2.1",
|
||||
"pretty-bytes": "5.6.0",
|
||||
@@ -79,7 +85,8 @@
|
||||
"sharp": "^0.33.4",
|
||||
"swagger-ui-express": "^4.1.4",
|
||||
"ulid": "^2.3.0",
|
||||
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-channels"
|
||||
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-channels",
|
||||
"write-file-atomic": "^6.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.8"
|
||||
@@ -88,9 +95,12 @@
|
||||
"bufferutil": "^4.0.8",
|
||||
"ws": "^8.18.0",
|
||||
"puppeteer": "^23.6.0",
|
||||
"whatwg-url": "13.0.0"
|
||||
"whatwg-url": "13.0.0",
|
||||
"libsignal": "github:devlikeapro/libsignal-node#fork-master"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@grpc/grpc-js": "^1.12.4",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@nestjs/cli": "^9.0.0",
|
||||
"@nestjs/schematics": "^9.0.1",
|
||||
"@nestjs/testing": "^9.0.9",
|
||||
@@ -104,14 +114,17 @@
|
||||
"eslint-config-prettier": "^6.10.0",
|
||||
"eslint-plugin-import": "^2.20.1",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"grpc-tools": "^1.12.4",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^1.19.1",
|
||||
"protoc-gen-ts": "^0.8.7",
|
||||
"supertest": "^4.0.2",
|
||||
"ts-jest": "^29.1.3",
|
||||
"ts-loader": "^6.2.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.1.0",
|
||||
"typescript": "^4.8.4"
|
||||
"typescript": "^4.8.4",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require('path');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const axios = require('axios');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { execSync } = require('child_process');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const yargs = require('yargs');
|
||||
|
||||
// Defaults
|
||||
const CONFIG_FILE = 'waha.config.json';
|
||||
// Load defaults from package.json
|
||||
gows = (() => {
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
return config.waha.gows || {};
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
|
||||
const DEFAULT_REPO = gows.repo;
|
||||
if (!DEFAULT_REPO) {
|
||||
throw new Error(`Missing default repo in ${CONFIG_FILE}`);
|
||||
}
|
||||
const DEFAULT_REF = gows.ref;
|
||||
if (!DEFAULT_REF) {
|
||||
throw new Error('Missing default ref in ${CONFIG_FILE}');
|
||||
}
|
||||
const DEFAULT_DIR = './src/core/engines/gows/proto';
|
||||
|
||||
const PROTO_FILES = ['gows.proto'];
|
||||
const PROTO_OUTPUT = './src/core/engines/gows/grpc';
|
||||
|
||||
// Helper function to clean directory
|
||||
function cleanDirectory(directory, suffix) {
|
||||
if (!fs.existsSync(directory)) {
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(directory);
|
||||
for (const file of files) {
|
||||
const filePath = path.join(directory, file);
|
||||
if (file.endsWith(suffix)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to download files
|
||||
async function downloadFiles(repo, ref, directory) {
|
||||
for (const file of PROTO_FILES) {
|
||||
const url = `https://raw.githubusercontent.com/${repo}/${ref}/proto/${file}`;
|
||||
const filePath = path.join(directory, file);
|
||||
try {
|
||||
const response = await axios.get(url, { responseType: 'arraybuffer' });
|
||||
fs.writeFileSync(filePath, response.data);
|
||||
console.log(`Downloaded: ${file}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to download ${file}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for fetch command
|
||||
async function handleFetch(repo, ref, dir) {
|
||||
console.log(`Fetching .proto files from ${repo}@${ref} to ${dir}...`);
|
||||
cleanDirectory(dir, '.proto');
|
||||
await downloadFiles(repo, ref, dir);
|
||||
}
|
||||
|
||||
// Handler for build command
|
||||
function handleBuild(dir) {
|
||||
console.log('Building gRPC files...');
|
||||
cleanDirectory(PROTO_OUTPUT);
|
||||
|
||||
const command = `grpc_tools_node_protoc \
|
||||
--plugin=protoc-gen-ts=node_modules/.bin/protoc-gen-ts \
|
||||
--plugin=protoc-gen-grpc=node_modules/.bin/grpc_tools_node_protoc_plugin \
|
||||
--js_out=import_style=commonjs,binary:${PROTO_OUTPUT} \
|
||||
--grpc_out=grpc_js:${PROTO_OUTPUT} \
|
||||
--ts_out=grpc_js:${PROTO_OUTPUT} \
|
||||
-I ${dir} ${dir}/gows.proto`;
|
||||
|
||||
try {
|
||||
execSync(command, { stdio: 'inherit' });
|
||||
console.log('gRPC files built successfully.');
|
||||
} catch (error) {
|
||||
console.error(`Failed to build gRPC files: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Commands
|
||||
//
|
||||
|
||||
// fetch
|
||||
yargs.command(
|
||||
'fetch',
|
||||
'Fetch .proto files from GitHub',
|
||||
(yargs) => {
|
||||
yargs
|
||||
.option('repo', {
|
||||
describe: 'GitHub repository (owner/repo)',
|
||||
type: 'string',
|
||||
default: DEFAULT_REPO,
|
||||
})
|
||||
.option('ref', {
|
||||
describe: 'Git reference (branch or commit SHA)',
|
||||
type: 'string',
|
||||
default: DEFAULT_REF,
|
||||
})
|
||||
.option('dir', {
|
||||
describe: 'Directory to output .proto files',
|
||||
type: 'string',
|
||||
default: DEFAULT_DIR,
|
||||
});
|
||||
},
|
||||
async (argv) => {
|
||||
await handleFetch(argv.repo, argv.ref, argv.dir);
|
||||
},
|
||||
);
|
||||
|
||||
// build
|
||||
yargs.command(
|
||||
'build',
|
||||
'Build gRPC files from .proto files',
|
||||
(yargs) => {
|
||||
yargs.option('dir', {
|
||||
describe: 'Directory containing .proto files',
|
||||
type: 'string',
|
||||
default: DEFAULT_DIR,
|
||||
});
|
||||
},
|
||||
(argv) => {
|
||||
handleBuild(argv.dir);
|
||||
},
|
||||
);
|
||||
|
||||
// Parse arguments
|
||||
yargs.parse();
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
echo "Starting WAHA..."
|
||||
|
||||
docker run -d \
|
||||
--restart=always \
|
||||
--name waha \
|
||||
-p 3000:3000/tcp \
|
||||
-v ./.sessions:/app/.sessions \
|
||||
-v ./.media:/app/.media \
|
||||
--env WHATSAPP_API_KEY=321 \
|
||||
--env WAHA_DASHBOARD_USERNAME=waha \
|
||||
--env WAHA_DASHBOARD_PASSWORD=waha \
|
||||
--env WAHA_LOG_FORMAT=JSON \
|
||||
--env WAHA_LOG_LEVEL=info \
|
||||
--env WHATSAPP_DEFAULT_ENGINE=WEBJS \
|
||||
--env WAHA_PRINT_QR=False \
|
||||
--env WHATSAPP_FILES_LIFETIME=0 \
|
||||
--env WHATSAPP_FILES_FOLDER=/app/.media \
|
||||
devlikeapro/waha-plus:latest
|
||||
|
||||
echo "WAHA started."
|
||||
@@ -1,21 +0,0 @@
|
||||
set -e
|
||||
echo "Updating WAHA..."
|
||||
echo "Logging in to Docker..."
|
||||
docker login -u devlikeapro # -p {KEY} - you can hard code key here https://portal.devlike.pro/
|
||||
|
||||
echo "Pulling the latest image..."
|
||||
docker pull devlikeapro/waha-plus
|
||||
|
||||
echo "Logging out of Docker..."
|
||||
docker logout
|
||||
|
||||
# Just pull if you're using WAHA Core
|
||||
# docker pull devlikeapro/waha
|
||||
|
||||
echo "Stopping the current container..."
|
||||
docker stop waha || true
|
||||
echo "Removing the current container..."
|
||||
docker rm waha || true
|
||||
|
||||
echo "Running WAHA..."
|
||||
sh ./waha-run.sh
|
||||
@@ -21,12 +21,15 @@ import {
|
||||
|
||||
import { SessionManager } from '../core/abc/manager.abc';
|
||||
import { WhatsappSession } from '../core/abc/session.abc';
|
||||
import { parseBool } from '../helpers';
|
||||
import {
|
||||
ChatPictureQuery,
|
||||
ChatPictureResponse,
|
||||
ChatsPaginationParams,
|
||||
ChatSummary,
|
||||
GetChatMessageQuery,
|
||||
GetChatMessagesFilter,
|
||||
GetChatMessagesQuery,
|
||||
OverviewPaginationParams,
|
||||
PinMessageRequest,
|
||||
} from '../structures/chats.dto';
|
||||
import { EditMessageRequest } from '../structures/chatting.dto';
|
||||
@@ -48,6 +51,20 @@ class ChatsController {
|
||||
return session.getChats(pagination);
|
||||
}
|
||||
|
||||
@Get('overview')
|
||||
@SessionApiParam
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Get chats overview. Includes all necessary things to build UI "your chats overview" page - chat id, name, picture, last message. Sorting by last message timestamp',
|
||||
})
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
getChatsOverview(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Query() pagination: OverviewPaginationParams,
|
||||
): Promise<ChatSummary[]> {
|
||||
return session.getChatsOverview(pagination);
|
||||
}
|
||||
|
||||
@Delete(':chatId')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Deletes the chat' })
|
||||
@@ -59,6 +76,19 @@ class ChatsController {
|
||||
return session.deleteChat(chatId);
|
||||
}
|
||||
|
||||
@Get(':chatId/picture')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Gets chat picture' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async getChatPicture(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Param('chatId') chatId: string,
|
||||
@Query() query: ChatPictureQuery,
|
||||
): Promise<ChatPictureResponse> {
|
||||
const url = await session.getContactProfilePicture(chatId, query.refresh);
|
||||
return { url: url };
|
||||
}
|
||||
|
||||
@Get(':chatId/messages')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Gets messages in the chat' })
|
||||
|
||||
@@ -171,15 +171,10 @@ export class ChattingController {
|
||||
return whatsapp.sendLinkPreview(request);
|
||||
}
|
||||
|
||||
@Get('/messages')
|
||||
@ApiOperation({ summary: 'Get messages in a chat' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async getMessages(
|
||||
@Query() query: GetMessageQuery,
|
||||
@Query() filter: GetChatMessagesFilter,
|
||||
) {
|
||||
const whatsapp = await this.manager.getWorkingSession(query.session);
|
||||
return whatsapp.getChatMessages(query.chatId, query, filter);
|
||||
@Post('/sendContactVcard')
|
||||
async sendContactVcard(@Body() request: MessageContactVcardRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendContactVCard(request);
|
||||
}
|
||||
|
||||
@Get('/sendText')
|
||||
@@ -192,10 +187,19 @@ export class ChattingController {
|
||||
return whatsapp.sendText(msg);
|
||||
}
|
||||
|
||||
@Post('/sendContactVcard')
|
||||
async sendContactVcard(@Body() request: MessageContactVcardRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendContactVCard(request);
|
||||
@Get('/messages')
|
||||
@ApiOperation({
|
||||
summary: 'Get messages in a chat',
|
||||
description: 'DEPRECATED. Use "GET /api/chats/{id}/messages" instead',
|
||||
deprecated: true,
|
||||
})
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async getMessages(
|
||||
@Query() query: GetMessageQuery,
|
||||
@Query() filter: GetChatMessagesFilter,
|
||||
) {
|
||||
const whatsapp = await this.manager.getWorkingSession(query.session);
|
||||
return whatsapp.getChatMessages(query.chatId, query, filter);
|
||||
}
|
||||
|
||||
@Get('/checkNumberStatus')
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
WANumberExistResult,
|
||||
} from '../structures/chatting.dto';
|
||||
import {
|
||||
ContactProfilePictureQuery,
|
||||
ContactQuery,
|
||||
ContactRequest,
|
||||
ContactsPaginationParams,
|
||||
@@ -75,9 +76,14 @@ export class ContactsController {
|
||||
description:
|
||||
'If privacy settings do not allow to get the picture, the method will return null.',
|
||||
})
|
||||
async getProfilePicture(@Query() query: ContactQuery) {
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async getProfilePicture(@Query() query: ContactProfilePictureQuery) {
|
||||
const whatsapp = await this.manager.getWorkingSession(query.session);
|
||||
return whatsapp.getContactProfilePicture(query);
|
||||
const url = await whatsapp.getContactProfilePicture(
|
||||
query.contactId,
|
||||
query.refresh,
|
||||
);
|
||||
return { profilePictureURL: url };
|
||||
}
|
||||
|
||||
@Post('/block')
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { GroupIdApiParam } from '@waha/nestjs/params/ChatIdApiParam';
|
||||
@@ -19,6 +22,9 @@ import { WhatsappSession } from '../core/abc/session.abc';
|
||||
import {
|
||||
CreateGroupRequest,
|
||||
DescriptionRequest,
|
||||
GroupsPaginationParams,
|
||||
JoinGroupRequest,
|
||||
JoinGroupResponse,
|
||||
ParticipantsRequest,
|
||||
SettingsSecurityChangeInfo,
|
||||
SubjectRequest,
|
||||
@@ -40,11 +46,48 @@ export class GroupsController {
|
||||
return session.createGroup(request);
|
||||
}
|
||||
|
||||
@Get('join-info')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Get info about the group before joining.' })
|
||||
async joinInfoGroup(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Query() query: JoinGroupRequest,
|
||||
): Promise<any> {
|
||||
// https://chat.whatsapp.com/123 => 123
|
||||
const code = query.code.split('/').pop();
|
||||
return session.joinInfoGroup(code);
|
||||
}
|
||||
|
||||
@Post('join')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Join group via code' })
|
||||
async joinGroup(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Body() request: JoinGroupRequest,
|
||||
): Promise<JoinGroupResponse> {
|
||||
// https://chat.whatsapp.com/123 => 123
|
||||
const code = request.code.split('/').pop();
|
||||
const id = await session.joinGroup(code);
|
||||
return { id: id };
|
||||
}
|
||||
|
||||
@Get('')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Get all groups.' })
|
||||
getGroups(@WorkingSessionParam session: WhatsappSession) {
|
||||
return session.getGroups();
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
getGroups(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Query() pagination: GroupsPaginationParams,
|
||||
) {
|
||||
return session.getGroups(pagination);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Refresh groups from the server.' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async refreshGroups(@WorkingSessionParam session: WhatsappSession) {
|
||||
return { success: await session.refreshGroups() };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -134,6 +134,13 @@ export class WhatsappConfigService {
|
||||
return this.configService.get('WHATSAPP_SESSIONS_MONGO_URL', undefined);
|
||||
}
|
||||
|
||||
getSessionPostgresUrl(): string | undefined {
|
||||
return this.configService.get(
|
||||
'WHATSAPP_SESSIONS_POSTGRESQL_URL',
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
get(name: string, defaultValue: any = undefined): any {
|
||||
return this.configService.get(name, defaultValue);
|
||||
}
|
||||
|
||||
+8
-5
@@ -1,13 +1,16 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const QRCode = require('qrcode');
|
||||
|
||||
export class QR {
|
||||
private base64: string;
|
||||
public raw?: string;
|
||||
|
||||
save(base64: string, raw?: string) {
|
||||
this.base64 = base64.replace(/^data:image\/png;base64,/, '');
|
||||
save(raw?: string) {
|
||||
this.raw = raw;
|
||||
}
|
||||
|
||||
get(): Buffer {
|
||||
return Buffer.from(this.base64, 'base64');
|
||||
async get(): Promise<Buffer> {
|
||||
const url = await QRCode.toDataURL(this.raw);
|
||||
const base64 = url.replace(/^data:image\/png;base64,/, '');
|
||||
return Buffer.from(base64, 'base64');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface EngineBootstrap {
|
||||
bootstrap(): Promise<void>;
|
||||
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
export class NoopEngineBootstrap implements EngineBootstrap {
|
||||
async bootstrap(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import {
|
||||
BeforeApplicationShutdown,
|
||||
OnApplicationBootstrap,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { WhatsappConfigService } from '@waha/config.service';
|
||||
import {
|
||||
EngineBootstrap,
|
||||
NoopEngineBootstrap,
|
||||
} from '@waha/core/abc/EngineBootstrap';
|
||||
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
|
||||
import { GowsBootstrap } from '@waha/core/engines/gows/GowsBootstrap';
|
||||
import { ISessionMeRepository } from '@waha/core/storage/ISessionMeRepository';
|
||||
import { ISessionWorkerRepository } from '@waha/core/storage/ISessionWorkerRepository';
|
||||
import { WAHAWebhook } from '@waha/structures/webhooks.dto';
|
||||
@@ -29,7 +36,9 @@ import { WhatsappSession } from './session.abc';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const AsyncLock = require('async-lock');
|
||||
|
||||
export abstract class SessionManager implements BeforeApplicationShutdown {
|
||||
export abstract class SessionManager
|
||||
implements BeforeApplicationShutdown, OnApplicationBootstrap
|
||||
{
|
||||
public store: any;
|
||||
public sessionAuthRepository: ISessionAuthRepository;
|
||||
public sessionConfigRepository: ISessionConfigRepository;
|
||||
@@ -39,12 +48,17 @@ export abstract class SessionManager implements BeforeApplicationShutdown {
|
||||
|
||||
WAIT_STATUS_INTERVAL = 500;
|
||||
WAIT_STATUS_TIMEOUT = 5_000;
|
||||
LOCK_TIMEOUT = 10_000;
|
||||
|
||||
protected constructor(
|
||||
protected config: WhatsappConfigService,
|
||||
protected log: PinoLogger,
|
||||
protected config: WhatsappConfigService,
|
||||
protected gowsConfigService: GowsEngineConfigService,
|
||||
) {
|
||||
this.lock = new AsyncLock({ maxPending: Infinity });
|
||||
this.lock = new AsyncLock({
|
||||
maxPending: Infinity,
|
||||
timeout: this.LOCK_TIMEOUT,
|
||||
});
|
||||
this.log.setContext(SessionManager.name);
|
||||
}
|
||||
|
||||
@@ -156,7 +170,21 @@ export abstract class SessionManager implements BeforeApplicationShutdown {
|
||||
beforeApplicationShutdown(signal?: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
onApplicationBootstrap(): any {
|
||||
return;
|
||||
}
|
||||
|
||||
protected getEngineBootstrap(engine: WAHAEngine): EngineBootstrap {
|
||||
const logger = this.log.logger.child({ engine: engine.toLowerCase() });
|
||||
if (engine === WAHAEngine.GOWS) {
|
||||
const config = this.gowsConfigService.getBootstrapConfig();
|
||||
return new GowsBootstrap(logger, config);
|
||||
}
|
||||
return new NoopEngineBootstrap();
|
||||
}
|
||||
}
|
||||
|
||||
export function populateSessionInfo(
|
||||
event: WAHAEvents,
|
||||
session: WhatsappSession,
|
||||
|
||||
+63
-11
@@ -4,6 +4,7 @@ import {
|
||||
ListChannelsQuery,
|
||||
} from '@waha/structures/channels.dto';
|
||||
import {
|
||||
ChatSummary,
|
||||
GetChatMessageQuery,
|
||||
GetChatMessagesFilter,
|
||||
GetChatMessagesQuery,
|
||||
@@ -19,6 +20,8 @@ import { complete } from '@waha/utils/reactive/complete';
|
||||
import { SwitchObservable } from '@waha/utils/reactive/SwitchObservable';
|
||||
import * as fs from 'fs';
|
||||
import * as lodash from 'lodash';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import * as NodeCache from 'node-cache';
|
||||
import { Logger } from 'pino';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
@@ -51,6 +54,7 @@ import {
|
||||
MessageTextRequest,
|
||||
MessageVideoRequest,
|
||||
MessageVoiceRequest,
|
||||
SendSeenRequest,
|
||||
} from '../../structures/chatting.dto';
|
||||
import { ContactQuery, ContactRequest } from '../../structures/contacts.dto';
|
||||
import {
|
||||
@@ -61,6 +65,7 @@ import {
|
||||
} from '../../structures/enums.dto';
|
||||
import {
|
||||
CreateGroupRequest,
|
||||
GroupsPaginationParams,
|
||||
ParticipantsRequest,
|
||||
SettingsSecurityChangeInfo,
|
||||
} from '../../structures/groups.dto';
|
||||
@@ -131,6 +136,9 @@ export abstract class WhatsappSession {
|
||||
private shouldPrintQR: boolean;
|
||||
protected events2: DefaultMap<WAHAEvents, SwitchObservable<any>>;
|
||||
private status$: Subject<WAHASessionStatus>;
|
||||
protected profilePictures: NodeCache = new NodeCache({
|
||||
stdTTL: 24 * 60 * 60, // 1 day
|
||||
});
|
||||
|
||||
public constructor({
|
||||
name,
|
||||
@@ -234,12 +242,13 @@ export abstract class WhatsappSession {
|
||||
// References:
|
||||
// https://github.com/pedroslopez/whatsapp-web.js/issues/1420
|
||||
// https://github.com/wppconnect-team/wppconnect/issues/1326
|
||||
// https://superuser.com/questions/654565/how-to-run-google-chrome-in-a-single-process
|
||||
// https://www.bannerbear.com/blog/ways-to-speed-up-puppeteer-screenshots/
|
||||
return [
|
||||
'--aggressive-cache-discard',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--disable-application-cache',
|
||||
'--disable-background-networking',
|
||||
// '--disable-background-networking', // COMMENTED to test WEBJS stability
|
||||
// DO NOT disable software rasterizer, it will break the video
|
||||
// https://github.com/devlikeapro/waha/issues/629
|
||||
// '--disable-software-rasterizer',
|
||||
@@ -249,13 +258,13 @@ export abstract class WhatsappSession {
|
||||
'--disable-default-apps',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-extensions',
|
||||
'--disable-features=site-per-process',
|
||||
'--disable-gpu',
|
||||
// '--disable-features=site-per-process', // COMMENTED to test WEBJS stability
|
||||
//'--disable-gpu', // COMMENTED to test WEBJS stability
|
||||
'--disable-offer-store-unmasked-wallet-cards',
|
||||
'--disable-offline-load-stale-cache',
|
||||
'--disable-popup-blocking',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-site-isolation-trials', // https://superuser.com/questions/654565/how-to-run-google-chrome-in-a-single-process
|
||||
'--disable-site-isolation-trials',
|
||||
'--disable-speech-api',
|
||||
'--disable-sync',
|
||||
'--disable-translate',
|
||||
@@ -264,7 +273,8 @@ export abstract class WhatsappSession {
|
||||
'--hide-scrollbars',
|
||||
'--ignore-certificate-errors',
|
||||
'--ignore-ssl-errors',
|
||||
'--in-process-gpu', // https://superuser.com/questions/654565/how-to-run-google-chrome-in-a-single-process
|
||||
// https://github.com/devlikeapro/waha/issues/725
|
||||
// '--in-process-gpu', // COMMENTED to test WEBJS stability
|
||||
'--metrics-recording-only',
|
||||
'--mute-audio',
|
||||
'--no-default-browser-check',
|
||||
@@ -273,7 +283,7 @@ export abstract class WhatsappSession {
|
||||
'--no-sandbox',
|
||||
'--no-zygote',
|
||||
'--password-store=basic',
|
||||
'--renderer-process-limit=2', // https://superuser.com/questions/654565/how-to-run-google-chrome-in-a-single-process
|
||||
// '--renderer-process-limit=2', // COMMENTED to test WEBJS stability
|
||||
'--safebrowsing-disable-auto-update',
|
||||
// '--single-process',
|
||||
'--use-mock-keychain',
|
||||
@@ -373,7 +383,7 @@ export abstract class WhatsappSession {
|
||||
|
||||
abstract reply(request: MessageReplyRequest);
|
||||
|
||||
abstract sendSeen(chat: ChatRequest);
|
||||
abstract sendSeen(chat: SendSeenRequest);
|
||||
|
||||
abstract startTyping(chat: ChatRequest);
|
||||
|
||||
@@ -392,6 +402,12 @@ export abstract class WhatsappSession {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public getChatsOverview(
|
||||
pagination: PaginationParams,
|
||||
): Promise<ChatSummary[]> {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public deleteChat(chatId) {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
@@ -503,8 +519,32 @@ export abstract class WhatsappSession {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public getContactProfilePicture(query: ContactQuery) {
|
||||
throw new NotImplementedByEngineError();
|
||||
/**
|
||||
* Fetch the latest profile picture of the contact (group, newsletter, etc.)
|
||||
* @param id
|
||||
*/
|
||||
abstract fetchContactProfilePicture(id: string): Promise<string | null>;
|
||||
|
||||
public async getContactProfilePicture(
|
||||
id: string,
|
||||
refresh: boolean,
|
||||
): Promise<string | null> {
|
||||
const has: boolean = this.profilePictures.has(id);
|
||||
if (!has || refresh) {
|
||||
await this.refreshProfilePicture(id);
|
||||
}
|
||||
return this.profilePictures.get(id) || null;
|
||||
}
|
||||
|
||||
protected async refreshProfilePicture(id: string) {
|
||||
this.profilePictures.del(id);
|
||||
const url = await this.fetchContactProfilePicture(id).catch((err) => {
|
||||
this.logger.warn('Error fetching profile picture');
|
||||
this.logger.warn(err, err.stack);
|
||||
return null;
|
||||
});
|
||||
this.profilePictures.set(id, url);
|
||||
return url;
|
||||
}
|
||||
|
||||
public blockContact(request: ContactRequest) {
|
||||
@@ -522,7 +562,19 @@ export abstract class WhatsappSession {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public getGroups() {
|
||||
public joinGroup(code: string): Promise<string> {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public joinInfoGroup(code: string): Promise<any> {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public getGroups(pagination: PaginationParams) {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public refreshGroups(): Promise<boolean> {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
@@ -602,7 +654,7 @@ export abstract class WhatsappSession {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public subscribePresence(id: string): Promise<void> {
|
||||
public subscribePresence(id: string): Promise<any> {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export class WebsocketGatewayCore
|
||||
OnGatewayDisconnect,
|
||||
BeforeApplicationShutdown
|
||||
{
|
||||
HEARTBEAT_INTERVAL = 10_000;
|
||||
HEARTBEAT_INTERVAL = 60_000;
|
||||
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
@@ -65,7 +65,12 @@ export class WebsocketGatewayCore
|
||||
.getSessionEvents(session, events)
|
||||
.subscribe((data) => {
|
||||
this.logger.debug(`Sending data to client, event.id: ${data.id}`, data);
|
||||
socket.send(JSON.stringify(data));
|
||||
socket.send(JSON.stringify(data), (err) => {
|
||||
if (!err) {
|
||||
return;
|
||||
}
|
||||
this.logger.error(`Error sending data to client: ${err}`);
|
||||
});
|
||||
});
|
||||
socket.on('close', () => {
|
||||
this.logger.debug(`Client disconnected - ${socket.id}`);
|
||||
@@ -91,17 +96,25 @@ export class WebsocketGatewayCore
|
||||
|
||||
async beforeApplicationShutdown(signal?: string) {
|
||||
this.logger.log('Shutting down websocket server');
|
||||
this.heartbeat?.stop();
|
||||
|
||||
// Allow pending messages to be sent, it can be even 1ms, just to release the event loop
|
||||
await sleep(100);
|
||||
this.server?.clients.forEach((options, client) => {
|
||||
// @ts-ignore
|
||||
this.logger.debug(`Closing client connection - ${client.id}...`);
|
||||
client.close(1001, 'Server is shutting down');
|
||||
});
|
||||
// Do not turn off heartbeat service here,
|
||||
// it's responsible for terminating the connection that is not alive
|
||||
// Close clients and server
|
||||
await this.close(this.server);
|
||||
this.logger.log('Websocket server is down');
|
||||
this.heartbeat?.stop();
|
||||
}
|
||||
|
||||
// Cherry-pick from nestjs new version
|
||||
// https://github.com/nestjs/nest/pull/13531/files
|
||||
private async close(server: any) {
|
||||
const closeEventSignal = new Promise((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve(undefined))),
|
||||
);
|
||||
for (const ws of server.clients) {
|
||||
ws.terminate();
|
||||
}
|
||||
await closeEventSignal;
|
||||
}
|
||||
|
||||
afterInit(server: Server) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ServerDebugController,
|
||||
} from '@waha/api/server.controller';
|
||||
import { WebsocketGatewayCore } from '@waha/core/api/websocket.gateway.core';
|
||||
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
|
||||
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';
|
||||
@@ -106,7 +107,9 @@ export const IMPORTS_CORE = [
|
||||
const IMPORTS_MEDIA = [
|
||||
ConfigModule.forRoot({
|
||||
validationSchema: Joi.object({
|
||||
WAHA_MEDIA_STORAGE: Joi.string().valid('LOCAL', 'S3').default('LOCAL'),
|
||||
WAHA_MEDIA_STORAGE: Joi.string()
|
||||
.valid('LOCAL', 'S3', 'POSTGRESQL')
|
||||
.default('LOCAL'),
|
||||
}),
|
||||
}),
|
||||
MediaLocalStorageModule,
|
||||
@@ -148,6 +151,7 @@ const PROVIDERS = [
|
||||
DashboardConfigServiceCore,
|
||||
SwaggerConfigServiceCore,
|
||||
WebJSEngineConfigService,
|
||||
GowsEngineConfigService,
|
||||
WhatsappConfigService,
|
||||
EngineConfigService,
|
||||
WebsocketGatewayCore,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { BootstrapConfig } from '@waha/core/engines/gows/GowsBootstrap';
|
||||
import { GowsConfig } from '@waha/core/engines/gows/session.gows.core';
|
||||
|
||||
@Injectable()
|
||||
export class GowsEngineConfigService {
|
||||
constructor(protected configService: ConfigService) {}
|
||||
|
||||
getBootstrapConfig(): BootstrapConfig {
|
||||
return {
|
||||
path: this.configService.get('WAHA_GOWS_PATH'),
|
||||
socket: this.getSocket(),
|
||||
};
|
||||
}
|
||||
|
||||
getSocket() {
|
||||
return this.configService.get('WAHA_GOWS_SOCKET', '/tmp/gows.sock');
|
||||
}
|
||||
|
||||
getConfig(): GowsConfig {
|
||||
return {
|
||||
connection: 'unix:' + this.getSocket(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum Jid {
|
||||
BROADCAST = 'status@broadcast',
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
|
||||
interface EventValue<T> {
|
||||
event: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export class EventsFromObservable<Events extends string> extends EventEmitter {
|
||||
private subscription: Subscription;
|
||||
|
||||
constructor(private observable: Observable<EventValue<any>>) {
|
||||
super();
|
||||
}
|
||||
|
||||
on(event: Events, listener: (...args: any[]) => void): this {
|
||||
return super.on(event as string, listener);
|
||||
}
|
||||
|
||||
start() {
|
||||
this.subscription = this.observable.subscribe({
|
||||
next: (data) => this.emit(data.event, data.data),
|
||||
complete: () => this.removeAllListeners(),
|
||||
error: (err) => null,
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { EngineBootstrap } from '@waha/core/abc/EngineBootstrap';
|
||||
import { GowsSubprocess } from '@waha/core/engines/gows/GowsSubprocess';
|
||||
import { promises as fs } from 'fs';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
export async function isUnixSocket(socketPath: string): Promise<boolean> {
|
||||
try {
|
||||
// Get the file stats
|
||||
const stats = await fs.lstat(socketPath);
|
||||
// Check if the path is a socket
|
||||
return stats.isSocket();
|
||||
} catch (error) {
|
||||
// Handle errors (e.g., file does not exist)
|
||||
if (error.code === 'ENOENT') {
|
||||
return false; // Path does not exist
|
||||
}
|
||||
// Re-throw other errors for debugging purposes
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BootstrapConfig {
|
||||
path: string;
|
||||
socket: string;
|
||||
}
|
||||
|
||||
export class GowsBootstrap implements EngineBootstrap {
|
||||
private gows: GowsSubprocess;
|
||||
|
||||
constructor(
|
||||
private logger: Logger,
|
||||
private config: BootstrapConfig,
|
||||
) {}
|
||||
|
||||
async bootstrap(): Promise<void> {
|
||||
if (!this.config.path) {
|
||||
this.logger.warn('GOWS path is not set, skipping GOWS initialization.');
|
||||
this.logger.warn('Make sure to run GOWS manually.');
|
||||
this.checkSocket(this.config.socket);
|
||||
return;
|
||||
}
|
||||
|
||||
this.gows = new GowsSubprocess(
|
||||
this.logger,
|
||||
this.config.path,
|
||||
this.config.socket,
|
||||
);
|
||||
this.gows.start(() => {
|
||||
this.logger.info(`GOWS stopped, exiting...`);
|
||||
process.kill(process.pid, 'SIGTERM');
|
||||
});
|
||||
await this.gows.waitWhenReady(10_000);
|
||||
await this.checkSocket(this.config.socket);
|
||||
return;
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.gows) {
|
||||
await this.gows.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the provided path exists and is a socket
|
||||
* @param path
|
||||
*/
|
||||
async checkSocket(path: string) {
|
||||
if (!(await isUnixSocket(path))) {
|
||||
throw new Error(`Invalid socket path: ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import * as grpc from '@grpc/grpc-js';
|
||||
import { messages } from '@waha/core/engines/gows/grpc/gows';
|
||||
import { EnginePayload } from '@waha/structures/webhooks.dto';
|
||||
import { sleep } from '@waha/utils/promiseTimeout';
|
||||
import { Logger } from 'pino';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Observable that listens to a gRPC stream and emits EnginePayload objects.
|
||||
* Pass a factory function that returns a client and a stream.
|
||||
*/
|
||||
export class GowsEventStreamObservable extends Observable<EnginePayload> {
|
||||
_client: grpc.Client;
|
||||
CLIENT_CLOSE_TIMEOUT = 1_000;
|
||||
|
||||
constructor(
|
||||
private logger: Logger,
|
||||
factory: () => {
|
||||
client: grpc.Client;
|
||||
stream: grpc.ClientReadableStream<messages.EventJson>;
|
||||
},
|
||||
) {
|
||||
super((subscriber) => {
|
||||
this.logger.debug('Creating grpc client and stream...');
|
||||
const { client, stream } = factory();
|
||||
this._client = client;
|
||||
|
||||
stream.on('data', (raw) => {
|
||||
const obj = raw.toObject();
|
||||
obj.data = JSON.parse(obj.data);
|
||||
subscriber?.next(obj);
|
||||
});
|
||||
|
||||
stream.on('end', (...args) => {
|
||||
this.logger.debug('Stream ended', args);
|
||||
subscriber?.complete();
|
||||
subscriber = null;
|
||||
});
|
||||
|
||||
stream.on('error', async (err: any) => {
|
||||
const CLIENT_CANCELLED_CODE = grpc.status.CANCELLED;
|
||||
if (err.code === CLIENT_CANCELLED_CODE) {
|
||||
this.logger.debug('Stream cancelled by client');
|
||||
return;
|
||||
}
|
||||
this.logger.error(err, 'Stream error');
|
||||
// Give some time to node event loop to process the error
|
||||
await sleep(100);
|
||||
subscriber?.error(err);
|
||||
subscriber = null;
|
||||
});
|
||||
|
||||
return async () => {
|
||||
this.logger.debug('Closing stream client...');
|
||||
client.close();
|
||||
await sleep(this.CLIENT_CLOSE_TIMEOUT);
|
||||
this.logger.debug('Stream client closed');
|
||||
|
||||
this.logger.debug('Cancelling stream...');
|
||||
stream.cancel();
|
||||
this.logger.debug('Stream cancelled');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get client(): Omit<grpc.Client, 'close'> {
|
||||
return this._client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
|
||||
import { spawn } from 'child_process';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
export class GowsSubprocess {
|
||||
private checkIntervalMs: number = 100;
|
||||
private readyDelayMs: number = 1_000;
|
||||
private readyText = 'gRPC server started!';
|
||||
|
||||
private child: any;
|
||||
private ready: boolean = false;
|
||||
|
||||
constructor(
|
||||
private logger: Logger,
|
||||
readonly path: string,
|
||||
readonly socket: string,
|
||||
) {}
|
||||
|
||||
start(onExit: (code: number) => void) {
|
||||
this.logger.info('Starting GOWS subprocess...');
|
||||
this.logger.debug(`GOWS path '${this.path}', socket: '${this.socket}'...`);
|
||||
|
||||
this.child = spawn(this.path, ['--socket', this.socket], {
|
||||
detached: true,
|
||||
});
|
||||
this.logger.debug(`GOWS started with PID: ${this.child.pid}`);
|
||||
this.child.on('close', async (code, singal) => {
|
||||
const msg = code
|
||||
? `GOWS subprocess closed with code ${code}`
|
||||
: `GOWS subprocess closed by signal ${singal}`;
|
||||
this.logger.debug(msg);
|
||||
onExit(code);
|
||||
});
|
||||
this.child.on('error', (err) => {
|
||||
this.logger.error(`GOWS subprocess error: ${err}`);
|
||||
});
|
||||
|
||||
this.child.stderr.setEncoding('utf8');
|
||||
this.child.stderr.on('data', (data) => {
|
||||
this.logger.error(data);
|
||||
});
|
||||
|
||||
this.child.stdout.setEncoding('utf8');
|
||||
this.child.stdout.on('data', async (data) => {
|
||||
// remove empty line at the end, split by \n
|
||||
const lines = data.trim().split('\n');
|
||||
lines.forEach((line) => this.log(line));
|
||||
});
|
||||
this.listenReady();
|
||||
}
|
||||
|
||||
listenReady() {
|
||||
this.child.stdout.on('data', async (data) => {
|
||||
if (this.ready) {
|
||||
return;
|
||||
}
|
||||
if (!data.includes(this.readyText)) {
|
||||
return;
|
||||
}
|
||||
await sleep(this.readyDelayMs);
|
||||
this.ready = true;
|
||||
this.logger.info('GOWS is ready');
|
||||
});
|
||||
}
|
||||
|
||||
async waitWhenReady(timeout: number) {
|
||||
const started = await waitUntil(
|
||||
async () => this.ready,
|
||||
this.checkIntervalMs,
|
||||
timeout,
|
||||
);
|
||||
if (!started) {
|
||||
const msg = 'GOWS did not start after 10 seconds';
|
||||
this.logger.error(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.logger.info('Stopping GOWS subprocess...');
|
||||
this.child?.kill('SIGTERM');
|
||||
this.logger.info('GOWS subprocess stopped');
|
||||
}
|
||||
|
||||
private log(msg) {
|
||||
if (msg.startsWith('ERROR | ')) {
|
||||
this.logger.error(msg.slice(8));
|
||||
} else if (msg.startsWith('WARN | ')) {
|
||||
this.logger.warn(msg.slice(7));
|
||||
} else if (msg.startsWith('INFO | ')) {
|
||||
this.logger.info(msg.slice(7));
|
||||
} else if (msg.startsWith('DEBUG | ')) {
|
||||
this.logger.debug(msg.slice(8));
|
||||
} else if (msg.startsWith('TRACE | ')) {
|
||||
this.logger.trace(msg.slice(8));
|
||||
} else {
|
||||
this.logger.info(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,4 @@
|
||||
export interface GowsAuth {
|
||||
address(): string;
|
||||
dialect(): string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { GowsAuthSimple } from '@waha/core/engines/gows/store/GowsAuthSimple';
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
|
||||
import { GowsAuth } from './GowsAuth';
|
||||
|
||||
export class GowsAuthFactoryCore {
|
||||
buildAuth(store: DataStore, name: string): Promise<GowsAuth> {
|
||||
if (store instanceof LocalStore) return this.buildSqlite3(store, name);
|
||||
throw new Error(`Unsupported store type '${store.constructor.name}'`);
|
||||
}
|
||||
|
||||
protected async buildSqlite3(
|
||||
store: LocalStore,
|
||||
name: string,
|
||||
): Promise<GowsAuth> {
|
||||
await store.init(name);
|
||||
const authFolder = store.getSessionDirectory(name);
|
||||
// resolve path
|
||||
const authFolderFullPath = path.resolve(authFolder);
|
||||
const connection = `file:${authFolderFullPath}/gows.db`;
|
||||
return new GowsAuthSimple(connection, 'sqlite3');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { GowsAuth } from '@waha/core/engines/gows/store/GowsAuth';
|
||||
|
||||
export class GowsAuthSimple implements GowsAuth {
|
||||
private _address: string;
|
||||
private _dialect: string;
|
||||
|
||||
constructor(address: string, dialect: string) {
|
||||
this._address = address;
|
||||
this._dialect = dialect;
|
||||
}
|
||||
|
||||
address() {
|
||||
return this._address;
|
||||
}
|
||||
|
||||
dialect(): string {
|
||||
return this._dialect;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface Presence {
|
||||
From: string;
|
||||
Unavailable: boolean;
|
||||
LastSeen: string;
|
||||
}
|
||||
|
||||
export enum ChatPresenceState {
|
||||
COMPOSING = 'composing',
|
||||
PAUSED = 'paused',
|
||||
}
|
||||
|
||||
export enum ChatPresenceMedia {
|
||||
TEXT = '',
|
||||
AUDIO = 'audio',
|
||||
}
|
||||
|
||||
export interface ChatPresence {
|
||||
Chat: string;
|
||||
Sender: string;
|
||||
IsFromMe: boolean;
|
||||
IsGroup: boolean;
|
||||
State: ChatPresenceState;
|
||||
Media: ChatPresenceMedia;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export class NowebAuthFactoryCore {
|
||||
protected async buildLocalAuth(store: LocalStore, name: string) {
|
||||
await store.init(name);
|
||||
const authFolder = store.getSessionDirectory(name);
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authFolder);
|
||||
return { state, saveCreds };
|
||||
const { state, saveCreds, close } = await useMultiFileAuthState(authFolder);
|
||||
return { state, saveCreds, close };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import makeWASocket, {
|
||||
Browsers,
|
||||
Chat,
|
||||
Contact,
|
||||
DisconnectReason,
|
||||
downloadMediaMessage,
|
||||
@@ -9,7 +10,9 @@ import makeWASocket, {
|
||||
getKeyAuthor,
|
||||
getUrlFromDirectPath,
|
||||
isJidGroup,
|
||||
isJidNewsletter,
|
||||
isJidStatusBroadcast,
|
||||
isJidUser,
|
||||
isRealMessage,
|
||||
jidNormalizedUser,
|
||||
makeCacheableSignalKeyStore,
|
||||
@@ -50,6 +53,7 @@ import {
|
||||
ListChannelsQuery,
|
||||
} from '@waha/structures/channels.dto';
|
||||
import {
|
||||
ChatSummary,
|
||||
GetChatMessageQuery,
|
||||
GetChatMessagesFilter,
|
||||
GetChatMessagesQuery,
|
||||
@@ -161,9 +165,6 @@ import { NowebPersistentStore } from './store/NowebPersistentStore';
|
||||
import { NowebStorageFactoryCore } from './store/NowebStorageFactoryCore';
|
||||
import { ensureNumber, extractMediaContent } from './utils';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const QRCode = require('qrcode');
|
||||
|
||||
export const BaileysEvents = {
|
||||
CONNECTION_UPDATE: 'connection.update',
|
||||
CREDS_UPDATE: 'creds.update',
|
||||
@@ -270,11 +271,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
return {
|
||||
agent: agent,
|
||||
fetchAgent: agent,
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
/** caching makes the store faster to send/recv messages */
|
||||
keys: makeCacheableSignalKeyStore(state.keys, this.engineLogger),
|
||||
},
|
||||
auth: state,
|
||||
printQRInTerminal: false,
|
||||
browser: browser,
|
||||
logger: this.engineLogger,
|
||||
@@ -291,10 +288,16 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
|
||||
async makeSocket(): Promise<any> {
|
||||
if (!this.authNOWEBStore) {
|
||||
this.authNOWEBStore = await this.authFactory.buildAuth(
|
||||
const store = await this.authFactory.buildAuth(
|
||||
this.sessionStore,
|
||||
this.name,
|
||||
);
|
||||
/** caching makes the store faster to send/recv messages */
|
||||
store.state.keys = makeCacheableSignalKeyStore(
|
||||
store.state.keys,
|
||||
this.engineLogger,
|
||||
);
|
||||
this.authNOWEBStore = store;
|
||||
}
|
||||
const { state, saveCreds } = this.authNOWEBStore;
|
||||
const agent = this.makeAgent();
|
||||
@@ -367,6 +370,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
this.connectStore();
|
||||
this.listenConnectionEvents();
|
||||
this.subscribeEngineEvents2();
|
||||
this.listenContactsUpdatePictureProfile();
|
||||
this.enableAutoRestart();
|
||||
}
|
||||
|
||||
@@ -451,8 +455,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
|
||||
// Save QR
|
||||
if (qr) {
|
||||
const url = await QRCode.toDataURL(qr);
|
||||
this.qr.save(url, qr);
|
||||
this.qr.save(qr);
|
||||
this.printQR(this.qr);
|
||||
this.status = WAHASessionStatus.SCAN_QR_CODE;
|
||||
}
|
||||
@@ -464,20 +467,25 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
this.startDelayedJob.cancel();
|
||||
this.autoRestartJob.stop();
|
||||
|
||||
if (this.authNOWEBStore && this.status == WAHASessionStatus.WORKING) {
|
||||
const hasCreds = this.authNOWEBStore?.state?.creds;
|
||||
if (hasCreds && this.status == WAHASessionStatus.WORKING) {
|
||||
this.logger.info('Saving creds before stopping...');
|
||||
await this.authNOWEBStore.saveCreds().catch((e) => {
|
||||
this.logger.error('Failed to save creds');
|
||||
this.logger.error(e, e.stack);
|
||||
});
|
||||
this.logger.info('Creds saved');
|
||||
this.authNOWEBStore = null;
|
||||
}
|
||||
this.status = WAHASessionStatus.STOPPED;
|
||||
this.stopEvents();
|
||||
|
||||
this.mediaManager.close();
|
||||
await this.end();
|
||||
await this.store?.close();
|
||||
this.authNOWEBStore?.close().catch((err) => {
|
||||
this.logger.error('Failed to close NOWEB auth store');
|
||||
this.logger.error(err, err.stack);
|
||||
});
|
||||
}
|
||||
|
||||
protected async failed() {
|
||||
@@ -624,7 +632,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
`The session is starting, please try again after few seconds`,
|
||||
);
|
||||
} else if (this.status === WAHASessionStatus.SCAN_QR_CODE) {
|
||||
return Promise.resolve(this.qr.get());
|
||||
return this.qr.get();
|
||||
} else if (this.status === WAHASessionStatus.WORKING) {
|
||||
throw new UnprocessableEntityException(
|
||||
`Can not get screenshot for non chrome based engine.`,
|
||||
@@ -899,12 +907,52 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
*/
|
||||
|
||||
async getChats(pagination: PaginationParams) {
|
||||
const chats = await this.store.getChats(pagination);
|
||||
const chats = await this.store.getChats(pagination, true);
|
||||
// Remove unreadCount, it's not ready yet
|
||||
chats.forEach((chat) => delete chat.unreadCount);
|
||||
return chats;
|
||||
}
|
||||
|
||||
public async getChatsOverview(
|
||||
pagination: PaginationParams,
|
||||
): Promise<ChatSummary[]> {
|
||||
const chats = await this.store.getChats(pagination, false);
|
||||
// Remove unreadCount, it's not ready yet
|
||||
chats.forEach((chat) => delete chat.unreadCount);
|
||||
|
||||
const promises = [];
|
||||
for (const chat of chats) {
|
||||
promises.push(this.fetchChatSummary(chat));
|
||||
}
|
||||
const result = await Promise.all(promises);
|
||||
return result;
|
||||
}
|
||||
|
||||
protected async fetchChatSummary(chat: Chat): Promise<ChatSummary> {
|
||||
const id = toCusFormat(chat.id);
|
||||
let name = chat.name;
|
||||
if (!name) {
|
||||
// Get name by contact
|
||||
const jid = toJID(chat.id);
|
||||
const contact = await this.store.getContactById(jid);
|
||||
name = contact?.name || contact?.notify;
|
||||
}
|
||||
const picture = await this.getContactProfilePicture(chat.id, false);
|
||||
const messages = await this.getChatMessages(
|
||||
chat.id,
|
||||
{ limit: 1, offset: 0, downloadMedia: false },
|
||||
{},
|
||||
);
|
||||
const message = messages.length > 0 ? messages[0] : null;
|
||||
return {
|
||||
id: id,
|
||||
name: name || null,
|
||||
picture: picture,
|
||||
lastMessage: message,
|
||||
_chat: chat,
|
||||
};
|
||||
}
|
||||
|
||||
protected async chatsPutArchive(
|
||||
chatId: string,
|
||||
archive: boolean,
|
||||
@@ -1060,10 +1108,26 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
throw new NotImplementedByEngineError();
|
||||
}
|
||||
|
||||
public async getContactProfilePicture(query: ContactQuery) {
|
||||
const contact = this.ensureSuffix(query.contactId);
|
||||
const url = await this.sock.profilePictureUrl(contact, 'image');
|
||||
return { profilePictureURL: url };
|
||||
public async fetchContactProfilePicture(id: string) {
|
||||
const contact = this.ensureSuffix(id);
|
||||
if (isJidNewsletter(id)) {
|
||||
return null;
|
||||
}
|
||||
if (isJidStatusBroadcast(id)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const url = await this.sock.profilePictureUrl(contact, 'image');
|
||||
return url;
|
||||
} catch (err) {
|
||||
if (err.message == 'item-not-found') {
|
||||
return null;
|
||||
}
|
||||
if (err.message == 'not-authorized') {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async blockContact(request: ContactRequest) {
|
||||
@@ -1082,12 +1146,28 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
return this.sock.groupCreate(request.name, participants);
|
||||
}
|
||||
|
||||
public async getGroups() {
|
||||
return await this.sock.groupFetchAllParticipating();
|
||||
public joinGroup(code: string) {
|
||||
return this.sock.groupAcceptInvite(code);
|
||||
}
|
||||
|
||||
public joinInfoGroup(code: string) {
|
||||
return this.sock.groupGetInviteInfo(code);
|
||||
}
|
||||
|
||||
public async getGroups(pagination: PaginationParams) {
|
||||
const groups = await this.store.getGroups(pagination);
|
||||
// return {id: group} mapping for backward compatability
|
||||
return lodash.keyBy(groups, 'id');
|
||||
}
|
||||
|
||||
public async refreshGroups(): Promise<boolean> {
|
||||
this.store.resetGroupsCache();
|
||||
await this.store.getGroups({});
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getGroup(id) {
|
||||
const groups = await this.sock.groupFetchAllParticipating();
|
||||
const groups = await this.getGroups({});
|
||||
return groups[id];
|
||||
}
|
||||
|
||||
@@ -1168,11 +1248,17 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
if (!(remoteJid in this.store.presences)) {
|
||||
this.store.presences[remoteJid] = [];
|
||||
await this.sock.presenceSubscribe(remoteJid);
|
||||
await sleep(1000);
|
||||
}
|
||||
const result = this.store.presences[remoteJid];
|
||||
return this.toWahaPresences(remoteJid, result);
|
||||
}
|
||||
|
||||
public subscribePresence(id: string): Promise<void> {
|
||||
const jid = toJID(id);
|
||||
return this.sock.presenceSubscribe(jid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status methods
|
||||
*/
|
||||
@@ -1502,6 +1588,26 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
|
||||
);
|
||||
}
|
||||
|
||||
protected listenContactsUpdatePictureProfile() {
|
||||
this.sock.ev.on('contacts.update', async (updates) => {
|
||||
for (const update of updates) {
|
||||
if (update.imgUrl !== 'changed') {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.debug({ jid: update.id }, 'Profile picture updated');
|
||||
const url = await this.refreshProfilePicture(update.id);
|
||||
if (isJidUser(update.id)) {
|
||||
// update 123@c.us and 123 profiles as well
|
||||
const cus = toCusFormat(update.id);
|
||||
this.profilePictures.set(cus, url);
|
||||
const phone = update.id.split('@')[0];
|
||||
this.profilePictures.set(phone, url);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* END - Methods for API
|
||||
*/
|
||||
@@ -1855,6 +1961,10 @@ export class NOWEBEngineMediaProcessor implements IMediaEngineProcessor<any> {
|
||||
return message.key.id;
|
||||
}
|
||||
|
||||
getChatId(message: any): string {
|
||||
return toCusFormat(message.key.remoteJid);
|
||||
}
|
||||
|
||||
getMimetype(message: any): string {
|
||||
const content = extractMediaContent(message.message);
|
||||
return content.mimetype;
|
||||
@@ -1894,7 +2004,7 @@ export class NOWEBEngineMediaProcessor implements IMediaEngineProcessor<any> {
|
||||
/**
|
||||
* Convert from 11111111111@s.whatsapp.net to 11111111111@c.us
|
||||
*/
|
||||
function toCusFormat(remoteJid) {
|
||||
export function toCusFormat(remoteJid) {
|
||||
if (!remoteJid) {
|
||||
return remoteJid;
|
||||
}
|
||||
@@ -1916,7 +2026,9 @@ function toCusFormat(remoteJid) {
|
||||
if (remoteJid == 'me') {
|
||||
return remoteJid;
|
||||
}
|
||||
const number = remoteJid.split('@')[0];
|
||||
let number = remoteJid.split('@')[0];
|
||||
// remove :{device} part
|
||||
number = number.split(':')[0];
|
||||
return ensureSuffix(number);
|
||||
}
|
||||
|
||||
@@ -1959,7 +2071,7 @@ function buildMessageId({ id, remoteJid, fromMe, participant }: WAMessageKey) {
|
||||
* false_11111111111@c.us_AAA
|
||||
* {id: "AAA", remoteJid: "11111111111@s.whatsapp.net", "fromMe": false}
|
||||
*/
|
||||
function parseMessageIdSerialized(
|
||||
export function parseMessageIdSerialized(
|
||||
messageId: string,
|
||||
soft: boolean = false,
|
||||
): WAMessageKey {
|
||||
|
||||
@@ -6,7 +6,10 @@ export interface IChatRepository {
|
||||
|
||||
getAllByIds(ids: string[]): Promise<Chat[]>;
|
||||
|
||||
getAllWithMessages(pagination: PaginationParams): Promise<Chat[]>;
|
||||
getAllWithMessages(
|
||||
pagination: PaginationParams,
|
||||
broadcast: boolean,
|
||||
): Promise<Chat[]>;
|
||||
|
||||
getById(id: string): Promise<Chat | null>;
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { GroupMetadata } from '@adiwajshing/baileys';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
|
||||
export interface IGroupRepository {
|
||||
getAll(pagination?: PaginationParams): Promise<GroupMetadata[]>;
|
||||
|
||||
getById(id: string): Promise<GroupMetadata | null>;
|
||||
|
||||
deleteAll(): Promise<void>;
|
||||
|
||||
deleteById(id: string): Promise<void>;
|
||||
|
||||
save(group: GroupMetadata): Promise<void>;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { WAMessage } from '@adiwajshing/baileys';
|
||||
import { LabelAssociation } from '@adiwajshing/baileys/lib/Types/LabelAssociation';
|
||||
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
|
||||
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
|
||||
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
|
||||
|
||||
@@ -16,25 +15,11 @@ export abstract class INowebStorage {
|
||||
|
||||
abstract getChatRepository(): IChatRepository;
|
||||
|
||||
abstract getGroupRepository(): IGroupRepository;
|
||||
|
||||
abstract getMessagesRepository(): IMessagesRepository;
|
||||
|
||||
abstract getLabelsRepository(): ILabelsRepository;
|
||||
|
||||
abstract getLabelAssociationRepository(): ILabelAssociationRepository;
|
||||
|
||||
protected getMessagesMetadata(): Map<string, any> {
|
||||
return new Map()
|
||||
.set('jid', (msg: WAMessage) => msg.key.remoteJid)
|
||||
.set('id', (msg: WAMessage) => msg.key.id)
|
||||
.set('messageTimestamp', (msg: WAMessage) => msg.messageTimestamp);
|
||||
}
|
||||
|
||||
protected getLabelAssociationMetadata() {
|
||||
return new Map().set(
|
||||
'id',
|
||||
(a: LabelAssociation) =>
|
||||
// @ts-ignore
|
||||
`${a.type}_${a.labelId}_${a.chatId}_${a.messageId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Contact,
|
||||
proto,
|
||||
} from '@adiwajshing/baileys';
|
||||
import { GroupMetadata } from '@adiwajshing/baileys/lib/Types/GroupMetadata';
|
||||
import { Label } from '@adiwajshing/baileys/lib/Types/Label';
|
||||
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
@@ -27,7 +28,7 @@ export interface INowebStore {
|
||||
|
||||
getMessageById(chatId: string, messageId: string): Promise<any>;
|
||||
|
||||
getChats(pagination: PaginationParams): Promise<Chat[]>;
|
||||
getChats(pagination: PaginationParams, broadcast: boolean): Promise<Chat[]>;
|
||||
|
||||
getContacts(pagination: PaginationParams): Promise<Contact[]>;
|
||||
|
||||
@@ -40,4 +41,8 @@ export interface INowebStore {
|
||||
getChatsByLabelId(labelId: string): Promise<Chat[]>;
|
||||
|
||||
getChatLabels(chatId: string): Promise<Label[]>;
|
||||
|
||||
getGroups(pagination: PaginationParams): Promise<GroupMetadata[]>;
|
||||
|
||||
resetGroupsCache(): void;
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Chat, Contact, makeInMemoryStore, proto } from '@adiwajshing/baileys';
|
||||
import makeWASocket, {
|
||||
Chat,
|
||||
Contact,
|
||||
GroupMetadata,
|
||||
makeInMemoryStore,
|
||||
proto,
|
||||
} from '@adiwajshing/baileys';
|
||||
import { Label } from '@adiwajshing/baileys/lib/Types/Label';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
import { PaginatorInMemory } from '@waha/utils/Paginator';
|
||||
|
||||
import { INowebStore } from './INowebStore';
|
||||
|
||||
@@ -10,6 +17,8 @@ import { INowebStore } from './INowebStore';
|
||||
const logger = require('pino')();
|
||||
|
||||
export class NowebInMemoryStore implements INowebStore {
|
||||
private socket: ReturnType<typeof makeWASocket>;
|
||||
|
||||
private store: ReturnType<typeof makeInMemoryStore>;
|
||||
errorMessage =
|
||||
'Enable NOWEB store "config.noweb.store.enabled=True" and "config.noweb.store.full_sync=True" when starting a new session. ' +
|
||||
@@ -33,6 +42,7 @@ export class NowebInMemoryStore implements INowebStore {
|
||||
|
||||
bind(ev: any, socket: any) {
|
||||
this.store.bind(ev);
|
||||
this.socket = socket;
|
||||
}
|
||||
|
||||
loadMessage(jid: string, id: string): Promise<proto.IWebMessageInfo> {
|
||||
@@ -51,7 +61,7 @@ export class NowebInMemoryStore implements INowebStore {
|
||||
throw new BadRequestException(this.errorMessage);
|
||||
}
|
||||
|
||||
getChats(pagination: PaginationParams): Promise<Chat[]> {
|
||||
getChats(pagination: PaginationParams, broadcast: boolean): Promise<Chat[]> {
|
||||
throw new BadRequestException(this.errorMessage);
|
||||
}
|
||||
|
||||
@@ -78,4 +88,15 @@ export class NowebInMemoryStore implements INowebStore {
|
||||
getChatLabels(chatId: string): Promise<Label[]> {
|
||||
throw new BadRequestException(this.errorMessage);
|
||||
}
|
||||
|
||||
async getGroups(pagination: PaginationParams): Promise<GroupMetadata[]> {
|
||||
const response = await this.socket?.groupFetchAllParticipating();
|
||||
const groups = Object.values(response);
|
||||
const paginator = new PaginatorInMemory(pagination);
|
||||
return paginator.apply(groups);
|
||||
}
|
||||
|
||||
resetGroupsCache() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,34 @@
|
||||
import {
|
||||
import makeWASocket, {
|
||||
areJidsSameUser,
|
||||
BaileysEventEmitter,
|
||||
Chat,
|
||||
ChatUpdate,
|
||||
Contact,
|
||||
GroupParticipant,
|
||||
isRealMessage,
|
||||
jidNormalizedUser,
|
||||
ParticipantAction,
|
||||
proto,
|
||||
updateMessageWithReaction,
|
||||
updateMessageWithReceipt,
|
||||
} from '@adiwajshing/baileys';
|
||||
import { GroupMetadata } from '@adiwajshing/baileys/lib/Types/GroupMetadata';
|
||||
import { Label } from '@adiwajshing/baileys/lib/Types/Label';
|
||||
import {
|
||||
LabelAssociation,
|
||||
LabelAssociationType,
|
||||
} from '@adiwajshing/baileys/lib/Types/LabelAssociation';
|
||||
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
|
||||
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
|
||||
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
|
||||
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
|
||||
import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
|
||||
import { DefaultMap } from '@waha/utils/DefaultMap';
|
||||
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
|
||||
import * as lodash from 'lodash';
|
||||
import { toNumber } from 'lodash';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
import { toJID } from '../session.noweb.core';
|
||||
import { IChatRepository } from './IChatRepository';
|
||||
import { IContactRepository } from './IContactRepository';
|
||||
import { IMessagesRepository } from './IMessagesRepository';
|
||||
@@ -31,15 +38,27 @@ import { INowebStore } from './INowebStore';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const AsyncLock = require('async-lock');
|
||||
|
||||
type ms = number;
|
||||
const HOUR: ms = 60 * 60 * 1000;
|
||||
|
||||
export class NowebPersistentStore implements INowebStore {
|
||||
private socket: any;
|
||||
private socket: ReturnType<typeof makeWASocket>;
|
||||
private chatRepo: IChatRepository;
|
||||
private groupRepo: IGroupRepository;
|
||||
private contactRepo: IContactRepository;
|
||||
private messagesRepo: IMessagesRepository;
|
||||
private labelsRepo: ILabelsRepository;
|
||||
private labelAssociationsRepo: ILabelAssociationRepository;
|
||||
public presences: any;
|
||||
private lock: any;
|
||||
private groupsFetchLock: any = new AsyncLock({
|
||||
maxPending: Infinity,
|
||||
maxExecutionTime: 60_000,
|
||||
});
|
||||
|
||||
private lastTimeGroupUpdate: Date = new Date(0);
|
||||
private lastTimeGroupFetch: Date = new Date(0);
|
||||
private GROUP_METADATA_CACHE_TIME = 24 * HOUR;
|
||||
|
||||
constructor(
|
||||
private logger: Logger,
|
||||
@@ -47,6 +66,7 @@ export class NowebPersistentStore implements INowebStore {
|
||||
) {
|
||||
this.socket = null;
|
||||
this.chatRepo = storage.getChatRepository();
|
||||
this.groupRepo = storage.getGroupRepository();
|
||||
this.contactRepo = storage.getContactsRepository();
|
||||
this.messagesRepo = storage.getMessagesRepository();
|
||||
this.labelsRepo = storage.getLabelsRepository();
|
||||
@@ -88,6 +108,19 @@ export class NowebPersistentStore implements INowebStore {
|
||||
ev.on('chats.delete', (data) =>
|
||||
this.withLock('chats', () => this.onChatDelete(data)),
|
||||
);
|
||||
// Groups
|
||||
ev.on('groups.upsert', (data) =>
|
||||
this.withLock('groups', () => this.onGroupUpsert(data)),
|
||||
);
|
||||
ev.on('groups.update', (data) =>
|
||||
this.withLock('groups', () => this.onGroupUpdate(data)),
|
||||
);
|
||||
ev.on('group-participants.update', (data) =>
|
||||
this.withLock(`group-${data.id}`, () =>
|
||||
this.onGroupParticipantsUpdate(data),
|
||||
),
|
||||
);
|
||||
|
||||
// Contacts
|
||||
ev.on('contacts.upsert', (data) =>
|
||||
this.withLock('contacts', () => this.onContactsUpsert(data)),
|
||||
@@ -192,17 +225,94 @@ export class NowebPersistentStore implements INowebStore {
|
||||
private async onChatUpsert(chats: Chat[]) {
|
||||
for (const chat of chats) {
|
||||
delete chat['messages'];
|
||||
chat.conversationTimestamp = toNumber(chat.conversationTimestamp);
|
||||
chat.conversationTimestamp = toNumber(chat.conversationTimestamp) || null;
|
||||
await this.chatRepo.save(chat);
|
||||
}
|
||||
this.logger.info(`history sync - '${chats.length}' synced chats`);
|
||||
this.logger.info(`store sync - '${chats.length}' synced chats`);
|
||||
}
|
||||
|
||||
private async onGroupUpsert(groups: GroupMetadata[]) {
|
||||
for (const group of groups) {
|
||||
await this.groupRepo.save(group);
|
||||
}
|
||||
this.logger.info(`store sync - '${groups.length}' synced groups`);
|
||||
}
|
||||
|
||||
private async onGroupUpdate(groups: Partial<GroupMetadata>[]) {
|
||||
for (const update of groups) {
|
||||
let group = await this.groupRepo.getById(update.id);
|
||||
group = Object.assign(group || {}, update) as GroupMetadata;
|
||||
await this.groupRepo.save(group);
|
||||
}
|
||||
this.logger.info(`store sync - '${groups.length}' updated groups`);
|
||||
this.lastTimeGroupUpdate = new Date();
|
||||
}
|
||||
|
||||
private async onGroupParticipantsUpdate(data) {
|
||||
const id: string = data.id;
|
||||
const participants: string[] = data.participants;
|
||||
const action: ParticipantAction = data.action;
|
||||
|
||||
if (action == 'remove') {
|
||||
// Remove the group if the current user is removed
|
||||
const myJid = this.socket?.authState?.creds?.me?.id;
|
||||
const participantsIncludesMe = lodash.find(participants, (p) =>
|
||||
areJidsSameUser(p, myJid),
|
||||
);
|
||||
if (participantsIncludesMe) {
|
||||
await this.groupRepo.deleteById(id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let group = await this.groupRepo.getById(id);
|
||||
if (!group) {
|
||||
group = { id: id, participants: [] } as GroupMetadata;
|
||||
}
|
||||
|
||||
const participantsById = new DefaultMap<string, GroupParticipant>((key) => {
|
||||
return { id: key, admin: null } as GroupParticipant;
|
||||
});
|
||||
for (const participant of group.participants) {
|
||||
participantsById.set(participant.id, participant);
|
||||
}
|
||||
|
||||
for (const participant of participants) {
|
||||
this.participantUpdate(participantsById, participant, action);
|
||||
}
|
||||
group.participants = Array.from(participantsById.values());
|
||||
await this.groupRepo.save(group);
|
||||
}
|
||||
|
||||
private participantUpdate(
|
||||
participantsById: DefaultMap<string, GroupParticipant>,
|
||||
participant: string,
|
||||
action: ParticipantAction,
|
||||
) {
|
||||
switch (action) {
|
||||
case 'add':
|
||||
// if there's no participant - add it (by id)
|
||||
participantsById.get(participant);
|
||||
break;
|
||||
case 'remove':
|
||||
// remove the participant (by id)
|
||||
participantsById.delete(participant);
|
||||
break;
|
||||
case 'promote':
|
||||
// set admin: admin
|
||||
participantsById.get(participant).admin = 'admin';
|
||||
break;
|
||||
case 'demote':
|
||||
participantsById.get(participant).admin = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async onChatUpdate(updates: ChatUpdate[]) {
|
||||
for (const update of updates) {
|
||||
const chat = (await this.chatRepo.getById(update.id)) || ({} as Chat);
|
||||
Object.assign(chat, update);
|
||||
chat.conversationTimestamp = toNumber(chat.conversationTimestamp);
|
||||
chat.conversationTimestamp = toNumber(chat.conversationTimestamp) || null;
|
||||
delete chat['messages'];
|
||||
await this.chatRepo.save(chat);
|
||||
}
|
||||
@@ -233,7 +343,7 @@ export class NowebPersistentStore implements INowebStore {
|
||||
|
||||
private async onContactUpdate(updates: Partial<Contact>[]) {
|
||||
for (const update of updates) {
|
||||
const contact = await this.contactRepo.getById(update.id);
|
||||
let contact = await this.contactRepo.getById(update.id);
|
||||
|
||||
if (!contact) {
|
||||
this.logger.warn(
|
||||
@@ -241,7 +351,7 @@ export class NowebPersistentStore implements INowebStore {
|
||||
update,
|
||||
)}'`,
|
||||
);
|
||||
continue;
|
||||
contact = {} as Contact;
|
||||
// TODO: Find contact by hash if not found
|
||||
// find contact by attrs.hash, when user is not saved as a contact
|
||||
// check the in-memory for that
|
||||
@@ -338,10 +448,45 @@ export class NowebPersistentStore implements INowebStore {
|
||||
return this.messagesRepo.getByJidById(chatId, messageId);
|
||||
}
|
||||
|
||||
getChats(pagination: PaginationParams): Promise<Chat[]> {
|
||||
getChats(pagination: PaginationParams, broadcast: boolean): Promise<Chat[]> {
|
||||
pagination.sortBy ||= 'conversationTimestamp';
|
||||
pagination.sortOrder ||= SortOrder.DESC;
|
||||
return this.chatRepo.getAllWithMessages(pagination);
|
||||
return this.chatRepo.getAllWithMessages(pagination, broadcast);
|
||||
}
|
||||
|
||||
private shouldFetchGroup(): boolean {
|
||||
const timePassed = new Date().getTime() - this.lastTimeGroupFetch.getTime();
|
||||
return timePassed > this.GROUP_METADATA_CACHE_TIME;
|
||||
}
|
||||
|
||||
private async fetchGroups() {
|
||||
await this.groupsFetchLock.acquire('groups-fetch', async () => {
|
||||
if (!this.shouldFetchGroup()) {
|
||||
// Update has been done by another request
|
||||
return;
|
||||
}
|
||||
const lastTimeGroupUpdate = this.lastTimeGroupUpdate;
|
||||
await this.groupRepo.deleteAll();
|
||||
await this.socket?.groupFetchAllParticipating();
|
||||
// Wait until the groups update is done
|
||||
await waitUntil(
|
||||
async () => this.lastTimeGroupUpdate > lastTimeGroupUpdate,
|
||||
100,
|
||||
5_000,
|
||||
);
|
||||
this.lastTimeGroupFetch = new Date();
|
||||
});
|
||||
}
|
||||
|
||||
resetGroupsCache() {
|
||||
this.lastTimeGroupFetch = new Date(0);
|
||||
}
|
||||
|
||||
async getGroups(pagination: PaginationParams): Promise<GroupMetadata[]> {
|
||||
if (this.shouldFetchGroup()) {
|
||||
await this.fetchGroups();
|
||||
}
|
||||
return this.groupRepo.getAll(pagination);
|
||||
}
|
||||
|
||||
getContactById(jid) {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
|
||||
import { DataStore } from '../../../abc/DataStore';
|
||||
import { INowebStorage } from './INowebStorage';
|
||||
import { Sqlite3Storage } from './sqlite3/Sqlite3Storage';
|
||||
|
||||
export class NowebStorageFactoryCore {
|
||||
createStorage(store: DataStore, name: string): INowebStorage {
|
||||
return new Sqlite3Storage(':memory:');
|
||||
if (store instanceof LocalStore) {
|
||||
return this.buildStorageSqlite3(store, name);
|
||||
}
|
||||
throw new Error(`Unsupported store type '${store.constructor.name}'`);
|
||||
}
|
||||
|
||||
private buildStorageSqlite3(store: LocalStore, name: string) {
|
||||
const filePath = store.getFilePath(name, 'store.sqlite3');
|
||||
return new Sqlite3Storage(filePath);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Field, Index, Schema } from '@waha/core/storage/sqlite3/Schema';
|
||||
|
||||
export const NOWEB_STORE_SCHEMA = [
|
||||
new Schema(
|
||||
'contacts',
|
||||
[new Field('id', 'TEXT'), new Field('data', 'TEXT')],
|
||||
[new Index('contacts_id_index', ['id'])],
|
||||
),
|
||||
new Schema(
|
||||
'chats',
|
||||
[
|
||||
new Field('id', 'TEXT'),
|
||||
new Field('conversationTimestamp', 'INTEGER'),
|
||||
new Field('data', 'TEXT'),
|
||||
],
|
||||
[
|
||||
new Index('chats_id_index', ['id']),
|
||||
new Index('chats_conversationTimestamp_index', ['conversationTimestamp']),
|
||||
],
|
||||
),
|
||||
new Schema(
|
||||
'messages',
|
||||
[
|
||||
new Field('jid', 'TEXT'),
|
||||
new Field('id', 'TEXT'),
|
||||
new Field('messageTimestamp', 'INTEGER'),
|
||||
new Field('data', 'TEXT'),
|
||||
],
|
||||
[
|
||||
new Index('messages_id_index', ['id']),
|
||||
new Index('messages_jid_id_index', ['jid', 'id']),
|
||||
new Index('messages_jid_timestamp_index', ['jid', 'messageTimestamp']),
|
||||
new Index('timestamp_index', ['messageTimestamp']),
|
||||
],
|
||||
),
|
||||
new Schema(
|
||||
'labels',
|
||||
[new Field('id', 'TEXT'), new Field('data', 'TEXT')],
|
||||
[new Index('labels_id_index', ['id'])],
|
||||
),
|
||||
new Schema(
|
||||
'labelAssociations',
|
||||
[
|
||||
new Field('id', 'TEXT'),
|
||||
new Field('type', 'TEXT'),
|
||||
new Field('labelId', 'TEXT'),
|
||||
new Field('chatId', 'TEXT'),
|
||||
new Field('messageId', 'TEXT'),
|
||||
new Field('data', 'TEXT'),
|
||||
],
|
||||
[
|
||||
new Index('label_assoc_id_index', ['id']),
|
||||
new Index('label_assoc_type_label_index', ['type', 'labelId']),
|
||||
new Index('label_assoc_type_chat_index', ['type', 'chatId']),
|
||||
new Index('label_assoc_type_message_index', ['type', 'messageId']),
|
||||
],
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,14 @@
|
||||
import { WAMessage } from '@adiwajshing/baileys';
|
||||
import { LabelAssociation } from '@adiwajshing/baileys/lib/Types/LabelAssociation';
|
||||
|
||||
export const NowebMessagesMetadata = new Map()
|
||||
.set('jid', (msg: WAMessage) => msg.key.remoteJid)
|
||||
.set('id', (msg: WAMessage) => msg.key.id)
|
||||
.set('messageTimestamp', (msg: WAMessage) => msg.messageTimestamp);
|
||||
|
||||
export const NowebLabelAssociationsMetadata = new Map().set(
|
||||
'id',
|
||||
(a: LabelAssociation) =>
|
||||
// @ts-ignore
|
||||
`${a.type}_${a.labelId}_${a.chatId}_${a.messageId}`,
|
||||
);
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Field, Index, Schema } from '@waha/core/storage/Schema';
|
||||
|
||||
export const NowebContactSchema = new Schema(
|
||||
'contacts',
|
||||
[new Field('id', 'TEXT'), new Field('data', 'TEXT')],
|
||||
[new Index('contacts_id_index', ['id'])],
|
||||
);
|
||||
|
||||
export const NowebChatSchema = new Schema(
|
||||
'chats',
|
||||
[
|
||||
new Field('id', 'TEXT'),
|
||||
new Field('conversationTimestamp', 'INTEGER'),
|
||||
new Field('data', 'TEXT'),
|
||||
],
|
||||
[
|
||||
new Index('chats_id_index', ['id']),
|
||||
new Index('chats_conversationTimestamp_index', ['conversationTimestamp']),
|
||||
],
|
||||
);
|
||||
|
||||
export const NowebGroupsSchema = new Schema(
|
||||
'groups',
|
||||
[new Field('id', 'TEXT'), new Field('data', 'TEXT')],
|
||||
[new Index('groups_id_index', ['id'])],
|
||||
);
|
||||
|
||||
export const NowebMessagesSchema = new Schema(
|
||||
'messages',
|
||||
[
|
||||
new Field('jid', 'TEXT'),
|
||||
new Field('id', 'TEXT'),
|
||||
new Field('messageTimestamp', 'INTEGER'),
|
||||
new Field('data', 'TEXT'),
|
||||
],
|
||||
[
|
||||
new Index('messages_id_index', ['id']),
|
||||
new Index('messages_jid_id_index', ['jid', 'id']),
|
||||
new Index('messages_jid_timestamp_index', ['jid', 'messageTimestamp']),
|
||||
new Index('timestamp_index', ['messageTimestamp']),
|
||||
],
|
||||
);
|
||||
|
||||
export const NowebLabelsSchema = new Schema(
|
||||
'labels',
|
||||
[new Field('id', 'TEXT'), new Field('data', 'TEXT')],
|
||||
[new Index('labels_id_index', ['id'])],
|
||||
);
|
||||
|
||||
export const NowebLabelAssociationsSchema = new Schema(
|
||||
'labelAssociations',
|
||||
[
|
||||
new Field('id', 'TEXT'),
|
||||
new Field('type', 'TEXT'),
|
||||
new Field('labelId', 'TEXT'),
|
||||
new Field('chatId', 'TEXT'),
|
||||
new Field('messageId', 'TEXT'),
|
||||
new Field('data', 'TEXT'),
|
||||
],
|
||||
[
|
||||
new Index('label_assoc_id_index', ['id']),
|
||||
new Index('label_assoc_type_label_index', ['type', 'labelId']),
|
||||
new Index('label_assoc_type_chat_index', ['type', 'chatId']),
|
||||
new Index('label_assoc_type_message_index', ['type', 'messageId']),
|
||||
],
|
||||
);
|
||||
|
||||
export const NOWEB_STORE_SCHEMA = [
|
||||
NowebContactSchema,
|
||||
NowebChatSchema,
|
||||
NowebGroupsSchema,
|
||||
NowebMessagesSchema,
|
||||
NowebLabelsSchema,
|
||||
NowebLabelAssociationsSchema,
|
||||
];
|
||||
|
||||
export const Migrations: string[] = [
|
||||
// Contacts
|
||||
'CREATE TABLE IF NOT EXISTS contacts (id TEXT PRIMARY KEY, data TEXT)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS contacts_id_index ON contacts (id)',
|
||||
// Chats
|
||||
'CREATE TABLE IF NOT EXISTS chats (id TEXT PRIMARY KEY, "conversationTimestamp" INTEGER, data TEXT)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS chats_id_index ON chats (id)',
|
||||
'CREATE INDEX IF NOT EXISTS "chats_conversationTimestamp_index" ON chats ("conversationTimestamp")',
|
||||
// Groups
|
||||
'CREATE TABLE IF NOT EXISTS groups (id TEXT PRIMARY KEY, data TEXT)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS groups_id_index ON groups (id)',
|
||||
// Messages
|
||||
'CREATE TABLE IF NOT EXISTS messages (jid TEXT, id TEXT, "messageTimestamp" INTEGER, data TEXT)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS messages_id_index ON messages (id)',
|
||||
'CREATE INDEX IF NOT EXISTS messages_jid_id_index ON messages (jid, id)',
|
||||
'CREATE INDEX IF NOT EXISTS messages_jid_timestamp_index ON messages (jid, "messageTimestamp")',
|
||||
'CREATE INDEX IF NOT EXISTS timestamp_index ON messages ("messageTimestamp")',
|
||||
// Labels
|
||||
'CREATE TABLE IF NOT EXISTS labels (id TEXT PRIMARY KEY, data TEXT)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS labels_id_index ON labels (id)',
|
||||
// Label associations
|
||||
'CREATE TABLE IF NOT EXISTS "labelAssociations" (id TEXT PRIMARY KEY, type TEXT, "labelId" TEXT, "chatId" TEXT, "messageId" TEXT, data TEXT)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS label_assoc_id_index ON "labelAssociations" (id)',
|
||||
'CREATE INDEX IF NOT EXISTS label_assoc_type_label_index ON "labelAssociations" (type, "labelId")',
|
||||
'CREATE INDEX IF NOT EXISTS label_assoc_type_chat_index ON "labelAssociations" (type, "chatId")',
|
||||
'CREATE INDEX IF NOT EXISTS label_assoc_type_message_index ON "labelAssociations" (type, "messageId")',
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Chat } from '@adiwajshing/baileys';
|
||||
import { SqlKVRepository } from '@waha/core/storage/sql/SqlKVRepository';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
|
||||
export class SqlChatMethods {
|
||||
constructor(private repository: SqlKVRepository<any>) {}
|
||||
|
||||
async getAllWithMessages(
|
||||
pagination: PaginationParams,
|
||||
broadcast: boolean,
|
||||
): Promise<Chat[]> {
|
||||
// Get chats with conversationTimestamp is not Null
|
||||
let query = this.repository.select().whereNotNull('conversationTimestamp');
|
||||
if (!broadcast) {
|
||||
// filter out chat by id if it ends at @newsletter or @broadcast
|
||||
query = query
|
||||
.andWhereNot('id', 'like', '%@broadcast')
|
||||
.andWhereNot('id', 'like', '%@newsletter');
|
||||
}
|
||||
query = this.repository.pagination(query, pagination);
|
||||
return await this.repository.all(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
LabelAssociation,
|
||||
LabelAssociationType,
|
||||
} from '@adiwajshing/baileys/lib/Types/LabelAssociation';
|
||||
import { SqlKVRepository } from '@waha/core/storage/sql/SqlKVRepository';
|
||||
|
||||
export class SqlLabelAssociationsMethods {
|
||||
constructor(private repository: SqlKVRepository<any>) {}
|
||||
|
||||
async deleteOne(association: LabelAssociation): Promise<void> {
|
||||
await this.repository.deleteBy({
|
||||
type: association.type,
|
||||
chatId: association.chatId,
|
||||
labelId: association.labelId,
|
||||
// @ts-ignore: messageId doesn't existing in ChatLabelAssociation
|
||||
messageId: association.messageId || null,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteByLabelId(labelId: string): Promise<void> {
|
||||
await this.repository.deleteBy({ labelId: labelId });
|
||||
}
|
||||
|
||||
getAssociationsByLabelId(
|
||||
labelId: string,
|
||||
type: LabelAssociationType,
|
||||
): Promise<LabelAssociation[]> {
|
||||
return this.repository.getAllBy({
|
||||
type: type,
|
||||
labelId: labelId,
|
||||
});
|
||||
}
|
||||
|
||||
getAssociationsByChatId(chatId: string): Promise<LabelAssociation[]> {
|
||||
return this.repository.getAllBy({
|
||||
chatId: chatId,
|
||||
type: LabelAssociationType.Chat,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ALL_JID } from '@waha/core/engines/noweb/session.noweb.core';
|
||||
import { SqlKVRepository } from '@waha/core/storage/sql/SqlKVRepository';
|
||||
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
|
||||
export class SqlMessagesMethods {
|
||||
constructor(private repository: SqlKVRepository<any>) {}
|
||||
|
||||
upsert(messages: any[]): Promise<void> {
|
||||
return this.repository.upsertMany(messages);
|
||||
}
|
||||
|
||||
async getAllByJid(
|
||||
jid: string,
|
||||
filter: GetChatMessagesFilter,
|
||||
pagination: PaginationParams,
|
||||
): Promise<any[]> {
|
||||
let query = this.repository.select();
|
||||
if (jid !== ALL_JID) {
|
||||
query = this.repository.select().where({ jid: jid });
|
||||
}
|
||||
if (filter['filter.timestamp.lte'] != null) {
|
||||
query = query.where(
|
||||
'messageTimestamp',
|
||||
'<=',
|
||||
filter['filter.timestamp.lte'],
|
||||
);
|
||||
}
|
||||
if (filter['filter.timestamp.gte'] != null) {
|
||||
query = query.where(
|
||||
'messageTimestamp',
|
||||
'>=',
|
||||
filter['filter.timestamp.gte'],
|
||||
);
|
||||
}
|
||||
if (filter['filter.fromMe'] != null) {
|
||||
// filter by data json inside
|
||||
const sql = this.repository.filterJson('data', 'key.fromMe');
|
||||
query = query.whereRaw(sql, [filter['filter.fromMe'] ? 'true' : 'false']);
|
||||
}
|
||||
query = this.repository.pagination(query, pagination);
|
||||
return this.repository.all(query);
|
||||
}
|
||||
|
||||
async getByJidById(jid: string, id: string): Promise<any> {
|
||||
return this.repository.getBy({ jid: jid, id: id });
|
||||
}
|
||||
|
||||
async updateByJidAndId(
|
||||
jid: string,
|
||||
id: string,
|
||||
update: any,
|
||||
): Promise<boolean> {
|
||||
const entity = await this.getByJidById(jid, id);
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
Object.assign(entity, update);
|
||||
await this.repository.upsertOne(entity);
|
||||
}
|
||||
|
||||
async deleteByJidByIds(jid: string, ids: string[]): Promise<void> {
|
||||
const query = `DELETE
|
||||
FROM "${this.repository.table}"
|
||||
WHERE jid = ?
|
||||
AND id IN (${ids.map(() => '?').join(', ')})`;
|
||||
await this.repository.raw(query, [jid, ...ids]);
|
||||
}
|
||||
|
||||
deleteAllByJid(jid: string): Promise<void> {
|
||||
return this.repository.deleteBy({ jid: jid });
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Chat } from '@adiwajshing/baileys';
|
||||
import { NowebChatSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { SqlChatMethods } from '@waha/core/engines/noweb/store/sql/SqlChatMethods';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
import { KnexPaginator } from '@waha/utils/Paginator';
|
||||
|
||||
@@ -15,10 +17,18 @@ export class Sqlite3ChatRepository
|
||||
{
|
||||
protected Paginator = ChatPaginator;
|
||||
|
||||
async getAllWithMessages(pagination: PaginationParams): Promise<Chat[]> {
|
||||
// Get chats with conversationTimestamp is not Null
|
||||
let query = this.select().whereNotNull('conversationTimestamp');
|
||||
query = this.pagination(query, pagination);
|
||||
return await this.all(query);
|
||||
get schema() {
|
||||
return NowebChatSchema;
|
||||
}
|
||||
|
||||
get methods() {
|
||||
return new SqlChatMethods(this);
|
||||
}
|
||||
|
||||
getAllWithMessages(
|
||||
pagination: PaginationParams,
|
||||
broadcast: boolean,
|
||||
): Promise<Chat[]> {
|
||||
return this.methods.getAllWithMessages(pagination, broadcast);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Contact } from '@adiwajshing/baileys';
|
||||
import { NowebContactSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { KnexPaginator } from '@waha/utils/Paginator';
|
||||
|
||||
import { IContactRepository } from '../IContactRepository';
|
||||
@@ -13,4 +14,8 @@ export class Sqlite3ContactRepository
|
||||
implements IContactRepository
|
||||
{
|
||||
protected Paginator = ContactPaginator;
|
||||
|
||||
get schema() {
|
||||
return NowebContactSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { GroupMetadata } from '@adiwajshing/baileys/lib/Types/GroupMetadata';
|
||||
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
|
||||
import { NowebGroupsSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { KnexPaginator } from '@waha/utils/Paginator';
|
||||
|
||||
import { NOWEBSqlite3KVRepository } from './NOWEBSqlite3KVRepository';
|
||||
|
||||
class Paginator extends KnexPaginator {
|
||||
indexes = ['id'];
|
||||
}
|
||||
|
||||
export class Sqlite3GroupRepository
|
||||
extends NOWEBSqlite3KVRepository<GroupMetadata>
|
||||
implements IGroupRepository
|
||||
{
|
||||
protected Paginator = Paginator;
|
||||
|
||||
get schema() {
|
||||
return NowebGroupsSchema;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
LabelAssociationType,
|
||||
} from '@adiwajshing/baileys/lib/Types/LabelAssociation';
|
||||
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
|
||||
import { NowebLabelAssociationsMetadata } from '@waha/core/engines/noweb/store/metadata';
|
||||
import { NowebLabelAssociationsSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { SqlLabelAssociationsMethods } from '@waha/core/engines/noweb/store/sql/SqlLabelAssociationsMethods';
|
||||
|
||||
import { NOWEBSqlite3KVRepository } from './NOWEBSqlite3KVRepository';
|
||||
|
||||
@@ -10,31 +13,34 @@ export class Sqlite3LabelAssociationsRepository
|
||||
extends NOWEBSqlite3KVRepository<LabelAssociation>
|
||||
implements ILabelAssociationRepository
|
||||
{
|
||||
get schema() {
|
||||
return NowebLabelAssociationsSchema;
|
||||
}
|
||||
|
||||
get metadata() {
|
||||
return NowebLabelAssociationsMetadata;
|
||||
}
|
||||
|
||||
get methods() {
|
||||
return new SqlLabelAssociationsMethods(this);
|
||||
}
|
||||
|
||||
async deleteOne(association: LabelAssociation): Promise<void> {
|
||||
await this.deleteBy({
|
||||
type: association.type,
|
||||
chatId: association.chatId,
|
||||
labelId: association.labelId,
|
||||
// @ts-ignore: messageId doesn't existing in ChatLabelAssociation
|
||||
messageId: association.messageId || null,
|
||||
});
|
||||
return this.methods.deleteOne(association);
|
||||
}
|
||||
|
||||
async deleteByLabelId(labelId: string): Promise<void> {
|
||||
await this.deleteBy({ labelId: labelId });
|
||||
return this.methods.deleteByLabelId(labelId);
|
||||
}
|
||||
|
||||
getAssociationsByLabelId(
|
||||
labelId: string,
|
||||
type: LabelAssociationType,
|
||||
): Promise<LabelAssociation[]> {
|
||||
return this.getAllBy({
|
||||
type: type,
|
||||
labelId: labelId,
|
||||
});
|
||||
return this.methods.getAssociationsByLabelId(labelId, type);
|
||||
}
|
||||
|
||||
getAssociationsByChatId(chatId: string): Promise<LabelAssociation[]> {
|
||||
return this.getAllBy({ chatId: chatId, type: LabelAssociationType.Chat });
|
||||
return this.methods.getAssociationsByChatId(chatId);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Label } from '@adiwajshing/baileys/lib/Types/Label';
|
||||
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
|
||||
import { NowebLabelsSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
|
||||
import { NOWEBSqlite3KVRepository } from './NOWEBSqlite3KVRepository';
|
||||
|
||||
export class Sqlite3LabelsRepository
|
||||
extends NOWEBSqlite3KVRepository<Label>
|
||||
implements ILabelsRepository {}
|
||||
implements ILabelsRepository
|
||||
{
|
||||
get schema() {
|
||||
return NowebLabelsSchema;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ALL_JID } from '@waha/core/engines/noweb/session.noweb.core';
|
||||
import { NowebMessagesMetadata } from '@waha/core/engines/noweb/store/metadata';
|
||||
import { NowebMessagesSchema } from '@waha/core/engines/noweb/store/schemas';
|
||||
import { SqlMessagesMethods } from '@waha/core/engines/noweb/store/sql/SqlMessagesMethods';
|
||||
import { GetChatMessagesFilter } from '@waha/structures/chats.dto';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
|
||||
@@ -9,8 +11,20 @@ export class Sqlite3MessagesRepository
|
||||
extends NOWEBSqlite3KVRepository<any>
|
||||
implements IMessagesRepository
|
||||
{
|
||||
get schema() {
|
||||
return NowebMessagesSchema;
|
||||
}
|
||||
|
||||
get methods() {
|
||||
return new SqlMessagesMethods(this);
|
||||
}
|
||||
|
||||
get metadata() {
|
||||
return NowebMessagesMetadata;
|
||||
}
|
||||
|
||||
upsert(messages: any[]): Promise<void> {
|
||||
return this.upsertMany(messages);
|
||||
return this.methods.upsert(messages);
|
||||
}
|
||||
|
||||
async getAllByJid(
|
||||
@@ -18,36 +32,11 @@ export class Sqlite3MessagesRepository
|
||||
filter: GetChatMessagesFilter,
|
||||
pagination: PaginationParams,
|
||||
): Promise<any[]> {
|
||||
let query = this.select();
|
||||
if (jid !== ALL_JID) {
|
||||
query = this.select().where({ jid: jid });
|
||||
}
|
||||
if (filter['filter.timestamp.lte'] != null) {
|
||||
query = query.where(
|
||||
'messageTimestamp',
|
||||
'<=',
|
||||
filter['filter.timestamp.lte'],
|
||||
);
|
||||
}
|
||||
if (filter['filter.timestamp.gte'] != null) {
|
||||
query = query.where(
|
||||
'messageTimestamp',
|
||||
'>=',
|
||||
filter['filter.timestamp.gte'],
|
||||
);
|
||||
}
|
||||
if (filter['filter.fromMe'] != null) {
|
||||
// filter by data->"$.key.fromMe"
|
||||
query = query.whereRaw("data->'$.key.fromMe' = ?", [
|
||||
filter['filter.fromMe'] ? 'true' : 'false',
|
||||
]);
|
||||
}
|
||||
query = this.pagination(query, pagination);
|
||||
return this.all(query);
|
||||
return this.methods.getAllByJid(jid, filter, pagination);
|
||||
}
|
||||
|
||||
async getByJidById(jid: string, id: string): Promise<any> {
|
||||
return this.getBy({ jid: jid, id: id });
|
||||
return this.methods.getByJidById(jid, id);
|
||||
}
|
||||
|
||||
async updateByJidAndId(
|
||||
@@ -55,23 +44,14 @@ export class Sqlite3MessagesRepository
|
||||
id: string,
|
||||
update: any,
|
||||
): Promise<boolean> {
|
||||
const entity = await this.getByJidById(jid, id);
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
Object.assign(entity, update);
|
||||
await this.upsertOne(entity);
|
||||
return this.methods.updateByJidAndId(jid, id, update);
|
||||
}
|
||||
|
||||
async deleteByJidByIds(jid: string, ids: string[]): Promise<void> {
|
||||
const query = `DELETE
|
||||
FROM ${this.table}
|
||||
WHERE jid = ?
|
||||
AND id IN (${ids.map(() => '?').join(', ')})`;
|
||||
this.db.prepare(query).run([jid, ...ids]);
|
||||
return this.methods.deleteByJidByIds(jid, ids);
|
||||
}
|
||||
|
||||
deleteAllByJid(jid: string): Promise<void> {
|
||||
return this.deleteBy({ jid: jid });
|
||||
return this.methods.deleteAllByJid(jid);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Schema } from '@waha/core/storage/sqlite3/Schema';
|
||||
import { Schema } from '@waha/core/storage/Schema';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { WAMessage } from '@adiwajshing/baileys';
|
||||
import { LabelAssociation } from '@adiwajshing/baileys/lib/Types/LabelAssociation';
|
||||
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
|
||||
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
|
||||
import { Sqlite3GroupRepository } from '@waha/core/engines/noweb/store/sqlite3/Sqlite3GroupRepository';
|
||||
import { Sqlite3LabelAssociationsRepository } from '@waha/core/engines/noweb/store/sqlite3/Sqlite3LabelAssociationsRepository';
|
||||
import { Sqlite3LabelsRepository } from '@waha/core/engines/noweb/store/sqlite3/Sqlite3LabelsRepository';
|
||||
import { Field, Index, Schema } from '@waha/core/storage/sqlite3/Schema';
|
||||
import { Schema } from '@waha/core/storage/Schema';
|
||||
|
||||
import { INowebStorage } from '../INowebStorage';
|
||||
import { NOWEB_STORE_SCHEMA } from '../Schema';
|
||||
import { Migrations, NOWEB_STORE_SCHEMA } from '../schemas';
|
||||
import { Sqlite3ChatRepository } from './Sqlite3ChatRepository';
|
||||
import { Sqlite3ContactRepository } from './Sqlite3ContactRepository';
|
||||
import { Sqlite3MessagesRepository } from './Sqlite3MessagesRepository';
|
||||
@@ -43,109 +42,36 @@ export class Sqlite3Storage extends INowebStorage {
|
||||
}
|
||||
|
||||
private migration0001init() {
|
||||
// Contacts
|
||||
this.db.exec(
|
||||
'CREATE TABLE IF NOT EXISTS contacts (id TEXT PRIMARY KEY, data TEXT)',
|
||||
);
|
||||
this.db.exec(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS contacts_id_index ON contacts (id)',
|
||||
);
|
||||
|
||||
// Chats
|
||||
this.db.exec(
|
||||
'CREATE TABLE IF NOT EXISTS chats (id TEXT PRIMARY KEY, conversationTimestamp INTEGER, data TEXT)',
|
||||
);
|
||||
this.db.exec(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS chats_id_index ON chats (id)',
|
||||
);
|
||||
this.db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS chats_conversationTimestamp_index ON chats (conversationTimestamp)',
|
||||
);
|
||||
|
||||
// Messages
|
||||
this.db.exec(
|
||||
'CREATE TABLE IF NOT EXISTS messages (jid TEXT, id TEXT, messageTimestamp INTEGER, data TEXT)',
|
||||
);
|
||||
this.db.exec(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS messages_id_index ON messages (id)',
|
||||
);
|
||||
this.db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS messages_jid_id_index ON messages (jid, id)',
|
||||
);
|
||||
this.db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS messages_jid_timestamp_index ON messages (jid, messageTimestamp)',
|
||||
);
|
||||
this.db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS timestamp_index ON messages (messageTimestamp)',
|
||||
);
|
||||
|
||||
//
|
||||
// Labels
|
||||
//
|
||||
this.db.exec(
|
||||
'CREATE TABLE IF NOT EXISTS labels (id TEXT PRIMARY KEY, data TEXT)',
|
||||
);
|
||||
this.db.exec(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS labels_id_index ON labels (id)',
|
||||
);
|
||||
|
||||
// Label associations
|
||||
this.db.exec(
|
||||
'CREATE TABLE IF NOT EXISTS labelAssociations (id TEXT PRIMARY KEY, type TEXT, labelId TEXT, chatId TEXT, messageId TEXT, data TEXT)',
|
||||
);
|
||||
this.db.exec(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS label_assoc_id_index ON labelAssociations (id)',
|
||||
);
|
||||
this.db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS label_assoc_type_label_index ON labelAssociations (type, labelId)',
|
||||
);
|
||||
this.db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS label_assoc_type_chat_index ON labelAssociations (type, chatId)',
|
||||
);
|
||||
this.db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS label_assoc_type_message_index ON labelAssociations (type, messageId)',
|
||||
);
|
||||
for (const migration of Migrations) {
|
||||
this.db.exec(migration);
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.db.close();
|
||||
return this.db.close();
|
||||
}
|
||||
|
||||
getContactsRepository() {
|
||||
return new Sqlite3ContactRepository(this.db, this.getSchema('contacts'));
|
||||
return new Sqlite3ContactRepository(this.db);
|
||||
}
|
||||
|
||||
getChatRepository() {
|
||||
return new Sqlite3ChatRepository(this.db, this.getSchema('chats'));
|
||||
return new Sqlite3ChatRepository(this.db);
|
||||
}
|
||||
|
||||
getGroupRepository() {
|
||||
return new Sqlite3GroupRepository(this.db);
|
||||
}
|
||||
|
||||
getLabelsRepository(): ILabelsRepository {
|
||||
return new Sqlite3LabelsRepository(this.db, this.getSchema('labels'));
|
||||
return new Sqlite3LabelsRepository(this.db);
|
||||
}
|
||||
|
||||
getLabelAssociationRepository(): ILabelAssociationRepository {
|
||||
const metadata = this.getLabelAssociationMetadata();
|
||||
return new Sqlite3LabelAssociationsRepository(
|
||||
this.db,
|
||||
this.getSchema('labelAssociations'),
|
||||
metadata,
|
||||
);
|
||||
return new Sqlite3LabelAssociationsRepository(this.db);
|
||||
}
|
||||
|
||||
getMessagesRepository() {
|
||||
const metadata = this.getMessagesMetadata();
|
||||
return new Sqlite3MessagesRepository(
|
||||
this.db,
|
||||
this.getSchema('messages'),
|
||||
metadata,
|
||||
);
|
||||
}
|
||||
|
||||
getSchema(name: string) {
|
||||
const schema = this.tables.find((table) => table.name === name);
|
||||
if (!schema) {
|
||||
throw new Error(`Schema not found: ${name}`);
|
||||
}
|
||||
return schema;
|
||||
return new Sqlite3MessagesRepository(this.db);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
proto,
|
||||
} from '@adiwajshing/baileys';
|
||||
import { BufferJSON, initAuthCreds } from '@adiwajshing/baileys/lib/Utils';
|
||||
import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises';
|
||||
import { mkdir, readFile, stat, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const writeFileAtomic = require('write-file-atomic');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const AsyncLock = require('async-lock');
|
||||
|
||||
@@ -29,11 +32,15 @@ export const useMultiFileAuthState = async (
|
||||
): Promise<{
|
||||
state: AuthenticationState;
|
||||
saveCreds: () => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}> => {
|
||||
const writeData = (data: any, file: string) => {
|
||||
const filePath = join(folder, fixFileName(file));
|
||||
return fileLock.acquire(filePath, () =>
|
||||
writeFile(join(filePath), JSON.stringify(data, BufferJSON.replacer)),
|
||||
writeFileAtomic(
|
||||
join(filePath),
|
||||
JSON.stringify(data, BufferJSON.replacer),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -111,5 +118,8 @@ export const useMultiFileAuthState = async (
|
||||
saveCreds: () => {
|
||||
return writeData(creds, 'creds.json');
|
||||
},
|
||||
close: async () => {
|
||||
return;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -13,7 +13,6 @@ import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
|
||||
import { QR } from '@waha/core/QR';
|
||||
import { splitAt } from '@waha/helpers';
|
||||
import { PairingCodeResponse } from '@waha/structures/auth.dto';
|
||||
import { CallData } from '@waha/structures/calls.dto';
|
||||
import {
|
||||
Channel,
|
||||
ChannelRole,
|
||||
@@ -21,8 +20,8 @@ import {
|
||||
ListChannelsQuery,
|
||||
} from '@waha/structures/channels.dto';
|
||||
import {
|
||||
ChatArchiveEvent,
|
||||
ChatSortField,
|
||||
ChatSummary,
|
||||
GetChatMessageQuery,
|
||||
GetChatMessagesFilter,
|
||||
GetChatMessagesQuery,
|
||||
@@ -55,19 +54,16 @@ import {
|
||||
} from '@waha/structures/enums.dto';
|
||||
import {
|
||||
CreateGroupRequest,
|
||||
GroupSortField,
|
||||
ParticipantsRequest,
|
||||
SettingsSecurityChangeInfo,
|
||||
} from '@waha/structures/groups.dto';
|
||||
import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto';
|
||||
import { ReplyToMessage } from '@waha/structures/message.dto';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
|
||||
import { WAMessage, WAMessageReaction } from '@waha/structures/responses.dto';
|
||||
import { MeInfo } from '@waha/structures/sessions.dto';
|
||||
import {
|
||||
BROADCAST_ID,
|
||||
StatusRequest,
|
||||
TextStatus,
|
||||
} from '@waha/structures/status.dto';
|
||||
import { StatusRequest, TextStatus } from '@waha/structures/status.dto';
|
||||
import {
|
||||
EnginePayload,
|
||||
WAMessageRevokedBody,
|
||||
@@ -75,9 +71,10 @@ import {
|
||||
import { PaginatorInMemory } from '@waha/utils/Paginator';
|
||||
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
|
||||
import { SingleDelayedJobRunner } from '@waha/utils/SingleDelayedJobRunner';
|
||||
import * as lodash from 'lodash';
|
||||
import { fromEvent, merge, mergeMap, Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import {
|
||||
import WAWebJS, {
|
||||
Call,
|
||||
Channel as WEBJSChannel,
|
||||
Chat,
|
||||
@@ -94,9 +91,6 @@ import {
|
||||
} from 'whatsapp-web.js';
|
||||
import { Message as MessageInstance } from 'whatsapp-web.js/src/structures';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const QRCode = require('qrcode');
|
||||
|
||||
export interface WebJSConfig {
|
||||
webVersion?: string;
|
||||
cacheType: 'local' | 'none';
|
||||
@@ -167,9 +161,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
|
||||
protected async buildClient() {
|
||||
const clientOptions = this.getClientOptions();
|
||||
const base = process.env.WAHA_LOCAL_STORE_BASE_DIR || './.sessions';
|
||||
clientOptions.authStrategy = new LocalAuth({
|
||||
clientId: this.name,
|
||||
dataPath: undefined,
|
||||
dataPath: `${base}/webjs/default`,
|
||||
logger: this.logger,
|
||||
rmMaxRetries: undefined,
|
||||
});
|
||||
@@ -269,6 +264,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
this.status = WAHASessionStatus.STOPPED;
|
||||
this.stopEvents();
|
||||
this.startDelayedJob.cancel();
|
||||
this.mediaManager.close();
|
||||
await this.end();
|
||||
}
|
||||
|
||||
@@ -338,8 +334,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
this.whatsapp.on(Events.QR_RECEIVED, async (qr) => {
|
||||
this.logger.debug('QR received');
|
||||
// Convert to image and save
|
||||
const url = await QRCode.toDataURL(qr);
|
||||
this.qr.save(url, qr);
|
||||
this.qr.save(qr);
|
||||
this.printQR(this.qr);
|
||||
this.status = WAHASessionStatus.SCAN_QR_CODE;
|
||||
});
|
||||
@@ -571,6 +566,41 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
return this.whatsapp.getChats(pagination);
|
||||
}
|
||||
|
||||
public async getChatsOverview(
|
||||
pagination: PaginationParams,
|
||||
): Promise<ChatSummary[]> {
|
||||
pagination = {
|
||||
...pagination,
|
||||
sortBy: ChatSortField.CONVERSATION_TIMESTAMP,
|
||||
sortOrder: SortOrder.DESC,
|
||||
};
|
||||
const chats = await this.getChats(pagination);
|
||||
|
||||
const promises = [];
|
||||
for (const chat of chats) {
|
||||
promises.push(this.fetchChatSummary(chat));
|
||||
}
|
||||
const result = await Promise.all(promises);
|
||||
return result;
|
||||
}
|
||||
|
||||
protected async fetchChatSummary(chat: Chat): Promise<ChatSummary> {
|
||||
const picture = await this.getContactProfilePicture(
|
||||
chat.id._serialized,
|
||||
false,
|
||||
);
|
||||
const lastMessage = !!chat.lastMessage
|
||||
? this.toWAMessage(chat.lastMessage)
|
||||
: null;
|
||||
return {
|
||||
id: chat.id._serialized,
|
||||
name: chat.name || null,
|
||||
picture: picture,
|
||||
lastMessage: lastMessage,
|
||||
_chat: chat,
|
||||
};
|
||||
}
|
||||
|
||||
public async getChatMessages(
|
||||
chatId: string,
|
||||
query: GetChatMessagesQuery,
|
||||
@@ -729,11 +759,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
return { about: await contact.getAbout() };
|
||||
}
|
||||
|
||||
public async getContactProfilePicture(query: ContactQuery) {
|
||||
const contact = await this.whatsapp.getContactById(
|
||||
this.ensureSuffix(query.contactId),
|
||||
);
|
||||
return { profilePictureURL: await contact.getProfilePicUrl() };
|
||||
public async fetchContactProfilePicture(id: string) {
|
||||
const contact = await this.whatsapp.getContactById(this.ensureSuffix(id));
|
||||
const url = await contact.getProfilePicUrl();
|
||||
return url;
|
||||
}
|
||||
|
||||
public async blockContact(request: ContactRequest) {
|
||||
@@ -760,6 +789,14 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
return this.whatsapp.createGroup(request.name, participantIds);
|
||||
}
|
||||
|
||||
public joinGroup(code: string) {
|
||||
return this.whatsapp.acceptInvite(code);
|
||||
}
|
||||
|
||||
public joinInfoGroup(code: string) {
|
||||
return this.whatsapp.getInviteInfo(code);
|
||||
}
|
||||
|
||||
public async getInfoAdminsOnly(id): Promise<SettingsSecurityChangeInfo> {
|
||||
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
|
||||
return {
|
||||
@@ -789,10 +826,25 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
return groupChat.setMessagesAdminsOnly(value);
|
||||
}
|
||||
|
||||
public getGroups() {
|
||||
return this.whatsapp
|
||||
.getChats()
|
||||
.then((chats) => chats.filter((chat) => chat.isGroup));
|
||||
public async getGroups(pagination: PaginationParams) {
|
||||
const chats = await this.whatsapp.getChats();
|
||||
const groups = lodash.filter(chats, (chat) => chat.isGroup);
|
||||
|
||||
switch (pagination.sortBy) {
|
||||
case GroupSortField.ID:
|
||||
pagination.sortBy = 'id._serialized';
|
||||
break;
|
||||
case GroupSortField.SUBJECT:
|
||||
pagination.sortBy = 'groupMetadata.subject';
|
||||
break;
|
||||
}
|
||||
|
||||
const paginator = new PaginatorInMemory(pagination);
|
||||
return paginator.apply(groups);
|
||||
}
|
||||
|
||||
public async refreshGroups(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
public getGroup(id) {
|
||||
@@ -1002,7 +1054,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
|
||||
* Status methods
|
||||
*/
|
||||
protected checkStatusRequest(request: StatusRequest) {
|
||||
if (request.contacts?.length > 0) {
|
||||
if (request.contacts && request.contacts?.length > 0) {
|
||||
const msg =
|
||||
"WEBJS doesn't accept 'contacts'. Remove the field to send status to all contacts.";
|
||||
throw new UnprocessableEntityException(msg);
|
||||
@@ -1270,6 +1322,10 @@ export class WEBJSEngineMediaProcessor
|
||||
return message.type !== 'revoked';
|
||||
}
|
||||
|
||||
getChatId(message: Message): string {
|
||||
return message.id.remote;
|
||||
}
|
||||
|
||||
getMessageId(message: Message): string {
|
||||
return message.id._serialized;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { EngineBootstrap } from '@waha/core/abc/EngineBootstrap';
|
||||
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
|
||||
import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService';
|
||||
import { WhatsappSessionGoWSCore } from '@waha/core/engines/gows/session.gows.core';
|
||||
import { WebhookConductor } from '@waha/core/integrations/webhooks/WebhookConductor';
|
||||
import { MediaStorageFactory } from '@waha/core/media/MediaStorageFactory';
|
||||
import { DefaultMap } from '@waha/utils/DefaultMap';
|
||||
@@ -54,7 +58,7 @@ enum DefaultSessionStatus {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionManagerCore extends SessionManager {
|
||||
export class SessionManagerCore extends SessionManager implements OnModuleInit {
|
||||
SESSION_STOP_TIMEOUT = 3000;
|
||||
|
||||
// session - exists and running (or failed or smth)
|
||||
@@ -66,19 +70,22 @@ export class SessionManagerCore extends SessionManager {
|
||||
|
||||
protected readonly EngineClass: typeof WhatsappSession;
|
||||
protected events2: DefaultMap<WAHAEvents, SwitchObservable<any>>;
|
||||
protected readonly engineBootstrap: EngineBootstrap;
|
||||
|
||||
constructor(
|
||||
config: WhatsappConfigService,
|
||||
private engineConfigService: EngineConfigService,
|
||||
private webjsEngineConfigService: WebJSEngineConfigService,
|
||||
gowsConfigService: GowsEngineConfigService,
|
||||
log: PinoLogger,
|
||||
private mediaStorageFactory: MediaStorageFactory,
|
||||
) {
|
||||
super(config, log);
|
||||
super(log, config, gowsConfigService);
|
||||
this.session = DefaultSessionStatus.STOPPED;
|
||||
this.sessionConfig = null;
|
||||
const engineName = this.engineConfigService.getDefaultEngineName();
|
||||
this.EngineClass = this.getEngine(engineName);
|
||||
this.engineBootstrap = this.getEngineBootstrap(engineName);
|
||||
|
||||
this.events2 = new DefaultMap<WAHAEvents, SwitchObservable<any>>(
|
||||
(key) =>
|
||||
@@ -89,7 +96,6 @@ export class SessionManagerCore extends SessionManager {
|
||||
|
||||
this.store = new LocalStoreCore(engineName.toLowerCase());
|
||||
this.sessionAuthRepository = new LocalSessionAuthRepository(this.store);
|
||||
this.startPredefinedSessions();
|
||||
this.clearStorage().catch((error) => {
|
||||
this.log.error({ error }, 'Error while clearing storage');
|
||||
});
|
||||
@@ -100,6 +106,8 @@ export class SessionManagerCore extends SessionManager {
|
||||
return WhatsappSessionWebJSCore;
|
||||
} else if (engine === WAHAEngine.NOWEB) {
|
||||
return WhatsappSessionNoWebCore;
|
||||
} else if (engine === WAHAEngine.GOWS) {
|
||||
return WhatsappSessionGoWSCore;
|
||||
} else {
|
||||
throw new NotFoundException(`Unknown whatsapp engine '${engine}'.`);
|
||||
}
|
||||
@@ -112,15 +120,21 @@ export class SessionManagerCore extends SessionManager {
|
||||
}
|
||||
|
||||
async beforeApplicationShutdown(signal?: string) {
|
||||
this.stopEvents();
|
||||
if (!this.session) {
|
||||
return;
|
||||
if (this.session) {
|
||||
await this.stop(this.DEFAULT, true);
|
||||
}
|
||||
await this.stop(this.DEFAULT, true);
|
||||
this.stopEvents();
|
||||
await this.engineBootstrap.shutdown();
|
||||
}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
await this.engineBootstrap.bootstrap();
|
||||
this.startPredefinedSessions();
|
||||
}
|
||||
|
||||
private async clearStorage() {
|
||||
const storage = this.mediaStorageFactory.build(
|
||||
const storage = await this.mediaStorageFactory.build(
|
||||
'all',
|
||||
this.log.logger.child({ name: 'Storage' }),
|
||||
);
|
||||
await storage.purge();
|
||||
@@ -156,7 +170,8 @@ export class SessionManagerCore extends SessionManager {
|
||||
logger.level = getPinoLogLevel(this.sessionConfig?.debug);
|
||||
const loggerBuilder: LoggerBuilder = logger;
|
||||
|
||||
const storage = this.mediaStorageFactory.build(
|
||||
const storage = await this.mediaStorageFactory.build(
|
||||
name,
|
||||
loggerBuilder.child({ name: 'Storage' }),
|
||||
);
|
||||
await storage.init();
|
||||
@@ -179,6 +194,8 @@ export class SessionManagerCore extends SessionManager {
|
||||
};
|
||||
if (this.EngineClass === WhatsappSessionWebJSCore) {
|
||||
sessionConfig.engineConfig = this.webjsEngineConfigService.getConfig();
|
||||
} else if (this.EngineClass === WhatsappSessionGoWSCore) {
|
||||
sessionConfig.engineConfig = this.gowsConfigService.getConfig();
|
||||
}
|
||||
await this.sessionAuthRepository.init(name);
|
||||
// @ts-ignore
|
||||
@@ -374,4 +391,12 @@ export class SessionManagerCore extends SessionManager {
|
||||
protected stopEvents() {
|
||||
complete(this.events2);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.store.init();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ interface IMediaEngineProcessor<Message> {
|
||||
|
||||
getMessageId(message: Message): string;
|
||||
|
||||
getChatId(message: Message): string;
|
||||
|
||||
getMediaBuffer(message: Message): Promise<Buffer | null>;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ interface IMediaManager {
|
||||
message: Message,
|
||||
session: string,
|
||||
): Promise<Message>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export { IMediaManager };
|
||||
@@ -6,6 +6,9 @@ import { S3MediaData } from '@waha/structures/media.s3.dto';
|
||||
interface Message {
|
||||
// false_111111111@c.us_AAAAAAAAAAAAAA
|
||||
id: string;
|
||||
|
||||
// 1111111@c.us
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
interface File {
|
||||
@@ -37,6 +40,19 @@ abstract class IMediaStorage {
|
||||
abstract getStorageData(data: MediaData): Promise<MediaStorageData>;
|
||||
|
||||
abstract purge(): Promise<void>;
|
||||
|
||||
abstract close(): Promise<void>;
|
||||
}
|
||||
|
||||
export function getMetadata(data: MediaData): any {
|
||||
const now = new Date();
|
||||
return {
|
||||
'waha-session': data.session,
|
||||
'waha-chat-id': data.message.chatId,
|
||||
'waha-message-id': data.message.id,
|
||||
'waha-media-filename': data.file.filename,
|
||||
'waha-created-at': now.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
export { IMediaStorage };
|
||||
@@ -52,6 +52,7 @@ export class MediaManager implements IMediaManager {
|
||||
session: string,
|
||||
): Promise<WAMedia | null> {
|
||||
const messageId = processor.getMessageId(message);
|
||||
const chatId = processor.getChatId(message);
|
||||
const mimetype = processor.getMimetype(message);
|
||||
const filename = processor.getFilename(message);
|
||||
if (!this.shouldProcessMimetype(mimetype)) {
|
||||
@@ -66,6 +67,7 @@ export class MediaManager implements IMediaManager {
|
||||
session: session,
|
||||
message: {
|
||||
id: messageId,
|
||||
chatId: chatId,
|
||||
},
|
||||
file: {
|
||||
extension: extension,
|
||||
@@ -195,4 +197,10 @@ export class MediaManager implements IMediaManager {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.storage.close().catch((err) => {
|
||||
this.log.error(`Failed to close media storage: ${err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,5 +2,5 @@ import { IMediaStorage } from '@waha/core/media/IMediaStorage';
|
||||
import { Logger } from 'pino';
|
||||
|
||||
export abstract class MediaStorageFactory {
|
||||
abstract build(logger: Logger): IMediaStorage;
|
||||
abstract build(name: string, logger: Logger): Promise<IMediaStorage>;
|
||||
}
|
||||
@@ -3,12 +3,12 @@ import { SECOND } from '@waha/structures/enums.dto';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { Logger } from 'pino';
|
||||
import { promisify } from 'util';
|
||||
import fs = require('fs');
|
||||
import del = require('del');
|
||||
import { fileExists } from '@waha/utils/files';
|
||||
|
||||
const writeFileAsync = promisify(fs.writeFile);
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const writeFileAtomic = require('write-file-atomic');
|
||||
|
||||
/**
|
||||
* Save files locally using the filesystem
|
||||
@@ -41,7 +41,7 @@ export class MediaLocalStorage implements IMediaStorage {
|
||||
const filepath = this.getFullPath(data);
|
||||
const folder = path.dirname(filepath);
|
||||
await fsp.mkdir(folder, { recursive: true });
|
||||
await writeFileAsync(filepath, buffer);
|
||||
await writeFileAtomic(filepath, buffer);
|
||||
this.postponeRemoval(filepath);
|
||||
return true;
|
||||
}
|
||||
@@ -92,4 +92,8 @@ export class MediaLocalStorage implements IMediaStorage {
|
||||
this.lifetimeMs,
|
||||
);
|
||||
}
|
||||
|
||||
async close() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ export class MediaLocalStorageFactory extends MediaStorageFactory {
|
||||
super();
|
||||
}
|
||||
|
||||
build(logger: Logger): IMediaStorage {
|
||||
async build(name: string, logger: Logger): Promise<IMediaStorage> {
|
||||
// Local storage uses the same folder for all sessions
|
||||
return new MediaLocalStorage(
|
||||
logger,
|
||||
this.config.filesFolder,
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { SessionConfig } from '../../structures/sessions.dto';
|
||||
|
||||
export abstract class ISessionConfigRepository {
|
||||
abstract save(
|
||||
abstract saveConfig(
|
||||
sessionName: string,
|
||||
config: SessionConfig | null,
|
||||
): Promise<void>;
|
||||
|
||||
abstract get(sessionName: string): Promise<SessionConfig | null>;
|
||||
abstract getConfig(sessionName: string): Promise<SessionConfig | null>;
|
||||
|
||||
abstract exists(sessionName: string): Promise<boolean>;
|
||||
|
||||
abstract delete(sessionName: string): Promise<void>;
|
||||
abstract deleteConfig(sessionName: string): Promise<void>;
|
||||
|
||||
abstract getAll(): Promise<string[]>;
|
||||
abstract getAllConfigs(): Promise<string[]>;
|
||||
|
||||
abstract init(): Promise<void>;
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import { SessionConfig } from '../../structures/sessions.dto';
|
||||
import { ISessionConfigRepository } from './ISessionConfigRepository';
|
||||
import { LocalStore } from './LocalStore';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const writeFileAtomic = require('write-file-atomic');
|
||||
|
||||
export class LocalSessionConfigRepository extends ISessionConfigRepository {
|
||||
FILENAME = '.waha.session.config.json';
|
||||
private store: LocalStore;
|
||||
@@ -27,7 +30,7 @@ export class LocalSessionConfigRepository extends ISessionConfigRepository {
|
||||
return true;
|
||||
}
|
||||
|
||||
async get(sessionName: string): Promise<SessionConfig | null> {
|
||||
async getConfig(sessionName: string): Promise<SessionConfig | null> {
|
||||
const filepath = this.getFilePath(sessionName);
|
||||
// Check file exists
|
||||
if (!(await fileExists(filepath))) {
|
||||
@@ -45,26 +48,26 @@ export class LocalSessionConfigRepository extends ISessionConfigRepository {
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
async save(sessionName: string, config: SessionConfig | null) {
|
||||
async saveConfig(sessionName: string, config: SessionConfig | null) {
|
||||
// Create folder if not exist
|
||||
const folder = this.store.getSessionDirectory(sessionName);
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
// Save config
|
||||
const filepath = this.getFilePath(sessionName);
|
||||
const content = JSON.stringify(config || null);
|
||||
await fs.writeFile(filepath, content);
|
||||
await writeFileAtomic(filepath, content);
|
||||
}
|
||||
|
||||
private getFilePath(sessionName): string {
|
||||
return this.store.getFilePath(sessionName, this.FILENAME);
|
||||
}
|
||||
|
||||
async delete(sessionName: string): Promise<void> {
|
||||
async deleteConfig(sessionName: string): Promise<void> {
|
||||
const sessionDirectory = this.store.getSessionDirectory(sessionName);
|
||||
await fs.remove(sessionDirectory);
|
||||
}
|
||||
|
||||
async getAll(): Promise<string[]> {
|
||||
async getAllConfigs(): Promise<string[]> {
|
||||
await this.store.init();
|
||||
const content = await fs.readdir(this.store.getEngineDirectory(), {
|
||||
withFileTypes: true,
|
||||
@@ -73,4 +76,8 @@ export class LocalSessionConfigRepository extends ISessionConfigRepository {
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.map((dirent) => dirent.name);
|
||||
}
|
||||
|
||||
async init() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Sqlite3SchemaValidation } from '@waha/core/engines/noweb/store/sqlite3/Sqlite3SchemaValidation';
|
||||
import {
|
||||
ISessionWorkerRepository,
|
||||
SessionWorkerInfo,
|
||||
} from '@waha/core/storage/ISessionWorkerRepository';
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { Field, Index, Schema } from '@waha/core/storage/sqlite3/Schema';
|
||||
import { Sqlite3KVRepository } from '@waha/core/storage/sqlite3/Sqlite3KVRepository';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const SCHEMA = new Schema(
|
||||
'session_worker',
|
||||
[
|
||||
new Field('id', 'TEXT'),
|
||||
new Field('worker', 'TEXT'),
|
||||
new Field('data', 'TEXT'),
|
||||
],
|
||||
[
|
||||
new Index('session_worker_id_idx', ['id']),
|
||||
new Index('session_worker_worker_idx', ['worker']),
|
||||
],
|
||||
);
|
||||
|
||||
export class LocalSessionWorkerRepository
|
||||
extends Sqlite3KVRepository<SessionWorkerInfo>
|
||||
implements ISessionWorkerRepository
|
||||
{
|
||||
constructor(store: LocalStore) {
|
||||
const db = store.getWAHADatabase();
|
||||
super(db, SCHEMA);
|
||||
}
|
||||
|
||||
assign(session: string, worker: string): Promise<void> {
|
||||
return this.upsertOne({ id: session, worker: worker });
|
||||
}
|
||||
|
||||
unassign(session: string, worker: string): Promise<void> {
|
||||
return this.deleteBy({ id: session, worker: worker });
|
||||
}
|
||||
|
||||
remove(session: string) {
|
||||
return this.deleteById(session);
|
||||
}
|
||||
|
||||
async getSessionsByWorker(worker: string): Promise<string[]> {
|
||||
const data = await this.getAllBy({ worker: worker });
|
||||
return data.map((d) => d.id);
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.migrations();
|
||||
this.validateSchema();
|
||||
}
|
||||
|
||||
private migrations() {
|
||||
this.db.exec(
|
||||
'CREATE TABLE IF NOT EXISTS session_worker (id TEXT, worker TEXT, data TEXT)',
|
||||
);
|
||||
// Session can have only one record
|
||||
this.db.exec(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS session_worker_id_idx ON session_worker (id)',
|
||||
);
|
||||
// Worker can have multiple records
|
||||
this.db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS session_worker_worker_idx ON session_worker (worker)',
|
||||
);
|
||||
}
|
||||
|
||||
private validateSchema() {
|
||||
new Sqlite3SchemaValidation(SCHEMA, this.db).validate();
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,8 @@ import { LocalStore } from './LocalStore';
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
export class LocalStoreCore extends LocalStore {
|
||||
protected readonly baseDirectory: string = path.join(
|
||||
os.tmpdir(),
|
||||
'waha-sessions',
|
||||
);
|
||||
protected readonly baseDirectory: string =
|
||||
process.env.WAHA_LOCAL_STORE_BASE_DIR || './.sessions';
|
||||
|
||||
private readonly engine: string;
|
||||
private db: any;
|
||||
@@ -54,8 +52,7 @@ export class LocalStoreCore extends LocalStore {
|
||||
}
|
||||
|
||||
protected getDirectoryPath(name: string): string {
|
||||
const suffix = crypto.createHash('md5').update(name).digest('hex');
|
||||
return path.join(this.getEngineDirectory(), `${name}-${suffix}`);
|
||||
return path.join(this.getEngineDirectory(), name);
|
||||
}
|
||||
|
||||
getWAHADatabase(): any {
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,4 @@
|
||||
export interface IJsonQuery {
|
||||
filter(field: string, key: string): string;
|
||||
sortBy(field: string, sortBy: string, direction: string): string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Knex from 'knex';
|
||||
|
||||
export interface ISQLEngine {
|
||||
/**
|
||||
* Run a query that does not return any data
|
||||
*/
|
||||
run(query: Knex.QueryBuilder): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get a single row from the database
|
||||
*/
|
||||
get(query: Knex.QueryBuilder): Promise<any | null>;
|
||||
|
||||
/**
|
||||
* Get all rows from the database
|
||||
*/
|
||||
all(query: Knex.QueryBuilder): Promise<any[]>;
|
||||
|
||||
/**
|
||||
* Run a raw SQL query with bindings
|
||||
*/
|
||||
raw(sql: string, bindings: any[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Execute a raw SQL query
|
||||
*/
|
||||
exec(sql: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Field, Schema } from '@waha/core/storage/Schema';
|
||||
import { IJsonQuery } from '@waha/core/storage/sql/IJsonQuery';
|
||||
import { ISQLEngine } from '@waha/core/storage/sql/ISQLEngine';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
import { KnexPaginator } from '@waha/utils/Paginator';
|
||||
import Knex from 'knex';
|
||||
|
||||
export type Migration = string;
|
||||
|
||||
export class SqlKVRepository<Entity> {
|
||||
protected UPSERT_BATCH_SIZE = 100;
|
||||
protected Paginator: typeof KnexPaginator = KnexPaginator;
|
||||
|
||||
protected jsonQuery: IJsonQuery;
|
||||
|
||||
get schema(): Schema {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
get metadata(): Map<string, (entity: Entity) => any> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
get migrations(): Migration[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
constructor(
|
||||
private engine: ISQLEngine,
|
||||
protected knex: Knex.Knex,
|
||||
) {}
|
||||
|
||||
get columns(): Field[] {
|
||||
return this.schema.columns;
|
||||
}
|
||||
|
||||
get table(): string {
|
||||
return this.schema.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrations
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
await this.applyMigrations();
|
||||
await this.validateSchema();
|
||||
}
|
||||
|
||||
protected async applyMigrations() {
|
||||
for (const migration of this.migrations) {
|
||||
await this.engine.exec(migration);
|
||||
}
|
||||
}
|
||||
|
||||
protected async validateSchema() {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update methods
|
||||
*/
|
||||
save(entity: Entity) {
|
||||
return this.upsertOne(entity);
|
||||
}
|
||||
|
||||
async upsertOne(entity: Entity): Promise<void> {
|
||||
await this.upsertMany([entity]);
|
||||
}
|
||||
|
||||
async upsertMany(entities: Entity[]): Promise<void> {
|
||||
if (entities.length === 0) {
|
||||
return;
|
||||
}
|
||||
const batchSize = this.UPSERT_BATCH_SIZE;
|
||||
for (let i = 0; i < entities.length; i += batchSize) {
|
||||
const batch = entities.slice(i, i + batchSize);
|
||||
await this.upsertBatch(batch);
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertBatch(entities: Entity[]): Promise<void> {
|
||||
const data = entities.map((entity) => this.dump(entity));
|
||||
const columns = this.columns.map((c) => `"${c.fieldName}"`);
|
||||
const values = data.map((d) => Object.values(d)).flat();
|
||||
const sql = `INSERT INTO "${this.table}" (${columns.join(', ')})
|
||||
VALUES ${data
|
||||
.map(() => `(${columns.map(() => '?').join(', ')})`)
|
||||
.join(', ')}
|
||||
ON CONFLICT(id) DO UPDATE
|
||||
SET ${columns
|
||||
.map((column) => `${column} = excluded.${column}`)
|
||||
.join(', ')}`;
|
||||
try {
|
||||
await this.raw(sql, values);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Error upserting data: ${err}, sql: ${sql}, values: ${values}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get methods
|
||||
*/
|
||||
getAll(pagination?: PaginationParams) {
|
||||
let query = this.select();
|
||||
query = this.pagination(query, pagination);
|
||||
return this.all(query);
|
||||
}
|
||||
|
||||
getAllByIds(ids: string[]) {
|
||||
return this.all(this.select().whereIn('id', ids));
|
||||
}
|
||||
|
||||
getById(id: string): Promise<Entity | null> {
|
||||
return this.getBy({ id: id });
|
||||
}
|
||||
|
||||
getAllBy(filters: any) {
|
||||
const query = this.select().where(filters);
|
||||
return this.all(query);
|
||||
}
|
||||
|
||||
public async getBy(filters: any) {
|
||||
const query = this.select().where(filters).limit(1);
|
||||
return this.get(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete methods
|
||||
*/
|
||||
async deleteAll() {
|
||||
const query = this.delete();
|
||||
await this.run(query);
|
||||
}
|
||||
|
||||
async deleteById(id: string) {
|
||||
await this.deleteBy({ id: id });
|
||||
}
|
||||
|
||||
public async deleteBy(filters: any) {
|
||||
const query = this.delete().where(filters);
|
||||
await this.run(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL Implementation details
|
||||
*/
|
||||
public async raw(sql: string, bindings: any[]): Promise<void> {
|
||||
await this.engine.raw(sql, bindings);
|
||||
}
|
||||
|
||||
protected async run(query: Knex.QueryBuilder): Promise<void> {
|
||||
await this.engine.run(query);
|
||||
}
|
||||
|
||||
protected async get(query: Knex.QueryBuilder): Promise<Entity | null> {
|
||||
const row = await this.engine.get(query);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return this.parse(row);
|
||||
}
|
||||
|
||||
public async all(query: Knex.QueryBuilder): Promise<Entity[]> {
|
||||
const rows = await this.engine.all(query);
|
||||
return rows.map((row) => this.parse(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL helpers
|
||||
*/
|
||||
public select() {
|
||||
return this.knex.select().from(this.table);
|
||||
}
|
||||
|
||||
protected delete() {
|
||||
return this.knex.delete().from(this.table);
|
||||
}
|
||||
|
||||
public pagination(query: any, pagination?: PaginationParams) {
|
||||
const paginator = new this.Paginator(pagination, this.jsonQuery);
|
||||
return paginator.apply(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON helpers
|
||||
*/
|
||||
public filterJson(field: string, key: string): string {
|
||||
return this.jsonQuery.filter(field, key);
|
||||
}
|
||||
|
||||
protected stringify(data: any): string {
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
protected parse(row: any) {
|
||||
return JSON.parse(row.data);
|
||||
}
|
||||
|
||||
protected dump(entity: Entity) {
|
||||
const data = {};
|
||||
const raw = entity;
|
||||
for (const field of this.columns) {
|
||||
const fn = this.metadata.get(field.fieldName);
|
||||
if (fn) {
|
||||
data[field.fieldName] = fn(raw);
|
||||
} else if (field.fieldName == 'data') {
|
||||
data['data'] = this.stringify(raw);
|
||||
} else {
|
||||
data[field.fieldName] = raw[field.fieldName] ?? null;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Field, Index, Schema } from '@waha/core/storage/Schema';
|
||||
import { Migration } from '@waha/core/storage/sql/SqlKVRepository';
|
||||
|
||||
/**
|
||||
* Session Config
|
||||
*/
|
||||
export const SQLSessionConfigSchema = new Schema(
|
||||
'session_config',
|
||||
[new Field('id', 'TEXT'), new Field('data', 'TEXT')],
|
||||
[new Index('session_config_id_index', ['id'])],
|
||||
);
|
||||
|
||||
export const SQLSessionConfigMigrations: Migration[] = [
|
||||
'CREATE TABLE IF NOT EXISTS session_config (id TEXT PRIMARY KEY, data TEXT)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS session_config_id_index ON session_config (id)',
|
||||
];
|
||||
|
||||
/**
|
||||
* Me
|
||||
*/
|
||||
export const SQLMeSchema = new Schema(
|
||||
'me',
|
||||
[new Field('id', 'TEXT'), new Field('data', 'TEXT')],
|
||||
[new Index('me_id_index', ['id'])],
|
||||
);
|
||||
|
||||
export const SQLMeMigrations: Migration[] = [
|
||||
'CREATE TABLE IF NOT EXISTS me (id TEXT PRIMARY KEY, data TEXT)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS me_id_index ON me (id)',
|
||||
];
|
||||
|
||||
/**
|
||||
* Worker
|
||||
*/
|
||||
export const SQLSessionWorkerSchema = new Schema(
|
||||
'session_worker',
|
||||
[
|
||||
new Field('id', 'TEXT'),
|
||||
new Field('worker', 'TEXT'),
|
||||
new Field('data', 'TEXT'),
|
||||
],
|
||||
[
|
||||
new Index('session_worker_id_idx', ['id']),
|
||||
new Index('session_worker_worker_idx', ['worker']),
|
||||
],
|
||||
);
|
||||
|
||||
export const SQLSessionWorkerMigrations: Migration[] = [
|
||||
'CREATE TABLE IF NOT EXISTS session_worker (id TEXT, worker TEXT, data TEXT)',
|
||||
// Session can have only one record
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS session_worker_id_idx ON session_worker (id)',
|
||||
// Worker can have multiple records
|
||||
'CREATE INDEX IF NOT EXISTS session_worker_worker_idx ON session_worker (worker)',
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ISQLEngine } from '@waha/core/storage/sql/ISQLEngine';
|
||||
import { Database } from 'better-sqlite3';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
export class Sqlite3Engine implements ISQLEngine {
|
||||
constructor(protected db: Database) {}
|
||||
|
||||
async all(query: Knex.QueryBuilder): Promise<any[]> {
|
||||
const sql = query.toSQL().sql;
|
||||
const bind = query.toSQL().bindings;
|
||||
return this.db.prepare(sql).all(bind);
|
||||
}
|
||||
|
||||
async get(query: Knex.QueryBuilder): Promise<any | null> {
|
||||
const sql = query.toSQL().sql;
|
||||
const bind = query.toSQL().bindings;
|
||||
const row: any = this.db.prepare(sql).get(bind);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async run(query: Knex.QueryBuilder): Promise<void> {
|
||||
const sql = query.toSQL().sql;
|
||||
const bind = query.toSQL().bindings;
|
||||
this.db.prepare(sql).run(bind);
|
||||
return;
|
||||
}
|
||||
|
||||
async raw(sql: string, bindings: any[]) {
|
||||
this.db.prepare(sql).run(bindings);
|
||||
}
|
||||
|
||||
async exec(sql): Promise<void> {
|
||||
this.db.exec(sql);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IJsonQuery } from '@waha/core/storage/sql/IJsonQuery';
|
||||
|
||||
export class Sqlite3JsonQuery implements IJsonQuery {
|
||||
filter(field: string, key: string): string {
|
||||
const jsonPath = `$.${key}`;
|
||||
return `${field}->'${jsonPath}' = ?`;
|
||||
}
|
||||
|
||||
sortBy(field: string, sortBy: string, direction: string): string {
|
||||
const jsonPath = `$.${sortBy}`;
|
||||
return `${field}->'${jsonPath}' ${direction}`;
|
||||
}
|
||||
}
|
||||
@@ -1,177 +1,21 @@
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
import { KnexPaginator } from '@waha/utils/Paginator';
|
||||
import { SqlKVRepository } from '@waha/core/storage/sql/SqlKVRepository';
|
||||
import { Sqlite3Engine } from '@waha/core/storage/sqlite3/Sqlite3Engine';
|
||||
import { Sqlite3JsonQuery } from '@waha/core/storage/sqlite3/Sqlite3JsonQuery';
|
||||
import { Database } from 'better-sqlite3';
|
||||
import Knex, { QueryBuilder } from 'knex';
|
||||
|
||||
import { Field, Schema } from './Schema';
|
||||
import Knex from 'knex';
|
||||
|
||||
/**
|
||||
* Key value repository with extra metadata
|
||||
*/
|
||||
export class Sqlite3KVRepository<Entity> {
|
||||
private UPSERT_BATCH_SIZE = 100;
|
||||
export class Sqlite3KVRepository<Entity> extends SqlKVRepository<Entity> {
|
||||
protected db: Database;
|
||||
protected jsonQuery = new Sqlite3JsonQuery();
|
||||
|
||||
private readonly metadata: Map<string, (entity: Entity) => any>;
|
||||
protected readonly table: string;
|
||||
private readonly columns: Field[];
|
||||
private knex: Knex.Knex;
|
||||
|
||||
protected Paginator = KnexPaginator;
|
||||
|
||||
constructor(
|
||||
db: Database,
|
||||
schema: Schema,
|
||||
metadata: Map<string, (entity: Entity) => any> | null = null,
|
||||
) {
|
||||
this.db = db;
|
||||
this.columns = schema.columns;
|
||||
this.table = schema.name;
|
||||
this.metadata = metadata || new Map();
|
||||
|
||||
constructor(db: Database) {
|
||||
// sqlite does not support inserting default values. Set the `useNullAsDefault` flag to hide this warning. (see docs https://knexjs.org/guide/query-builder.html#insert).
|
||||
this.knex = Knex({ client: 'better-sqlite3', useNullAsDefault: true });
|
||||
}
|
||||
|
||||
getAll(pagination?: PaginationParams) {
|
||||
let query = this.select();
|
||||
query = this.pagination(query, pagination);
|
||||
return this.all(query);
|
||||
}
|
||||
|
||||
getAllByIds(ids: string[]) {
|
||||
return this.all(this.select().whereIn('id', ids));
|
||||
}
|
||||
|
||||
protected getAllBy(filters: any) {
|
||||
const query = this.select().where(filters);
|
||||
return this.all(query);
|
||||
}
|
||||
|
||||
protected async getBy(filters: any) {
|
||||
const query = this.select().where(filters).limit(1);
|
||||
return this.get(query);
|
||||
}
|
||||
|
||||
protected dump(entity: Entity) {
|
||||
const data = {};
|
||||
const raw = entity;
|
||||
for (const field of this.columns) {
|
||||
const fn = this.metadata.get(field.fieldName);
|
||||
if (fn) {
|
||||
data[field.fieldName] = fn(raw);
|
||||
} else if (field.fieldName == 'data') {
|
||||
data['data'] = this.stringify(raw);
|
||||
} else {
|
||||
data[field.fieldName] = raw[field.fieldName];
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
save(entity: Entity) {
|
||||
return this.upsertOne(entity);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Entity | null> {
|
||||
return this.getBy({ id: id });
|
||||
}
|
||||
|
||||
async upsertOne(entity: Entity): Promise<void> {
|
||||
await this.upsertMany([entity]);
|
||||
}
|
||||
|
||||
async upsertMany(entities: Entity[]): Promise<void> {
|
||||
if (entities.length === 0) {
|
||||
return;
|
||||
}
|
||||
const batchSize = this.UPSERT_BATCH_SIZE;
|
||||
for (let i = 0; i < entities.length; i += batchSize) {
|
||||
const batch = entities.slice(i, i + batchSize);
|
||||
await this.upsertBatch(batch);
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertBatch(entities: Entity[]): Promise<void> {
|
||||
const data = entities.map((entity) => this.dump(entity));
|
||||
const keys = this.columns.map((c) => c.fieldName);
|
||||
const values = data.map((d) => Object.values(d)).flat();
|
||||
try {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO ${this.table} (${keys.join(', ')})
|
||||
VALUES ${data
|
||||
.map(() => `(${keys.map(() => '?').join(', ')})`)
|
||||
.join(', ')}
|
||||
ON CONFLICT(id) DO UPDATE
|
||||
SET ${keys.map((key) => `${key} = excluded.${key}`).join(', ')}`,
|
||||
)
|
||||
.run(values);
|
||||
} catch (err) {
|
||||
console.error(`Error upserting data: ${err}, values: ${values}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
protected async deleteBy(filters: any) {
|
||||
const query = this.delete().where(filters);
|
||||
await this.run(query);
|
||||
}
|
||||
|
||||
async deleteAll() {
|
||||
const query = this.delete();
|
||||
await this.run(query);
|
||||
}
|
||||
|
||||
async deleteById(id: string) {
|
||||
await this.deleteBy({ id: id });
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL helpers
|
||||
*/
|
||||
|
||||
protected select() {
|
||||
return this.knex.select().from(this.table);
|
||||
}
|
||||
|
||||
protected delete() {
|
||||
return this.knex.delete().from(this.table);
|
||||
}
|
||||
|
||||
protected async all(query: any) {
|
||||
const sql = query.toSQL().sql;
|
||||
const bind = query.toSQL().bindings;
|
||||
const rows: any[] = this.db.prepare(sql).all(bind);
|
||||
return rows.map((row) => this.parse(row));
|
||||
}
|
||||
|
||||
protected async get(query: any) {
|
||||
const sql = query.toSQL().sql;
|
||||
const bind = query.toSQL().bindings;
|
||||
const row: any = this.db.prepare(sql).get(bind);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return this.parse(row);
|
||||
}
|
||||
|
||||
protected async run(query: any) {
|
||||
const sql = query.toSQL().sql;
|
||||
const bind = query.toSQL().bindings;
|
||||
return this.db.prepare(sql).run(bind);
|
||||
}
|
||||
|
||||
protected stringify(data: any): string {
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
protected parse(row: any) {
|
||||
return JSON.parse(row.data);
|
||||
}
|
||||
|
||||
protected pagination(query: any, pagination?: PaginationParams) {
|
||||
const paginator = new this.Paginator(pagination);
|
||||
return paginator.apply(query);
|
||||
const knex = Knex({ client: 'better-sqlite3', useNullAsDefault: true });
|
||||
const engine = new Sqlite3Engine(db);
|
||||
super(engine, knex);
|
||||
this.db = db;
|
||||
}
|
||||
}
|
||||
+14
-27
@@ -1,32 +1,30 @@
|
||||
import { Sqlite3SchemaValidation } from '@waha/core/engines/noweb/store/sqlite3/Sqlite3SchemaValidation';
|
||||
import { ISessionMeRepository } from '@waha/core/storage/ISessionMeRepository';
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import { Field, Index, Schema } from '@waha/core/storage/sqlite3/Schema';
|
||||
import { SQLMeMigrations, SQLMeSchema } from '@waha/core/storage/sql/schemas';
|
||||
import { Sqlite3KVRepository } from '@waha/core/storage/sqlite3/Sqlite3KVRepository';
|
||||
import { MeInfo } from '@waha/structures/sessions.dto';
|
||||
import * as path from 'path';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const SCHEMA = new Schema(
|
||||
'me',
|
||||
[new Field('id', 'TEXT'), new Field('data', 'TEXT')],
|
||||
[new Index('me_id_index', ['id'])],
|
||||
);
|
||||
|
||||
class SessionMeInfo {
|
||||
id: string;
|
||||
me?: MeInfo;
|
||||
}
|
||||
|
||||
export class LocalSessionMeRepository
|
||||
export class Sqlite3SessionMeRepository
|
||||
extends Sqlite3KVRepository<SessionMeInfo>
|
||||
implements ISessionMeRepository
|
||||
{
|
||||
get schema() {
|
||||
return SQLMeSchema;
|
||||
}
|
||||
|
||||
get migrations() {
|
||||
return SQLMeMigrations;
|
||||
}
|
||||
|
||||
constructor(store: LocalStore) {
|
||||
const db = store.getWAHADatabase();
|
||||
super(db, SCHEMA);
|
||||
super(db);
|
||||
}
|
||||
|
||||
upsertMe(sessionName: string, me: MeInfo): Promise<void> {
|
||||
@@ -42,19 +40,8 @@ export class LocalSessionMeRepository
|
||||
return this.deleteById(sessionName);
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.migrations();
|
||||
this.validateSchema();
|
||||
}
|
||||
|
||||
private migrations() {
|
||||
this.db.exec(
|
||||
'CREATE TABLE IF NOT EXISTS me (id TEXT PRIMARY KEY, data TEXT)',
|
||||
);
|
||||
this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS me_id_index ON me (id)');
|
||||
}
|
||||
|
||||
private validateSchema() {
|
||||
new Sqlite3SchemaValidation(SCHEMA, this.db).validate();
|
||||
protected async validateSchema() {
|
||||
const validation = new Sqlite3SchemaValidation(this.schema, this.db);
|
||||
validation.validate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Sqlite3SchemaValidation } from '@waha/core/engines/noweb/store/sqlite3/Sqlite3SchemaValidation';
|
||||
import {
|
||||
ISessionWorkerRepository,
|
||||
SessionWorkerInfo,
|
||||
} from '@waha/core/storage/ISessionWorkerRepository';
|
||||
import { LocalStore } from '@waha/core/storage/LocalStore';
|
||||
import {
|
||||
SQLSessionWorkerMigrations,
|
||||
SQLSessionWorkerSchema,
|
||||
} from '@waha/core/storage/sql/schemas';
|
||||
import { Sqlite3KVRepository } from '@waha/core/storage/sqlite3/Sqlite3KVRepository';
|
||||
|
||||
export class Sqlite3SessionWorkerRepository
|
||||
extends Sqlite3KVRepository<SessionWorkerInfo>
|
||||
implements ISessionWorkerRepository
|
||||
{
|
||||
get schema() {
|
||||
return SQLSessionWorkerSchema;
|
||||
}
|
||||
|
||||
get migrations() {
|
||||
return SQLSessionWorkerMigrations;
|
||||
}
|
||||
|
||||
constructor(store: LocalStore) {
|
||||
const db = store.getWAHADatabase();
|
||||
super(db);
|
||||
}
|
||||
|
||||
assign(session: string, worker: string): Promise<void> {
|
||||
return this.upsertOne({ id: session, worker: worker });
|
||||
}
|
||||
|
||||
unassign(session: string, worker: string): Promise<void> {
|
||||
return this.deleteBy({ id: session, worker: worker });
|
||||
}
|
||||
|
||||
remove(session: string) {
|
||||
return this.deleteById(session);
|
||||
}
|
||||
|
||||
async getSessionsByWorker(worker: string): Promise<string[]> {
|
||||
const data = await this.getAllBy({ worker: worker });
|
||||
return data.map((d) => d.id);
|
||||
}
|
||||
|
||||
protected async validateSchema() {
|
||||
const validation = new Sqlite3SchemaValidation(this.schema, this.db);
|
||||
validation.validate();
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ async function bootstrap() {
|
||||
logger: getNestJSLogLevels(),
|
||||
httpsOptions: httpsOptions,
|
||||
bufferLogs: true,
|
||||
forceCloseConnections: true,
|
||||
});
|
||||
app.useLogger(app.get(NestJSPinoLogger));
|
||||
|
||||
|
||||
@@ -22,11 +22,11 @@ export class WebsocketHeartbeatJob {
|
||||
this.logger.debug(
|
||||
`Terminating client connection due to heartbeat timeout, ${client.id}`,
|
||||
);
|
||||
return client.terminate();
|
||||
client.terminate();
|
||||
}
|
||||
|
||||
client.isAlive = false;
|
||||
this.logger.debug(`Sending heartbeat (ping) to ${client.id}`);
|
||||
this.logger.verbose(`Sending heartbeat (ping) to ${client.id}`);
|
||||
client.ping();
|
||||
});
|
||||
}, this.intervalTime);
|
||||
@@ -40,7 +40,7 @@ export class WebsocketHeartbeatJob {
|
||||
private onPong(ws: WebSocket) {
|
||||
return (event: any) => {
|
||||
ws.isAlive = true;
|
||||
this.logger.debug(`Heartbeat (pong) received from ${ws.id}`);
|
||||
this.logger.verbose(`Heartbeat (pong) received from ${ws.id}`);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export const WHATSAPP_DEFAULT_SESSION_NAME = 'default';
|
||||
|
||||
@@ -7,6 +7,7 @@ export const WHATSAPP_DEFAULT_SESSION_NAME = 'default';
|
||||
*/
|
||||
export class SessionQuery {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
session: string = WHATSAPP_DEFAULT_SESSION_NAME;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { BooleanString } from '@waha/nestjs/validation/BooleanString';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
import {
|
||||
LimitOffsetParams,
|
||||
PaginationParams,
|
||||
} from '@waha/structures/pagination.dto';
|
||||
import { ChatIdProperty } from '@waha/structures/properties.dto';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsNumber, IsOptional } from 'class-validator';
|
||||
@@ -38,6 +41,23 @@ export class GetChatMessagesFilter {
|
||||
'filter.fromMe'?: boolean;
|
||||
}
|
||||
|
||||
export class ChatPictureQuery {
|
||||
@Transform(BooleanString)
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiProperty({
|
||||
example: false,
|
||||
required: false,
|
||||
description:
|
||||
'Refresh the picture from the server (24h cache by default). Do not refresh if not needed, you can get rate limit error',
|
||||
})
|
||||
refresh?: boolean = false;
|
||||
}
|
||||
|
||||
export class ChatPictureResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export class GetChatMessagesQuery {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@@ -50,7 +70,7 @@ export class GetChatMessagesQuery {
|
||||
offset?: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: true,
|
||||
example: false,
|
||||
required: false,
|
||||
description: 'Download media for messages',
|
||||
})
|
||||
@@ -105,6 +125,21 @@ export class PinMessageRequest {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export class OverviewPaginationParams extends LimitOffsetParams {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number = 20;
|
||||
}
|
||||
|
||||
export class ChatSummary {
|
||||
id: string;
|
||||
name: string | null;
|
||||
picture: string | null;
|
||||
lastMessage: any;
|
||||
_chat: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { BooleanString } from '@waha/nestjs/validation/BooleanString';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
import { ChatIdProperty } from '@waha/structures/properties.dto';
|
||||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { SessionBaseRequest, SessionQuery } from './base.dto';
|
||||
|
||||
@@ -15,6 +17,19 @@ export class ContactQuery extends SessionQuery {
|
||||
contactId: string;
|
||||
}
|
||||
|
||||
export class ContactProfilePictureQuery extends ContactQuery {
|
||||
@Transform(BooleanString)
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiProperty({
|
||||
example: false,
|
||||
required: false,
|
||||
description:
|
||||
'Refresh the picture from the server (24h cache by default). Do not refresh if not needed, you can get rate limit error',
|
||||
})
|
||||
refresh?: boolean = false;
|
||||
}
|
||||
|
||||
enum ContactSortField {
|
||||
ID = 'id',
|
||||
NAME = 'name',
|
||||
|
||||
@@ -41,6 +41,7 @@ export enum WAHASessionStatus {
|
||||
export enum WAHAEngine {
|
||||
WEBJS = 'WEBJS',
|
||||
NOWEB = 'NOWEB',
|
||||
GOWS = 'GOWS',
|
||||
}
|
||||
|
||||
export enum WAHAPresenceStatus {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsString } from 'class-validator';
|
||||
import { BooleanString } from '@waha/nestjs/validation/BooleanString';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
import { PaginationParams } from './pagination.dto';
|
||||
|
||||
/**
|
||||
* Structures
|
||||
@@ -46,3 +56,35 @@ export class CreateGroupRequest {
|
||||
@IsArray()
|
||||
participants: Array<Participant>;
|
||||
}
|
||||
|
||||
export class JoinGroupRequest {
|
||||
@ApiProperty({
|
||||
description: 'Group code (123) or url (https://chat.whatsapp.com/123)',
|
||||
example: 'https://chat.whatsapp.com/1234567890abcdef',
|
||||
})
|
||||
@IsString()
|
||||
code: string;
|
||||
}
|
||||
|
||||
export class JoinGroupResponse {
|
||||
@ApiProperty({
|
||||
description: 'Group ID',
|
||||
example: '123@g.us',
|
||||
})
|
||||
id: string;
|
||||
}
|
||||
|
||||
export enum GroupSortField {
|
||||
ID = 'id',
|
||||
SUBJECT = 'subject',
|
||||
}
|
||||
|
||||
export class GroupsPaginationParams extends PaginationParams {
|
||||
@ApiProperty({
|
||||
description: 'Sort by field',
|
||||
enum: GroupSortField,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(GroupSortField)
|
||||
sortBy?: string;
|
||||
}
|
||||
@@ -6,8 +6,7 @@ export enum SortOrder {
|
||||
DESC = 'desc',
|
||||
ASC = 'asc',
|
||||
}
|
||||
|
||||
export class PaginationParams {
|
||||
export class LimitOffsetParams {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@@ -17,7 +16,9 @@ export class PaginationParams {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export class PaginationParams extends LimitOffsetParams {
|
||||
@ApiProperty({
|
||||
description: 'Sort by field',
|
||||
})
|
||||
|
||||
@@ -166,8 +166,9 @@ export class SessionDetailedInfo extends SessionInfo {
|
||||
engine?: any;
|
||||
}
|
||||
|
||||
const MONGO_DB_NAME_LIMIT = 64;
|
||||
const MONGO_DB_NAME_PREFIX_LEN = 'waha_noweb'.length;
|
||||
// Affect almost all Databases - Sqlite, MongoDB, Postgres.
|
||||
const DB_NAME_LIMIT = 64;
|
||||
const DB_NAME_MAX_PREFIX_LEN = 'waha_noweb'.length;
|
||||
|
||||
export class SessionCreateRequest {
|
||||
@ApiProperty({
|
||||
@@ -177,7 +178,7 @@ export class SessionCreateRequest {
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(MONGO_DB_NAME_LIMIT - MONGO_DB_NAME_PREFIX_LEN)
|
||||
@MaxLength(DB_NAME_LIMIT - DB_NAME_MAX_PREFIX_LEN)
|
||||
name: string | undefined;
|
||||
|
||||
@ValidateNested()
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface StatusRequest {
|
||||
export class TextStatus {
|
||||
text: string = 'Have a look! https://github.com/';
|
||||
backgroundColor: string = '#38b42f';
|
||||
font: number = 1;
|
||||
font: number = 0;
|
||||
|
||||
@ContactsProperty
|
||||
contacts?: string[];
|
||||
|
||||
@@ -32,6 +32,8 @@ export class WAMessageAckBody {
|
||||
fromMe: boolean;
|
||||
ack: WAMessageAck;
|
||||
ackName: string;
|
||||
|
||||
_data?: any;
|
||||
}
|
||||
|
||||
export class WAGroupPayload {
|
||||
|
||||
+13
-4
@@ -1,4 +1,5 @@
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
import { IJsonQuery } from '@waha/core/storage/sql/IJsonQuery';
|
||||
import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
|
||||
import * as lodash from 'lodash';
|
||||
|
||||
export abstract class Paginator {
|
||||
@@ -42,19 +43,27 @@ export class KnexPaginator extends Paginator {
|
||||
indexes: string[] = [];
|
||||
dataField: string = 'data';
|
||||
|
||||
constructor(
|
||||
pagination: PaginationParams,
|
||||
protected jsonQuery: IJsonQuery,
|
||||
) {
|
||||
super(pagination);
|
||||
}
|
||||
|
||||
protected sort(query: any) {
|
||||
if (!this.pagination?.sortBy) {
|
||||
return query;
|
||||
}
|
||||
const sortBy = this.pagination.sortBy;
|
||||
const direction = this.pagination.sortOrder || 'asc';
|
||||
const direction = this.pagination.sortOrder || SortOrder.ASC;
|
||||
if (this.indexes.includes(sortBy)) {
|
||||
return query.orderBy(sortBy, direction);
|
||||
}
|
||||
// Use data->"$.{field}"
|
||||
|
||||
// Make sure to sanitize sortBy to prevent SQL injection
|
||||
// sqlite3 doesn't support binding for column names and direction
|
||||
return query.orderByRaw(`${this.dataField}->'$.${sortBy}' ${direction}`);
|
||||
const sql = this.jsonQuery.sortBy(this.dataField, sortBy, direction);
|
||||
return query.orderByRaw(sql);
|
||||
}
|
||||
|
||||
protected limit(query: any) {
|
||||
|
||||
@@ -25,10 +25,14 @@ function getNestJSLogLevels(): LogLevel[] {
|
||||
|
||||
export function getPinoLogLevel(debug: boolean = false): Level {
|
||||
const enableDebug = process.env.DEBUG != undefined || debug;
|
||||
const level = getDefaultPinoLogLevel();
|
||||
if (level === 'trace') {
|
||||
return 'trace';
|
||||
}
|
||||
if (enableDebug) {
|
||||
return 'debug';
|
||||
}
|
||||
return getDefaultPinoLogLevel();
|
||||
return level;
|
||||
}
|
||||
|
||||
export function isDebugEnabled() {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { filter, pipe } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
interface EventValue<T> {
|
||||
event: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export function onlyEvent<T>(event: any) {
|
||||
return pipe(
|
||||
filter<EventValue<T>>((obj) => obj.event === event),
|
||||
map((event) => event.data),
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -33,7 +33,7 @@ export function getEngineName(): string {
|
||||
}
|
||||
|
||||
export const VERSION: WAHAEnvironment = {
|
||||
version: '2024.11.10',
|
||||
version: '2025.1.6',
|
||||
engine: getEngineName(),
|
||||
tier: getWAHAVersion(),
|
||||
browser: getBrowserExecutablePath(),
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const axios = require('axios');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const yargs = require('yargs/yargs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { hideBin } = require('yargs/helpers');
|
||||
|
||||
// Parse CLI arguments
|
||||
const argv = yargs(hideBin(process.argv))
|
||||
.option('number', {
|
||||
alias: 'n',
|
||||
type: 'number',
|
||||
description: 'Number of messages to send',
|
||||
default: 10,
|
||||
})
|
||||
.option('chat-id', {
|
||||
alias: 'to',
|
||||
type: 'number',
|
||||
description: 'Chat ID to send messages to',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('session', {
|
||||
type: 'string',
|
||||
description: 'Session ID to use',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('api-key', {
|
||||
alias: 'k',
|
||||
type: 'string',
|
||||
description: 'API key for authentication',
|
||||
demandOption: true,
|
||||
})
|
||||
.help().argv;
|
||||
|
||||
const sendTextMessage = async (session, numMessages, chatId, apiKey) => {
|
||||
const url = 'http://localhost:3000/api/sendText/';
|
||||
for (let i = 1; i <= numMessages; i++) {
|
||||
console.log(`Sending message ${i}`);
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{
|
||||
chatId: chatId.toString(),
|
||||
session: session,
|
||||
text: `Message - ${i}`,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Api-Key': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
console.log(`Message ${i} sent successfully:`, response.data);
|
||||
}
|
||||
};
|
||||
|
||||
// Run the script
|
||||
sendTextMessage(argv.session, argv.number, argv['chat-id'], argv['api-key']);
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user