Compare commits

..
94 Commits
Author SHA1 Message Date
devlikepro 9b1771c693 [core] 2025.2.7
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-02-27 14:34:23 +07:00
devlikepro 823e604737 [core] GOWS v1.0.0 2025-02-27 14:34:23 +07:00
devlikepro 97a31bdf17 [core] NOWEB - ignore EPHEMERAL_SYNC_RESPONSE 2025-02-27 14:34:22 +07:00
devlikepro ee7a198ae6 [core] GOWS - startTyping/stopTyping 2025-02-27 14:34:22 +07:00
devlikepro f143a51010 [core] group id in picture 2025-02-27 14:34:21 +07:00
devlikepro e483185b2c [core] fields.exclude optional 2025-02-27 14:34:21 +07:00
devlikepro 40e1c75746 [core] GOWS - v1.0.0-dev.1 2025-02-27 14:34:21 +07:00
devlikepro 456bd4693c [core] GOWS - fetch proto from ref with no v 2025-02-27 14:34:21 +07:00
devlikepro 5ac9c0f760 [core] NOWEB - no auto restart 2025-02-27 14:34:20 +07:00
devlikepro f621d84b83 [core] Exclude groups participants
fix #242
2025-02-27 14:34:20 +07:00
devlikepro 0dd001e95a [core] GOWS Groups API 2025-02-27 14:34:19 +07:00
devlikepro 69b5235db9 [core] Groups API - return 200 on POST in not create API 2025-02-27 14:34:19 +07:00
devlikepro a673d63b33 [core] Groups Picture API - Get, Update, Remove
fix #802
2025-02-27 14:34:18 +07:00
devlikepro 36485e10f2 [core] NOWEB add admin only / messages admin settings API
fix #695
fix #808
2025-02-27 14:34:18 +07:00
devlikepro 8a1a062127 [core] Place Channels Search API at the end 2025-02-27 14:34:18 +07:00
devlikepro 5961e0de08 [core] Fix group.participants undefined
fix #810
2025-02-27 14:34:18 +07:00
devlikepro 60bda521a0 [core] Wait until session is running and check if exist after 2025-02-27 14:34:18 +07:00
devlikepro 49f63c0f0d [core] 2025.2.6
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
fix #820
2025-02-21 10:06:27 +07:00
devlikepro 33910aedb8 [core] 2025.2.5
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-02-19 09:20:27 +07:00
devlikepro 3359a81a14 [core] NOWEB add message.revoked event
fix #813
2025-02-19 09:20:26 +07:00
devlikepro e27f850e68 [core] NOWEB Fix body extract from messages
fix #813
2025-02-19 09:20:26 +07:00
devlikepro 07340b8f53 [core] Up NOWEB
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
fix #809
2025-02-17 16:53:08 +07:00
devlikepro 2f9dd7faa8 [core] catch uncaughtException
fix #809
2025-02-17 16:53:07 +07:00
devlikepro 3f81fd4a87 [core] 2025.2.4 2025-02-17 16:53:07 +07:00
devlikepro 4dd961fc66 [core] Up dashboard 2025-02-17 16:53:07 +07:00
devlikepro 0bd70bfb32 [core] Deprecate webhooks: state.change, group.join, group.leave
fix #404
2025-02-17 16:53:06 +07:00
devlikepro 3ab845d2d6 [core] Groups V2 Events
fix #583
fix #527
fix #404
2025-02-17 16:53:06 +07:00
devlikepro 443f09d5a2 [core] 2025.2.3
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-02-12 17:18:00 +07:00
devlikepro 3500952bb7 [core] Up GOWS 2025-02-12 17:18:00 +07:00
devlikepro e98182c4b5 [core] Add /profile API
fix #529
2025-02-12 17:18:00 +07:00
devlikepro 2337827744 [core] GOWS hasMedia fix 2025-02-12 17:18:00 +07:00
devlikepro 556dd66af3 [core] NOWEB hasMedia fix 2025-02-12 17:18:00 +07:00
devlikepro 8bdad3f4f9 [core] WEBJS hasMedia fix 2025-02-12 17:17:59 +07:00
devlikepro 6346d4143d [core] 2025.2.2
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-02-10 08:57:14 +07:00
devlikepro fd8dc88760 [core] rm auth/authorize-code 2025-02-10 08:57:14 +07:00
devlikepro b2810f390e [core] max connections 3000 2025-02-10 08:57:14 +07:00
devlikepro 23d3cd0f4b [core] Up GOWS - build proto 2025-02-10 08:57:13 +07:00
devlikepro 4a3c76773d [core] Up GOWS 2025-02-10 08:57:12 +07:00
devlikepro d23e7604f0 [core] NOWEB Channels Search 2025-02-10 08:57:12 +07:00
devlikepro 0b3f6c6eff [core] GOWS - Search Channels 2025-02-10 08:57:12 +07:00
devlikepro f2869c892b [core] WEBJS - ensure suffix 2025-02-10 08:57:12 +07:00
devlikepro 3755a9529b [core] GOWS - Channels Search, Messages Preview 2025-02-10 08:57:11 +07:00
devlikepro 890f0593ee [core] NOWEB - Channels Search, Messages Preview 2025-02-10 08:57:11 +07:00
devlikepro e6d666b5b0 [core] WEBJS Channels Search, Messages Preview 2025-02-10 08:57:10 +07:00
devlikepro f440acf145 [core] Channels Search, Messages Preview - API 2025-02-10 08:57:10 +07:00
devlikepro 1ad0e0c6dd [core] Add proto files
fixes #784
2025-02-07 08:41:37 +07:00
devlikepro bb8691ddc0 [core] Use waha docker image
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
2025-02-02 13:25:24 +07:00
devlikepro cf3041d07b [core] swagger 2025-02-02 13:23:24 +07:00
devlikepro 313b3d5d6b [core] up NOWEB 2025-02-02 13:23:24 +07:00
devlikepro 122e7336be [core] NOWEB - use channels-6712 branch 2025-02-02 13:23:24 +07:00
devlikepro e954453f6b [core] up GOWS 2025-02-02 13:23:24 +07:00
devlikepro 55ef3d09f3 [core] 2025.2.1 2025-01-30 14:35:08 +07:00
devlikepro 88b9cc5ac9 [core] Up dashboard 2025-01-30 14:35:08 +07:00
devlikepro f07cb7470d [core] GOWS - contacts, chats 2025-01-30 14:33:50 +07:00
devlikepro bd874a0232 [core] Up NOWEB 2025-01-30 14:33:50 +07:00
devlikepro 7cc4cccb9c [core] Fetch gows bin and proto from gows releases 2025-01-30 14:33:50 +07:00
devlikepro e85a3bacb7 [core] GOWS - Add ack to incoming messages 2025-01-30 14:33:50 +07:00
devlikepro 530dfa758f [core] Handle keep alive restored 2025-01-30 14:33:50 +07:00
devlikepro 8bc3489060 [core] 2025.1.6
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-01-22 09:20:20 +07:00
devlikepro 6914cb171a [core] WEBJS send image to channel 2025-01-22 09:20:12 +07:00
devlikepro 176c2bbd4c [core] 2025.1.5
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-01-21 11:57:40 +07:00
devlikepro 7771954455 [core] build proto on package install 2025-01-21 11:57:28 +07:00
devlikepro a66743bb20 [core] rm old scripts 2025-01-21 11:57:27 +07:00
devlikepro 851c7b75f9 [core] GOWS Channels 2025-01-21 11:57:26 +07:00
devlikepro ed0b406027 [core] GOWS Send Status 2025-01-21 11:57:26 +07:00
devlikepro 71985972a2 [core] GOWS check exists 2025-01-21 11:57:26 +07:00
devlikepro 2dbeef9290 [core] font 0 by default in status 2025-01-21 11:57:25 +07:00
devlikepro a79453270d [core] WEBJS handle status 2025-01-21 11:57:25 +07:00
devlikepro ae71808c61 [core] ws heartbeat do not return after closing one client 2025-01-21 11:57:25 +07:00
devlikepro 5c195ba37b [core] ws 60 seconds heartbeat 2025-01-21 11:57:24 +07:00
devlikepro 607ee17a86 [core] perf tests 2025-01-21 11:57:23 +07:00
devlikepro a15aacc5b3 [core] perf tests 2025-01-21 11:57:23 +07:00
devlikepro 78fb4b1a65 [core] Init store
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-01-19 15:18:07 +07:00
devlikepro 8e566cba72 [core] 2025.1.4 2025-01-19 14:44:06 +07:00
devlikepro 36184288de [core] NOWEB PostgreSQL 2025-01-19 14:42:59 +07:00
devlikepro 6fc5bf38a7 [core] NOWEB - handle not found profile picture 2025-01-19 14:42:59 +07:00
devlikepro e146ecde70 [core] Add local store to WAHA Core
fix #741
2025-01-19 14:42:58 +07:00
devlikepro 72b796019a [core] PostgreSQL Media 2025-01-19 14:42:58 +07:00
devlikepro 0f66d15ba8 [core] PSQL Media 2025-01-19 14:42:57 +07:00
devlikepro 4695e933b8 [core] Up GOWS
[core] GOWS up
2025-01-19 14:42:57 +07:00
devlikepro 5e1da6cd6f [core] PostgreSQL docker-compose.yaml 2025-01-19 14:42:56 +07:00
devlikepro 0c3bbc0980 [core] PostgreSQL 2025-01-19 14:42:56 +07:00
devlikepro d2bb5bea63 [core] Add session config schema + migrations 2025-01-19 14:42:55 +07:00
devlikepro c76e191d4d [core] Extract general SqlKVRepository 2025-01-19 14:42:54 +07:00
devlikepro 171594d0ae [core] NOWEB Update contact if not found 2025-01-19 14:42:53 +07:00
devlikepro 4e5f499b48 [core] Rename db max name limit 2025-01-19 14:42:53 +07:00
devlikepro bb75ad8b8e [core] Add pg 2025-01-19 14:42:52 +07:00
devlikepro 482cc7028b [core] Move sqlite3 2025-01-19 14:42:51 +07:00
devlikepro 735bdb3854 [core] Websocket - close server and then terminate clients 2025-01-19 14:42:51 +07:00
devlikepro 1ee1bd78af [core] Stop events after we stop the session 2025-01-19 14:42:51 +07:00
devlikepro 00463c729d [core] SIGTERM to GOWS 2025-01-19 14:42:51 +07:00
devlikepro dac636b3bb [core] detached GOWS subprocess 2025-01-19 14:42:51 +07:00
devlikepro ad87121506 [core] make copy-dashboard (dist) 2025-01-19 14:42:51 +07:00
devlikepro 55d802f005 [core] Disable some flag for WEBJS stability 2025-01-19 14:42:51 +07:00
104 changed files with 26569 additions and 1016 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
Dockerfile
src/core/engines/gows/grpc
src/core/engines/gows/proto
.*sessions
sessions
files
+32 -17
View File
@@ -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
#
+1
View File
@@ -1 +1,2 @@
src/core/engines/gows/grpc/*
src/core/engines/gows/proto/*
+1 -1
View File
@@ -1,4 +1,4 @@
src/core/engines/gows/grpc
src/core/engines/gows/proto
.env
*.heapsnapshot
.yarn
+3
View File
@@ -3,6 +3,7 @@ exclude: |
^src/dashboard/.*|
^src/core/engines/webjs/.*html|
^src/core/engines/gows/grpc/.*|
^src/core/engines/gows/proto/.*|
^src/plus/engines/webjs/.*html|
^.yarn/.*
)$
@@ -74,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/.*|
)$
+26 -37
View File
@@ -1,9 +1,3 @@
# GOWS
ARG GOWS_GITHUB_REPO=devlikeapro/gows
ARG GOWS_SHA=95ca63a1dee5faec75232a9f59a548dfd4e414ff
ARG WAHA_DASHBOARD_GITHUB_REPO=devlikeapro/dashboard
ARG WAHA_DASHBOARD_SHA=a1c839d8da94d1cade182addffdfc42fc0cf7ed3
#
# Build
#
@@ -11,12 +5,8 @@ ARG NODE_VERSION=22.8-bullseye
FROM node:${NODE_VERSION} AS build
ENV PUPPETEER_SKIP_DOWNLOAD=True
# install protoc
RUN apt-get update && \
apt-get install protobuf-compiler -y
# npm packages
WORKDIR /src
WORKDIR /git
COPY package.json .
COPY yarn.lock .
ENV YARN_CHECKSUM_BEHAVIOR=update
@@ -25,19 +15,9 @@ RUN yarn set version 3.6.3
RUN yarn install
# App
WORKDIR /src
ADD . /src
WORKDIR /git
ADD . /git
RUN yarn install
# Build node grpc client for GOWS engine
WORKDIR /gows/proto
ARG GOWS_GITHUB_REPO
ARG GOWS_SHA
RUN wget https://raw.githubusercontent.com/${GOWS_GITHUB_REPO}/${GOWS_SHA}/proto/gows.proto
WORKDIR /src
RUN make proto-gows
RUN yarn build && find ./dist -name "*.d.ts" -delete
#
@@ -45,10 +25,13 @@ RUN yarn build && find ./dist -name "*.d.ts" -delete
#
FROM node:${NODE_VERSION} AS dashboard
# Download WAHA Dashboard
ARG WAHA_DASHBOARD_GITHUB_REPO
ARG WAHA_DASHBOARD_SHA
# 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 \
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 \
@@ -60,6 +43,10 @@ RUN \
# 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
@@ -69,16 +56,18 @@ RUN apt-get update \
&& apt-get install -y libvips-dev \
&& rm -rf /var/lib/apt/lists/*
ARG GOWS_GITHUB_REPO
ARG GOWS_SHA
RUN git clone https://github.com/${GOWS_GITHUB_REPO} gows && \
cd gows && \
git checkout ${GOWS_SHA}
COPY waha.config.json /tmp/waha.config.json
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
RUN \
GOWS_GITHUB_REPO=$(jq -r '.waha.gows.repo' /tmp/waha.config.json) && \
GOWS_SHA=$(jq -r '.waha.gows.ref' /tmp/waha.config.json) && \
ARCH=$(uname -m) && \
if [ "$ARCH" = "x86_64" ]; then ARCH="amd64"; \
elif [ "$ARCH" = "aarch64" ]; then ARCH="arm64"; \
else echo "Unsupported architecture: $ARCH" && exit 1; fi && \
mkdir -p /go/gows/bin && \
wget -O /go/gows/bin/gows https://github.com/${GOWS_GITHUB_REPO}/releases/download/${GOWS_SHA}/gows-${ARCH} && \
chmod +x /go/gows/bin/gows
#
@@ -163,8 +152,8 @@ 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
+8 -11
View File
@@ -35,7 +35,7 @@ for-swagger:
WHATSAPP_SWAGGER_CONFIG_ADVANCED=true . ${NVM_DIR}/nvm.sh && nvm exec yarn start
up-noweb:
. ${NVM_DIR}/nvm.sh && nvm exec yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-channels-678
. ${NVM_DIR}/nvm.sh && nvm exec yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-channels-6712
up-noweb-libsignal:
. ${NVM_DIR}/nvm.sh && nvm exec yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
@@ -47,17 +47,14 @@ start-proxy:
docker run --rm -d --name squid-container -e TZ=UTC -p 3128:3128 ubuntu/squid:5.2-22.04_beta
proto-gows:
rm -rf src/core/engines/gows/grpc
mkdir -p src/core/engines/gows/grpc
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:src/core/engines/gows/grpc \
--grpc_out=grpc_js:src/core/engines/gows/grpc \
--ts_out=grpc_js:src/core/engines/gows/grpc \
-I ../gows/proto ../gows/proto/gows.proto
node scripts/gows-proto.js build --dir ../gows/proto
gows:
cd ../gows && \
export PATH=${HOME}/go/bin:${PATH} && \
make all
make all
copy-dashboard:
cd ../waha-hub/ui && \
make copy-waha
+21 -59
View File
@@ -6,22 +6,16 @@
**WAHA** - **W**hats**A**pp **H**TTP **A**PI (REST API) that you can install on your own server and run in less than 5 minutes!
[![Docker Pulls](https://img.shields.io/docker/pulls/devlikeapro/whatsapp-http-api)](https://hub.docker.com/r/devlikeapro/waha)
[![Docker Pulls](https://img.shields.io/docker/pulls/devlikeapro/waha)](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
View File
@@ -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=3000"
# 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: {}
+8 -3
View File
@@ -20,10 +20,13 @@
"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",
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-channels-6712",
"@adiwajshing/keyed-db": "^0.2.4",
"@aws-sdk/client-s3": "^3.633.0",
"@aws-sdk/s3-request-presigner": "^3.633.0",
@@ -68,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",
@@ -119,7 +123,8 @@
"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": [
+144
View File
@@ -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://github.com/${repo}/releases/download/${ref}/${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();
-22
View File
@@ -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."
-21
View File
@@ -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
-39
View File
@@ -6,7 +6,6 @@ import {
Query,
UseInterceptors,
} from '@nestjs/common';
import { UnprocessableEntityException } from '@nestjs/common/exceptions/unprocessable-entity.exception';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
import {
@@ -19,8 +18,6 @@ import { SessionManager } from '../core/abc/manager.abc';
import { WhatsappSession } from '../core/abc/session.abc';
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
import {
CaptchaBody,
OTPRequest,
QRCodeFormat,
QRCodeQuery,
QRCodeValue,
@@ -63,42 +60,6 @@ class AuthController {
) {
return session.requestCode(request.phoneNumber, request.method, request);
}
@Post('authorize-code')
@SessionApiParam
@ApiOperation({
summary: 'Send OTP authentication code.',
})
authorizeCode(
@SessionParam session: WhatsappSession,
@Body() request: OTPRequest,
) {
return session.authorizeCode(request.code);
}
@Get('captcha')
@SessionApiParam
@ApiFileAcceptHeader()
@UseInterceptors(new BufferResponseInterceptor())
@ApiOperation({
summary: 'Get captcha image.',
})
async getCaptcha(@SessionParam session: WhatsappSession): Promise<Buffer> {
const image = await session.getCaptcha();
return image.get();
}
@Post('captcha')
@SessionApiParam
@ApiOperation({
summary: 'Enter captcha code.',
})
saveCaptcha(
@SessionParam session: WhatsappSession,
@Body() body: CaptchaBody,
) {
return session.saveCaptcha(body.code);
}
}
export { AuthController };
+102 -5
View File
@@ -3,31 +3,51 @@ import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Query,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ChannelsInfoServiceCore } from '@waha/core/services/ChannelsInfoServiceCore';
import {
SessionApiParam,
WorkingSessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import {
Channel,
ChannelCategory,
ChannelCountry,
ChannelListResult,
ChannelMessage,
ChannelSearchByText,
ChannelSearchByView,
ChannelView,
CreateChannelRequest,
ListChannelsQuery,
NewsletterIdApiParam,
NewsletterIdOrInviteCodeApiParam,
PreviewChannelMessages,
} from '@waha/structures/channels.dto';
import { SessionManager } from '../core/abc/manager.abc';
import { isNewsletter, WhatsappSession } from '../core/abc/session.abc';
import {
isNewsletter,
parseChannelInviteLink,
WhatsappSession,
} from '../core/abc/session.abc';
@ApiSecurity('api_key')
@Controller('api/:session/channels')
@ApiTags('📢 Channels')
export class ChannelsController {
constructor(private manager: SessionManager) {}
constructor(
private manager: SessionManager,
private channelsInfoService: ChannelsInfoServiceCore,
) {}
@Get('')
@SessionApiParam
@@ -76,12 +96,44 @@ export class ChannelsController {
if (isNewsletter(id)) {
return session.channelsGetChannel(id);
} else {
// https://www.whatsapp.com/channel/123 => 123
const inviteCode = id.split('/').pop();
const inviteCode = parseChannelInviteLink(id);
return session.channelsGetChannelByInviteCode(inviteCode);
}
}
@Get(':id/messages/preview')
@SessionApiParam
@UsePipes(new WAHAValidationPipe())
@ApiParam({
name: 'id',
description: 'Channel id or invite code',
required: true,
type: 'string',
schema: {
default: '0029Va4K0PZ5a245NkngBA2M',
},
})
@ApiOperation({
summary: 'Preview channel messages',
description:
'You can use either ' +
'invite code (https://www.whatsapp.com/channel/123) or (123)' +
'OR' +
'Channel ID (123@newsletter).',
})
async previewChannelMessages(
@WorkingSessionParam session: WhatsappSession,
@Param('id') code: string,
@Query() query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
if (isNewsletter(code)) {
const channel = await session.channelsGetChannel(code);
code = parseChannelInviteLink(channel.invite);
}
const inviteCode = parseChannelInviteLink(code);
return session.previewChannelMessages(inviteCode, query);
}
@Post(':id/follow')
@SessionApiParam
@NewsletterIdApiParam
@@ -125,4 +177,49 @@ export class ChannelsController {
): Promise<void> {
return session.channelsUnmuteChannel(id);
}
@Post('/search/by-view')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@UsePipes(new WAHAValidationPipe())
@ApiOperation({ summary: 'Search for channels (by view)' })
async searchByView(
@WorkingSessionParam session: WhatsappSession,
@Body() request: ChannelSearchByView,
): Promise<ChannelListResult> {
return session.searchChannelsByView(request);
}
@Post('/search/by-text')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@UsePipes(new WAHAValidationPipe())
@ApiOperation({ summary: 'Search for channels (by text)' })
async searchByText(
@WorkingSessionParam session: WhatsappSession,
@Body() request: ChannelSearchByText,
): Promise<ChannelListResult> {
return session.searchChannelsByText(request);
}
@Get('/search/views')
@SessionApiParam
@ApiOperation({ summary: 'Get list of views for channel search' })
getSearchViews(): Promise<ChannelView[]> {
return this.channelsInfoService.getViews();
}
@Get('/search/countries')
@SessionApiParam
@ApiOperation({ summary: 'Get list of countries for channel search' })
getSearchCountries(): Promise<ChannelCountry[]> {
return this.channelsInfoService.getCountries();
}
@Get('/search/categories')
@SessionApiParam
@ApiOperation({ summary: 'Get list of categories for channel search' })
getSearchCategories(): Promise<ChannelCategory[]> {
return this.channelsInfoService.getCategories();
}
}
+119 -61
View File
@@ -3,6 +3,8 @@ import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Put,
@@ -16,12 +18,20 @@ import {
SessionApiParam,
WorkingSessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import { Result } from '@waha/structures/base.dto';
import {
ChatPictureQuery,
ChatPictureResponse,
} from '@waha/structures/chats.dto';
import { ProfilePictureRequest } from '@waha/structures/profile.dto';
import { SessionManager } from '../core/abc/manager.abc';
import { WhatsappSession } from '../core/abc/session.abc';
import { parseGroupInviteLink, WhatsappSession } from '../core/abc/session.abc';
import {
CreateGroupRequest,
DescriptionRequest,
GroupField,
GroupsListFields,
GroupsPaginationParams,
JoinGroupRequest,
JoinGroupResponse,
@@ -53,20 +63,19 @@ export class GroupsController {
@WorkingSessionParam session: WhatsappSession,
@Query() query: JoinGroupRequest,
): Promise<any> {
// https://chat.whatsapp.com/123 => 123
const code = query.code.split('/').pop();
const code = parseGroupInviteLink(query.code);
return session.joinInfoGroup(code);
}
@Post('join')
@HttpCode(HttpStatus.OK)
@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 code = parseGroupInviteLink(request.code);
const id = await session.joinGroup(code);
return { id: id };
}
@@ -75,14 +84,18 @@ export class GroupsController {
@SessionApiParam
@ApiOperation({ summary: 'Get all groups.' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
getGroups(
async getGroups(
@WorkingSessionParam session: WhatsappSession,
@Query() pagination: GroupsPaginationParams,
@Query() fields: GroupsListFields,
) {
return session.getGroups(pagination);
let groups: any = await session.getGroups(pagination);
groups = session.filterGroupsFields(groups, fields);
return groups;
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@ApiOperation({ summary: 'Refresh groups from the server.' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@@ -101,6 +114,100 @@ export class GroupsController {
return session.getGroup(id);
}
@Delete(':id')
@SessionApiParam
@GroupIdApiParam
@ApiOperation({ summary: 'Delete the group.' })
deleteGroup(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
) {
return session.deleteGroup(id);
}
@Post(':id/leave')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@ApiOperation({ summary: 'Leave the group.' })
leaveGroup(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
) {
return session.leaveGroup(id);
}
@Get(':id/picture')
@SessionApiParam
@GroupIdApiParam
@ApiOperation({ summary: 'Get group picture' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getChatPicture(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Query() query: ChatPictureQuery,
): Promise<ChatPictureResponse> {
const url = await session.getContactProfilePicture(id, query.refresh);
return { url: url };
}
@Put(':id/picture')
@SessionApiParam
@GroupIdApiParam
@ApiOperation({ summary: 'Set group picture' })
async setPicture(
@Param('id') id: string,
@WorkingSessionParam session: WhatsappSession,
@Body() request: ProfilePictureRequest,
): Promise<Result> {
const success = await session.updateGroupPicture(id, request.file);
return { success: success ?? true };
}
@Delete(':id/picture')
@SessionApiParam
@GroupIdApiParam
@ApiOperation({ summary: 'Delete group picture' })
async deletePicture(
@Param('id') id: string,
@WorkingSessionParam session: WhatsappSession,
): Promise<Result> {
const success = await session.updateGroupPicture(id, null);
return { success: success || true };
}
@Put(':id/description')
@ApiOperation({
summary: 'Updates the group description.',
description:
'Returns "true" if the subject was properly updated. This can return "false" if the user does not have the necessary permissions.',
})
@SessionApiParam
@GroupIdApiParam
setDescription(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: DescriptionRequest,
) {
return session.setDescription(id, request.description);
}
@Put(':id/subject')
@SessionApiParam
@GroupIdApiParam
@ApiOperation({
summary: 'Updates the group subject',
description:
'Returns "true" if the subject was properly updated. This can return "false" if the user does not have the necessary permissions.',
})
setSubject(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: SubjectRequest,
) {
return session.setSubject(id, request.subject);
}
@Put(':id/settings/security/info-admin-only')
@SessionApiParam
@GroupIdApiParam
@@ -162,60 +269,6 @@ export class GroupsController {
return session.getMessagesAdminsOnly(id);
}
@Delete(':id')
@SessionApiParam
@GroupIdApiParam
@ApiOperation({ summary: 'Delete the group.' })
deleteGroup(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
) {
return session.deleteGroup(id);
}
@Post(':id/leave')
@SessionApiParam
@GroupIdApiParam
@ApiOperation({ summary: 'Leave the group.' })
leaveGroup(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
) {
return session.leaveGroup(id);
}
@Put(':id/description')
@ApiOperation({
summary: 'Updates the group description.',
description:
'Returns "true" if the subject was properly updated. This can return "false" if the user does not have the necessary permissions.',
})
@SessionApiParam
@GroupIdApiParam
setDescription(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: DescriptionRequest,
) {
return session.setDescription(id, request.description);
}
@Put(':id/subject')
@SessionApiParam
@GroupIdApiParam
@ApiOperation({
summary: 'Updates the group subject',
description:
'Returns "true" if the subject was properly updated. This can return "false" if the user does not have the necessary permissions.',
})
setSubject(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: SubjectRequest,
) {
return session.setSubject(id, request.subject);
}
@Get(':id/invite-code')
@SessionApiParam
@GroupIdApiParam
@@ -228,6 +281,7 @@ export class GroupsController {
}
@Post(':id/invite-code/revoke')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@ApiOperation({
@@ -253,6 +307,7 @@ export class GroupsController {
}
@Post(':id/participants/add')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@ApiOperation({ summary: 'Add participants' })
@@ -265,6 +320,7 @@ export class GroupsController {
}
@Post(':id/participants/remove')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@ApiOperation({
@@ -279,6 +335,7 @@ export class GroupsController {
}
@Post(':id/admin/promote')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@ApiOperation({ summary: 'Promote participants to admin users.' })
@@ -291,6 +348,7 @@ export class GroupsController {
}
@Post(':id/admin/demote')
@HttpCode(HttpStatus.OK)
@SessionApiParam
@GroupIdApiParam
@ApiOperation({ summary: 'Demotes participants to regular users.' })
+94
View File
@@ -0,0 +1,94 @@
import {
Body,
Controller,
Delete,
Get,
Put,
UnprocessableEntityException,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import {
SessionApiParam,
WorkingSessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { Result } from '@waha/structures/base.dto';
import {
MyProfile,
ProfileNameRequest,
ProfilePictureRequest,
ProfileStatusRequest,
} from '@waha/structures/profile.dto';
@ApiSecurity('api_key')
@Controller('api/:session/profile')
@ApiTags('🆔 Profile')
export class ProfileController {
constructor(private manager: SessionManager) {}
@Get('')
@SessionApiParam
@ApiOperation({ summary: 'Get my profile' })
async getMyProfile(
@WorkingSessionParam session: WhatsappSession,
): Promise<MyProfile> {
const me = session.getSessionMeInfo();
if (!me) {
throw new UnprocessableEntityException('No profile found');
}
const picture = await session.getContactProfilePicture(me.id, false);
return {
id: me.id,
name: me.pushName,
picture: picture || null,
};
}
@Put('/name')
@SessionApiParam
@UsePipes(new WAHAValidationPipe())
@ApiOperation({ summary: 'Set my profile name' })
async setProfileName(
@WorkingSessionParam session: WhatsappSession,
@Body() request: ProfileNameRequest,
): Promise<Result> {
const success = await session.setProfileName(request.name);
return { success: success ?? true };
}
@Put('/status')
@SessionApiParam
@UsePipes(new WAHAValidationPipe())
@ApiOperation({ summary: 'Set profile status (About)' })
async setProfileStatus(
@WorkingSessionParam session: WhatsappSession,
@Body() request: ProfileStatusRequest,
): Promise<Result> {
const success = await session.setProfileStatus(request.status);
return { success: success ?? true };
}
@Put('/picture')
@SessionApiParam
@ApiOperation({ summary: 'Set profile picture' })
async setProfilePicture(
@WorkingSessionParam session: WhatsappSession,
@Body() request: ProfilePictureRequest,
): Promise<Result> {
const success = await session.updateProfilePicture(request.file);
return { success: success ?? true };
}
@Delete('/picture')
@SessionApiParam
@ApiOperation({ summary: 'Delete profile picture' })
async deleteProfilePicture(
@WorkingSessionParam session: WhatsappSession,
): Promise<Result> {
const success = await session.updateProfilePicture(null);
return { success: success || true };
}
}
+7
View File
@@ -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);
}
+3 -4
View File
@@ -57,13 +57,11 @@ export class SwaggerConfiguratorCore {
.setVersion(VERSION.version)
.addTag('🖥️ Sessions', 'Control WhatsApp sessions (accounts)')
.addTag('🔑 Auth', 'Authentication')
.addTag('🆔 Profile', 'Your profile information')
.addTag('🖼️ Screenshot', 'Get screenshot of WhatsApp and show QR code')
.addTag('📤 Chatting', 'Chatting methods')
.addTag('📢 Channels', 'Channels (newsletters) methods')
.addTag(
'🟢 Status',
'Status (aka stories) methods. <b>NOWEB</b> engine only!',
)
.addTag('🟢 Status', 'Status (aka stories) methods')
.addTag('💬 Chats', `Chats methods`)
.addTag(
'👤 Contacts',
@@ -140,6 +138,7 @@ export class SwaggerConfiguratorCore {
webhooks[event] = {
post: {
summary: eventMetadata.description,
deprecated: eventMetadata.deprecated || false,
requestBody: {
content: {
'application/json': {
+28 -1
View File
@@ -46,8 +46,10 @@ export abstract class SessionManager
protected sessionWorkerRepository: ISessionWorkerRepository;
private lock: any;
WAIT_SESSION_RUNNING_INTERVAL = 500;
WAIT_SESSION_RUNNING_TIMEOUT = 5_000;
WAIT_STATUS_INTERVAL = 500;
WAIT_STATUS_TIMEOUT = 5_000;
WAIT_STATUS_TIMEOUT = 10_000;
LOCK_TIMEOUT = 10_000;
protected constructor(
@@ -148,6 +150,31 @@ export abstract class SessionManager
sessionName: string,
expected: WAHASessionStatus[],
): Promise<WhatsappSession> {
const running = await waitUntil(
async () => this.isRunning(sessionName),
this.WAIT_SESSION_RUNNING_INTERVAL,
this.WAIT_SESSION_RUNNING_TIMEOUT,
);
if (!running) {
const exists = await this.exists(sessionName);
if (!exists) {
throw new UnprocessableEntityException({
error: `Session "${sessionName}" does not exist`,
session: sessionName,
});
}
const msg = {
error:
'Session status is not as expected. Try again later or restart the session',
session: sessionName,
status: 'STOPPED',
expected: expected,
};
throw new UnprocessableEntityException(msg);
}
const session = this.getSession(sessionName);
const valid = await waitUntil(
async () => expected.includes(session.status),
+161 -17
View File
@@ -1,7 +1,12 @@
import {
Channel,
ChannelListResult,
ChannelMessage,
ChannelSearchByText,
ChannelSearchByView,
CreateChannelRequest,
ListChannelsQuery,
PreviewChannelMessages,
} from '@waha/structures/channels.dto';
import {
ChatSummary,
@@ -10,6 +15,7 @@ import {
GetChatMessagesQuery,
} from '@waha/structures/chats.dto';
import { SendButtonsRequest } from '@waha/structures/chatting.buttons.dto';
import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { WAMessage } from '@waha/structures/responses.dto';
@@ -65,6 +71,8 @@ import {
} from '../../structures/enums.dto';
import {
CreateGroupRequest,
GroupField,
GroupsListFields,
GroupsPaginationParams,
ParticipantsRequest,
SettingsSecurityChangeInfo,
@@ -242,12 +250,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',
@@ -257,13 +266,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',
@@ -273,7 +282,7 @@ export abstract class WhatsappSession {
'--ignore-certificate-errors',
'--ignore-ssl-errors',
// https://github.com/devlikeapro/waha/issues/725
// '--in-process-gpu', // https://superuser.com/questions/654565/how-to-run-google-chrome-in-a-single-process
// '--in-process-gpu', // COMMENTED to test WEBJS stability
'--metrics-recording-only',
'--mute-audio',
'--no-default-browser-check',
@@ -282,7 +291,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',
@@ -325,21 +334,58 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public authorizeCode(code: string) {
throw new NotImplementedByEngineError();
}
abstract getScreenshot(): Promise<Buffer>;
public getSessionMeInfo(): MeInfo | null {
return null;
}
public getCaptcha(): Promise<QR> {
/**
* Profile methods
*/
public setProfileName(name: string): Promise<boolean> {
throw new NotImplementedByEngineError();
}
public saveCaptcha(code: string) {
public setProfileStatus(status: string): Promise<boolean> {
throw new NotImplementedByEngineError();
}
public async updateProfilePicture(
file: BinaryFile | RemoteFile | null,
): Promise<boolean> {
if (file) {
await this.setProfilePicture(file);
} else {
await this.deleteProfilePicture();
}
// Refresh profile picture after update
setTimeout(() => {
this.logger.debug('Refreshing my profile picture after update...');
this.refreshMyProfilePicture()
.then(() => {
this.logger.debug('Refreshed my profile picture after update');
})
.catch((err) => {
this.logger.error('Error refreshing my profile picture after update');
this.logger.error(err, err.stack);
});
}, 3_000);
return true;
}
protected async refreshMyProfilePicture() {
const me = this.getSessionMeInfo();
await this.getContactProfilePicture(me.id, true);
}
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
throw new NotImplementedByEngineError();
}
protected deleteProfilePicture(): Promise<boolean> {
throw new NotImplementedByEngineError();
}
@@ -514,7 +560,7 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public getContactAbout(query: ContactQuery) {
public getContactAbout(query: ContactQuery): Promise<{ about: string }> {
throw new NotImplementedByEngineError();
}
@@ -536,10 +582,18 @@ export abstract class WhatsappSession {
}
protected async refreshProfilePicture(id: string) {
let fn: Promise<string>;
if (isNewsletter(id)) {
fn = this.channelsGetChannel(id).then(
(channel: Channel) => channel.picture || channel.preview,
);
} else {
fn = this.fetchContactProfilePicture(id);
}
this.profilePictures.del(id);
const url = await this.fetchContactProfilePicture(id).catch((err) => {
this.logger.error('Error fetching profile picture');
this.logger.error(err, err.stack);
const url = await fn.catch((err) => {
this.logger.warn('Error fetching profile picture');
this.logger.warn(err, err.stack);
return null;
});
this.profilePictures.set(id, url);
@@ -569,10 +623,22 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public getGroups(pagination: PaginationParams) {
public getGroups(pagination: PaginationParams): Promise<any> {
throw new NotImplementedByEngineError();
}
public filterGroupsFields(data: any, fields: GroupsListFields) {
const groups: any[] = Array.isArray(data) ? data : Object.values(data);
if (fields.exclude?.includes(GroupField.PARTICIPANTS)) {
groups.forEach((group) => this.removeGroupsFieldParticipant(group));
}
return data;
}
protected removeGroupsFieldParticipant(group: any) {
return;
}
public refreshGroups(): Promise<boolean> {
throw new NotImplementedByEngineError();
}
@@ -609,6 +675,43 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public async updateGroupPicture(
id: string,
file: BinaryFile | RemoteFile | null,
): Promise<boolean> {
if (file) {
await this.setGroupPicture(id, file);
} else {
await this.deleteGroupPicture(id);
}
// Refresh picture after update
setTimeout(() => {
this.logger.debug('Refreshing group profile picture after update...');
this.refreshProfilePicture(id)
.then(() => {
this.logger.debug('Refreshed group profile picture after update');
})
.catch((err) => {
this.logger.error('Error refreshing my profile picture after update');
this.logger.error(err, err.stack);
});
}, 3_000);
return true;
}
protected setGroupPicture(
id: string,
file: BinaryFile | RemoteFile,
): Promise<boolean> {
throw new NotImplementedByEngineError();
}
protected deleteGroupPicture(id: string): Promise<boolean> {
throw new NotImplementedByEngineError();
}
public setSubject(id, description) {
throw new NotImplementedByEngineError();
}
@@ -660,6 +763,25 @@ export abstract class WhatsappSession {
/**
* Channels methods
*/
public searchChannelsByView(
query: ChannelSearchByView,
): Promise<ChannelListResult> {
throw new NotImplementedByEngineError();
}
public searchChannelsByText(
query: ChannelSearchByText,
): Promise<ChannelListResult> {
throw new NotImplementedByEngineError();
}
public async previewChannelMessages(
inviteCode: string,
query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
throw new NotImplementedByEngineError();
}
public channelsList(query: ListChannelsQuery): Promise<Channel[]> {
throw new NotImplementedByEngineError();
}
@@ -768,6 +890,28 @@ export function isNewsletter(jid: string) {
return jid.endsWith('@newsletter');
}
export function getGroupInviteLink(code: string) {
if (code.startsWith('https://')) {
return code;
}
return `https://chat.whatsapp.com/${code}`;
}
export function parseGroupInviteLink(link: string) {
// https://chat.whatsapp.com/123 => 123
return link.split('/').pop();
}
export function getChannelInviteLink(code: string) {
return `https://whatsapp.com/channel/${code}`;
}
export function parseChannelInviteLink(link: string): string {
// https://www.whatsapp.com/channel/123 => 123
const code = link.split('/').pop();
return code;
}
export function getPublicUrlFromDirectPath(directPath: string) {
return `https://pps.whatsapp.net${directPath}`;
}
+17 -9
View File
@@ -32,7 +32,7 @@ export class WebsocketGatewayCore
OnGatewayDisconnect,
BeforeApplicationShutdown
{
HEARTBEAT_INTERVAL = 10_000;
HEARTBEAT_INTERVAL = 60_000;
@WebSocketServer()
server: Server;
@@ -96,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) {
+7 -1
View File
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
import { ServeStaticModule } from '@nestjs/serve-static';
import { TerminusModule } from '@nestjs/terminus';
import { ChannelsController } from '@waha/api/channels.controller';
import { ProfileController } from '@waha/api/profile.controller';
import {
ServerController,
ServerDebugController,
@@ -14,6 +15,7 @@ import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigServi
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';
import { ChannelsInfoServiceCore } from '@waha/core/services/ChannelsInfoServiceCore';
import { BufferJsonReplacerInterceptor } from '@waha/nestjs/BufferJsonReplacerInterceptor';
import {
getPinoHttpUseLevel,
@@ -107,7 +109,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,
@@ -118,6 +122,7 @@ const IMPORTS = [...IMPORTS_CORE, ...IMPORTS_MEDIA];
export const CONTROLLERS = [
AuthController,
SessionsController,
ProfileController,
ChattingController,
ChatsController,
ChannelsController,
@@ -146,6 +151,7 @@ const PROVIDERS = [
provide: APP_INTERCEPTOR,
useClass: BufferJsonReplacerInterceptor,
},
ChannelsInfoServiceCore,
DashboardConfigServiceCore,
SwaggerConfigServiceCore,
WebJSEngineConfigService,
+3
View File
@@ -0,0 +1,3 @@
export enum Jid {
BROADCAST = 'status@broadcast',
}
+4 -2
View File
@@ -20,7 +20,9 @@ export class GowsSubprocess {
this.logger.info('Starting GOWS subprocess...');
this.logger.debug(`GOWS path '${this.path}', socket: '${this.socket}'...`);
this.child = spawn(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
@@ -76,7 +78,7 @@ export class GowsSubprocess {
async stop() {
this.logger.info('Stopping GOWS subprocess...');
this.child?.kill();
this.child?.kill('SIGTERM');
this.logger.info('GOWS subprocess stopped');
}
+191
View File
@@ -0,0 +1,191 @@
import {
GOWSGroupParticipant,
GroupInfoEvent,
GroupInfoFull,
JoinedGroupEvent,
} from '@waha/core/engines/gows/types.group';
import { toJID } from '@waha/core/engines/noweb/session.noweb.core';
import {
GroupId,
GroupInfo,
GroupParticipant,
GroupParticipantRole,
} from '@waha/structures/groups.dto';
import {
GroupParticipantType,
GroupV2JoinEvent,
GroupV2LeaveEvent,
GroupV2ParticipantsEvent,
GroupV2UpdateEvent,
} from '@waha/structures/groups.events.dto';
import { MeInfo } from '@waha/structures/sessions.dto';
export function ToGroupV2JoinEvent(event: JoinedGroupEvent): GroupV2JoinEvent {
return {
timestamp: Date.now(),
group: ToGroupInfo(event),
_data: event,
};
}
export function ToGroupV2LeaveEvent(
me: MeInfo | null,
event: GroupInfoEvent,
): GroupV2LeaveEvent | null {
if (!event.Leave || event.Leave.length === 0) {
return null;
}
if (!me?.id) {
return null;
}
const jid = toJID(me.id);
const left = event.Leave;
if (!left.includes(jid)) {
return null;
}
return {
timestamp: Date.now(),
group: {
id: event.JID,
},
_data: event,
};
}
export function ToGroupV2UpdateEvent(
event: GroupInfoEvent,
): GroupV2UpdateEvent | null {
const group = ToGroupInfoPartial(event);
if (!group) {
return null;
}
return {
timestamp: Date.now(),
group: group,
_data: event,
};
}
export function ToGroupV2ParticipantsEvents(
event: GroupInfoEvent,
): GroupV2ParticipantsEvent[] {
const id: GroupId = {
id: event.JID,
};
const events: GroupV2ParticipantsEvent[] = [];
const maps = [
{ participants: event.Leave, type: GroupParticipantType.LEAVE },
{ participants: event.Join, type: GroupParticipantType.JOIN },
{ participants: event.Promote, type: GroupParticipantType.PROMOTE },
{ participants: event.Demote, type: GroupParticipantType.DEMOTE },
];
for (const map of maps) {
const participants = getParticipants(map.participants, map.type);
if (participants) {
events.push({
group: id,
type: map.type,
timestamp: Date.now(),
participants: participants,
_data: event,
});
}
}
return events;
}
function getParticipants(jids: string[] | null, type: GroupParticipantType) {
if (!jids) {
return;
}
let role: GroupParticipantRole;
switch (type) {
case GroupParticipantType.JOIN:
role = GroupParticipantRole.PARTICIPANT;
break;
case GroupParticipantType.LEAVE:
role = GroupParticipantRole.LEFT;
break;
case GroupParticipantType.PROMOTE:
role = GroupParticipantRole.ADMIN;
break;
case GroupParticipantType.DEMOTE:
role = GroupParticipantRole.PARTICIPANT;
break;
}
const participants: GroupParticipant[] = [];
for (const jid of jids) {
participants.push({
id: jid,
role: role,
});
}
return participants;
}
function ToGroupInfo(group: GroupInfoFull): GroupInfo {
const participants: GroupParticipant[] = extractParticipants(
group.Participants,
);
return {
id: group.JID,
subject: group.Name,
description: group.Topic,
invite: undefined,
participants: participants,
membersCanAddNewMember: group.MemberAddMode === 'all_member_add',
membersCanSendMessages: !!group.IsAnnounce,
newMembersApprovalRequired: group.IsJoinApprovalRequired,
};
}
function ToGroupInfoPartial(group: GroupInfoEvent): GroupInfo | null {
const announce: boolean | undefined = group.Announce?.IsAnnounce;
let membersCanSendMessages: boolean | undefined;
// revert announce or undefined
if (announce === undefined) {
membersCanSendMessages = undefined;
} else {
membersCanSendMessages = !announce;
}
const info = {
id: group.JID,
subject: group.Name?.Name,
description: group.Topic?.Topic,
invite: group.NewInviteLink || undefined,
membersCanAddNewMember: undefined,
membersCanSendMessages: membersCanSendMessages,
newMembersApprovalRequired:
group.MembershipApprovalMode?.IsJoinApprovalRequired,
participants: undefined,
};
// check if all fields are undefined but id - return null
for (const key in info) {
if (key !== 'id' && info[key] !== undefined) {
return info;
}
}
return null;
}
function extractParticipants(
participants: GOWSGroupParticipant[],
): GroupParticipant[] {
const result: GroupParticipant[] = [];
for (const participant of participants) {
let role: GroupParticipantRole;
if (participant.IsSuperAdmin) {
role = GroupParticipantRole.SUPERADMIN;
} else if (participant.IsAdmin) {
role = GroupParticipantRole.ADMIN;
} else {
role = GroupParticipantRole.PARTICIPANT;
}
result.push({
id: participant.JID,
role: role,
});
}
return result;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
import { messages } from '@waha/core/engines/gows/grpc/gows';
import { MessageStatus } from '@waha/core/engines/gows/types';
import { WAMessageAck } from '@waha/structures/enums.dto';
export function optional(value: any, type) {
if (value === null || value === undefined) {
return null;
}
return new type({ value: value });
}
export function parseJson(value: messages.Json | { data?: string }) {
if (value instanceof messages.Json) {
value = value.toObject();
}
if (value.data === undefined) {
return undefined;
}
return JSON.parse(value.data);
}
export function parseJsonList(value: messages.JsonList) {
return value.toObject().elements.map((value) => parseJson(value));
}
export function statusToAck(status: MessageStatus): WAMessageAck | null {
switch (status) {
case MessageStatus.Error:
return WAMessageAck.ERROR;
case MessageStatus.Pending:
return WAMessageAck.PENDING;
case MessageStatus.ServerAck:
return WAMessageAck.SERVER;
case MessageStatus.DeliveryAck:
return WAMessageAck.DEVICE;
case MessageStatus.Read:
return WAMessageAck.READ;
case MessageStatus.Played:
return WAMessageAck.PLAYED;
}
return null;
}
+713 -25
View File
@@ -1,8 +1,17 @@
import { isJidGroup, jidNormalizedUser } from '@adiwajshing/baileys';
import {
getUrlFromDirectPath,
isJidGroup,
isJidStatusBroadcast,
jidNormalizedUser,
} from '@adiwajshing/baileys';
import * as grpc from '@grpc/grpc-js';
import { connectivityState } from '@grpc/grpc-js';
import { UnprocessableEntityException } from '@nestjs/common';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import {
getChannelInviteLink,
WhatsappSession,
} from '@waha/core/abc/session.abc';
import { Jid } from '@waha/core/engines/const';
import { EventsFromObservable } from '@waha/core/engines/gows/EventsFromObservable';
import { GowsEventStreamObservable } from '@waha/core/engines/gows/GowsEventStreamObservable';
import { messages } from '@waha/core/engines/gows/grpc/gows';
@@ -12,9 +21,23 @@ import {
toCusFormat,
toJID,
} from '@waha/core/engines/noweb/session.noweb.core';
import { AvailableInPlusVersion } from '@waha/core/exceptions';
import {
AvailableInPlusVersion,
NotImplementedByEngineError,
} from '@waha/core/exceptions';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { QR } from '@waha/core/QR';
import {
Channel,
ChannelListResult,
ChannelMessage,
ChannelRole,
ChannelSearchByText,
ChannelSearchByView,
CreateChannelRequest,
ListChannelsQuery,
PreviewChannelMessages,
} from '@waha/structures/channels.dto';
import {
ChatRequest,
CheckNumberStatusQuery,
@@ -27,6 +50,7 @@ import {
MessageTextRequest,
MessageVoiceRequest,
SendSeenRequest,
WANumberExistResult,
} from '@waha/structures/chatting.dto';
import {
ACK_UNKNOWN,
@@ -41,6 +65,7 @@ import {
} from '@waha/structures/presence.dto';
import { WAMessage, WAMessageReaction } from '@waha/structures/responses.dto';
import { MeInfo, ProxyConfig } from '@waha/structures/sessions.dto';
import { StatusRequest, TextStatus } from '@waha/structures/status.dto';
import { EnginePayload, WAMessageAckBody } from '@waha/structures/webhooks.dto';
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { onlyEvent } from '@waha/utils/reactive/ops/onlyEvent';
@@ -61,11 +86,43 @@ import { promisify } from 'util';
import * as gows from './types';
import MessageServiceClient = messages.MessageServiceClient;
import {
ToGroupV2JoinEvent,
ToGroupV2LeaveEvent,
ToGroupV2ParticipantsEvents,
ToGroupV2UpdateEvent,
} from '@waha/core/engines/gows/groups.gows';
import {
optional,
parseJson,
parseJsonList,
statusToAck,
} from '@waha/core/engines/gows/helpers';
import { extractMediaContent } from '@waha/core/engines/noweb/utils';
import {
ChatSortField,
ChatSummary,
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
} from '@waha/structures/chats.dto';
import { ContactQuery } from '@waha/structures/contacts.dto';
import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
import {
CreateGroupRequest,
GroupSortField,
Participant,
ParticipantsRequest,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
import { PaginatorInMemory } from '@waha/utils/Paginator';
enum WhatsMeowEvent {
CONNECTED = 'gows.ConnectedEventData',
DISCONNECTED = 'events.Disconnected',
KEEP_ALIVE_TIMEOUT = 'events.KeepAliveTimeout',
KEEP_ALIVE_RESTORED = 'events.KeepAliveRestored',
QR_CHANNEL_ITEM = 'whatsmeow.QRChannelItem',
MESSAGE = 'events.Message',
RECEIPT = 'events.Receipt',
@@ -73,8 +130,16 @@ enum WhatsMeowEvent {
CHAT_PRESENCE = 'events.ChatPresence',
PUSH_NAME_SETTING = 'events.PushNameSetting',
LOGGED_OUT = 'events.LoggedOut',
// Groups
GROUP_INFO = 'events.GroupInfo',
JOINED_GROUP = 'events.JoinedGroup',
}
const gRPCClientConfig = {
'grpc.max_send_message_length': 128 * 1024 * 1024,
'grpc.max_receive_message_length': 128 * 1024 * 1024,
};
export interface GowsConfig {
connection: string;
}
@@ -134,6 +199,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
this.client = new MessageServiceClient(
this.engineConfig.connection,
grpc.credentials.createInsecure(),
gRPCClientConfig,
);
try {
@@ -142,6 +208,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
this.logger.error('Failed to start the client');
this.logger.error(err, err.stack);
this.status = WAHASessionStatus.FAILED;
throw err;
}
}
@@ -162,6 +229,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
const client = new messages.EventStreamClient(
this.engineConfig.connection,
grpc.credentials.createInsecure(),
gRPCClientConfig,
);
const stream = client.StreamEvents(this.session);
return { client, stream };
@@ -194,6 +262,11 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
this.status = WAHASessionStatus.STARTING;
}
});
events.on(WhatsMeowEvent.KEEP_ALIVE_RESTORED, () => {
if (this.status != WAHASessionStatus.WORKING) {
this.status = WAHASessionStatus.WORKING;
}
});
events.on(WhatsMeowEvent.QR_CHANNEL_ITEM, async (data) => {
if (!data.Event) {
return;
@@ -298,6 +371,34 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
map((event) => this.toWahaPresences(event.From || event.Chat, [event])),
);
this.events2.get(WAHAEvents.PRESENCE_UPDATE).switch(presenceUpdates$);
//
// Group Events
//
const joinedGroup$ = all$.pipe(onlyEvent(WhatsMeowEvent.JOINED_GROUP));
const groupV2Join$ = joinedGroup$.pipe(map(ToGroupV2JoinEvent));
this.events2.get(WAHAEvents.GROUP_V2_JOIN).switch(groupV2Join$);
const groupInfo$ = all$.pipe(onlyEvent(WhatsMeowEvent.GROUP_INFO));
const groupV2Leave$ = groupInfo$.pipe(
map((event) => ToGroupV2LeaveEvent(this.me, event)),
filter(Boolean),
);
this.events2.get(WAHAEvents.GROUP_V2_LEAVE).switch(groupV2Leave$);
const groupV2Participants$ = groupInfo$.pipe(
map(ToGroupV2ParticipantsEvents),
mergeMap((events) => events),
);
this.events2
.get(WAHAEvents.GROUP_V2_PARTICIPANTS)
.switch(groupV2Participants$);
const groupV2Update$ = groupInfo$.pipe(
map(ToGroupV2UpdateEvent),
filter(Boolean),
);
this.events2.get(WAHAEvents.GROUP_V2_UPDATE).switch(groupV2Update$);
}
async fetchContactProfilePicture(id: string): Promise<string> {
@@ -326,6 +427,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
this.events?.stop();
this.stopEvents();
this.client?.close();
this.mediaManager.close();
}
public async requestCode(phoneNumber: string, method: string, params?: any) {
@@ -388,6 +490,40 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
}
/**
* Profile methods
*/
public async setProfileName(name: string): Promise<boolean> {
const request = new messages.ProfileNameRequest({
session: this.session,
name: name,
});
const response = await promisify(this.client.SetProfileName)(request);
response.toObject();
return true;
}
public async setProfileStatus(status: string): Promise<boolean> {
const request = new messages.ProfileStatusRequest({
session: this.session,
status: status,
});
const response = await promisify(this.client.SetProfileStatus)(request);
response.toObject();
return true;
}
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
throw new AvailableInPlusVersion();
}
protected deleteProfilePicture(): Promise<boolean> {
throw new AvailableInPlusVersion();
}
/**
* Other methods
*/
async sendText(request: MessageTextRequest) {
const jid = toJID(this.ensureSuffix(request.chatId));
const message = new messages.MessageRequest({
@@ -400,11 +536,38 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return this.messageResponse(jid, data);
}
protected checkStatusRequest(request: StatusRequest) {
if (request.contacts && request.contacts?.length > 0) {
const msg =
"GOWS doesn't accept 'contacts'. Remove the field to send status to all contacts.";
throw new UnprocessableEntityException(msg);
}
}
public async sendTextStatus(status: TextStatus) {
this.checkStatusRequest(status);
const message = new messages.MessageRequest({
jid: Jid.BROADCAST,
text: status.text,
session: this.session,
backgroundColor: new messages.OptionalString({
value: status.backgroundColor,
}),
font: new messages.OptionalUInt32({
value: status.font,
}),
});
const response = await promisify(this.client.SendMessage)(message);
const data = response.toObject();
return this.messageResponse(Jid.BROADCAST, data);
}
protected messageResponse(jid, data) {
const id = buildMessageId({
ID: data.id,
IsFromMe: true,
IsGroup: isJidGroup(jid),
IsGroup: isJidGroup(jid) || isJidStatusBroadcast(jid),
Chat: jid,
Sender: this.me.id,
});
@@ -414,8 +577,20 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
};
}
checkNumberStatus(request: CheckNumberStatusQuery) {
throw new Error('Method not implemented.');
async checkNumberStatus(
request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
const req = new messages.CheckPhonesRequest({
session: this.session,
phones: [request.phone],
});
const response = await promisify(this.client.CheckPhones)(req);
const data = response.toObject();
const info = data.infos[0];
return {
numberExists: info.registered,
chatId: toCusFormat(info.jid),
};
}
sendLocation(request: MessageLocationRequest) {
@@ -456,11 +631,207 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
startTyping(chat: ChatRequest) {
throw new Error('Method not implemented.');
return this.setPresence(WAHAPresenceStatus.TYPING, chat.chatId);
}
stopTyping(chat: ChatRequest) {
throw new Error('Method not implemented.');
return this.setPresence(WAHAPresenceStatus.PAUSED, chat.chatId);
}
/**
* Group methods
*/
public async createGroup(request: CreateGroupRequest) {
const req = new messages.CreateGroupRequest({
session: this.session,
name: request.name,
participants: request.participants.map((p) => toJID(p.id)),
});
const response = await promisify(this.client.CreateGroup)(req);
const data = parseJson(response);
return data;
}
public async joinInfoGroup(code: string): Promise<any> {
const req = new messages.GroupCodeRequest({
session: this.session,
code: code,
});
const response = await promisify(this.client.GetGroupInfoFromLink)(req);
const data = parseJson(response);
return data;
}
public async joinGroup(code: string): Promise<string> {
const req = new messages.GroupCodeRequest({
session: this.session,
code: code,
});
const response = await promisify(this.client.JoinGroupWithLink)(req);
const data = parseJson(response);
return data.jid;
}
public async getGroups(pagination: PaginationParams) {
const req = this.session;
const response = await promisify(this.client.GetGroups)(req);
const data = parseJsonList(response);
switch (pagination.sortBy) {
case GroupSortField.ID:
pagination.sortBy = 'JID';
break;
case GroupSortField.SUBJECT:
pagination.sortBy = 'Name';
break;
}
const paginator = new PaginatorInMemory(pagination);
return paginator.apply(data);
}
protected removeGroupsFieldParticipant(group: any) {
delete group.Participants;
}
public async refreshGroups(): Promise<boolean> {
const req = this.session;
await promisify(this.client.FetchGroups)(req);
return true;
}
public async getGroup(id) {
const req = new messages.JidRequest({
session: this.session,
jid: id,
});
const response = await promisify(this.client.GetGroupInfo)(req);
const data = parseJson(response);
return data;
}
public async getInfoAdminsOnly(id): Promise<SettingsSecurityChangeInfo> {
const group = await this.getGroup(id);
return {
adminsOnly: group.IsLocked,
};
}
public async setInfoAdminsOnly(id, value) {
const req = new messages.JidBoolRequest({
session: this.session,
jid: id,
value: value,
});
await promisify(this.client.SetGroupLocked)(req);
return;
}
public async getMessagesAdminsOnly(id): Promise<SettingsSecurityChangeInfo> {
const group = await this.getGroup(id);
return {
adminsOnly: group.IsAnnounce,
};
}
public async setMessagesAdminsOnly(id, value) {
const req = new messages.JidBoolRequest({
session: this.session,
jid: id,
value: value,
});
await promisify(this.client.SetGroupAnnounce)(req);
return;
}
public deleteGroup(id) {
throw new NotImplementedByEngineError();
}
public async leaveGroup(id) {
const req = new messages.JidRequest({
session: this.session,
jid: id,
});
await promisify(this.client.LeaveGroup)(req);
}
public async setDescription(id, description) {
const req = new messages.JidStringRequest({
session: this.session,
jid: id,
value: description,
});
await promisify(this.client.SetGroupDescription)(req);
}
public async setSubject(id, description) {
const req = new messages.JidStringRequest({
session: this.session,
jid: id,
value: description,
});
await promisify(this.client.SetGroupName)(req);
}
public async getInviteCode(id): Promise<string> {
const req = new messages.JidRequest({
session: this.session,
jid: id,
});
const response = await promisify(this.client.GetGroupInviteLink)(req);
const data = response.toObject();
return data.value;
}
public async revokeInviteCode(id): Promise<string> {
const req = new messages.JidRequest({
session: this.session,
jid: id,
});
const response = await promisify(this.client.RevokeGroupInviteLink)(req);
const data = response.toObject();
return data.value;
}
public async getParticipants(id) {
const group = await this.getGroup(id);
return group.Participants;
}
private async updateParticipants(
id: string,
participants: Array<Participant>,
action: messages.ParticipantAction,
): Promise<any> {
const jids = participants.map((p) => toJID(p.id));
const req = new messages.UpdateParticipantsRequest({
session: this.session,
jid: id,
participants: jids,
action: action,
});
const response = await promisify(this.client.UpdateGroupParticipants)(req);
const data = parseJsonList(response);
return data;
}
public addParticipants(id: string, request: ParticipantsRequest) {
const action = messages.ParticipantAction.ADD;
return this.updateParticipants(id, request.participants, action);
}
public removeParticipants(id, request: ParticipantsRequest) {
const action = messages.ParticipantAction.REMOVE;
return this.updateParticipants(id, request.participants, action);
}
public promoteParticipantsToAdmin(id, request: ParticipantsRequest) {
const action = messages.ParticipantAction.PROMOTE;
return this.updateParticipants(id, request.participants, action);
}
public demoteParticipantsToUser(id, request: ParticipantsRequest) {
const action = messages.ParticipantAction.DEMOTE;
return this.updateParticipants(id, request.participants, action);
}
async setReaction(request: MessageReactionRequest) {
@@ -567,7 +938,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return {
participant: toCusFormat(data.From),
lastKnownPresence: lastKnownPresence,
lastSeen: parseTimestamp(data.LastSeen),
lastSeen: parseTimestampToSeconds(data.LastSeen),
};
}
@@ -604,11 +975,318 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
};
}
/**
* Channels methods
*/
public searchChannelsByView(
query: ChannelSearchByView,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
}
public searchChannelsByText(
query: ChannelSearchByText,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
}
public async previewChannelMessages(
inviteCode: string,
query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
throw new AvailableInPlusVersion();
}
protected toChannel(newsletter: messages.Newsletter): Channel {
const role: ChannelRole =
(newsletter.role?.toUpperCase() as ChannelRole) || ChannelRole.GUEST;
let picture = newsletter.picture;
if (picture.startsWith('/')) {
picture = getUrlFromDirectPath(picture);
}
let preview = newsletter.preview;
if (preview.startsWith('/')) {
preview = getUrlFromDirectPath(preview);
}
return {
id: newsletter.id,
name: newsletter.name,
description: newsletter.description,
invite: getChannelInviteLink(newsletter.invite),
picture: picture,
preview: preview,
verified: newsletter.verified,
role: role,
subscribersCount: newsletter.subscriberCount,
};
}
public async channelsList(query: ListChannelsQuery): Promise<Channel[]> {
const request = new messages.NewsletterListRequest({
session: this.session,
});
const response = await promisify(this.client.GetSubscribedNewsletters)(
request,
);
const data = response.toObject();
const newsletters = data.newsletters;
let channels: Channel[] = newsletters.map(this.toChannel.bind(this));
if (query.role) {
// @ts-ignore
channels = channels.filter((channel) => channel.role === query.role);
}
return channels;
}
public async channelsCreateChannel(
request: CreateChannelRequest,
): Promise<Channel> {
const req = new messages.CreateNewsletterRequest({
session: this.session,
name: request.name,
description: request.description,
});
const response = await promisify(this.client.CreateNewsletter)(req);
const newsletter = response.toObject() as messages.Newsletter;
return this.toChannel(newsletter);
}
public async channelsGetChannel(id: string): Promise<Channel> {
return await this.channelsGetChannelByInviteCode(id);
}
public async channelsGetChannelByInviteCode(
inviteCode: string,
): Promise<Channel> {
const request = new messages.NewsletterInfoRequest({
session: this.session,
id: inviteCode,
});
const response = await promisify(this.client.GetNewsletterInfo)(request);
const newsletter = response.toObject() as messages.Newsletter;
return this.toChannel(newsletter);
}
public channelsFollowChannel(id: string): Promise<any> {
return this.channelsToggleFollow(id, true);
}
public channelsUnfollowChannel(id: string): Promise<any> {
return this.channelsToggleFollow(id, false);
}
protected async channelsToggleFollow(id: string, follow: boolean) {
const request = new messages.NewsletterToggleFollowRequest({
session: this.session,
jid: id,
follow: follow,
});
const response = await promisify(this.client.NewsletterToggleFollow)(
request,
);
return response.toObject();
}
public channelsMuteChannel(id: string): Promise<void> {
return this.channelsToggleMute(id, true);
}
public channelsUnmuteChannel(id: string): Promise<void> {
return this.channelsToggleMute(id, false);
}
protected async channelsToggleMute(id: string, mute: boolean): Promise<any> {
const request = new messages.NewsletterToggleMuteRequest({
session: this.session,
jid: id,
mute: mute,
});
const response = await promisify(this.client.NewsletterToggleMute)(request);
return response.toObject();
}
/**
* Contacts methods
*/
protected toWAContact(contact) {
return {
id: toCusFormat(contact.Jid),
name: contact.Name,
pushname: contact.PushName,
};
}
public async getContact(query: ContactQuery) {
const jid = toJID(query.contactId);
const request = new messages.EntityByIdRequest({
session: this.session,
id: jid,
});
const response = await promisify(this.client.GetContact)(request);
const data = parseJson(response);
return this.toWAContact(data);
}
public async getContacts(pagination: PaginationParams) {
const request = new messages.GetContactsRequest({
session: this.session,
pagination: new messages.Pagination({
limit: pagination.limit,
offset: pagination.offset,
}),
sortBy: new messages.SortBy({
field: pagination.sortBy || 'id',
order:
pagination.sortOrder === SortOrder.DESC
? messages.SortBy.Order.DESC
: messages.SortBy.Order.ASC,
}),
});
const response = await promisify(this.client.GetContacts)(request);
const data = parseJsonList(response);
return data.map(this.toWAContact.bind(this));
}
/**
* Chats methods
*/
public async getChatsOverview(
pagination: PaginationParams,
): Promise<ChatSummary[]> {
if (!pagination.sortBy) {
pagination.sortBy = 'timestamp';
}
if (!pagination.sortOrder) {
pagination.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): Promise<ChatSummary> {
const id = toCusFormat(chat.id);
const name = chat.name;
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 toWAChat(chat) {
return {
id: toCusFormat(chat.Jid),
name: chat.Name,
conversationTimestamp: parseTimestampToSeconds(
chat.ConversationTimestamp,
),
};
}
public async getChats(pagination: PaginationParams) {
if (pagination.sortBy === ChatSortField.CONVERSATION_TIMESTAMP) {
pagination.sortBy = 'timestamp';
}
const request = new messages.GetChatsRequest({
session: this.session,
pagination: new messages.Pagination({
limit: pagination.limit,
offset: pagination.offset,
}),
sortBy: new messages.SortBy({
field: pagination.sortBy || 'id',
order:
pagination.sortOrder === SortOrder.DESC
? messages.SortBy.Order.DESC
: messages.SortBy.Order.ASC,
}),
});
const response = await promisify(this.client.GetChats)(request);
const data = parseJsonList(response);
return data.map(this.toWAChat.bind(this));
}
public async getChatMessages(
chatId: string,
query: GetChatMessagesQuery,
filter: GetChatMessagesFilter,
) {
const downloadMedia = query.downloadMedia;
let jid: messages.OptionalString;
if (chatId === 'all') {
jid = null;
} else {
jid = new messages.OptionalString({
value: toJID(this.ensureSuffix(chatId)),
});
}
const request = new messages.GetMessagesRequest({
session: this.session,
filters: new messages.MessageFilters({
jid: jid,
timestampGte: optional(
filter['filter.timestamp.gte'],
messages.OptionalUInt64,
),
timestampLte: optional(
filter['filter.timestamp.lte'],
messages.OptionalUInt64,
),
fromMe: optional(filter['filter.fromMe'], messages.OptionalBool),
}),
pagination: new messages.Pagination({
limit: query.limit,
offset: query.offset,
}),
});
const response = await promisify(this.client.GetMessages)(request);
const msgs = parseJsonList(response);
const promises = [];
for (const msg of msgs) {
promises.push(this.processIncomingMessage(msg, downloadMedia));
}
let result = await Promise.all(promises);
result = result.filter(Boolean);
return result;
}
public async getChatMessage(
chatId: string,
messageId: string,
query: GetChatMessageQuery,
): Promise<null | WAMessage> {
const key = parseMessageIdSerialized(messageId, true);
const request = new messages.EntityByIdRequest({
session: this.session,
id: key.id,
});
const response = await promisify(this.client.GetMessageById)(request);
const msg = parseJson(response);
return this.processIncomingMessage(msg, query.downloadMedia);
}
//
// END - Methods for API
//
private async processIncomingMessage(message, downloadMedia = true) {
protected async processIncomingMessage(message, downloadMedia = true) {
// if there is no text or media message
if (!message) return;
if (!message.Message) return;
@@ -640,19 +1318,26 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
const id = buildMessageId(message);
const body = this.extractBody(message.Message);
const replyTo = null; // TODO: this.extractReplyTo(message.message);
const ack = null; // TODO: Extract
let ack;
if (message.Status) {
ack = statusToAck(message.Status);
} else {
ack = message.Info.IsFromMe ? WAMessageAck.SERVER : WAMessageAck.DEVICE;
}
const mediaContent = extractMediaContent(message.Message);
return {
id: id,
timestamp: parseTimestamp(message.Info.Timestamp),
timestamp: parseTimestampToSeconds(message.Info.Timestamp),
from: toCusFormat(fromToParticipant.from),
fromMe: message.Info.IsFromMe,
body: body,
to: toCusFormat(fromToParticipant.to),
participant: toCusFormat(fromToParticipant.participant),
// Media
hasMedia: Boolean(message.media),
media: message.media,
hasMedia: Boolean(mediaContent),
media: message.media || null,
mediaUrl: message.media?.url,
// @ts-ignore
ack: ack,
@@ -771,7 +1456,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
const messageId = this.buildMessageIdFromKey(reactionMessage.key);
const reaction: WAMessageReaction = {
id: id,
timestamp: parseTimestamp(message.Info.Timestamp),
timestamp: parseTimestampToSeconds(message.Info.Timestamp),
from: toCusFormat(fromToParticipant.from),
fromMe: message.Info.IsFromMe,
to: toCusFormat(fromToParticipant.to),
@@ -804,6 +1489,10 @@ export class GOWSEngineMediaProcessor implements IMediaEngineProcessor<any> {
return Boolean(extractMediaContent(message.Message));
}
getChatId(message: any): string {
return toCusFormat(message.Info.Chat);
}
getMessageId(message: any): string {
return message.Info.ID;
}
@@ -816,6 +1505,7 @@ export class GOWSEngineMediaProcessor implements IMediaEngineProcessor<any> {
async getMediaBuffer(message: any): Promise<Buffer | null> {
const data = JSON.stringify(message.Message);
const request = new messages.DownloadMediaRequest({
// double "session" it's not a mistake here
session: this.session.session,
message: data,
});
@@ -832,16 +1522,6 @@ export class GOWSEngineMediaProcessor implements IMediaEngineProcessor<any> {
}
}
export function extractMediaContent(message: any) {
const mediaContent =
message?.documentMessage ||
message?.imageMessage ||
message?.videoMessage ||
message?.audioMessage ||
message?.stickerMessage;
return mediaContent;
}
function isMine(message) {
return !!message.Info.IsFromMe;
}
@@ -888,3 +1568,11 @@ function parseTimestamp(timestamp: string): number {
// "2024-12-25T14:28:42+03:00" => 1234567890
return new Date(timestamp).getTime();
}
function parseTimestampToSeconds(timestamp: string): number {
const ms = parseTimestamp(timestamp);
if (!ms) {
return ms;
}
return Math.floor(ms / 1000);
}
+108
View File
@@ -0,0 +1,108 @@
// JID just a string alias, we dump it as String in golang
type JID = string;
interface GroupName {
Name: string;
NameSetAt: string; // ISO string format for time
NameSetBy: JID;
}
interface GroupTopic {
Topic: string;
TopicID: string;
TopicSetAt: string;
TopicSetBy: JID;
TopicDeleted: boolean;
}
interface GroupLocked {
IsLocked: boolean;
}
interface GroupAnnounce {
IsAnnounce: boolean;
AnnounceVersionID: string;
}
interface GroupEphemeral {
IsEphemeral: boolean;
DisappearingTimer: number;
}
interface GroupMembershipApprovalMode {
IsJoinApprovalRequired: boolean;
}
interface GroupDelete {
Deleted: boolean;
DeleteReason: string;
}
interface GroupLinkTarget {
JID: JID;
GroupName: GroupName;
GroupIsDefaultSub: GroupIsDefaultSub;
}
interface GroupLinkChange {
Type: string; // Enum in Go: GroupLinkChangeType
UnlinkReason: string; // Enum in Go: GroupUnlinkReason
Group: GroupLinkTarget;
}
interface GroupIsDefaultSub {
IsDefaultSubGroup: boolean;
}
export interface GroupInfoEvent {
JID: JID;
Notify: string;
Sender?: JID;
Timestamp: string;
Name?: GroupName;
Topic?: GroupTopic;
Locked?: GroupLocked;
Announce?: GroupAnnounce;
Ephemeral?: GroupEphemeral;
MembershipApprovalMode?: GroupMembershipApprovalMode;
Delete?: GroupDelete;
Link?: GroupLinkChange;
Unlink?: GroupLinkChange;
NewInviteLink?: string;
PrevParticipantVersionID: string;
ParticipantVersionID: string;
JoinReason: string;
Join: JID[];
Leave: JID[];
Promote: JID[];
Demote: JID[];
}
export interface GOWSGroupParticipant {
JID: JID;
IsAdmin: boolean;
IsSuperAdmin: boolean;
}
export interface GroupInfoFull {
JID: JID;
OwnerJID: JID;
Name: string; // Name has additional properties here
Topic: string; // Topic has additional properties here
IsLocked: boolean; // specifies whether the group info can only be edited by admins.
IsAnnounce: boolean; // specifies whether only admins can send messages in the group
Participants: GOWSGroupParticipant[];
MemberAddMode: string; // all_member_add | admin_add
IsJoinApprovalRequired: boolean;
}
export interface JoinedGroupEvent extends GroupInfoFull {
Reason: string;
Type: string;
}
+17
View File
@@ -22,3 +22,20 @@ export interface ChatPresence {
State: ChatPresenceState;
Media: ChatPresenceMedia;
}
/**
* StatusError Status = 0
* StatusPending Status = 1
* StatusServerAck Status = 2
* StatusDeliveryAck Status = 3
* StatusRead Status = 4
* StatusPlayed Status = 5
*/
export enum MessageStatus {
Error = 0,
Pending = 1,
ServerAck = 2,
DeliveryAck = 3,
Read = 4,
Played = 5,
}
@@ -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 };
}
}
+143
View File
@@ -0,0 +1,143 @@
import { GroupMetadata, jidNormalizedUser } from '@adiwajshing/baileys';
import type { Contact } from '@adiwajshing/baileys/lib/Types/Contact';
import {
GroupParticipant as NOWEBGroupParticipant,
ParticipantAction,
} from '@adiwajshing/baileys/lib/Types/GroupMetadata';
import { getGroupInviteLink } from '@waha/core/abc/session.abc';
import { toCusFormat } from '@waha/core/engines/noweb/session.noweb.core';
import {
GroupInfo,
GroupParticipant,
GroupParticipantRole,
} from '@waha/structures/groups.dto';
import {
GroupParticipantType,
GroupV2JoinEvent,
GroupV2LeaveEvent,
GroupV2ParticipantsEvent,
GroupV2UpdateEvent,
} from '@waha/structures/groups.events.dto';
export function ToGroupInfo(group: Partial<GroupMetadata>): GroupInfo {
let participants: GroupParticipant[] = undefined;
if (group.participants && group.participants.length > 0) {
participants = group.participants.map(ToGroupParticipant);
}
return {
id: group.id,
subject: group.subject,
description: group.desc,
invite: group.inviteCode ? getGroupInviteLink(group.inviteCode) : undefined,
participants: participants,
membersCanAddNewMember: group.memberAddMode,
membersCanSendMessages: group.announce,
newMembersApprovalRequired: group.joinApprovalMode,
};
}
export function ToGroupV2JoinEvent(group: GroupMetadata): GroupV2JoinEvent {
return {
timestamp: Date.now(),
group: ToGroupInfo(group),
_data: group,
};
}
export function ToGroupParticipant(
participant: NOWEBGroupParticipant,
): GroupParticipant {
let role: GroupParticipantRole = GroupParticipantRole.PARTICIPANT;
if (participant.admin === 'admin') {
role = GroupParticipantRole.ADMIN;
} else if (participant.admin === 'superadmin') {
role = GroupParticipantRole.SUPERADMIN;
}
return {
id: toCusFormat(participant.id),
role: role,
};
}
interface GroupParticipantUpdate {
id: string;
author: string;
participants: string[];
action: ParticipantAction;
}
export function ToGroupV2Participants(
update: GroupParticipantUpdate,
): GroupV2ParticipantsEvent {
let role: GroupParticipantRole;
let type: GroupParticipantType;
switch (update.action) {
case 'add':
role = GroupParticipantRole.PARTICIPANT;
type = GroupParticipantType.JOIN;
break;
case 'remove':
role = GroupParticipantRole.LEFT;
type = GroupParticipantType.LEAVE;
break;
case 'promote':
role = GroupParticipantRole.ADMIN;
type = GroupParticipantType.PROMOTE;
break;
case 'demote':
role = GroupParticipantRole.ADMIN;
type = GroupParticipantType.DEMOTE;
break;
}
const participants: GroupParticipant[] = update.participants.map((id) => {
return {
id: toCusFormat(id),
role: role,
};
});
return {
group: {
id: toCusFormat(update.id),
},
type: type,
timestamp: Date.now(),
participants: participants,
_data: update,
};
}
export function ToGroupV2UpdateEvent(
group: Partial<GroupMetadata>,
): GroupV2UpdateEvent {
return {
timestamp: Date.now(),
group: ToGroupInfo(group),
_data: group,
};
}
export function ToGroupV2LeaveEvent(
me: Contact,
update: GroupParticipantUpdate,
): GroupV2LeaveEvent | null {
if (update.action !== 'remove') {
return null;
}
if (!me) {
return null;
}
const meId = jidNormalizedUser(me.id);
if (!update.participants.includes(meId)) {
return null;
}
return {
timestamp: Date.now(),
group: {
id: toCusFormat(update.id),
},
_data: update,
};
}
+198 -25
View File
@@ -40,6 +40,12 @@ import { MessageUserReceiptUpdate } from '@adiwajshing/baileys/lib/Types/Message
import { isLidUser } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
import { Logger as BaileysLogger } from '@adiwajshing/baileys/node_modules/pino';
import { UnprocessableEntityException } from '@nestjs/common';
import {
ToGroupV2JoinEvent,
ToGroupV2LeaveEvent,
ToGroupV2Participants,
ToGroupV2UpdateEvent,
} from '@waha/core/engines/noweb/groups.noweb';
import { sendButtonMessage } from '@waha/core/engines/noweb/noweb.buttons';
import { NowebInMemoryStore } from '@waha/core/engines/noweb/store/NowebInMemoryStore';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
@@ -48,9 +54,14 @@ import { PairingCodeResponse } from '@waha/structures/auth.dto';
import { CallData } from '@waha/structures/calls.dto';
import {
Channel,
ChannelListResult,
ChannelMessage,
ChannelRole,
ChannelSearchByText,
ChannelSearchByView,
CreateChannelRequest,
ListChannelsQuery,
PreviewChannelMessages,
} from '@waha/structures/channels.dto';
import {
ChatSummary,
@@ -75,6 +86,7 @@ import {
PollVote,
PollVotePayload,
WAMessageAckBody,
WAMessageRevokedBody,
} from '@waha/structures/webhooks.dto';
import { LoggerBuilder } from '@waha/utils/logging';
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
@@ -131,6 +143,7 @@ import {
import {
CreateGroupRequest,
ParticipantsRequest,
SettingsSecurityChangeInfo,
} from '../../../structures/groups.dto';
import {
WAHAChatPresences,
@@ -149,6 +162,7 @@ import {
import {
ensureSuffix,
getChannelInviteLink,
getPublicUrlFromDirectPath,
isNewsletter,
WhatsappSession,
} from '../../abc/session.abc';
@@ -371,7 +385,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
this.listenConnectionEvents();
this.subscribeEngineEvents2();
this.listenContactsUpdatePictureProfile();
this.enableAutoRestart();
// this.enableAutoRestart();
}
private enableAutoRestart() {
@@ -475,13 +489,17 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
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() {
@@ -638,6 +656,27 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
}
/**
* Profile methods
*/
public async setProfileName(name: string): Promise<boolean> {
await this.sock.updateProfileName(name);
return true;
}
public async setProfileStatus(status: string): Promise<boolean> {
await this.sock.updateProfileStatus(status);
return true;
}
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
throw new AvailableInPlusVersion();
}
protected deleteProfilePicture(): Promise<boolean> {
throw new AvailableInPlusVersion();
}
/**
* Other methods
*/
@@ -1100,10 +1139,6 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return contacts.map(this.toWAContact);
}
public async getContactAbout(query: ContactQuery) {
throw new NotImplementedByEngineError();
}
public async fetchContactProfilePicture(id: string) {
const contact = this.ensureSuffix(id);
if (isJidNewsletter(id)) {
@@ -1112,8 +1147,18 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
if (isJidStatusBroadcast(id)) {
return null;
}
const url = await this.sock.profilePictureUrl(contact, 'image');
return url;
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) {
@@ -1146,6 +1191,10 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return lodash.keyBy(groups, 'id');
}
protected removeGroupsFieldParticipant(group: any) {
delete group.participants;
}
public async refreshGroups(): Promise<boolean> {
this.store.resetGroupsCache();
await this.store.getGroups({});
@@ -1154,13 +1203,37 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
public async getGroup(id) {
const groups = await this.getGroups({});
return groups[id];
const group = groups[id];
if (!group) {
throw new Error(`Group with id '${id}' not found`);
}
return group;
}
public async deleteGroup(id) {
throw new NotImplementedByEngineError();
}
public async getInfoAdminsOnly(id): Promise<SettingsSecurityChangeInfo> {
const group = await this.getGroup(id);
return { adminsOnly: group.restrict };
}
public async setInfoAdminsOnly(id, value) {
const setting = value ? 'locked' : 'unlocked';
return await this.sock.groupSettingUpdate(id, setting);
}
public async getMessagesAdminsOnly(id): Promise<SettingsSecurityChangeInfo> {
const group = await this.getGroup(id);
return { adminsOnly: group.announce };
}
public async setMessagesAdminsOnly(id, value) {
const setting = value ? 'announcement' : 'not_announcement';
return await this.sock.groupSettingUpdate(id, setting);
}
public async leaveGroup(id) {
return this.sock.groupLeave(id);
}
@@ -1302,6 +1375,25 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
/**
* Channels methods
*/
public searchChannelsByView(
query: ChannelSearchByView,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
}
public searchChannelsByText(
query: ChannelSearchByText,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
}
public async previewChannelMessages(
inviteCode: string,
query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
throw new AvailableInPlusVersion();
}
protected toChannel(newsletter: NewsletterMetadata): Channel {
const role =
// @ts-ignore
@@ -1309,20 +1401,21 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
(newsletter.viewer_metadata?.view_role as ChannelRole) ||
ChannelRole.GUEST;
const preview = newsletter.preview
? getUrlFromDirectPath(newsletter.preview)
? getPublicUrlFromDirectPath(newsletter.preview)
: null;
const picture = newsletter.picture
? getUrlFromDirectPath(newsletter.picture)
? getPublicUrlFromDirectPath(newsletter.picture)
: null;
return {
id: newsletter.id,
name: newsletter.name,
description: newsletter.description,
invite: getChannelInviteLink(newsletter.invite),
preview: preview,
picture: picture,
preview: preview || picture,
picture: picture || preview,
verified: newsletter.verification === 'VERIFIED',
role: role,
subscribersCount: newsletter.subscribers,
};
}
@@ -1412,6 +1505,24 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
this.events2.get(WAHAEvents.MESSAGE).switch(messagesFromOthers$);
this.events2.get(WAHAEvents.MESSAGE_ANY).switch(messagesFromAll$);
const messagesRevoked$ = messagesUpsert$.pipe(
// @ts-ignore
filter(
(message) =>
message.message.protocolMessage?.type ===
proto.Message.ProtocolMessage.Type.REVOKE,
),
mergeMap(async (message): Promise<WAMessageRevokedBody> => {
const afterMessage = await this.toWAMessage(message);
return {
after: afterMessage,
before: null,
_data: message,
};
}),
);
this.events2.get(WAHAEvents.MESSAGE_REVOKED).switch(messagesRevoked$);
//
// Message Reactions
//
@@ -1455,12 +1566,51 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
this.events2
.get(WAHAEvents.STATE_CHANGE)
.switch(fromEvent(this.sock.ev, 'connection.update'));
this.events2.get(WAHAEvents.GROUP_JOIN).switch(
const groupsUpsert$: Observable<GroupMetadata> = fromEvent(
this.sock.ev,
'groups.upsert',
).pipe(
// @ts-ignore
fromEvent<GroupMetadata[]>(this.sock.ev, 'groups.upsert').pipe(
mergeAll(),
),
mergeAll(),
);
const groupsUpdate$: Observable<Partial<GroupMetadata>> = fromEvent(
this.sock.ev,
'groups.update',
).pipe(
// @ts-ignore
mergeAll(),
);
const groupsParticipantsUpdate$: Observable<any> = fromEvent(
this.sock.ev,
'group-participants.update',
);
this.events2.get(WAHAEvents.GROUP_JOIN).switch(groupsUpsert$);
const groupV2Join$ = groupsUpsert$.pipe(
map((group) => ToGroupV2JoinEvent(group)),
);
this.events2.get(WAHAEvents.GROUP_V2_JOIN).switch(groupV2Join$);
const groupV2Update$ = merge(groupsUpdate$).pipe(map(ToGroupV2UpdateEvent));
this.events2.get(WAHAEvents.GROUP_V2_UPDATE).switch(groupV2Update$);
const groupV2Participants$ = groupsParticipantsUpdate$.pipe(
map(ToGroupV2Participants),
);
this.events2
.get(WAHAEvents.GROUP_V2_PARTICIPANTS)
.switch(groupV2Participants$);
const groupV2Leave$ = groupsParticipantsUpdate$.pipe(
map((group) =>
ToGroupV2LeaveEvent(this.sock?.authState?.creds?.me, group),
),
filter(Boolean),
);
this.events2.get(WAHAEvents.GROUP_V2_LEAVE).switch(groupV2Leave$);
this.events2
.get(WAHAEvents.PRESENCE_UPDATE)
.switch(
@@ -1622,7 +1772,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return reaction;
}
private async processIncomingMessage(message, downloadMedia = true) {
protected async processIncomingMessage(message, downloadMedia = true) {
// if there is no text or media message
if (!message) return;
if (!message.message) return;
@@ -1630,6 +1780,17 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
if (message.message.reactionMessage) return;
// Ignore poll votes, we have dedicated handler for that
if (message.message.pollUpdateMessage) return;
// Ignore revoke, we have a dedicated handler for that
if (
message.message.protocolMessage?.type ===
proto.Message.ProtocolMessage.Type.REVOKE
)
return;
if (
message.message.protocolMessage?.type ===
proto.Message.ProtocolMessage.Type.EPHEMERAL_SYNC_RESPONSE
)
return;
if (downloadMedia) {
try {
@@ -1656,17 +1817,18 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
const body = this.extractBody(message.message);
const replyTo = this.extractReplyTo(message.message);
const ack = message.ack || message.status - 1;
const mediaContent = extractMediaContent(message.message);
return Promise.resolve({
id: id,
timestamp: ensureNumber(message.messageTimestamp),
from: toCusFormat(fromToParticipant.from),
fromMe: message.key.fromMe,
body: body,
body: body || null,
to: toCusFormat(fromToParticipant.to),
participant: toCusFormat(fromToParticipant.participant),
// Media
hasMedia: Boolean(message.media),
media: message.media,
hasMedia: Boolean(mediaContent),
media: message.media || null,
mediaUrl: message.media?.url,
// @ts-ignore
ack: ack,
@@ -1683,21 +1845,28 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
if (!message) {
return null;
}
let body = message.conversation;
const content = extractMessageContent(message);
if (!content) {
return null;
}
let body = content.conversation || null;
if (!body) {
// Some of the messages have no conversation, but instead have text in extendedTextMessage
// https://github.com/devlikeapro/waha/issues/90
body = message.extendedTextMessage?.text;
body = content.extendedTextMessage?.text;
}
if (!body) {
// Populate from caption
const mediaContent = extractMediaContent(message);
const mediaContent = extractMediaContent(content);
// @ts-ignore - AudioMessage doesn't have caption field
body = mediaContent?.caption;
}
// Response for buttons
if (!body) {
body = message.templateButtonReplyMessage?.selectedDisplayText;
body = content.templateButtonReplyMessage?.selectedDisplayText;
}
if (!body) {
body = content.buttonsResponseMessage?.selectedDisplayText;
}
return body;
}
@@ -1947,6 +2116,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;
@@ -1,5 +1,3 @@
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';
@@ -24,20 +22,4 @@ export abstract class INowebStorage {
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}`,
);
}
}
@@ -225,7 +225,7 @@ 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(`store sync - '${chats.length}' synced chats`);
@@ -269,6 +269,9 @@ export class NowebPersistentStore implements INowebStore {
if (!group) {
group = { id: id, participants: [] } as GroupMetadata;
}
if (!group.participants) {
group.participants = [];
}
const participantsById = new DefaultMap<string, GroupParticipant>((key) => {
return { id: key, admin: null } as GroupParticipant;
@@ -312,7 +315,7 @@ export class NowebPersistentStore implements INowebStore {
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);
}
@@ -343,7 +346,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(
@@ -351,7 +354,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
@@ -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);
}
}
-63
View File
@@ -1,63 +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(
'groups',
[new Field('id', 'TEXT'), new Field('data', 'TEXT')],
[new Index('groups_id_index', ['id'])],
),
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']),
],
),
];
+14
View File
@@ -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}`,
);
+103
View File
@@ -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,19 +17,18 @@ export class Sqlite3ChatRepository
{
protected Paginator = ChatPaginator;
async getAllWithMessages(
get schema() {
return NowebChatSchema;
}
get methods() {
return new SqlChatMethods(this);
}
getAllWithMessages(
pagination: PaginationParams,
broadcast: boolean,
): Promise<Chat[]> {
// Get chats with conversationTimestamp is not Null
let query = this.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.pagination(query, pagination);
return await this.all(query);
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;
}
}
@@ -1,5 +1,6 @@
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';
@@ -13,4 +14,8 @@ export class Sqlite3GroupRepository
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,14 +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';
@@ -44,121 +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)',
);
// Groups
this.db.exec(
'CREATE TABLE IF NOT EXISTS groups (id TEXT PRIMARY KEY, data TEXT)',
);
this.db.exec(
'CREATE UNIQUE INDEX IF NOT EXISTS groups_id_index ON groups (id)',
);
// 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, this.getSchema('groups'));
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);
}
}
@@ -32,6 +32,7 @@ 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));
@@ -117,5 +118,8 @@ export const useMultiFileAuthState = async (
saveCreds: () => {
return writeData(creds, 'creds.json');
},
close: async () => {
return;
},
};
};
+1 -1
View File
@@ -1,7 +1,7 @@
import { extractMessageContent, proto } from '@adiwajshing/baileys';
export function extractMediaContent(
content: proto.IMessage | null | undefined,
content: any | proto.IMessage | null | undefined,
) {
content = extractMessageContent(content);
const mediaContent =
@@ -18,17 +18,32 @@ const { LoadPaginator } = require('./_Paginator.js');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ChatFactory = require('whatsapp-web.js/src/factories/ChatFactory');
export class WebjsClient extends Client {
export class WebjsClientCore extends Client {
constructor(options) {
super(options);
// Wait until it's READY and inject more utils
this.on(Events.READY, async () => {
await this.pupPage.evaluate(LoadLodash);
await this.pupPage.evaluate(LoadPaginator);
await this.pupPage.evaluate(LoadWAHA);
await this.injectWaha();
});
}
async injectWaha() {
await this.pupPage.evaluate(LoadLodash);
await this.pupPage.evaluate(LoadPaginator);
await this.pupPage.evaluate(LoadWAHA);
}
async setPushName(name: string) {
await this.pupPage.evaluate(async (pushName) => {
return await window['WAHA'].WAWebSetPushnameConnAction.setPushname(
pushName,
);
}, name);
if (this.info) {
this.info.pushname = name;
}
}
async unpair() {
await this.pupPage.evaluate(async () => {
if (
+10
View File
@@ -11,4 +11,14 @@ exports.LoadWAHA = () => {
const chatPromises = chats.map((chat) => window.WWebJS.getChatModel(chat));
return await Promise.all(chatPromises);
};
// Set push name
window.WAHA.WAWebSetPushnameConnAction = window.require(
'WAWebSetPushnameConnAction',
);
// Get my status
window.WAHA.WAWebTextStatusCollection = window.require(
'WAWebTextStatusCollection',
);
};
+154
View File
@@ -0,0 +1,154 @@
import { WebjsClientCore } from '@waha/core/engines/webjs/WebjsClientCore';
import {
GroupId,
GroupInfo,
GroupParticipant,
GroupParticipantRole,
} from '@waha/structures/groups.dto';
import {
GroupParticipantType,
GroupV2JoinEvent,
GroupV2LeaveEvent,
GroupV2ParticipantsEvent,
GroupV2UpdateEvent,
} from '@waha/structures/groups.events.dto';
import {
GroupChat,
GroupNotification,
GroupNotificationTypes,
GroupParticipant as WEBJSGroupParticipant,
} from 'whatsapp-web.js';
function ToGroupInfo(
group: GroupChat,
invite: string,
participants = [],
): GroupInfo {
// @ts-ignore
const groupMetadata = group.groupMetadata;
const info: GroupInfo = {
// @ts-ignore
id: group.id._serialized,
subject: group.name,
description: group.description,
invite: invite,
membersCanAddNewMember: groupMetadata.restrict,
membersCanSendMessages: groupMetadata.announce,
newMembersApprovalRequired: groupMetadata.membershipApprovalMode,
participants: participants,
};
return info;
}
export async function ToGroupV2JoinEvent(
client: WebjsClientCore,
me: string,
notification: GroupNotification,
): Promise<GroupV2JoinEvent | null> {
if (!notification.recipientIds.includes(me)) {
return null;
}
// @ts-ignore
const group: GroupChat = await client.getChatById(notification.id.remote);
const invite = await group.getInviteCode();
const participants = getParticipants(group.participants);
const info: GroupInfo = ToGroupInfo(group, invite, participants);
return {
timestamp: notification.timestamp,
group: info,
_data: notification,
};
}
function getParticipants(
participants: WEBJSGroupParticipant[],
): GroupParticipant[] {
return participants.map((participant) => {
let role: GroupParticipantRole = GroupParticipantRole.PARTICIPANT;
if (participant.isSuperAdmin) {
role = GroupParticipantRole.SUPERADMIN;
} else if (participant.isAdmin) {
role = GroupParticipantRole.ADMIN;
}
return {
id: participant.id._serialized,
role: role,
};
});
}
export function ToGroupV2LeaveEvent(
me: string,
notification: GroupNotification,
): GroupV2LeaveEvent | null {
if (!notification.recipientIds.includes(me)) {
return null;
}
const group: GroupId = {
// @ts-ignore
id: notification.id.remote,
};
return {
timestamp: notification.timestamp,
group: group,
_data: notification,
};
}
export async function ToGroupV2UpdateEvent(
client: WebjsClientCore,
notification: GroupNotification,
): Promise<GroupV2UpdateEvent> {
// @ts-ignore
const group: GroupChat = await client.getChatById(notification.id.remote);
const invite = await group.getInviteCode();
const info: GroupInfo = ToGroupInfo(group, invite, undefined);
return {
group: info,
timestamp: notification.timestamp,
_data: notification,
};
}
export function ToGroupV2ParticipantsEvent(
notification: GroupNotification,
): GroupV2ParticipantsEvent | null {
let type: GroupParticipantType;
let role: GroupParticipantRole;
if (['add', 'invite', 'linked_group_join'].includes(notification.type)) {
type = GroupParticipantType.JOIN;
role = GroupParticipantRole.PARTICIPANT;
} else if (['remove', 'leave'].includes(notification.type)) {
type = GroupParticipantType.LEAVE;
role = GroupParticipantRole.LEFT;
} else if (['promote'].includes(notification.type)) {
type = GroupParticipantType.PROMOTE;
role = GroupParticipantRole.ADMIN;
} else if (['demote'].includes(notification.type)) {
type = GroupParticipantType.DEMOTE;
role = GroupParticipantRole.PARTICIPANT;
} else {
return null;
}
const participants: GroupParticipant[] = notification.recipientIds.map(
(id) => {
return {
id: id,
role: role,
};
},
);
const group: GroupId = {
// @ts-ignore
id: notification.id.remote,
};
return {
group: group,
type: type,
timestamp: notification.timestamp,
participants: participants,
_data: notification,
};
}
+155 -21
View File
@@ -3,8 +3,14 @@ import {
getChannelInviteLink,
WhatsappSession,
} from '@waha/core/abc/session.abc';
import {
ToGroupV2JoinEvent,
ToGroupV2LeaveEvent,
ToGroupV2ParticipantsEvent,
ToGroupV2UpdateEvent,
} from '@waha/core/engines/webjs/groups.webjs';
import { LocalAuth } from '@waha/core/engines/webjs/LocalAuth';
import { WebjsClient } from '@waha/core/engines/webjs/WebjsClient';
import { WebjsClientCore } from '@waha/core/engines/webjs/WebjsClientCore';
import {
AvailableInPlusVersion,
NotImplementedByEngineError,
@@ -15,9 +21,14 @@ import { splitAt } from '@waha/helpers';
import { PairingCodeResponse } from '@waha/structures/auth.dto';
import {
Channel,
ChannelListResult,
ChannelMessage,
ChannelRole,
ChannelSearchByText,
ChannelSearchByView,
CreateChannelRequest,
ListChannelsQuery,
PreviewChannelMessages,
} from '@waha/structures/channels.dto';
import {
ChatSortField,
@@ -52,6 +63,7 @@ import {
WAHASessionStatus,
WAMessageAck,
} from '@waha/structures/enums.dto';
import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
import {
CreateGroupRequest,
GroupSortField,
@@ -72,7 +84,7 @@ 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 { filter, fromEvent, merge, mergeMap, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import {
Call,
@@ -82,6 +94,7 @@ import {
Contact,
Events,
GroupChat,
GroupNotification,
Label as WEBJSLabel,
Location,
Message,
@@ -106,7 +119,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
private engineStateCheckDelayedJob: SingleDelayedJobRunner;
private shouldRestart: boolean;
whatsapp: WebjsClient;
whatsapp: WebjsClientCore;
protected qr: QR;
public constructor(config) {
@@ -161,14 +174,15 @@ 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,
});
this.addProxyConfig(clientOptions);
return new WebjsClient(clientOptions);
return new WebjsClientCore(clientOptions);
}
private restartClient() {
@@ -263,6 +277,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
this.status = WAHASessionStatus.STOPPED;
this.stopEvents();
this.startDelayedJob.cancel();
this.mediaManager.close();
await this.end();
}
@@ -448,6 +463,30 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
};
}
/**
* Profile methods
*/
public async setProfileName(name: string): Promise<boolean> {
await this.whatsapp.setPushName(name);
return true;
}
public async setProfileStatus(status: string): Promise<boolean> {
await this.whatsapp.setStatus(status);
return true;
}
protected setProfilePicture(file: BinaryFile | RemoteFile): Promise<boolean> {
throw new AvailableInPlusVersion();
}
protected deleteProfilePicture(): Promise<boolean> {
throw new AvailableInPlusVersion();
}
/**
* Other methods
*/
sendText(request: MessageTextRequest) {
const options = this.getMessageOptions(request);
return this.whatsapp.sendMessage(
@@ -477,7 +516,11 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
reply(request: MessageReplyRequest) {
const options = this.getMessageOptions(request);
return this.whatsapp.sendMessage(request.chatId, request.text, options);
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
request.text,
options,
);
}
sendImage(request: MessageImageRequest) {
@@ -497,12 +540,16 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
name: request.title,
});
const options = this.getMessageOptions(request);
return this.whatsapp.sendMessage(request.chatId, location, options);
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
location,
options,
);
}
async forwardMessage(request: MessageForwardRequest): Promise<WAMessage> {
const forwardMessage = this.recreateMessage(request.messageId);
const msg = await forwardMessage.forward(request.chatId);
const msg = await forwardMessage.forward(this.ensureSuffix(request.chatId));
// Return "sent: true" for now
// need to research how to get the data from WebJS
// @ts-ignore
@@ -510,17 +557,23 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
async sendSeen(request: SendSeenRequest) {
const chat: Chat = await this.whatsapp.getChatById(request.chatId);
const chat: Chat = await this.whatsapp.getChatById(
this.ensureSuffix(request.chatId),
);
await chat.sendSeen();
}
async startTyping(request: ChatRequest) {
const chat: Chat = await this.whatsapp.getChatById(request.chatId);
const chat: Chat = await this.whatsapp.getChatById(
this.ensureSuffix(request.chatId),
);
await chat.sendStateTyping();
}
async stopTyping(request: ChatRequest) {
const chat: Chat = await this.whatsapp.getChatById(request.chatId);
const chat: Chat = await this.whatsapp.getChatById(
this.ensureSuffix(request.chatId),
);
await chat.clearState();
}
@@ -656,7 +709,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
async deleteChat(chatId) {
const chat = await this.whatsapp.getChatById(chatId);
const chat = await this.whatsapp.getChatById(this.ensureSuffix(chatId));
return chat.delete();
}
@@ -841,6 +894,13 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return paginator.apply(groups);
}
protected removeGroupsFieldParticipant(group: any) {
delete group.groupMetadata?.participants;
delete group.groupMetadata?.pendingParticipants;
delete group.groupMetadata?.pastParticipants;
delete group.groupMetadata?.membershipApprovalRequests;
}
public async refreshGroups(): Promise<boolean> {
return true;
}
@@ -920,6 +980,25 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
/**
* Channels methods
*/
public searchChannelsByView(
query: ChannelSearchByView,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
}
public searchChannelsByText(
query: ChannelSearchByText,
): Promise<ChannelListResult> {
throw new AvailableInPlusVersion();
}
public async previewChannelMessages(
inviteCode: string,
query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
throw new AvailableInPlusVersion();
}
protected ChatToChannel(chat: WEBJSChannel): Channel {
// @ts-ignore
const metadata = chat.channelMetadata;
@@ -936,6 +1015,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
picture: null,
verified: metadata.verified,
role: role,
subscribersCount: null,
};
}
@@ -953,6 +1033,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
picture: metadata.pictureUrl,
verified: metadata.isVerified,
role: role,
subscribersCount: metadata.subscribersCount,
};
}
@@ -964,6 +1045,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
channels = channels.filter((channel) => channel.role === query.role);
}
// Exclude GUEST, browser saves the data
// when we search channels or getting messages
channels = channels.filter((channel) => channel.role === 'GUEST');
const promises = channels.map(async (channel) =>
this.whatsapp.getProfilePicUrl(channel.id),
);
@@ -1052,7 +1137,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);
@@ -1162,10 +1247,52 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
//
// Groups
//
const groupJoin$ = fromEvent(this.whatsapp, Events.GROUP_JOIN);
this.events2.get(WAHAEvents.GROUP_JOIN).switch(groupJoin$);
const groupLeave$ = fromEvent(this.whatsapp, Events.GROUP_LEAVE);
this.events2.get(WAHAEvents.GROUP_LEAVE).switch(groupLeave$);
const groupJoin$ = fromEvent<GroupNotification>(
this.whatsapp,
Events.GROUP_JOIN,
);
this.events2.get(WAHAEvents.GROUP_JOIN).switch(groupJoin$); // v1
const groupV2Join$ = groupJoin$.pipe(
mergeMap((evt) =>
ToGroupV2JoinEvent(this.whatsapp, this.getSessionMeInfo().id, evt),
),
filter(Boolean),
);
this.events2.get(WAHAEvents.GROUP_V2_JOIN).switch(groupV2Join$);
const groupLeave$ = fromEvent<GroupNotification>(
this.whatsapp,
Events.GROUP_LEAVE,
);
this.events2.get(WAHAEvents.GROUP_LEAVE).switch(groupLeave$); // v1
const groupV2Leave$ = groupLeave$.pipe(
map((evt) => ToGroupV2LeaveEvent(this.getSessionMeInfo().id, evt)),
filter(Boolean),
);
this.events2.get(WAHAEvents.GROUP_V2_LEAVE).switch(groupV2Leave$);
const groupAdminChanged$ = fromEvent(
this.whatsapp,
Events.GROUP_ADMIN_CHANGED,
);
const groupV2Participants = merge(
groupJoin$,
groupLeave$,
groupAdminChanged$,
).pipe(map(ToGroupV2ParticipantsEvent), filter(Boolean));
this.events2
.get(WAHAEvents.GROUP_V2_PARTICIPANTS)
.switch(groupV2Participants);
const groupUpdate$ = fromEvent<GroupNotification>(
this.whatsapp,
Events.GROUP_UPDATE,
);
const groupV2Update$ = groupUpdate$.pipe(
mergeMap((evt) => ToGroupV2UpdateEvent(this.whatsapp, evt)),
filter(Boolean),
);
this.events2.get(WAHAEvents.GROUP_V2_UPDATE).switch(groupV2Update$);
//
// Chats
@@ -1209,7 +1336,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
this.events2.get(WAHAEvents.CALL_RECEIVED).switch(calls$);
}
private async processIncomingMessage(message: Message, downloadMedia = true) {
protected async processIncomingMessage(
message: Message,
downloadMedia = true,
) {
if (downloadMedia) {
try {
message = await this.downloadMedia(message);
@@ -1248,9 +1378,9 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
body: message.body,
// Media
// @ts-ignore
hasMedia: Boolean(message.media),
hasMedia: Boolean(message.hasMedia),
// @ts-ignore
media: message.media,
media: message.media || null,
// @ts-ignore
mediaUrl: message.media?.url,
// @ts-ignore
@@ -1265,7 +1395,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
protected extractReplyTo(message: Message): ReplyToMessage | null {
// @ts-ignore
const quotedMsg = message.rawData.quotedMsg;
const quotedMsg = message.rawData?.quotedMsg;
if (!quotedMsg) {
return;
}
@@ -1320,6 +1450,10 @@ export class WEBJSEngineMediaProcessor
return message.type !== 'revoked';
}
getChatId(message: Message): string {
return message.id.remote;
}
getMessageId(message: Message): string {
return message.id._serialized;
}
+17 -7
View File
@@ -1,6 +1,7 @@
import {
Injectable,
NotFoundException,
OnModuleInit,
UnprocessableEntityException,
} from '@nestjs/common';
import { EngineBootstrap } from '@waha/core/abc/EngineBootstrap';
@@ -57,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)
@@ -119,11 +120,10 @@ 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();
}
@@ -133,7 +133,8 @@ export class SessionManagerCore extends SessionManager {
}
private async clearStorage() {
const storage = this.mediaStorageFactory.build(
const storage = await this.mediaStorageFactory.build(
'all',
this.log.logger.child({ name: 'Storage' }),
);
await storage.purge();
@@ -169,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();
@@ -389,4 +391,12 @@ export class SessionManagerCore extends SessionManager {
protected stopEvents() {
complete(this.events2);
}
async onModuleInit() {
await this.init();
}
async init() {
await this.store.init();
}
}
+2
View File
@@ -11,6 +11,8 @@ interface IMediaEngineProcessor<Message> {
getMessageId(message: Message): string;
getChatId(message: Message): string;
getMediaBuffer(message: Message): Promise<Buffer | null>;
}
+1
View File
@@ -10,6 +10,7 @@ interface IMediaManager {
message: Message,
session: string,
): Promise<Message>;
close(): void;
}
export { IMediaManager };
+16
View File
@@ -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 };
+8
View File
@@ -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}`);
});
}
}
+1 -1
View File
@@ -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>;
}
@@ -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,
@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { AvailableInPlusVersion } from '@waha/core/exceptions';
import {
ChannelCategory,
ChannelCountry,
ChannelView,
} from '@waha/structures/channels.dto';
@Injectable()
export class ChannelsInfoServiceCore {
async getCountries(): Promise<ChannelCountry[]> {
throw new AvailableInPlusVersion();
}
async getCategories(): Promise<ChannelCategory[]> {
throw new AvailableInPlusVersion();
}
async getViews(): Promise<ChannelView[]> {
throw new AvailableInPlusVersion();
}
}
+6 -4
View File
@@ -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>;
}
@@ -30,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))) {
@@ -48,7 +48,7 @@ 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 });
@@ -62,12 +62,12 @@ export class LocalSessionConfigRepository extends ISessionConfigRepository {
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,
@@ -76,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();
}
}
+3 -6
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
export interface IJsonQuery {
filter(field: string, key: string): string;
sortBy(field: string, sortBy: string, direction: string): string;
}
+28
View File
@@ -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>;
}
+217
View File
@@ -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;
}
}
+54
View File
@@ -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)',
];
+38
View File
@@ -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}`;
}
}
+11 -167
View File
@@ -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;
}
}
@@ -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();
}
}
+7
View File
@@ -30,4 +30,11 @@ function splitAt(str: string, index) {
return [fst.join(''), snd.join('')];
}
export function sortObjectByValues(obj: any) {
return Object.fromEntries(
// @ts-ignore
Object.entries(obj).sort(([, v1], [, v2]) => v2 - v1),
);
}
export { flipObject, parseBool, splitAt };
+16 -6
View File
@@ -1,5 +1,6 @@
import { NestFactory } from '@nestjs/core';
import { WsAdapter } from '@nestjs/platform-ws';
import { WAHA_WEBHOOKS } from '@waha/structures/webhooks';
import {
getNestJSLogLevels,
getPinoLogLevel,
@@ -15,7 +16,6 @@ import { WhatsappConfigService } from './config.service';
import { AppModuleCore } from './core/app.module.core';
import { SwaggerConfiguratorCore } from './core/SwaggerConfiguratorCore';
import { AllExceptionsFilter } from './nestjs/AllExceptionsFilter';
import { WAHA_WEBHOOKS } from './structures/webhooks.dto';
import { getWAHAVersion, VERSION, WAHAVersion } from './version';
const logger: Logger = pino({
@@ -23,12 +23,22 @@ const logger: Logger = pino({
transport: getPinoTransport(),
}).child({ name: 'Bootstrap' });
logger.info('NODE - Catching unhandled rejection enabled');
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
// @ts-ignore
logger.error(reason.stack);
process.on('uncaughtException', (err) => {
logger.error('Uncaught Exception:', err);
if (err instanceof Error) {
logger.error(err.stack);
}
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise);
if (reason instanceof Error) {
logger.error(reason.stack);
} else {
logger.error('Unhandled rejection reason:', reason);
}
});
logger.info('NODE - Catching unhandled rejections and exceptions enabled');
process.on('SIGINT', () => {
logger.info('SIGINT received');
});
+1 -1
View File
@@ -22,7 +22,7 @@ export class WebsocketHeartbeatJob {
this.logger.debug(
`Terminating client connection due to heartbeat timeout, ${client.id}`,
);
return client.terminate();
client.terminate();
}
client.isAlive = false;
-8
View File
@@ -35,14 +35,6 @@ export class RequestCodeRequest {
localeCountry: string;
}
export class OTPRequest {
code: string;
}
export class CaptchaBody {
code: string;
}
export class PairingCodeResponse {
code: string;
}
+4
View File
@@ -18,3 +18,7 @@ export class SessionQuery {
export class SessionBaseRequest {
session: string = WHATSAPP_DEFAULT_SESSION_NAME;
}
export class Result {
success: boolean = true;
}
+102 -1
View File
@@ -1,5 +1,17 @@
import { ApiParam, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { BooleanString } from '@waha/nestjs/validation/BooleanString';
import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
import { WAMessage } from '@waha/structures/responses.dto';
import { Transform, Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
export class CreateChannelRequest {
@ApiProperty({
@@ -38,7 +50,7 @@ export class ListChannelsQuery {
role?: ChannelRoleFilter;
}
export class Channel {
class ChannelBase {
@ApiProperty({
description: 'Newsletter id',
example: '123123123123@newsletter',
@@ -73,9 +85,15 @@ export class Channel {
verified: boolean;
subscribersCount: number;
}
export class Channel extends ChannelBase {
role: ChannelRole;
}
export class ChannelPublicInfo extends ChannelBase {}
export const NewsletterIdApiParam = ApiParam({
name: 'id',
required: true,
@@ -96,3 +114,86 @@ export const NewsletterIdOrInviteCodeApiParam = ApiParam({
description:
'WhatsApp Channel ID or invite code from invite link https://www.whatsapp.com/channel/11111',
});
export class ChannelCountry {
code: string;
name: string;
}
export class ChannelCategory {
value: string;
name: string;
}
export class ChannelView {
value: string;
name: string;
}
export class ChannelSearch {
@IsNumber()
@IsOptional()
limit: number = 50;
@IsString()
startCursor: string = '';
}
export class ChannelSearchByView extends ChannelSearch {
@IsString()
view: string = 'RECOMMENDED';
@IsArray()
@IsString({ each: true })
@ArrayMinSize(1)
countries: string[] = ['US'];
@IsArray()
@IsString({ each: true })
categories: string[] = [];
}
export class ChannelSearchByText extends ChannelSearch {
@IsString()
@IsNotEmpty()
text: string = 'Donald Trump';
@IsArray()
@IsString({ each: true })
categories: string[] = [];
}
export class ChannelPagination {
startCursor: string | null;
endCursor: string | null;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
export class ChannelListResult {
page: ChannelPagination;
channels: ChannelPublicInfo[];
}
export class ChannelMessage {
message: WAMessage;
@ApiProperty({
example: { '👍': 10, '❤️': 5 },
type: Object,
additionalProperties: { type: 'number' },
})
reactions: { [emoji: string]: number };
viewCount: number;
}
export class PreviewChannelMessages {
@Transform(BooleanString)
@IsBoolean()
downloadMedia: boolean = false;
@IsNumber()
@Type(() => Number)
limit: number = 100;
}
+4
View File
@@ -11,6 +11,10 @@ export enum WAHAEvents {
STATE_CHANGE = 'state.change',
GROUP_JOIN = 'group.join',
GROUP_LEAVE = 'group.leave',
GROUP_V2_JOIN = 'group.v2.join',
GROUP_V2_LEAVE = 'group.v2.leave',
GROUP_V2_UPDATE = 'group.v2.update',
GROUP_V2_PARTICIPANTS = 'group.v2.participants',
PRESENCE_UPDATE = 'presence.update',
POLL_VOTE = 'poll.vote',
POLL_VOTE_FAILED = 'poll.vote.failed',
+83
View File
@@ -74,6 +74,23 @@ export class JoinGroupResponse {
id: string;
}
export enum GroupField {
NONE = '',
PARTICIPANTS = 'participants',
}
export class GroupsListFields {
@IsOptional()
@ApiProperty({
description: 'Exclude fields',
enum: GroupField,
isArray: true,
required: false,
})
@IsEnum(GroupField, { each: true })
exclude: string[];
}
export enum GroupSortField {
ID = 'id',
SUBJECT = 'subject',
@@ -88,3 +105,69 @@ export class GroupsPaginationParams extends PaginationParams {
@IsEnum(GroupSortField)
sortBy?: string;
}
export enum GroupParticipantRole {
LEFT = 'left',
PARTICIPANT = 'participant',
ADMIN = 'admin',
SUPERADMIN = 'superadmin',
}
export class GroupParticipant {
@ApiProperty({
example: '123456789@c.us',
})
id: string;
@ApiProperty({
example: GroupParticipantRole.PARTICIPANT,
})
role: GroupParticipantRole;
}
export class GroupId {
@ApiProperty({
example: '123456789@g.us',
})
id: string;
}
export class GroupInfo {
@ApiProperty({
example: '123456789@g.us',
})
id: string;
@ApiProperty({
example: 'Group Name',
})
subject: string;
@ApiProperty({
example: 'Group Description',
})
description: string;
participants: GroupParticipant[];
@ApiProperty({
description: 'Invite URL',
example: 'https://chat.whatsapp.com/1234567890abcdef',
})
invite?: string;
@ApiProperty({
description: 'Members can add new members',
})
membersCanAddNewMember: boolean;
@ApiProperty({
description: 'Members can send messages to the group',
})
membersCanSendMessages: boolean;
@ApiProperty({
description: 'Admin approval required for new members',
})
newMembersApprovalRequired: boolean;
}
+71
View File
@@ -0,0 +1,71 @@
import { ApiProperty } from '@nestjs/swagger';
import { GroupId, GroupInfo, GroupParticipant } from './groups.dto';
/**
* Happens when you join or are added to a group.
*/
export class GroupV2JoinEvent {
@ApiProperty({
description: 'Unix timestamp',
example: 1666943582,
})
timestamp: number;
group: GroupInfo;
_data: any;
}
/**
* Happens when you leave or are removed from a group.
*/
export class GroupV2LeaveEvent {
@ApiProperty({
description: 'Unix timestamp',
example: 1666943582,
})
timestamp: number;
group: GroupId;
_data: any;
}
export class GroupV2UpdateEvent {
@ApiProperty({
description: 'Unix timestamp',
example: 1666943582,
})
timestamp: number;
group: Partial<GroupInfo>;
_data: any;
}
export enum GroupParticipantType {
JOIN = 'join',
LEAVE = 'leave',
PROMOTE = 'promote',
DEMOTE = 'demote',
}
export class GroupV2ParticipantsEvent {
group: GroupId;
@ApiProperty({
description: 'Type of the event',
})
type: GroupParticipantType;
@ApiProperty({
description: 'Unix timestamp',
example: 1666943582,
})
timestamp: number;
participants: GroupParticipant[];
_data: any;
}
+45
View File
@@ -0,0 +1,45 @@
import { ApiProperty } from '@nestjs/swagger';
import { WAHAEvents } from '@waha/structures/enums.dto';
import {
GroupV2JoinEvent,
GroupV2LeaveEvent,
GroupV2ParticipantsEvent,
GroupV2UpdateEvent,
} from '@waha/structures/groups.events.dto';
import { WAHAWebhook } from '@waha/structures/webhooks.dto';
export class WebhookGroupV2Join extends WAHAWebhook {
@ApiProperty({
description: 'When you joined or were added to a group',
})
event = WAHAEvents.GROUP_V2_JOIN;
payload: GroupV2JoinEvent;
}
export class WebhookGroupV2Leave extends WAHAWebhook {
@ApiProperty({
description: 'When you left or were removed from a group',
})
event = WAHAEvents.GROUP_V2_LEAVE;
payload: GroupV2LeaveEvent;
}
export class WebhookGroupV2Update extends WAHAWebhook {
@ApiProperty({
description: 'When group info is updated',
})
event = WAHAEvents.GROUP_V2_UPDATE;
payload: GroupV2UpdateEvent;
}
export class WebhookGroupV2Participants extends WAHAWebhook {
@ApiProperty({
description: 'When participants changed - join, leave, promote to admin',
})
event = WAHAEvents.GROUP_V2_PARTICIPANTS;
payload: GroupV2ParticipantsEvent;
}
+45
View File
@@ -0,0 +1,45 @@
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
import { ChatIdProperty } from '@waha/structures/properties.dto';
import { IsNotEmpty, IsString } from 'class-validator';
export class MyProfile {
@ChatIdProperty()
id: string;
name: string;
@ApiProperty({
example: 'https://example.com/picture.jpg',
})
picture: string | null;
}
export class ProfileNameRequest {
@IsString()
@IsNotEmpty()
@ApiProperty({
example: 'My New Name',
})
name: string;
}
export class ProfileStatusRequest {
@IsString()
@IsNotEmpty()
@ApiProperty({
example: '🎉 Hey there! I am using WhatsApp 🎉',
})
status: string;
}
@ApiExtraModels(RemoteFile, BinaryFile)
export class ProfilePictureRequest {
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(RemoteFile) },
{ $ref: getSchemaPath(BinaryFile) },
],
})
file: BinaryFile | RemoteFile;
}
+4 -3
View File
@@ -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()
+1 -1
View File
@@ -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[];
+24 -45
View File
@@ -91,6 +91,7 @@ export class PollVotePayload {
export class WAMessageRevokedBody {
after?: WAMessage;
before?: WAMessage;
_data?: any;
}
export class WASessionStatusBody {
@@ -148,14 +149,14 @@ export class WAHAWebhookSessionStatus extends WAHAWebhook {
payload: WASessionStatusBody;
}
class WAHAWebhookMessage extends WAHAWebhook {
export class WAHAWebhookMessage extends WAHAWebhook {
@ApiProperty({ description: 'Incoming message.' })
event = WAHAEvents.MESSAGE;
payload: WAMessage;
}
class WAHAWebhookMessageReaction extends WAHAWebhook {
export class WAHAWebhookMessageReaction extends WAHAWebhook {
@ApiProperty({
description:
'The event is triggered when a user reacts or removes a reaction.',
@@ -165,7 +166,7 @@ class WAHAWebhookMessageReaction extends WAHAWebhook {
payload: WAMessageReaction;
}
class WAHAWebhookMessageAny extends WAHAWebhook {
export class WAHAWebhookMessageAny extends WAHAWebhook {
@ApiProperty({
description: 'Fired on all message creations, including your own.',
})
@@ -174,7 +175,7 @@ class WAHAWebhookMessageAny extends WAHAWebhook {
payload: WAMessage;
}
class WAHAWebhookMessageAck extends WAHAWebhook {
export class WAHAWebhookMessageAck extends WAHAWebhook {
@ApiProperty({
description:
'Receive events when server or recipient gets the message, read or played it.',
@@ -184,7 +185,7 @@ class WAHAWebhookMessageAck extends WAHAWebhook {
payload: WAMessageAckBody;
}
class WAHAWebhookMessageRevoked extends WAHAWebhook {
export class WAHAWebhookMessageRevoked extends WAHAWebhook {
@ApiProperty({
description:
'The event is triggered when a user, whether it be you or any other participant, ' +
@@ -195,34 +196,37 @@ class WAHAWebhookMessageRevoked extends WAHAWebhook {
payload: WAMessageRevokedBody;
}
class WAHAWebhookStateChange extends WAHAWebhook {
export class WAHAWebhookStateChange extends WAHAWebhook {
@ApiProperty({
description: 'It’s an internal engine’s state, not session status.',
deprecated: true,
})
event = WAHAEvents.STATE_CHANGE;
payload: any;
}
class WAHAWebhookGroupJoin extends WAHAWebhook {
export class WAHAWebhookGroupJoin extends WAHAWebhook {
@ApiProperty({
description: 'Some one join a group.',
deprecated: true,
})
event = WAHAEvents.GROUP_JOIN;
payload: any;
}
class WAHAWebhookGroupLeave extends WAHAWebhook {
export class WAHAWebhookGroupLeave extends WAHAWebhook {
@ApiProperty({
description: 'Some one left a group.',
deprecated: true,
})
event = WAHAEvents.GROUP_LEAVE;
payload: any;
}
class WAHAWebhookPresenceUpdate extends WAHAWebhook {
export class WAHAWebhookPresenceUpdate extends WAHAWebhook {
@ApiProperty({
description: 'The most recent presence information for a chat.',
})
@@ -231,7 +235,7 @@ class WAHAWebhookPresenceUpdate extends WAHAWebhook {
payload: WAHAChatPresences;
}
class WAHAWebhookPollVote extends WAHAWebhook {
export class WAHAWebhookPollVote extends WAHAWebhook {
@ApiProperty({
description: 'With this event, you receive new votes for the poll sent.',
})
@@ -240,7 +244,7 @@ class WAHAWebhookPollVote extends WAHAWebhook {
payload: PollVotePayload;
}
class WAHAWebhookPollVoteFailed extends WAHAWebhook {
export class WAHAWebhookPollVoteFailed extends WAHAWebhook {
@ApiProperty({
description:
'There may be cases when it fails to decrypt a vote from the user. ' +
@@ -251,7 +255,7 @@ class WAHAWebhookPollVoteFailed extends WAHAWebhook {
payload: PollVotePayload;
}
class WAHAWebhookChatArchive extends WAHAWebhook {
export class WAHAWebhookChatArchive extends WAHAWebhook {
@ApiProperty({
description:
'The event is triggered when the chat is archived or unarchived',
@@ -261,7 +265,7 @@ class WAHAWebhookChatArchive extends WAHAWebhook {
payload: ChatArchiveEvent;
}
class WAHAWebhookCallReceived extends WAHAWebhook {
export class WAHAWebhookCallReceived extends WAHAWebhook {
@ApiProperty({
description:
'The event is triggered when the call is received by the user.',
@@ -271,7 +275,7 @@ class WAHAWebhookCallReceived extends WAHAWebhook {
payload: CallData;
}
class WAHAWebhookCallAccepted extends WAHAWebhook {
export class WAHAWebhookCallAccepted extends WAHAWebhook {
@ApiProperty({
description:
'The event is triggered when the call is accepted by the user.',
@@ -281,7 +285,7 @@ class WAHAWebhookCallAccepted extends WAHAWebhook {
payload: CallData;
}
class WAHAWebhookCallRejected extends WAHAWebhook {
export class WAHAWebhookCallRejected extends WAHAWebhook {
@ApiProperty({
description:
'The event is triggered when the call is rejected by the user.',
@@ -291,7 +295,7 @@ class WAHAWebhookCallRejected extends WAHAWebhook {
payload: CallData;
}
class WAHAWebhookLabelUpsert extends WAHAWebhook {
export class WAHAWebhookLabelUpsert extends WAHAWebhook {
@ApiProperty({
description: 'The event is triggered when a label is created or updated',
})
@@ -300,7 +304,7 @@ class WAHAWebhookLabelUpsert extends WAHAWebhook {
payload: Label;
}
class WAHAWebhookLabelDeleted extends WAHAWebhook {
export class WAHAWebhookLabelDeleted extends WAHAWebhook {
@ApiProperty({
description: 'The event is triggered when a label is deleted',
})
@@ -309,7 +313,7 @@ class WAHAWebhookLabelDeleted extends WAHAWebhook {
payload: Label;
}
class WAHAWebhookLabelChatAdded extends WAHAWebhook {
export class WAHAWebhookLabelChatAdded extends WAHAWebhook {
@ApiProperty({
description: 'The event is triggered when a label is added to a chat',
})
@@ -318,7 +322,7 @@ class WAHAWebhookLabelChatAdded extends WAHAWebhook {
payload: LabelChatAssociation;
}
class WAHAWebhookLabelChatDeleted extends WAHAWebhook {
export class WAHAWebhookLabelChatDeleted extends WAHAWebhook {
@ApiProperty({
description: 'The event is triggered when a label is deleted from a chat',
})
@@ -332,7 +336,7 @@ export class EnginePayload {
data: any;
}
class WAHAWebhookEngineEvent extends WAHAWebhook {
export class WAHAWebhookEngineEvent extends WAHAWebhook {
@ApiProperty({
description: 'Internal engine event.',
})
@@ -340,28 +344,3 @@ class WAHAWebhookEngineEvent extends WAHAWebhook {
payload: EnginePayload;
}
const WAHA_WEBHOOKS = [
WAHAWebhookSessionStatus,
WAHAWebhookMessage,
WAHAWebhookMessageReaction,
WAHAWebhookMessageAny,
WAHAWebhookMessageAck,
WAHAWebhookMessageRevoked,
WAHAWebhookStateChange,
WAHAWebhookGroupJoin,
WAHAWebhookGroupLeave,
WAHAWebhookPresenceUpdate,
WAHAWebhookPollVote,
WAHAWebhookPollVoteFailed,
WAHAWebhookChatArchive,
WAHAWebhookCallReceived,
WAHAWebhookCallAccepted,
WAHAWebhookCallRejected,
WAHAWebhookLabelUpsert,
WAHAWebhookLabelDeleted,
WAHAWebhookLabelChatAdded,
WAHAWebhookLabelChatDeleted,
WAHAWebhookEngineEvent,
];
export { WAHA_WEBHOOKS };
+58
View File
@@ -0,0 +1,58 @@
import {
WebhookGroupV2Join,
WebhookGroupV2Leave,
WebhookGroupV2Participants,
WebhookGroupV2Update,
} from '@waha/structures/groups.webhooks.dto';
import {
WAHAWebhookCallAccepted,
WAHAWebhookCallReceived,
WAHAWebhookCallRejected,
WAHAWebhookChatArchive,
WAHAWebhookEngineEvent,
WAHAWebhookGroupJoin,
WAHAWebhookGroupLeave,
WAHAWebhookLabelChatAdded,
WAHAWebhookLabelChatDeleted,
WAHAWebhookLabelDeleted,
WAHAWebhookLabelUpsert,
WAHAWebhookMessage,
WAHAWebhookMessageAck,
WAHAWebhookMessageAny,
WAHAWebhookMessageReaction,
WAHAWebhookMessageRevoked,
WAHAWebhookPollVote,
WAHAWebhookPollVoteFailed,
WAHAWebhookPresenceUpdate,
WAHAWebhookSessionStatus,
WAHAWebhookStateChange,
} from '@waha/structures/webhooks.dto';
const WAHA_WEBHOOKS = [
WAHAWebhookSessionStatus,
WAHAWebhookMessage,
WAHAWebhookMessageReaction,
WAHAWebhookMessageAny,
WAHAWebhookMessageAck,
WAHAWebhookMessageRevoked,
WebhookGroupV2Join,
WebhookGroupV2Leave,
WebhookGroupV2Update,
WebhookGroupV2Participants,
WAHAWebhookPresenceUpdate,
WAHAWebhookPollVote,
WAHAWebhookPollVoteFailed,
WAHAWebhookChatArchive,
WAHAWebhookCallReceived,
WAHAWebhookCallAccepted,
WAHAWebhookCallRejected,
WAHAWebhookLabelUpsert,
WAHAWebhookLabelDeleted,
WAHAWebhookLabelChatAdded,
WAHAWebhookLabelChatDeleted,
WAHAWebhookEngineEvent,
WAHAWebhookGroupJoin,
WAHAWebhookGroupLeave,
WAHAWebhookStateChange,
];
export { WAHA_WEBHOOKS };
+13 -4
View File
@@ -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) {
+1 -1
View File
@@ -33,7 +33,7 @@ export function getEngineName(): string {
}
export const VERSION: WAHAEnvironment = {
version: '2025.1.3',
version: '2025.2.7',
engine: getEngineName(),
tier: getWAHAVersion(),
browser: getBrowserExecutablePath(),
+59
View File
@@ -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