Compare commits

...
64 Commits
Author SHA1 Message Date
devlikepro faf3c77508 [core] CI - no unit tests (rust bridge is too big)
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
2026-02-18 17:11:30 +07:00
devlikepro e6fdfb88ca [core] 2026.2.2 2026-02-18 17:11:20 +07:00
devlikepro 99746df626 [core] Dashboard
fix for crypto.randomUUID in apps
2026-02-18 17:11:16 +07:00
devlikepro 43aeeb55e8 [core] GOWS - Update engine
Fix "Edit message with image not working" - fix #1852
2026-02-18 17:11:16 +07:00
devlikepro 4f89f49d44 [core] README.md - build rust whatsapp bridge 2026-02-18 17:11:15 +07:00
devlikepro be583e7e40 [core] Dockerfile - build rust whatsapp bridge 2026-02-18 17:11:14 +07:00
devlikepro e8c8f89733 [core] AGENTS.md 2026-02-18 17:11:13 +07:00
devlikepro 13b6d3ddf7 [core] NOWEB - edit text messages in channels (doesn't work for image/video yet)
fix #1352
2026-02-18 17:11:13 +07:00
devlikepro 5e97fb74b1 [core] NOWEB - edit caption for media message
fix #1352
2026-02-18 17:11:12 +07:00
devlikepro cc47321cad [core] GOWS - add seconds to video messages
fix #1893
2026-02-18 17:11:11 +07:00
devlikepro 607dff8f7a [core] NOWEB - add seconds to video messages
fix #1893
2026-02-18 17:11:11 +07:00
devlikepro 0a74f8dbcd [core] picsum photos 2026-02-18 17:11:10 +07:00
devlikepro 796befcf87 [core] NOWEB - use rust-bridge fork 2026-02-18 17:11:10 +07:00
devlikepro 2c5610dd01 [core] NOWEB - update engine 2026-02-18 17:11:10 +07:00
devlikepro 43d110fa31 [core] WEBJS - update engine
fix "Lid is missing in chat table" - fix #1824

fix "No LID for user" - fix #1881
2026-02-18 17:11:09 +07:00
devlikepro bf52dd4874 [core] Fix linter 2026-02-18 17:11:09 +07:00
devlikepro f8268180bf [core] Use github runners instead of buildjet
RIP buildjet
2026-02-18 17:11:09 +07:00
devlikepro 04cf9bc8c0 [core] NOWEB - fix send message to group community - "TypeError: jid.endsWith is not a function" - fix #1901 2026-02-18 17:11:09 +07:00
devlikepro ff38cf7660 [core] init-waha 2026-02-18 17:11:09 +07:00
devlikepro 4c0b44c4b5 [core] NOWEB - fix WhatsApp template with an image
fix #1886
2026-02-18 17:11:08 +07:00
Dani LipariandDani Lipari e75c29d57f [core] fix: strip escaped newlines from ChatWoot v4.10.1+ messages (#1900)
ChatWoot v4.10.1 escapes newlines by prepending a trailing backslash
before each line break in webhook payloads. This caused literal
backslash characters to appear in WhatsApp messages.

Strip the trailing backslash before newlines in MarkdownToWhatsApp()
so line breaks are preserved correctly.

Closes #1833
Mentions chatwoot/chatwoot#13348

Co-authored-by: Dani Lipari <dani.lipari@dontouch.ch>
2026-02-16 14:37:32 +07:00
Dani LipariandDani Lipari f8329c29b1 [core] fix: harden auth middleware, WebSocket guard, and policies guard (#1899)
- Add missing return after socket.close() in WebSocket gateway to
  prevent cross-session event subscription on forbidden connections
- Return 401 Unauthorized instead of 500 Internal Server Error when
  API key header is missing or malformed
- Deny by default in PoliciesGuard when no @CheckPolicies handlers
  are defined, preventing accidental exposure of undecorated endpoints

Co-authored-by: Dani Lipari <dani.lipari@dontouch.ch>
2026-02-16 14:34:50 +07:00
devlikepro 52222a6b98 [core] 2026.2.1
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
2026-02-02 14:10:58 +07:00
devlikepro 99a0a19d8c [core] NOWEB - fix send image using proxy
fix #1859
2026-02-02 14:10:58 +07:00
devlikepro 48ed33bc94 [core] Up WEBJS
Fix GET groups internal error
 - fix #1860

Fix Send message to channel - fix #1863

Fix contact update - fix #1839

[WEBJS] - Get groups count and Get groups failed fix #1879 fix #1873
2026-02-02 14:10:57 +07:00
devlikepro b234e134bf [core] GOWS - exclude all AppState events 2026-02-02 14:10:57 +07:00
devlikepro f6004fd294 [core] openapi - POST /api/{session}/presence chatId param
fix #1842
2026-02-02 14:10:57 +07:00
devlikepro 91d541387b [core] 2026.1.5
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
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
2026-01-28 17:32:34 +07:00
devlikepro 9dbd45da6e [core] Up WEBJS - fix #1855 fix #1856 2026-01-28 17:32:34 +07:00
devlikepro adc1c3960c [core] for-swagger 2026-01-28 17:32:33 +07:00
devlikepro ff742d2e84 [core] WEBJS - add removeExposedFunction WPage 2026-01-28 17:32:33 +07:00
devlikepro 10bd4d6271 [core] Up Dashboard 2026-01-28 17:32:33 +07:00
devlikepro 874cafe114 [core] GOWS - disable storage
fix #823
2026-01-28 17:32:33 +07:00
devlikepro 0ce2c4b9fa [core] GOWS - Exclude AppState, HistorySync, Contact events 2026-01-28 17:32:33 +07:00
devlikepro a8a7988bfa [core] GOWS - Exclude event from engine.events 2026-01-28 17:32:33 +07:00
devlikepro 2940d93b81 [core] Up GOWS 2026-01-28 17:32:32 +07:00
devlikepro 88707cf72b [core] GOWS - BuildClient 2026-01-28 17:32:32 +07:00
devlikepro b0f883c6a8 [core] ChatWoot - no POSTGRES_HOST_AUTH_METHOD
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
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
2026-01-26 11:06:54 +07:00
devlikepro 4cddb766c2 [core] 2026.1.4 2026-01-26 10:14:19 +07:00
devlikepro 6675492901 [core] Optimize get me 2026-01-26 10:14:19 +07:00
devlikepro a9d030af2a [core] Up NOWEB 2026-01-26 10:11:07 +07:00
devlikepro 2782a188a9 [core] Webhooks - add worker.id
fix #1840
2026-01-26 10:11:07 +07:00
devlikepro 256688497e [core] WEBJS - get 500 error on getChats
fix #1834
2026-01-26 10:11:07 +07:00
devlikepro 4def193c57 [core] WEBJS - inject on script READY too 2026-01-26 10:08:52 +07:00
devlikepro 7f3a2e6aa5 [core] AGENTS.md 2026-01-26 10:08:52 +07:00
devlikepro 60e4f13b67 [core] Fix DeprecationWarning: url.parse() 2026-01-26 10:08:51 +07:00
devlikepro 2d6cfeb0fa [core] ChatWoot - use chat id from incoming message to use either @lid or @c.us (likely @lid in MOST cases now) 2026-01-26 10:08:50 +07:00
devlikepro 03e85434df [core] GOWS subprocess 2026-01-26 10:08:49 +07:00
devlikepro 0ce4c01c48 [core] Optimize get config 2026-01-26 10:08:49 +07:00
devlikepro 0e95890ca4 [core] Optimize get me 2026-01-26 10:08:48 +07:00
devlikepro 072f4b2ab0 [core] Up GOWS 2026-01-26 10:08:48 +07:00
devlikepro 47a91ef669 [core] websocket - use setImmediate 2026-01-26 10:08:47 +07:00
devlikepro 5143bd1ef8 [core] Webhooks - use setImmediate 2026-01-26 10:08:46 +07:00
devlikepro 0efc0751c9 [core] GOWS - send data to subscribers in a setImmediate 2026-01-26 10:08:45 +07:00
devlikepro 51dda739cb [core] ChatWoot - add delay config
WAHA_APPS_JOBS_ATTEMPTS
WAHA_APPS_JOBS_DELAY
WAHA_APPS_JOBS_BACKOFF_TYPE
WAHA_APPS_JOBS_BACKOFF_DELAY

fix #1828
2026-01-26 10:08:45 +07:00
devlikepro 3b28826679 [core] dev 2026-01-26 10:08:45 +07:00
devlikepro 81646bbaa2 [core] Up Dashboard 2026-01-26 10:08:44 +07:00
devlikepro d8d84107fb [core] yarn:test in ci 2026-01-26 10:08:43 +07:00
devlikepro 58d917d374 [core] Api Keys - admin, per sessions
fix #387
fix #937
fix #1642
fix #1772
2026-01-26 10:08:41 +07:00
devlikepro f8f0063975 [core] Openapi - use pairing tag for qr/code/screenshot, move apps below 2026-01-26 10:08:40 +07:00
devlikepro 73faf330e3 [core] AGENTS.md 2026-01-26 10:08:40 +07:00
devlikepro 5a1ebb186d [core] import crypto 2026-01-26 10:08:40 +07:00
devlikepro 54d6d715b0 [core] Add "chrome" to dev build 2026-01-26 10:08:40 +07:00
devlikepro 5d9d6e29e2 [core] Up WEBJS - fix SendSeen not working
fix #1818
2026-01-26 10:08:40 +07:00
92 changed files with 3216 additions and 277 deletions

No files matched your search

+8 -8
View File
@@ -15,7 +15,7 @@ jobs:
matrix:
include:
# Chromium - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
- runner: 'ubuntu-latest'
tag: 'latest'
platform: 'amd64'
browser: 'chromium'
@@ -23,7 +23,7 @@ jobs:
goss: 'goss-linux-amd64'
# Chromium - ARM
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
- runner: 'ubuntu-24.04-arm'
tag: 'arm'
platform: 'linux/arm64'
browser: 'chromium'
@@ -31,7 +31,7 @@ jobs:
goss: 'goss-linux-arm'
# Chrome - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
- runner: 'ubuntu-latest'
tag: 'chrome'
platform: 'amd64'
browser: 'chrome'
@@ -39,14 +39,14 @@ jobs:
goss: 'goss-linux-amd64'
# Chrome - ARM (Chrome is not available for ARM)
#- runner: "buildjet-4vcpu-ubuntu-2204-arm"
#- runner: "ubuntu-24.04-arm"
# tag: "chrome-arm"
# platform: "linux/arm64"
# browser: "chrome"
# goss: 'goss-linux-arm'
# NOWEB - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
- runner: 'ubuntu-latest'
tag: 'noweb'
platform: 'amd64'
browser: 'none'
@@ -54,7 +54,7 @@ jobs:
goss: 'goss-linux-amd64'
# NOWEB - ARM
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
- runner: 'ubuntu-24.04-arm'
tag: 'noweb-arm'
platform: 'linux/arm64'
browser: 'none'
@@ -62,7 +62,7 @@ jobs:
goss: 'goss-linux-arm'
# GOWS - x86
- runner: 'buildjet-4vcpu-ubuntu-2204'
- runner: 'ubuntu-latest'
tag: 'gows'
platform: 'amd64'
browser: 'none'
@@ -70,7 +70,7 @@ jobs:
goss: 'goss-linux-amd64'
# No browser - ARM
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
- runner: 'ubuntu-24.04-arm'
tag: 'gows-arm'
platform: 'linux/arm64'
browser: 'none'
+42 -2
View File
@@ -6,8 +6,37 @@ on:
workflow_dispatch:
jobs:
# unit-tests:
# if: github.repository == 'devlikeapro/waha-plus'
# runs-on: ubuntu-22.04
# name: Unit tests
# steps:
# - name: Checkout
# uses: actions/checkout@v4
# with:
# fetch-depth: 0
#
# - name: Set up Node
# uses: actions/setup-node@v4
# with:
# node-version: 22
#
# - name: Enable Corepack
# run: |
# corepack enable
# corepack prepare yarn@4.9.2 --activate
#
# - name: Install dependencies
# run: |
# unset GIT_CONFIG_PARAMETERS
# yarn install --immutable
#
# - name: Run unit tests
# run: yarn test:unit
docker-build:
if: github.repository == 'devlikeapro/waha-plus'
# needs: unit-tests
runs-on: ${{ matrix.runner }}
name:
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
@@ -15,13 +44,24 @@ jobs:
strategy:
matrix:
include:
- runner: 'buildjet-4vcpu-ubuntu-2204'
# Chromium - x86
- runner: 'ubuntu-latest'
tag: 'dev'
platform: 'amd64'
browser: 'chromium'
engine: 'WEBJS'
goss: 'goss-linux-amd64'
- runner: 'buildjet-4vcpu-ubuntu-2204-arm'
# Chrome - x86
- runner: 'ubuntu-latest'
tag: 'dev-chrome'
platform: 'amd64'
browser: 'chrome'
engine: 'WEBJS'
goss: 'goss-linux-amd64'
# Chromium - ARM
- runner: 'ubuntu-24.04-arm'
tag: 'dev-arm'
platform: 'linux/arm64'
browser: 'chromium'
+9 -1
View File
@@ -3,6 +3,11 @@
This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
(WAHA) codebase when assisting as an automation or coding agent.
- When you asked to refactor or "apply new lib" instead of current one - do not
change the behavior, make a minimum amount of changes possible. If you see
some edge case during that process that haven't been covered - tell it,
suggest fix, but don't change the code.
## Product & Variants
- WAHA ships in **Core** and **Plus** editions. Core lives under `src/core` and
@@ -70,6 +75,7 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
`src/utils/logging.ts`. `console.log` is blocked by pre-commit.
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`; keep imports
consistent (use absolute aliases, not relative `../../../`).
- Prefer named function declarations over `const` arrow functions when possible.
- Avoid naming unused variables with a leading underscore; if a parameter is
required by a signature, explicitly `void` it instead.
- For configs, prefer runtime configurability over constants. Environment keys
@@ -126,7 +132,9 @@ You can find related source code in the following paths:
- WEBJS: `../whatsapp-web.js`
- NOWEB: `../WhiskeySockets-Baileys`
- GOWS: `../gows` + `../whatsmeow`
- whatsapp-rust-bridge - `../whatsapp-rust-bridge`
- GOWS: `../gows`
- whatsmeow - `../whatsmeow`
- ChatWoot: `../chatwoot`
Following this playbook keeps contributions aligned with WAHA’s structure,
+20 -1
View File
@@ -6,9 +6,28 @@ ARG GOLANG_IMAGE_TAG=1.24-bookworm
#
FROM node:${NODE_IMAGE_TAG} AS build
ENV PUPPETEER_SKIP_DOWNLOAD=True
ENV RUST_BUN_VERSION=1.3.9
ENV RUSTUP_TOOLCHAIN=nightly-2026-01-30
ENV RUST_WASM_PACK_VERSION=0.14.0
ENV RUST_BUN_INSTALL=/root/.bun
ENV PATH=${RUST_BUN_INSTALL}/bin:/root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# git + build toolchain for git deps
RUN apt-get update && apt-get install -y git python3 build-essential && rm -rf /var/lib/apt/lists/*
RUN apt-get update && \
apt-get install -y --no-install-recommends git python3 build-essential curl ca-certificates unzip && \
rm -rf /var/lib/apt/lists/*
# bun + rust toolchains for whatsapp-rust-bridge prepare scripts
RUN set -eux; \
mkdir -p "${RUST_BUN_INSTALL}"; \
curl -fsSL https://bun.sh/install | bash -s -- bun-v${RUST_BUN_VERSION}; \
curl -fsSL https://sh.rustup.rs | bash -s -- -y --default-toolchain ${RUSTUP_TOOLCHAIN}; \
/root/.cargo/bin/rustup target add wasm32-unknown-unknown; \
/root/.cargo/bin/cargo install wasm-pack --vers ${RUST_WASM_PACK_VERSION} --locked; \
"${RUST_BUN_INSTALL}/bin/bun" --version; \
/root/.cargo/bin/cargo --version; \
/root/.cargo/bin/rustc --version; \
/root/.cargo/bin/wasm-pack --version
# npm packages
WORKDIR /git
+6 -4
View File
@@ -32,16 +32,19 @@ push:
docker push devlikeapro/waha
for-swagger:
WHATSAPP_SWAGGER_CONFIG_ADVANCED=true WHATSAPP_SWAGGER_PASSWORD=666 nvm exec yarn start
export WHATSAPP_SWAGGER_CONFIG_ADVANCED=true && export WHATSAPP_SWAGGER_PASSWORD=666 && yarn start
up-noweb:
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-12-17
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2026-02-11
up-noweb-libsignal:
yarn up libsignal@github:devlikeapro/libsignal-node#fork-master
up-webjs:
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2025-12-17
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-02-01
up-rust-bridge:
yarn up -R whatsapp-rust-bridge
start-proxy:
docker run --rm -d --name squid-container -e TZ=UTC -p 3128:3128 ubuntu/squid:5.2-22.04_beta
@@ -57,4 +60,3 @@ gows:
copy-dashboard:
cd ../waha-hub/ui && \
make copy-waha
+12 -1
View File
@@ -147,7 +147,18 @@ curl -d "{\"chatId\": \"${PHONE}@c.us\", \"text\": \"Hello from WhatsApp HTTP AP
## Start the project
1. Clone the repository
2. Make sure you're using node>=22 (check [.nvmrc](/.nvmrc) to get the version)
3. Run the following commands:
3. Install the **whatsapp-rust-bridge prerequisites**:
```bash
# Bun runtime used by whatsapp-rust-bridge prepare scripts
curl -fsSL https://bun.sh/install | bash -s -- bun-v1.3.9
# Rust nightly toolchain used for whatsapp-rust-bridge
curl -fsSL https://sh.rustup.rs | bash -s -- -y --default-toolchain nightly-2026-01-30
rustup target add wasm32-unknown-unknown
cargo install wasm-pack --vers 0.14.0 --locked
```
4. Run the following commands:
```bash
# Install dependencies
yarn install
@@ -75,7 +75,6 @@ services:
- POSTGRES_USER=postgres
# Please provide your own password.
- POSTGRES_PASSWORD=postgres
- POSTGRES_HOST_AUTH_METHOD=trust
redis:
image: redis:alpine
+54 -23
View File
@@ -16,23 +16,27 @@
"start:prod-exit": "node dist/main",
"lint": "oxlint --deny-warnings",
"lint-fix": "oxlint --fix --deny-warnings",
"test": "jest",
"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": "yarn test:unit",
"test:watch": "jest --selectProjects unit --watch",
"test:cov": "jest --selectProjects unit --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --selectProjects unit --runInBand",
"test:unit": "jest --selectProjects unit",
"test:e2e": "jest --selectProjects e2e",
"test:e8e": "yarn test:e2e",
"test:all": "yarn test:unit && yarn test:e8e",
"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-2025-12-17",
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2026-02-11",
"@adiwajshing/keyed-db": "^0.2.4",
"@aws-sdk/client-s3": "^3.633.0",
"@aws-sdk/s3-request-presigner": "^3.633.0",
"@bull-board/api": "^6.9.1",
"@bull-board/express": "^6.9.1",
"@bull-board/nestjs": "^6.9.1",
"@casl/ability": "^6.8.0",
"@figuro/chatwoot-sdk": "^1.1.17",
"@liaoliaots/nestjs-redis": "^9",
"@nestjs/axios": "^3.0.2",
@@ -107,7 +111,7 @@
"ulid": "^2.3.0",
"undici": "^7.16.0",
"uniqid": "^5.4.0",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2025-12-17",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-02-01",
"write-file-atomic": "^6.0.0",
"yaml": "^2.7.1"
},
@@ -120,7 +124,8 @@
"ws": "^8.18.0",
"puppeteer": "^24.31.0",
"whatwg-url": "13.0.0",
"axios": "^1.9.0"
"axios": "^1.9.0",
"whatsapp-rust-bridge": "github:devlikeapro/whatsapp-rust-bridge.git#fork-master-2026-02-04"
},
"devDependencies": {
"@grpc/grpc-js": "^1.14.1",
@@ -148,21 +153,47 @@
"typescript": "5.9.2"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@waha/(.*)$": "<rootDir>/$1"
},
"testRegex": ".test.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"projects": [
{
"displayName": "unit",
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@waha/(.*)$": "<rootDir>/$1"
},
"testRegex": ".test.ts$",
"testPathIgnorePatterns": [
"/__tests__/e2e/"
],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
{
"displayName": "e2e",
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@waha/(.*)$": "<rootDir>/$1"
},
"testRegex": "__tests__/e2e/.*\\.test\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
]
},
"packageManager": "yarn@4.9.2"
}
+17 -3
View File
@@ -76,17 +76,31 @@ const updatedContent = exampleContent
fs.writeFileSync(targetPath, updatedContent, { encoding: 'utf8' });
const generatedEnvValues = [
`WAHA_API_KEY=${apiKey}`,
`WAHA_API_KEY_PLAIN=${apiKey}`,
'WAHA_DASHBOARD_USERNAME=admin',
`WAHA_DASHBOARD_PASSWORD=${adminPassword}`,
'WHATSAPP_SWAGGER_USERNAME=admin',
`WHATSAPP_SWAGGER_PASSWORD=${adminPassword}`,
];
const lines = [
'Credentials generated.',
'',
'Dashboard and Swagger:',
'Generated env values:',
...generatedEnvValues.map((line) => ` - ${line}`),
'',
'Use these credentials to login in Dashboard or Swagger:',
' - Username: admin',
` - Password: ${adminPassword}`,
'',
'API key: ',
'Use this API key in the x-api-key header:',
` - ${apiKey}`,
'',
'Use it in the Dashboard connection flow: https://waha.devlike.pro/docs/how-to/dashboard/#api-key',
'Read more:',
' - https://waha.devlike.pro/docs/how-to/dashboard/#api-key',
' - https://waha.devlike.pro/docs/how-to/security/',
];
console.log(lines.join('\n'));
+391
View File
@@ -0,0 +1,391 @@
import axios from 'axios';
import WebSocket = require('ws');
const VALID_API_KEY = '666';
const VALID_USERNAME = 'admin';
const VALID_PASSWORD = '666';
const VALID_SESSION_API_KEY = 'key_0fQfI3n3rFVsczBbBfknLXKUreY2my6J';
const BASE_URL = (process.env.WAHA_BASE_URL ?? 'http://localhost:3000').replace(
/\/$/,
'',
);
const WS_BASE_URL = BASE_URL.replace(/^http/, 'ws');
const HTTP_OK = 200;
const HTTP_CREATED = 201;
const HTTP_UNAUTHORIZED = 401;
const HTTP_FORBIDDEN = 403;
const HTTP_NOT_FOUND = 404;
const HTTP_UNPROCESSABLE = 422;
const WS_POLICY_VIOLATION = 1008;
const WS_OK_CODES = [1000, 1005];
describe('admin - GET /api/sessions/{name}', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('invalid api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': '123' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('admin - POST /api/sessions', () => {
test('admin key can create a session without name and remove it', async () => {
const createResponse = await axios.post(
`${BASE_URL}/api/sessions`,
{},
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
expect(createResponse.status).toBe(HTTP_CREATED);
expect(createResponse.data?.name).toBeTruthy();
const deleteResponse = await axios.delete(
`${BASE_URL}/api/sessions/${createResponse.data.name}`,
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
expect(deleteResponse.status).toBe(HTTP_OK);
});
});
describe('GET /api/sessions?all=true', () => {
beforeAll(async () => {
const createResponse = await axios.post(
`${BASE_URL}/api/sessions`,
{ name: 'another' },
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
if (![HTTP_CREATED, HTTP_UNPROCESSABLE].includes(createResponse.status)) {
throw new Error(
`Unexpected status for creating "another" session: ${createResponse.status}`,
);
}
});
afterAll(async () => {
const deleteResponse = await axios.delete(
`${BASE_URL}/api/sessions/another`,
{
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
},
);
if (![HTTP_OK, HTTP_NOT_FOUND].includes(deleteResponse.status)) {
throw new Error(
`Unexpected status for deleting "another" session: ${deleteResponse.status}`,
);
}
});
test('admin key returns two sessions', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
expect(response.data).toHaveLength(2);
expect(response.data.map((session) => session.name).sort()).toEqual([
'another',
'default',
]);
});
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('session key returns one session', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions?all=true`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
expect(response.data).toHaveLength(1);
expect(response.data[0]?.name).toBe('default');
});
});
describe('GET /api/server/version', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/server/version`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('admin api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/server/version`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('session api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/server/version`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
});
describe('GET /api/server/environment', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('admin api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('session api key is forbidden', async () => {
const response = await axios.get(`${BASE_URL}/api/server/environment`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_FORBIDDEN);
});
});
describe('admin - GET /health', () => {
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/health`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/health`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('invalid api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/health`, {
headers: { 'X-Api-Key': '123' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('/ws', () => {
const buildWsUrl = (apiKey?: string) => {
const wsUrl = new URL('/ws', WS_BASE_URL);
wsUrl.searchParams.set('session', '*');
wsUrl.searchParams.set('events', '*');
if (apiKey) {
wsUrl.searchParams.set('x-api-key', apiKey);
}
return wsUrl.toString();
};
const waitForOpen = (socket: WebSocket, timeoutMs = 5_000) =>
new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('WebSocket open timeout'));
}, timeoutMs);
socket.once('open', () => {
clearTimeout(timeout);
resolve();
});
socket.once('error', (error) => {
clearTimeout(timeout);
reject(error);
});
});
const waitForClose = (socket: WebSocket, timeoutMs = 5_000) =>
new Promise<{ code: number; reason: string }>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('WebSocket close timeout'));
}, timeoutMs);
socket.once('close', (code, reason) => {
clearTimeout(timeout);
resolve({ code, reason: reason.toString() });
});
socket.once('error', (error) => {
clearTimeout(timeout);
reject(error);
});
});
test('no api key is unauthorized', async () => {
const socket = new WebSocket(buildWsUrl());
const { code } = await waitForClose(socket);
expect(code).toBe(WS_POLICY_VIOLATION);
});
test('admin api key is ok', async () => {
const socket = new WebSocket(buildWsUrl(VALID_API_KEY));
await waitForOpen(socket);
socket.close();
const { code } = await waitForClose(socket);
expect(WS_OK_CODES).toContain(code);
});
});
describe('GET /api/files/test.txt', () => {
// create "test.txt" in current dir/.media/test.txt
let file = null;
beforeAll(() => {
const fs = require('fs');
const path = require('path');
const mediaDir = path.resolve('./.media');
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir);
}
const filePath = path.join(mediaDir, 'test.txt');
fs.writeFileSync(filePath, 'This is a test file.');
file = filePath;
});
test('no api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid api key is ok', async () => {
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
headers: { 'X-Api-Key': VALID_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('invalid api key is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/files/test.txt`, {
headers: { 'X-Api-Key': '123' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('GET /', () => {
test('no auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid auth is ok', async () => {
const response = await axios.get(`${BASE_URL}/`, {
auth: { username: VALID_USERNAME, password: VALID_PASSWORD },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('wrong auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/`, {
auth: { username: VALID_USERNAME, password: 'password-another' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('GET /dashboard', () => {
test('no auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/dashboard/`, {
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('valid auth is ok', async () => {
const response = await axios.get(`${BASE_URL}/dashboard/`, {
auth: { username: VALID_USERNAME, password: VALID_PASSWORD },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('wrong auth is unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/dashboard/`, {
auth: { username: VALID_USERNAME, password: 'password-another' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
});
describe('session key - GET /api/sessions/{name}', () => {
test('default key - default session - is authorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_OK);
});
test('default key - create a new sessions - forbidden', async () => {
const response = await axios.post(
`${BASE_URL}/api/sessions`,
{ name: 'newone' },
{
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
},
);
expect(response.status).toBe(HTTP_FORBIDDEN);
});
test('another key - default session - unauthorized', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/default`, {
headers: { 'X-Api-Key': 'key_another' },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_UNAUTHORIZED);
});
test('default key - another session - forbidden', async () => {
const response = await axios.get(`${BASE_URL}/api/sessions/another`, {
headers: { 'X-Api-Key': VALID_SESSION_API_KEY },
validateStatus: () => true,
});
expect(response.status).toBe(HTTP_FORBIDDEN);
});
});
+139
View File
@@ -0,0 +1,139 @@
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
Put,
UnprocessableEntityException,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { Action } from '@waha/core/auth/casl.types';
import { CanServer } from '@waha/core/auth/policies';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { ApiKey, CheckInvariant } from '@waha/core/storage/IApiKeyRepository';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { ApiKeyDTO, ApiKeyRequest } from '@waha/structures/apikeys.dto';
import { generatePrefixedId, generateSecret } from '@waha/utils/ids';
@ApiSecurity('api_key')
@Controller('api/keys')
@ApiTags('🔑 Api Keys')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Manage))
export class ApiKeysController {
constructor(private manager: SessionManager) {}
@Post('/')
@ApiOperation({ summary: 'Create a new API key' })
@UsePipes(new WAHAValidationPipe())
async create(@Body() body: ApiKeyRequest): Promise<ApiKeyDTO> {
CheckInvariant(body);
if (body.session) {
const exists = await this.manager.exists(body.session);
if (!exists) {
throw new UnprocessableEntityException(
`Session "${body.session}" does not exist`,
);
}
}
let apikey: ApiKey | null = null;
// Try 5 times to check there's no conflict on id and key
for (let i = 0; i < 5; i++) {
apikey = {
id: generatePrefixedId('key_id'),
key: `key_${generateSecret(32)}`,
isActive: body.isActive,
isAdmin: body.isAdmin,
session: body.session,
rules: null,
};
const idExists = await this.manager.apiKeyRepository.getById(apikey.id);
if (idExists) {
continue;
}
const keyExists = await this.manager.apiKeyRepository.getByKey(
apikey.key,
);
if (keyExists) {
continue;
}
break;
}
if (!apikey) {
throw new UnprocessableEntityException(
`Failed to generate API key, try again`,
);
}
CheckInvariant(apikey);
await this.manager.apiKeyRepository.upsert(apikey);
return ApiKeyToDTO(apikey);
}
@Get('/')
@ApiOperation({ summary: 'Get all API keys' })
async list(): Promise<ApiKeyDTO[]> {
const keys = await this.manager.apiKeyRepository.list();
return keys.map((key) => ApiKeyToDTO(key));
}
@Put('/:id')
@ApiOperation({ summary: 'Update an API key' })
@UsePipes(new WAHAValidationPipe())
async update(
@Param('id') id: string,
@Body() body: ApiKeyRequest,
): Promise<ApiKeyDTO> {
const existing = await this.manager.apiKeyRepository.getById(id);
if (!existing) {
throw new NotFoundException('API key not found');
}
const apikey: ApiKey = {
...existing,
isActive: body.isActive,
isAdmin: body.isAdmin,
session: body.session,
};
CheckInvariant(apikey);
if (apikey.session) {
const exists = await this.manager.exists(apikey.session);
if (!exists) {
throw new UnprocessableEntityException(
`Session "${apikey.session}" does not exist`,
);
}
}
CheckInvariant(apikey);
await this.manager.apiKeyRepository.upsert(apikey);
return ApiKeyToDTO(apikey);
}
@Delete('/:id')
@ApiOperation({ summary: 'Delete an API key' })
async delete(@Param('id') id: string): Promise<{ result: true }> {
const existing = await this.manager.apiKeyRepository.getById(id);
if (!existing) {
throw new NotFoundException('API key not found');
}
await this.manager.apiKeyRepository.deleteById(id);
return { result: true };
}
}
function ApiKeyToDTO(apikey: ApiKey): ApiKeyDTO {
return {
id: apikey.id,
key: apikey.key,
isActive: apikey.isActive,
isAdmin: apikey.isAdmin,
session: apikey.session,
};
}
+9 -1
View File
@@ -5,6 +5,7 @@ import {
Post,
Query,
UseInterceptors,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
@@ -24,10 +25,17 @@ import {
RequestCodeRequest,
} from '../structures/auth.dto';
import { Base64File } from '../structures/files.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/auth')
@ApiTags('🔑 Auth')
@ApiTags('📱 Pairing')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class AuthController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -2,6 +2,7 @@ import {
Body,
Controller,
Post,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -14,10 +15,17 @@ import {
import { SessionManager } from '../core/abc/manager.abc';
import { WhatsappSession } from '../core/abc/session.abc';
import { RejectCallRequest } from '../structures/calls.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/calls')
@ApiTags('📞 Calls')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class CallsController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -8,6 +8,7 @@ import {
Param,
Post,
Query,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -39,10 +40,17 @@ import {
parseChannelInviteLink,
WhatsappSession,
} from '../core/abc/session.abc';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/channels')
@ApiTags('📢 Channels')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ChannelsController {
constructor(
private manager: SessionManager,
+8
View File
@@ -8,6 +8,7 @@ import {
Post,
Put,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -40,11 +41,18 @@ import {
} from '../structures/chats.dto';
import { EditMessageRequest } from '../structures/chatting.dto';
import { SortOrder } from '@waha/structures/pagination.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/chats')
@ApiTags('💬 Chats')
@UsePipes(new ValidationPipe({ transform: true }))
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class ChatsController {
constructor(private manager: SessionManager) {}
+31
View File
@@ -5,6 +5,7 @@ import {
Post,
Put,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -48,15 +49,22 @@ import {
mentionsAll,
validateRequestMentions,
} from '@waha/core/utils/mentions.all';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromBody, FromQuery } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api')
@ApiTags('📤 Chatting')
@UseGuards(PoliciesGuard)
export class ChattingController {
constructor(private manager: SessionManager) {}
@Post('/sendText')
@ApiOperation({ summary: 'Send a text message' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendText(@Body() request: MessageTextRequest): Promise<WAMessage> {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -72,6 +80,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendImage(@Body() request: MessageImageRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -87,6 +96,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendFile(@Body() request: MessageFileRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -102,6 +112,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendVoice(@Body() request: MessageVoiceRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendVoice(request);
@@ -113,6 +124,7 @@ export class ChattingController {
description:
'Either from an URL or base64 data - look at the request schemas for details.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendVideo(@Body() request: MessageVideoRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
if (mentionsAll(request)) {
@@ -128,6 +140,7 @@ export class ChattingController {
description:
'You can use regular /api/sendText if you wanna send auto-generated link preview.',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendLinkCustomPreview(
@Body() request: MessageLinkCustomPreviewRequest,
@@ -147,6 +160,7 @@ export class ChattingController {
description: 'Send Buttons',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendButtons(@Body() request: SendButtonsRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -158,6 +172,7 @@ export class ChattingController {
summary: 'Send a list message (interactive)',
description: 'Send a List message with sections and rows',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendList(@Body() request: SendListRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -165,6 +180,7 @@ export class ChattingController {
}
@Post('/forwardMessage')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async forwardMessage(
@Body() request: MessageForwardRequest,
): Promise<WAMessage> {
@@ -173,6 +189,7 @@ export class ChattingController {
}
@Post('/sendSeen')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendSeen(@Body() chat: SendSeenRequest) {
const hasMessageId = chat.messageIds?.length > 0 || Boolean(chat.messageId);
if (!hasMessageId) {
@@ -188,6 +205,7 @@ export class ChattingController {
}
@Post('/startTyping')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async startTyping(@Body() chat: ChatRequest) {
// It's infinitive action
const whatsapp = await this.manager.getWorkingSession(chat.session);
@@ -196,6 +214,7 @@ export class ChattingController {
}
@Post('/stopTyping')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async stopTyping(@Body() chat: ChatRequest) {
const whatsapp = await this.manager.getWorkingSession(chat.session);
await whatsapp.stopTyping(chat);
@@ -204,6 +223,7 @@ export class ChattingController {
@Put('/reaction')
@ApiOperation({ summary: 'React to a message with an emoji' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async setReaction(@Body() request: MessageReactionRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.setReaction(request);
@@ -211,6 +231,7 @@ export class ChattingController {
@Put('/star')
@ApiOperation({ summary: 'Star or unstar a message' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async setStar(@Body() request: MessageStarRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
await whatsapp.setStar(request);
@@ -222,6 +243,7 @@ export class ChattingController {
summary: 'Send a poll with options',
description: 'You can use it as buttons or list replacement',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendPoll(@Body() request: MessagePollRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendPoll(request);
@@ -232,6 +254,7 @@ export class ChattingController {
summary: 'Vote on a poll',
description: 'Cast vote(s) on an existing poll message',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendPollVote(@Body() request: MessagePollVoteRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -239,12 +262,14 @@ export class ChattingController {
}
@Post('/sendLocation')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendLocation(@Body() request: MessageLocationRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendLocation(request);
}
@Post('/sendContactVcard')
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendContactVcard(@Body() request: MessageContactVcardRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendContactVCard(request);
@@ -254,6 +279,7 @@ export class ChattingController {
@ApiOperation({
summary: 'Reply on a button message',
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async sendButtonsReply(@Body() request: MessageButtonReply) {
const whatsapp = await this.manager.getWorkingSession(request.session);
@@ -262,6 +288,7 @@ export class ChattingController {
@Get('/sendText')
@ApiOperation({ summary: 'Send a text message', deprecated: true })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async sendTextGet(@Query() query: MessageTextQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
const msg = new MessageTextRequest();
@@ -276,6 +303,7 @@ export class ChattingController {
description: 'DEPRECATED. Use "GET /api/chats/{id}/messages" instead',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getMessages(
@Query() query: GetMessageQuery,
@@ -292,6 +320,7 @@ export class ChattingController {
description: 'DEPRECATED. Use "POST /contacts/check-exists" instead',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async DEPRECATED_checkNumberStatus(
@Query() request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -305,6 +334,7 @@ export class ChattingController {
'DEPRECATED - you can set "reply_to" field when sending text, image, etc',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async reply(@Body() request: MessageReplyRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.reply(request);
@@ -312,6 +342,7 @@ export class ChattingController {
@Post('/sendLinkPreview')
@ApiOperation({ deprecated: true })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async sendLinkPreview_DEPRECATED(@Body() request: MessageLinkPreviewRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendLinkPreview(request);
+14
View File
@@ -4,6 +4,7 @@ import {
Get,
Post,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -21,15 +22,22 @@ import {
ContactRequest,
ContactsPaginationParams,
} from '../structures/contacts.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromBody, FromQuery } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/contacts')
@ApiTags('👤 Contacts')
@UseGuards(PoliciesGuard)
export class ContactsController {
constructor(private manager: SessionManager) {}
@Get('/all')
@ApiOperation({ summary: 'Get all contacts' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getAll(
@Query() query: SessionQuery,
@@ -45,6 +53,7 @@ export class ContactsController {
description:
'The method always return result, even if the phone number is not registered in WhatsApp. For that - use /contacts/check-exists endpoint below.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async get(@Query() query: ContactQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
return whatsapp.getContact(query);
@@ -52,6 +61,7 @@ export class ContactsController {
@Get('/check-exists')
@ApiOperation({ summary: 'Check phone number is registered in WhatsApp.' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async checkExists(
@Query() request: CheckNumberStatusQuery,
): Promise<WANumberExistResult> {
@@ -65,6 +75,7 @@ export class ContactsController {
description:
'Returns null if you do not have permission to read their status.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
async getAbout(@Query() query: ContactQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
return whatsapp.getContactAbout(query);
@@ -76,6 +87,7 @@ export class ContactsController {
description:
'If privacy settings do not allow to get the picture, the method will return null.',
})
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async getProfilePicture(@Query() query: ContactProfilePictureQuery) {
const whatsapp = await this.manager.getWorkingSession(query.session);
@@ -88,6 +100,7 @@ export class ContactsController {
@Post('/block')
@ApiOperation({ summary: 'Block contact' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async block(@Body() request: ContactRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.blockContact(request);
@@ -95,6 +108,7 @@ export class ContactsController {
@Post('/unblock')
@ApiOperation({ summary: 'Unblock contact' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
async unblock(@Body() request: ContactRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.unblockContact(request);
@@ -1,5 +1,15 @@
import { Body, Controller, Delete, Param, Put, UsePipes } from '@nestjs/common';
import {
Body,
Controller,
Param,
Put,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { ChatIdApiParam } from '@waha/nestjs/params/ChatIdApiParam';
import {
@@ -8,13 +18,16 @@ import {
} from '@waha/nestjs/params/SessionApiParam';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { SessionManager } from './core/abc/manager.abc';
import { Result } from './structures/base.dto';
import { ContactUpdateBody } from './structures/contacts.dto';
import { SessionManager } from '../core/abc/manager.abc';
import { Result } from '../structures/base.dto';
import { ContactUpdateBody } from '../structures/contacts.dto';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/contacts')
@ApiTags('👤 Contacts')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ContactsSessionController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -3,6 +3,7 @@ import {
Controller,
Param,
Post,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -19,10 +20,17 @@ import {
EventMessageRequest,
} from '../structures/events.dto';
import { WAMessage } from '../structures/responses.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/events')
@ApiTags('📅 Events')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class EventsController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -9,6 +9,7 @@ import {
Post,
Put,
Query,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -40,10 +41,17 @@ import {
SettingsSecurityChangeInfo,
SubjectRequest,
} from '../structures/groups.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/groups')
@ApiTags('👥 Groups')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class GroupsController {
constructor(private manager: SessionManager) {}
+8 -1
View File
@@ -1,12 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { HealthCheck } from '@nestjs/terminus';
import { WAHAHealthCheckService } from '../core/abc/WAHAHealthCheckService';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('health')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Manage))
export class HealthController {
constructor(private wahaHealth: WAHAHealthCheckService) {}
+8
View File
@@ -8,6 +8,7 @@ import {
Post,
Put,
UnprocessableEntityException,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -26,10 +27,17 @@ import {
import * as lodash from 'lodash';
import { SessionManager } from '../core/abc/manager.abc';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/labels')
@ApiTags('🏷️ Labels')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class LabelsController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -4,6 +4,7 @@ import {
Param,
Query,
UnprocessableEntityException,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
@@ -22,10 +23,17 @@ import { PaginationParams, SortOrder } from '@waha/structures/pagination.dto';
import { SessionManager } from '../core/abc/manager.abc';
import { isLidUser } from '@waha/core/utils/jids';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/lids')
@ApiTags('👤 Contacts')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class LidsController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -3,6 +3,7 @@ import {
Controller,
Post,
UnprocessableEntityException,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -21,10 +22,17 @@ import {
import { WhatsappSession } from '../core/abc/session.abc';
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/media')
@ApiTags('🖼️ Media')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class MediaController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -5,6 +5,7 @@ import {
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ChatIdApiParam } from '@waha/nestjs/params/ChatIdApiParam';
@@ -20,10 +21,17 @@ import {
WAHAChatPresences,
WAHASessionPresence,
} from '../structures/presence.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/presence')
@ApiTags('✅ Presence')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class PresenceController {
constructor(private manager: SessionManager) {}
+8
View File
@@ -5,6 +5,7 @@ import {
Get,
Put,
UnprocessableEntityException,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -22,10 +23,17 @@ import {
ProfilePictureRequest,
ProfileStatusRequest,
} from '@waha/structures/profile.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/profile')
@ApiTags('🆔 Profile')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
export class ProfileController {
constructor(private manager: SessionManager) {}
+14 -2
View File
@@ -4,23 +4,35 @@ import {
Query,
Res,
StreamableFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
import { Response } from 'express';
import { SessionManager } from '../core/abc/manager.abc';
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
import { SessionQuery } from '../structures/base.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromQuery } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api')
@ApiTags('🖼️ Screenshot')
@ApiTags('📱 Pairing')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
export class ScreenshotController {
constructor(private manager: SessionManager) {}
@Get('/screenshot')
@ApiOperation({
summary:
'Get a screenshot of the current WhatsApp session (**WEBJS** only)',
})
@UseInterceptors(new BufferResponseInterceptor('image/jpeg'))
@ApiFileAcceptHeader('image/jpeg')
async screenshot(
+11
View File
@@ -7,6 +7,7 @@ import {
Logger,
Post,
Query,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -22,10 +23,16 @@ import {
import { sleep } from '@waha/utils/promiseTimeout';
import { VERSION } from '@waha/version';
import * as lodash from 'lodash';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/server')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
export class ServerController {
private logger: Logger;
@@ -35,12 +42,14 @@ export class ServerController {
@Get('version')
@ApiOperation({ summary: 'Get the version of the server' })
@CheckPolicies(CanServer(Action.Read))
get(): WAHAEnvironment {
return VERSION;
}
@Get('environment')
@ApiOperation({ summary: 'Get the server environment' })
@CheckPolicies(CanServer(Action.Manage))
environment(
@Query(new WAHAValidationPipe()) query: EnvironmentQuery,
// eslint-disable-next-line @typescript-eslint/ban-types
@@ -67,6 +76,7 @@ export class ServerController {
@Get('status')
@ApiOperation({ summary: 'Get the server status' })
@CheckPolicies(CanServer(Action.Read))
async status(): Promise<ServerStatusResponse> {
const now = Date.now();
const uptime = Math.floor(process.uptime() * 1000);
@@ -87,6 +97,7 @@ export class ServerController {
"If you're using docker, after calling this endpoint Docker will start a new container, " +
'so you can use this endpoint to restart the server',
})
@CheckPolicies(CanServer(Action.Manage))
@UsePipes(new WAHAValidationPipe())
async stop(@Body() request: StopRequest): Promise<StopResponse> {
const timeout = 1_000;
+8
View File
@@ -9,6 +9,7 @@ import {
NotFoundException,
Query,
StreamableFile,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -25,10 +26,17 @@ import {
CpuProfileQuery,
} from '@waha/structures/server.debug.dto';
import { createReadStream } from 'fs';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/server/debug')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Manage))
export class ServerDebugController {
private logger: Logger;
private readonly enabled: boolean;
+39 -5
View File
@@ -9,10 +9,18 @@ import {
Post,
Put,
Query,
Req,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { UnprocessableEntityException } from '@nestjs/common/exceptions/unprocessable-entity.exception';
import { ApiBody, ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
ApiBody,
ApiOAuth2,
ApiOperation,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import {
SessionApiParam,
SessionParam,
@@ -34,18 +42,24 @@ import { WhatsappSession } from '../core/abc/session.abc';
import {
ListSessionsQuery,
MeInfo,
SessionExpand,
SessionInfoQuery,
SessionCreateRequest,
SessionDTO,
SessionExpand,
SessionInfo,
SessionInfoQuery,
SessionUpdateRequest,
} from '../structures/sessions.dto';
import { SessionExamples } from './sessions.examples';
import { FilterSessions } from '../core/auth/casl.ability';
import { CheckPolicies } from '../core/auth/policies.decorator';
import { PoliciesGuard } from '../core/auth/policies.guard';
import { CanSession, FromBody, FromParam } from '../core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/sessions')
@ApiTags('🖥️ Sessions')
@UseGuards(PoliciesGuard)
class SessionsController {
constructor(
private manager: SessionManager,
@@ -57,11 +71,19 @@ class SessionsController {
}
@Get('/')
@ApiOperation({ summary: 'List all sessions' })
@ApiOperation({
summary: 'List all sessions',
})
@CheckPolicies(CanSession(Action.List))
@ApiOAuth2(['read:items'])
async list(
@Query(new WAHAValidationPipe()) query: ListSessionsQuery,
@Req() req,
): Promise<SessionInfo[]> {
const sessions = await this.manager.getSessions(query.all);
let sessions = await this.manager.getSessions(query.all);
if (!req.user.isAdmin) {
sessions = FilterSessions(req.ability, Action.Read, sessions);
}
if (query.expand?.includes(SessionExpand.apps)) {
for (const session of sessions) {
session.apps = await this.appsService.list(this.manager, session.name);
@@ -73,6 +95,7 @@ class SessionsController {
@Get('/:session')
@ApiOperation({ summary: 'Get session information' })
@SessionApiParam
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async get(
@Param('session') name: string,
@@ -91,6 +114,7 @@ class SessionsController {
@Get(':session/me')
@SessionApiParam
@ApiOperation({ summary: 'Get information about the authenticated account' })
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
getMe(@SessionParam session: WhatsappSession): MeInfo | null {
return session.getSessionMeInfo();
}
@@ -102,6 +126,7 @@ class SessionsController {
'Create session a new session (and start it at the same time if required).',
})
@ApiBody({ type: SessionCreateRequest, examples: SessionExamples })
@CheckPolicies(CanSession(Action.Create))
@UsePipes(new WAHAValidationPipe())
async create(@Body() request: SessionCreateRequest): Promise<SessionDTO> {
const name = request.name || generatePrefixedId('session');
@@ -139,6 +164,7 @@ class SessionsController {
})
@SessionApiParam
@ApiBody({ type: SessionUpdateRequest, examples: SessionExamples })
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe({ forbidNonWhitelisted: false }))
async update(
@Param('session') name: string,
@@ -177,6 +203,7 @@ class SessionsController {
description:
'Delete the session with the given name. Stop and logout as well. Idempotent operation.',
})
@CheckPolicies(CanSession(Action.Delete, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async delete(@Param('session') name: string): Promise<void> {
await this.withLock(name, async () => {
@@ -195,6 +222,7 @@ class SessionsController {
description:
'Start the session with the given name. The session must exist. Idempotent operation.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async start(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
@@ -214,6 +242,7 @@ class SessionsController {
summary: 'Stop the session',
description: 'Stop the session with the given name. Idempotent operation.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async stop(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
@@ -229,6 +258,7 @@ class SessionsController {
summary: 'Logout from the session',
description: 'Logout the session, restart a session if it was not STOPPED',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async logout(@Param('session') name: string): Promise<SessionDTO> {
await this.withLock(name, async () => {
@@ -253,6 +283,7 @@ class SessionsController {
summary: 'Restart the session',
description: 'Restart the session with the given name.',
})
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async restart(@Param('session') name: string): Promise<SessionDTO> {
await this.manager.restart(name);
@@ -266,6 +297,7 @@ class SessionsController {
'Create session (if not exists) or update a config (if exists) and start it.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
async DEPRACATED_start(
@Body() request: SessionStartDeprecatedRequest,
): Promise<SessionDTO> {
@@ -292,6 +324,7 @@ class SessionsController {
description: 'Stop session and Logout by default.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
async DEPRECATED_stop(
@Body() request: SessionStopDeprecatedRequest,
): Promise<void> {
@@ -323,6 +356,7 @@ class SessionsController {
description: 'Stop, Logout and Delete session.',
deprecated: true,
})
@CheckPolicies(CanSession(Action.Use, FromBody('name')))
async DEPRECATED_logout(
@Body() request: SessionLogoutDeprecatedRequest,
): Promise<void> {
+8 -1
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
SessionApiParam,
@@ -15,10 +15,17 @@ import {
VideoStatus,
VoiceStatus,
} from '../structures/status.dto';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/:session/status')
@ApiTags('🟢 Status')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanSession(Action.Use, FromParam('session')))
class StatusController {
constructor(private manager: SessionManager) {}
+9 -7
View File
@@ -1,17 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import {
ApiExtraModels,
ApiOperation,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { WAHAEnvironment } from '../structures/environment.dto';
import { VERSION } from '../version';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { CanServer } from '@waha/core/auth/policies';
import { Action } from '@waha/core/auth/casl.types';
@ApiSecurity('api_key')
@Controller('api/version')
@ApiTags('🔍 Observability')
@UseGuards(PoliciesGuard)
@CheckPolicies(CanServer(Action.Read))
export class VersionController {
@Get('')
@ApiOperation({
+42 -19
View File
@@ -19,8 +19,10 @@ import { WAHAEvents, WAHAEventsWild } from '@waha/structures/enums.dto';
import { EventWildUnmask } from '@waha/utils/events';
import { generatePrefixedId } from '@waha/utils/ids';
import { IncomingMessage } from 'http';
import * as url from 'url';
import { URL } from 'url';
import { Server } from 'ws';
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
export enum WebSocketCloseCode {
NORMAL = 1000,
@@ -54,6 +56,7 @@ export class WebsocketGatewayCore
constructor(
private manager: SessionManager,
private auth: WebSocketAuth,
private readonly casl: CaslAbilityFactory,
) {
this.logger = new Logger('WebsocketGateway');
this.heartbeat = new WebsocketHeartbeatJob(
@@ -62,11 +65,16 @@ export class WebsocketGatewayCore
);
}
handleConnection(socket: WebSocket, request: IncomingMessage, ...args): any {
async handleConnection(
socket: WebSocket,
request: IncomingMessage,
...args
): Promise<any> {
// wsc - websocket client
socket.id = generatePrefixedId('wsc');
if (!this.auth.validateRequest(request)) {
const user = await this.auth.validateRequest(request);
if (!user) {
// Not authorized - close connection
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Unauthorized');
this.logger.debug(
@@ -75,9 +83,20 @@ export class WebsocketGatewayCore
return;
}
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
const params = this.getParams(request);
const session: string = params.session;
let session: string = params.session;
const ability = this.casl.createForUser(user);
if (session == '*' && !ability.can(Action.Use, 'all')) {
// Limit user to listen only the session events
session = user.session;
}
if (!ability.can(Action.Use, new SessionName(session))) {
socket.close(WebSocketCloseCode.POLICY_VIOLATION, 'Forbidden');
return;
}
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
const events: WAHAEvents[] = params.events;
this.logger.debug(
`Client connected to session: '${session}', events: ${events}, ${socket.id}`,
@@ -86,12 +105,17 @@ export class WebsocketGatewayCore
const sub = this.manager
.getSessionEvents(session, events)
.subscribe((data) => {
this.logger.debug(`Sending data to client, event.id: ${data.id}`, data);
socket.send(JSON.stringify(data), (err) => {
if (!err) {
return;
}
this.logger.error(`Error sending data to client: ${err}`);
setImmediate(() => {
this.logger.debug(
`Sending data to client, event.id: ${data.id}`,
data,
);
socket.send(JSON.stringify(data), (err) => {
if (!err) {
return;
}
this.logger.error(`Error sending data to client: ${err}`);
});
});
});
socket.on('close', () => {
@@ -101,14 +125,13 @@ export class WebsocketGatewayCore
}
private getParams(request: IncomingMessage) {
const query = url.parse(request.url, true).query;
const session = (query.session as string) || '*';
let paramsEvents = (query.events as string[]) || '*';
// if params events string - split by ","
if (typeof paramsEvents === 'string') {
paramsEvents = paramsEvents.split(',');
}
const events = this.eventUnmask.unmask(paramsEvents);
// We need only search params, so localhost is fine here
const query = new URL(request.url, 'http://localhost').searchParams;
const session = query.get('session') || '*';
const paramsEvents = query.getAll('events');
const eventsRaw = paramsEvents.length > 0 ? paramsEvents : ['*'];
const eventsList = eventsRaw.flatMap((value) => value.split(','));
const events = this.eventUnmask.unmask(eventsList);
return { session, events };
}
+43 -4
View File
@@ -2,6 +2,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Inject,
NotFoundException,
@@ -9,6 +10,8 @@ import {
Post,
Put,
Query,
Req,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
@@ -17,6 +20,10 @@ import {
IAppsService,
} from '@waha/apps/app_sdk/services/IAppsService';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { CanSession, FromBody, FromQuery } from '@waha/core/auth/policies';
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { App } from '../dto/app.dto';
@@ -25,6 +32,7 @@ import { ListAppsQuery } from '../dto/query.dto';
@ApiSecurity('api_key')
@Controller('api/apps')
@ApiTags('🧩 Apps')
@UseGuards(PoliciesGuard)
export class AppsController {
constructor(
@Inject(AppsService)
@@ -34,6 +42,7 @@ export class AppsController {
@Get('/')
@ApiOperation({ summary: 'List all apps for a session' })
@CheckPolicies(CanSession(Action.Use, FromQuery('session')))
@UsePipes(new WAHAValidationPipe())
async list(
@Query(new WAHAValidationPipe()) query: ListAppsQuery,
@@ -43,6 +52,7 @@ export class AppsController {
@Post('/')
@ApiOperation({ summary: 'Create a new app' })
@CheckPolicies(CanSession(Action.Use, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async create(@Body() app: App): Promise<App> {
const result = await this.appsService.create(this.manager, app);
@@ -56,14 +66,36 @@ export class AppsController {
@Get('/:id')
@ApiOperation({ summary: 'Get app by ID' })
@UsePipes(new WAHAValidationPipe())
async get(@Param('id') id: string): Promise<App> {
return await this.appsService.get(this.manager, id);
async get(@Param('id') id: string, @Req() req: any): Promise<App> {
const app = await this.appsService.get(this.manager, id);
if (!app) {
throw new NotFoundException(`App '${id}' not found`);
}
if (!req.ability?.can(Action.Use, new SessionName(app.session))) {
throw new ForbiddenException();
}
return app;
}
@Put('/:id')
@ApiOperation({ summary: 'Update an existing app' })
@UsePipes(new WAHAValidationPipe())
async update(@Param('id') id: string, @Body() app: App): Promise<App> {
async update(
@Param('id') id: string,
@Body() app: App,
@Req() req: any,
): Promise<App> {
const existing = await this.appsService.get(this.manager, id);
if (existing) {
if (!req.ability?.can(Action.Use, new SessionName(existing.session))) {
throw new ForbiddenException();
}
} else {
if (!req.ability?.can(Action.Use, new SessionName(app.session))) {
throw new ForbiddenException();
}
}
if (!app.id) {
app.id = id;
} else if (app.id !== id) {
@@ -83,7 +115,14 @@ export class AppsController {
@Delete('/:id')
@ApiOperation({ summary: 'Delete an app' })
@UsePipes(new WAHAValidationPipe())
async delete(@Param('id') id: string): Promise<void> {
async delete(@Param('id') id: string, @Req() req: any): Promise<void> {
const existing = await this.appsService.get(this.manager, id);
if (!existing) {
throw new NotFoundException(`App '${id}' not found`);
}
if (!req.ability?.can(Action.Use, new SessionName(existing.session))) {
throw new ForbiddenException();
}
const app = await this.appsService.delete(this.manager, id);
const isRunning = this.manager.isRunning(app.session);
if (isRunning) {
+12 -7
View File
@@ -25,13 +25,18 @@ function jobRemoveOptions() {
export const JobRemoveOptions = jobRemoveOptions();
export const ExponentialRetriesJobOptions: DefaultJobOptions = {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
};
function exponentialRetriesJobOptions(): DefaultJobOptions {
return {
attempts: parseInt(process.env.WAHA_APPS_JOBS_ATTEMPTS) || 3,
delay: parseInt(process.env.WAHA_APPS_JOBS_DELAY) || 0,
backoff: {
type: process.env.WAHA_APPS_JOBS_BACKOFF_TYPE || 'exponential',
delay: parseInt(process.env.WAHA_APPS_JOBS_BACKOFF_DELAY) || 1000,
},
};
}
export const ExponentialRetriesJobOptions = exponentialRetriesJobOptions();
export const NoRetriesJobOptions: DefaultJobOptions = {
attempts: 1,
@@ -21,7 +21,7 @@ export class AppsDisabledService implements IAppsService {
throw new AppsIsDisabledError();
}
async get(manager: SessionManager, appId: string): Promise<App> {
async get(manager: SessionManager, appId: string): Promise<App | null> {
throw new AppsIsDisabledError();
}
@@ -104,12 +104,12 @@ export class AppsEnabledService implements IAppsService {
return result;
}
async get(manager: SessionManager, appId: string): Promise<App> {
async get(manager: SessionManager, appId: string): Promise<App | null> {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
const app = await repo.getById(appId);
if (!app) {
throw new NotFoundException(`App '${appId}' not found`);
return null;
}
delete (app as any).pk;
return app;
+1 -1
View File
@@ -7,7 +7,7 @@ import { Knex } from 'knex';
export interface IAppsService {
list(manager: SessionManager, session: string): Promise<App[]>;
get(manager: SessionManager, appId: string): Promise<App>;
get(manager: SessionManager, appId: string): Promise<App | null>;
create(manager: SessionManager, app: App): Promise<App>;
@@ -19,6 +19,7 @@ import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { CacheForConfig } from '../cache/ConversationCache';
import { IConversationCache } from '../cache/IConversationCache';
import { AttributeKey } from '@waha/apps/chatwoot/const';
export interface ContactInfo {
ChatId(): string;
@@ -63,6 +64,8 @@ export class ContactConversationService {
`Updating if required contact custom attributes for chat.id: ${chatId}, contact.id: ${cwContact.data.id}`,
);
const attributes = await contactInfo.Attributes();
// Keep the current chat id in sync to reply using the latest address.
attributes[AttributeKey.WA_CHAT_ID] = chatId;
await this.contactService.upsertCustomAttributes(
cwContact.data,
attributes,
+3 -3
View File
@@ -27,15 +27,15 @@ export function GetAllChatIDs(contact: any): Array<string> {
}
export function FindChatID(contact: any): string | null {
if (GetChatID(contact)) {
return GetChatID(contact);
}
if (GetJID(contact)) {
return GetJID(contact);
}
if (GetLID(contact)) {
return GetLID(contact);
}
if (GetChatID(contact)) {
return GetChatID(contact);
}
return null;
}
@@ -15,6 +15,14 @@ describe('MarkdownToWhatsApp', () => {
expect(MarkdownToWhatsApp(input)).toBe(expected);
});
// ChatWoot v4.10.1+ escapes newlines with trailing backslash
it('strips trailing backslash before newlines', () => {
const input = 'Hi \\\ntext message \\\nwith line \\\nbreak';
expect(MarkdownToWhatsApp(input)).toBe(
'Hi \ntext message \nwith line \nbreak',
);
});
// Tests for URL protection
it('preserves URLs with multiple underscores', () => {
const input = 'https://example.com/page__name__test';
@@ -4,6 +4,8 @@ export function MarkdownToWhatsApp(text: string): string {
}
return (
text
// Remove trailing backslash before newlines (ChatWoot v4.10.1+ escaping)
.replace(/\\\n/g, '\n')
// Triple-backtick blocks → WhatsApp monospace blocks (same syntax)
.replace(/```([\s\S]*?)```/g, '```$1```')
// Italic **first**, but only single‐star or single‐underscore
+5 -5
View File
@@ -74,15 +74,14 @@ export class SwaggerConfiguratorCore {
.setExternalDoc(this.title, this.externalDocUrl)
.setVersion(VERSION.version)
.addTag('🖥️ Sessions', 'Control WhatsApp sessions (accounts)')
.addTag('🧩 Apps', 'Applications (built-in integrations)')
.addTag('🔑 Auth', 'Authentication')
.addTag('📱 Pairing', 'Pair a session with WhatsApp on your phone.')
.addTag('🆔 Profile', 'Your profile information')
.addTag('🖼️ Screenshot', 'Get screenshot of WhatsApp and show QR code')
.addTag('📤 Chatting', 'Chatting methods')
.addTag('📞 Calls', 'Call handling methods')
.addTag('✅ Presence', `Presence information`)
.addTag('📢 Channels', 'Channels (newsletters) methods')
.addTag('🟢 Status', 'Status (aka stories) methods')
.addTag('💬 Chats', `Chats methods`)
.addTag('🔑 Api Keys', 'API Keys management')
.addTag(
'👤 Contacts',
`Contacts methods.<br>
@@ -90,13 +89,14 @@ export class SwaggerConfiguratorCore {
'E.g: \`12312312310\` OR \`12312312310@c.us\`<br>`,
)
.addTag('👥 Groups', `Groups methods.<br>`)
.addTag('✅ Presence', `Presence information`)
.addTag('📞 Calls', 'Call handling methods')
.addTag('📅 Events', `Event Message`)
.addTag(
'🏷️ Labels',
'Labels - available only for WhatsApp Business accounts',
)
.addTag('🖼️ Media', 'Media methods')
.addTag('🧩 Apps', 'Applications (built-in integrations)')
.addTag('🔍 Observability', 'Other methods')
.addTag('🗄️ Storage', 'Storage methods')
.addApiKey({
+2
View File
@@ -36,6 +36,7 @@ import {
import { ISessionAuthRepository } from '../storage/ISessionAuthRepository';
import { ISessionConfigRepository } from '../storage/ISessionConfigRepository';
import { WhatsappSession } from './session.abc';
import { IApiKeyRepository } from '@waha/core/storage/IApiKeyRepository';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const AsyncLock = require('async-lock');
@@ -48,6 +49,7 @@ export abstract class SessionManager
public sessionConfigRepository: ISessionConfigRepository;
protected sessionMeRepository: ISessionMeRepository;
protected sessionWorkerRepository: ISessionWorkerRepository;
public apiKeyRepository: IApiKeyRepository;
private lock: any;
WAIT_SESSION_RUNNING_INTERVAL = 500;
+16 -4
View File
@@ -9,15 +9,16 @@ import { ServeStaticModule } from '@nestjs/serve-static';
import { TerminusModule } from '@nestjs/terminus';
import { ChannelsController } from '@waha/api/channels.controller';
import { LidsController } from '@waha/api/lids.controller';
import { ApiKeysController } from '@waha/api/apikeys.controller';
import { ProfileController } from '@waha/api/profile.controller';
import { ServerController } from '@waha/api/server.controller';
import { ServerDebugController } from '@waha/api/server.debug.controller';
import { WebsocketGatewayCore } from '@waha/api/websocket.gateway.core';
import { AppsModuleExports } from '@waha/apps/apps.module';
import { ContactsSessionController } from '@waha/contacts.session.controller';
import { ContactsSessionController } from '@waha/api/contacts.session.controller';
import { ApiKeyStrategy } from '@waha/core/auth/apiKey.strategy';
import { IApiKeyAuth } from '@waha/core/auth/auth';
import { AuthMiddleware } from '@waha/core/auth/auth.middleware';
import { ApiKeyAuthMiddleware } from '@waha/core/auth/api-key-auth.middleware';
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth';
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
@@ -64,6 +65,9 @@ import { EngineConfigService } from './config/EngineConfigService';
import { SwaggerConfigServiceCore } from './config/SwaggerConfigServiceCore';
import { WAHAHealthCheckServiceCore } from './health/WAHAHealthCheckServiceCore';
import { SessionManagerCore } from './manager.core';
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { ApiKeyService } from '@waha/core/auth/ApiKeyService';
export const IMPORTS_CORE = [
...AppsModuleExports.imports,
@@ -140,6 +144,7 @@ const IMPORTS = [...IMPORTS_CORE, ...IMPORTS_MEDIA];
export const CONTROLLERS = [
AuthController,
ApiKeysController,
SessionsController,
ProfileController,
ChattingController,
@@ -178,6 +183,9 @@ export const PROVIDERS_BASE: Provider[] = [
MediaLocalStorageConfig,
WebSocketAuth,
ApiKeyStrategy,
ApiKeyService,
CaslAbilityFactory,
PoliciesGuard,
{
provide: IApiKeyAuth,
useFactory: ApiKeyAuthFactory,
@@ -234,11 +242,15 @@ export class AppModuleCore {
}
configure(consumer: MiddlewareConsumer) {
// Because we use ServeStaticModule, we need to inject a middleware
// ServeStaticModule does not support @UseGuards
const exclude = this.config.getExcludedPaths();
consumer
.apply(AuthMiddleware)
.apply(ApiKeyAuthMiddleware)
.exclude(...exclude)
.forRoutes('api', 'health', 'ws');
.forRoutes('api', 'health');
// Dashboard
const dashboardCredentials = this.dashboardConfig.credentials;
if (dashboardCredentials) {
const username = dashboardCredentials[0];
+22
View File
@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { User } from '@waha/core/auth/apiKey.strategy';
import { SessionManager } from '@waha/core/abc/manager.abc';
@Injectable()
export class ApiKeyService {
constructor(private manager: SessionManager) {}
async get(apikey: string): Promise<User | null> {
if (!apikey) {
return null;
}
const key = await this.manager.apiKeyRepository.getActiveByKey(apikey);
if (!key) {
return null;
}
return {
isAdmin: key.isAdmin,
session: key.session,
};
}
}
+20 -22
View File
@@ -1,34 +1,32 @@
import { Injectable } from '@nestjs/common';
import { IncomingMessage } from 'http';
import * as url from 'url';
import { IApiKeyAuth } from './auth';
import { URL } from 'url';
import { ApiKeyStrategy } from '@waha/core/auth/apiKey.strategy';
@Injectable()
export class WebSocketAuth {
constructor(private auth: IApiKeyAuth) {}
constructor(private strategy: ApiKeyStrategy) {}
validateRequest(request: IncomingMessage) {
if (this.auth.skipAuth()) {
return true;
}
const provided = this.getKeyFromQueryParams(request);
return this.auth.isValid(provided);
async validateRequest(request: IncomingMessage) {
const apikey = this.getKeyFromQueryParams('x-api-key', request);
return await this.strategy.user(apikey);
}
private getKeyFromQueryParams(request: IncomingMessage) {
let query = url.parse(request.url, true).query;
// case-insensitive query params
query = Object.keys(query).reduce((acc, key) => {
acc[key.toLowerCase()] = query[key];
return acc;
}, {});
private getKeyFromQueryParams(name: string, request: IncomingMessage) {
// Case-insensitive
name = name.toLowerCase();
const query = new URL(request.url || '', 'http://localhost').searchParams;
const matches: string[] = [];
const provided = query['x-api-key'];
// Check if it's array - return first
if (Array.isArray(provided)) {
return provided[0];
for (const [key, value] of query.entries()) {
if (key.toLowerCase() === name) {
matches.push(value);
}
}
return provided;
if (matches.length === 0) {
return undefined;
}
return matches[0];
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class ApiKeyAuthGuard extends AuthGuard('headerapikey') {}
+31
View File
@@ -0,0 +1,31 @@
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from '@nestjs/common';
import * as passport from 'passport';
@Injectable()
export class ApiKeyAuthMiddleware implements NestMiddleware {
constructor() {}
use(req: any, res: any, next: () => void) {
passport.authenticate('headerapikey', { session: false }, (err, user?) => {
if (err) {
const exception =
err instanceof UnauthorizedException
? err
: new UnauthorizedException();
res.status(exception.getStatus()).json(exception.getResponse());
return;
}
if (!user) {
const exception = new UnauthorizedException();
res.status(exception.getStatus()).json(exception.getResponse());
return;
}
req.user = user;
next();
})(req, res, next);
}
}
+29 -6
View File
@@ -2,19 +2,42 @@ import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { IApiKeyAuth } from '@waha/core/auth/auth';
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
import { ApiKeyService } from '@waha/core/auth/ApiKeyService';
export interface User {
isAdmin: boolean;
session?: string;
}
function AdminUser(): User {
return {
isAdmin: true,
session: null,
};
}
@Injectable()
export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) {
constructor(private auth: IApiKeyAuth) {
constructor(
private auth: IApiKeyAuth,
private apiKeyService: ApiKeyService,
) {
// @ts-ignore
super({ header: 'X-Api-Key', prefix: '' }, true, (apikey, done) => {
const isValid = this.auth.isValid(apikey);
return done(isValid);
return this.validate(apikey, done);
});
}
validate(apikey: string, done: (result: boolean) => void): void {
const isValid = this.auth.isValid(apikey);
return done(isValid);
validate(apikey: string, done: (err?, user?: User) => void): void {
this.user(apikey)
.then((user) => done(null, user))
.catch((err) => done(err, null));
}
async user(apikey: string): Promise<User> {
if (this.auth.isValid(apikey)) {
return AdminUser();
}
return this.apiKeyService.get(apikey);
}
}
-28
View File
@@ -1,28 +0,0 @@
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from '@nestjs/common';
import * as passport from 'passport';
import { IApiKeyAuth } from './auth';
@Injectable()
export class AuthMiddleware implements NestMiddleware {
constructor(private auth: IApiKeyAuth) {}
use(req: any, res: any, next: () => void) {
// Skip authentication if auth says so
if (this.auth.skipAuth()) {
next();
return;
}
passport.authenticate('headerapikey', { session: false }, (value) => {
if (!value) {
throw new UnauthorizedException();
}
next();
})(req, res, next);
}
}
-14
View File
@@ -2,18 +2,12 @@ import * as crypto from 'crypto';
export abstract class IApiKeyAuth {
abstract isValid(plain: string): boolean;
abstract skipAuth(): boolean;
}
export class NoAuth implements IApiKeyAuth {
isValid(plain: string): boolean {
return true;
}
skipAuth(): boolean {
return true;
}
}
export class PlainApiKeyAuth implements IApiKeyAuth {
@@ -22,10 +16,6 @@ export class PlainApiKeyAuth implements IApiKeyAuth {
isValid(plain: string): boolean {
return compare(plain, this.key);
}
skipAuth(): boolean {
return false;
}
}
export class HashAuth implements IApiKeyAuth {
@@ -41,10 +31,6 @@ export class HashAuth implements IApiKeyAuth {
const hash = crypto.createHash(this.algorithm).update(plain).digest('hex');
return compare(hash, this.hash);
}
skipAuth(): boolean {
return false;
}
}
/**
+32
View File
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { User } from './apiKey.strategy';
import { createMongoAbility } from '@casl/ability';
import { AdminRules, SessionRules } from './casl.rules';
import { Action, AppAbility, session } from './casl.types';
@Injectable()
export class CaslAbilityFactory {
createForUser(user: User): AppAbility {
// when we disable auth using WHATSAPP_API_KEY_EXCLUDE_PATH
// req.use will be null, so we allow any request
if (!user) {
return createMongoAbility(AdminRules());
}
// Admin user
if (user.isAdmin) {
return createMongoAbility(AdminRules());
}
if (user.session) {
return createMongoAbility(SessionRules(user.session));
}
return createMongoAbility([]);
}
}
export function FilterSessions<T extends { name: string }>(
ability: AppAbility,
action: Action,
sessions: T[],
) {
return sessions.filter((s) => ability.can(action, new session(s.name)));
}
+45
View File
@@ -0,0 +1,45 @@
import { RawRuleOf } from '@casl/ability';
import { Action, AppAbility } from './casl.types';
export function AdminRules(): RawRuleOf<AppAbility>[] {
return [
{
action: Action.Manage,
subject: 'all',
},
];
}
export function SessionRules(name: string): RawRuleOf<AppAbility>[] {
return [
//
// Server
//
{
action: 'read',
subject: 'server',
},
//
// Session
//
{
action: Action.List,
subject: 'session',
},
{
action: Action.Read,
subject: 'session',
conditions: { name: name },
},
// {
// action: Action.Delete,
// subject: 'session',
// conditions: { name: name },
// },
{
action: Action.Use,
subject: 'session',
conditions: { name: name },
},
];
}
+28
View File
@@ -0,0 +1,28 @@
import { InferSubjects, MongoAbility } from '@casl/ability';
export class session {
constructor(public name: string) {}
}
export class server {}
export enum Action {
//
// CASL
//
Manage = 'manage', // it's a special action in casl, meaning "any actions"
//
// Session
//
List = 'list', // list sessions
Read = 'read', // read session info (not messages)
Create = 'create', // create a new session
Delete = 'delete', // delete a session
Use = 'use', // all actions - send a message, retrieve, manage session status
}
type Subjects =
| InferSubjects<typeof session | typeof server | 'session' | 'server'>
| 'all';
type Actions = keyof typeof Action | Action | `${Action}`;
export type AppAbility = MongoAbility<[Actions, Subjects]>;
+1
View File
@@ -1,4 +1,5 @@
import { parseBool } from '@waha/helpers';
import * as crypto from 'crypto';
export interface SValue {
param: string;
+15
View File
@@ -0,0 +1,15 @@
import { SetMetadata } from '@nestjs/common';
import { ExecutionContext } from '@nestjs/common';
import { AppAbility } from './casl.types';
export const CHECK_POLICIES_KEY = 'check_policies';
export type PolicyHandlerCallback = (
ability: AppAbility,
context: ExecutionContext,
) => boolean;
export type PolicyHandler = PolicyHandlerCallback;
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
SetMetadata(CHECK_POLICIES_KEY, handlers);
+43
View File
@@ -0,0 +1,43 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
import {
CHECK_POLICIES_KEY,
PolicyHandler,
} from '@waha/core/auth/policies.decorator';
@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly caslAbilityFactory: CaslAbilityFactory,
) {}
canActivate(context: ExecutionContext): boolean {
const handlers =
this.reflector.getAllAndOverride<PolicyHandler[]>(CHECK_POLICIES_KEY, [
context.getHandler(),
context.getClass(),
]) || [];
if (handlers.length === 0) {
throw new ForbiddenException();
}
const req = context.switchToHttp().getRequest();
const user = req.user;
const ability = this.caslAbilityFactory.createForUser(user);
req.ability = ability;
const ok = handlers.every((handler) => handler(ability, context));
if (!ok) {
throw new ForbiddenException();
}
return true;
}
}
+46
View File
@@ -0,0 +1,46 @@
import { BadRequestException, ExecutionContext } from '@nestjs/common';
import {
Action,
AppAbility,
server,
session,
} from '@waha/core/auth/casl.types';
type GetSession = (req: any) => unknown;
function requireSessionName(value: unknown) {
if (typeof value !== 'string' || !value.trim()) {
throw new BadRequestException('Session name is required');
}
return value;
}
export function CanSession(action: Action, name?: GetSession) {
return (ability: AppAbility, context: ExecutionContext) => {
const req = context.switchToHttp().getRequest();
if (name) {
const sessionName = requireSessionName(name(req));
return ability.can(action, new session(sessionName));
}
return ability.can(action, 'session');
};
}
export function FromParam(key = 'session'): GetSession {
return (req) => req.params?.[key];
}
export function FromBody(key = 'session'): GetSession {
return (req) => req.body?.[key];
}
export function FromQuery(key = 'session'): GetSession {
return (req) => req.query?.[key];
}
export function CanServer(action: Action) {
return (ability: AppAbility, context: ExecutionContext) => {
return ability.can(action, 'server');
};
}
@@ -52,9 +52,11 @@ export class GowsEventStreamObservable extends Observable<EnginePayload> {
};
stream.on('data', (raw) => {
const obj = raw.toObject();
obj.data = JSON.parse(obj.data);
subscriber?.next(obj);
setImmediate(() => {
const obj = raw.toObject();
obj.data = JSON.parse(obj.data);
subscriber?.next(obj);
});
});
stream.on('end', (...args) => {
+34 -25
View File
@@ -9,6 +9,7 @@ export class GowsSubprocess {
private child: any;
private ready: boolean = false;
private stdoutBuffer: string = '';
constructor(
private logger: Logger,
@@ -17,7 +18,7 @@ export class GowsSubprocess {
readonly pprof: boolean = false,
) {}
start(onExit: (code: number) => void) {
start(onExit: (code: number | null, signal: NodeJS.Signals | null) => void) {
this.logger.info('Starting GOWS subprocess...');
this.logger.debug(`GOWS path '${this.path}', socket: '${this.socket}'...`);
@@ -33,43 +34,51 @@ export class GowsSubprocess {
detached: true,
});
this.logger.debug(`GOWS started with PID: ${this.child.pid}`);
this.child.on('close', async (code, singal) => {
const msg = code
? `GOWS subprocess closed with code ${code}`
: `GOWS subprocess closed by signal ${singal}`;
this.child.on('close', (code, signal) => {
const msg =
code !== null
? `GOWS subprocess closed with code ${code}`
: `GOWS subprocess closed by signal ${signal}`;
this.logger.debug(msg);
onExit(code);
onExit(code, signal);
});
this.child.on('error', (err) => {
this.logger.error(`GOWS subprocess error: ${err}`);
});
this.child.stderr.setEncoding('utf8');
this.child.stderr.on('data', (data) => {
this.logger.error(data);
this.child.stderr?.setEncoding('utf8');
this.child.stderr?.on('data', (data) => {
this.logger.error(data.toString().trim());
});
this.child.stdout.setEncoding('utf8');
this.child.stdout.on('data', async (data) => {
// remove empty line at the end, split by \n
const lines = data.trim().split('\n');
lines.forEach((line) => this.log(line));
this.child.stdout?.setEncoding('utf8');
this.child.stdout?.on('data', (data) => {
this.handleStdout(data.toString());
});
this.listenReady();
}
listenReady() {
this.child.stdout.on('data', async (data) => {
if (this.ready) {
private handleStdout(chunk: string) {
this.stdoutBuffer += chunk;
const parts = this.stdoutBuffer.split('\n');
this.stdoutBuffer = parts.pop() ?? '';
parts.forEach((line) => {
const trimmed = line.trim();
if (!trimmed) {
return;
}
if (!data.includes(this.readyText)) {
return;
}
await sleep(this.readyDelayMs);
this.ready = true;
this.logger.info('GOWS is ready');
this.log(trimmed);
void this.checkReady(trimmed);
});
void this.checkReady(this.stdoutBuffer);
}
private async checkReady(text: string) {
if (this.ready || !text.includes(this.readyText)) {
return;
}
await sleep(this.readyDelayMs);
this.ready = true;
this.logger.info('GOWS is ready');
}
async waitWhenReady(timeout: number) {
@@ -79,7 +88,7 @@ export class GowsSubprocess {
timeout,
);
if (!started) {
const msg = 'GOWS did not start after 10 seconds';
const msg = `GOWS did not start after ${timeout} ms`;
this.logger.error(msg);
throw new Error(msg);
}
+6 -8
View File
@@ -1,24 +1,22 @@
import * as grpc from '@grpc/grpc-js';
import { messages } from '@waha/core/engines/gows/grpc/gows';
export const GetMessageServiceClient = (
session: string,
export function BuildMessageServiceClient(
address: string,
credentials: grpc.ChannelCredentials,
): messages.MessageServiceClient => {
): messages.MessageServiceClient {
return new messages.MessageServiceClient(address, credentials, {
'grpc.max_send_message_length': 128 * 1024 * 1024,
'grpc.max_receive_message_length': 128 * 1024 * 1024,
});
};
}
export const GetEventStreamClient = (
session: string,
export function BuildEventStreamClient(
address: string,
credentials: grpc.ChannelCredentials,
): messages.EventStreamClient => {
): messages.EventStreamClient {
return new messages.EventStreamClient(address, credentials, {
'grpc.max_send_message_length': 128 * 1024 * 1024,
'grpc.max_receive_message_length': 128 * 1024 * 1024,
});
};
}
+414 -5
View File
@@ -879,6 +879,99 @@ export namespace messages {
return EventJson.deserialize(bytes);
}
}
export class StreamEventsRequest extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
session?: Session;
exclude?: string[];
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [2], this.#one_of_decls);
if (!Array.isArray(data) && typeof data == "object") {
if ("session" in data && data.session != undefined) {
this.session = data.session;
}
if ("exclude" in data && data.exclude != undefined) {
this.exclude = data.exclude;
}
}
}
get session() {
return pb_1.Message.getWrapperField(this, Session, 1) as Session;
}
set session(value: Session) {
pb_1.Message.setWrapperField(this, 1, value);
}
get has_session() {
return pb_1.Message.getField(this, 1) != null;
}
get exclude() {
return pb_1.Message.getFieldWithDefault(this, 2, []) as string[];
}
set exclude(value: string[]) {
pb_1.Message.setField(this, 2, value);
}
static fromObject(data: {
session?: ReturnType<typeof Session.prototype.toObject>;
exclude?: string[];
}): StreamEventsRequest {
const message = new StreamEventsRequest({});
if (data.session != null) {
message.session = Session.fromObject(data.session);
}
if (data.exclude != null) {
message.exclude = data.exclude;
}
return message;
}
toObject() {
const data: {
session?: ReturnType<typeof Session.prototype.toObject>;
exclude?: string[];
} = {};
if (this.session != null) {
data.session = this.session.toObject();
}
if (this.exclude != null) {
data.exclude = this.exclude;
}
return data;
}
serialize(): Uint8Array;
serialize(w: pb_1.BinaryWriter): void;
serialize(w?: pb_1.BinaryWriter): Uint8Array | void {
const writer = w || new pb_1.BinaryWriter();
if (this.has_session)
writer.writeMessage(1, this.session, () => this.session.serialize(writer));
if (this.exclude.length)
writer.writeRepeatedString(2, this.exclude);
if (!w)
return writer.getResultBuffer();
}
static deserialize(bytes: Uint8Array | pb_1.BinaryReader): StreamEventsRequest {
const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new StreamEventsRequest();
while (reader.nextField()) {
if (reader.isEndGroup())
break;
switch (reader.getFieldNumber()) {
case 1:
reader.readMessage(message.session, () => message.session = Session.deserialize(reader));
break;
case 2:
pb_1.Message.addToRepeatedField(message, 2, reader.readString());
break;
default: reader.skipField();
}
}
return message;
}
serializeBinary(): Uint8Array {
return this.serialize();
}
static deserializeBinary(bytes: Uint8Array): StreamEventsRequest {
return StreamEventsRequest.deserialize(bytes);
}
}
export class PairCodeRequest extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
@@ -1236,6 +1329,193 @@ export namespace messages {
return SessionStoreConfig.deserialize(bytes);
}
}
export class SessionStorageConfig extends pb_1.Message {
#one_of_decls: number[][] = [[1], [2], [3], [4]];
constructor(data?: any[] | ({} & (({
messages?: boolean;
}) | ({
groups?: boolean;
}) | ({
chats?: boolean;
}) | ({
labels?: boolean;
})))) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
if (!Array.isArray(data) && typeof data == "object") {
if ("messages" in data && data.messages != undefined) {
this.messages = data.messages;
}
if ("groups" in data && data.groups != undefined) {
this.groups = data.groups;
}
if ("chats" in data && data.chats != undefined) {
this.chats = data.chats;
}
if ("labels" in data && data.labels != undefined) {
this.labels = data.labels;
}
}
}
get messages() {
return pb_1.Message.getFieldWithDefault(this, 1, false) as boolean;
}
set messages(value: boolean) {
pb_1.Message.setOneofField(this, 1, this.#one_of_decls[0], value);
}
get has_messages() {
return pb_1.Message.getField(this, 1) != null;
}
get groups() {
return pb_1.Message.getFieldWithDefault(this, 2, false) as boolean;
}
set groups(value: boolean) {
pb_1.Message.setOneofField(this, 2, this.#one_of_decls[1], value);
}
get has_groups() {
return pb_1.Message.getField(this, 2) != null;
}
get chats() {
return pb_1.Message.getFieldWithDefault(this, 3, false) as boolean;
}
set chats(value: boolean) {
pb_1.Message.setOneofField(this, 3, this.#one_of_decls[2], value);
}
get has_chats() {
return pb_1.Message.getField(this, 3) != null;
}
get labels() {
return pb_1.Message.getFieldWithDefault(this, 4, false) as boolean;
}
set labels(value: boolean) {
pb_1.Message.setOneofField(this, 4, this.#one_of_decls[3], value);
}
get has_labels() {
return pb_1.Message.getField(this, 4) != null;
}
get _messages() {
const cases: {
[index: number]: "none" | "messages";
} = {
0: "none",
1: "messages"
};
return cases[pb_1.Message.computeOneofCase(this, [1])];
}
get _groups() {
const cases: {
[index: number]: "none" | "groups";
} = {
0: "none",
2: "groups"
};
return cases[pb_1.Message.computeOneofCase(this, [2])];
}
get _chats() {
const cases: {
[index: number]: "none" | "chats";
} = {
0: "none",
3: "chats"
};
return cases[pb_1.Message.computeOneofCase(this, [3])];
}
get _labels() {
const cases: {
[index: number]: "none" | "labels";
} = {
0: "none",
4: "labels"
};
return cases[pb_1.Message.computeOneofCase(this, [4])];
}
static fromObject(data: {
messages?: boolean;
groups?: boolean;
chats?: boolean;
labels?: boolean;
}): SessionStorageConfig {
const message = new SessionStorageConfig({});
if (data.messages != null) {
message.messages = data.messages;
}
if (data.groups != null) {
message.groups = data.groups;
}
if (data.chats != null) {
message.chats = data.chats;
}
if (data.labels != null) {
message.labels = data.labels;
}
return message;
}
toObject() {
const data: {
messages?: boolean;
groups?: boolean;
chats?: boolean;
labels?: boolean;
} = {};
if (this.messages != null) {
data.messages = this.messages;
}
if (this.groups != null) {
data.groups = this.groups;
}
if (this.chats != null) {
data.chats = this.chats;
}
if (this.labels != null) {
data.labels = this.labels;
}
return data;
}
serialize(): Uint8Array;
serialize(w: pb_1.BinaryWriter): void;
serialize(w?: pb_1.BinaryWriter): Uint8Array | void {
const writer = w || new pb_1.BinaryWriter();
if (this.has_messages)
writer.writeBool(1, this.messages);
if (this.has_groups)
writer.writeBool(2, this.groups);
if (this.has_chats)
writer.writeBool(3, this.chats);
if (this.has_labels)
writer.writeBool(4, this.labels);
if (!w)
return writer.getResultBuffer();
}
static deserialize(bytes: Uint8Array | pb_1.BinaryReader): SessionStorageConfig {
const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new SessionStorageConfig();
while (reader.nextField()) {
if (reader.isEndGroup())
break;
switch (reader.getFieldNumber()) {
case 1:
message.messages = reader.readBool();
break;
case 2:
message.groups = reader.readBool();
break;
case 3:
message.chats = reader.readBool();
break;
case 4:
message.labels = reader.readBool();
break;
default: reader.skipField();
}
}
return message;
}
serializeBinary(): Uint8Array {
return this.serialize();
}
static deserializeBinary(bytes: Uint8Array): SessionStorageConfig {
return SessionStorageConfig.deserialize(bytes);
}
}
export class SessionProxyConfig extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
@@ -1440,13 +1720,15 @@ export namespace messages {
}
}
export class SessionConfig extends pb_1.Message {
#one_of_decls: number[][] = [[4]];
#one_of_decls: number[][] = [[4], [5]];
constructor(data?: any[] | ({
store?: SessionStoreConfig;
log?: SessionLogConfig;
proxy?: SessionProxyConfig;
} & (({
ignore?: SessionIgnoreJidsConfig;
}) | ({
storage?: SessionStorageConfig;
})))) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
@@ -1463,6 +1745,9 @@ export namespace messages {
if ("ignore" in data && data.ignore != undefined) {
this.ignore = data.ignore;
}
if ("storage" in data && data.storage != undefined) {
this.storage = data.storage;
}
}
}
get store() {
@@ -1501,6 +1786,15 @@ export namespace messages {
get has_ignore() {
return pb_1.Message.getField(this, 4) != null;
}
get storage() {
return pb_1.Message.getWrapperField(this, SessionStorageConfig, 5) as SessionStorageConfig;
}
set storage(value: SessionStorageConfig) {
pb_1.Message.setOneofWrapperField(this, 5, this.#one_of_decls[1], value);
}
get has_storage() {
return pb_1.Message.getField(this, 5) != null;
}
get _ignore() {
const cases: {
[index: number]: "none" | "ignore";
@@ -1510,11 +1804,21 @@ export namespace messages {
};
return cases[pb_1.Message.computeOneofCase(this, [4])];
}
get _storage() {
const cases: {
[index: number]: "none" | "storage";
} = {
0: "none",
5: "storage"
};
return cases[pb_1.Message.computeOneofCase(this, [5])];
}
static fromObject(data: {
store?: ReturnType<typeof SessionStoreConfig.prototype.toObject>;
log?: ReturnType<typeof SessionLogConfig.prototype.toObject>;
proxy?: ReturnType<typeof SessionProxyConfig.prototype.toObject>;
ignore?: ReturnType<typeof SessionIgnoreJidsConfig.prototype.toObject>;
storage?: ReturnType<typeof SessionStorageConfig.prototype.toObject>;
}): SessionConfig {
const message = new SessionConfig({});
if (data.store != null) {
@@ -1529,6 +1833,9 @@ export namespace messages {
if (data.ignore != null) {
message.ignore = SessionIgnoreJidsConfig.fromObject(data.ignore);
}
if (data.storage != null) {
message.storage = SessionStorageConfig.fromObject(data.storage);
}
return message;
}
toObject() {
@@ -1537,6 +1844,7 @@ export namespace messages {
log?: ReturnType<typeof SessionLogConfig.prototype.toObject>;
proxy?: ReturnType<typeof SessionProxyConfig.prototype.toObject>;
ignore?: ReturnType<typeof SessionIgnoreJidsConfig.prototype.toObject>;
storage?: ReturnType<typeof SessionStorageConfig.prototype.toObject>;
} = {};
if (this.store != null) {
data.store = this.store.toObject();
@@ -1550,6 +1858,9 @@ export namespace messages {
if (this.ignore != null) {
data.ignore = this.ignore.toObject();
}
if (this.storage != null) {
data.storage = this.storage.toObject();
}
return data;
}
serialize(): Uint8Array;
@@ -1564,6 +1875,8 @@ export namespace messages {
writer.writeMessage(3, this.proxy, () => this.proxy.serialize(writer));
if (this.has_ignore)
writer.writeMessage(4, this.ignore, () => this.ignore.serialize(writer));
if (this.has_storage)
writer.writeMessage(5, this.storage, () => this.storage.serialize(writer));
if (!w)
return writer.getResultBuffer();
}
@@ -1585,6 +1898,9 @@ export namespace messages {
case 4:
reader.readMessage(message.ignore, () => message.ignore = SessionIgnoreJidsConfig.deserialize(reader));
break;
case 5:
reader.readMessage(message.storage, () => message.storage = SessionStorageConfig.deserialize(reader));
break;
default: reader.skipField();
}
}
@@ -2680,6 +2996,73 @@ export namespace messages {
return AudioInfo.deserialize(bytes);
}
}
export class VideoInfo extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
duration?: number;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
if (!Array.isArray(data) && typeof data == "object") {
if ("duration" in data && data.duration != undefined) {
this.duration = data.duration;
}
}
}
get duration() {
return pb_1.Message.getFieldWithDefault(this, 1, 0) as number;
}
set duration(value: number) {
pb_1.Message.setField(this, 1, value);
}
static fromObject(data: {
duration?: number;
}): VideoInfo {
const message = new VideoInfo({});
if (data.duration != null) {
message.duration = data.duration;
}
return message;
}
toObject() {
const data: {
duration?: number;
} = {};
if (this.duration != null) {
data.duration = this.duration;
}
return data;
}
serialize(): Uint8Array;
serialize(w: pb_1.BinaryWriter): void;
serialize(w?: pb_1.BinaryWriter): Uint8Array | void {
const writer = w || new pb_1.BinaryWriter();
if (this.duration != 0)
writer.writeFloat(1, this.duration);
if (!w)
return writer.getResultBuffer();
}
static deserialize(bytes: Uint8Array | pb_1.BinaryReader): VideoInfo {
const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new VideoInfo();
while (reader.nextField()) {
if (reader.isEndGroup())
break;
switch (reader.getFieldNumber()) {
case 1:
message.duration = reader.readFloat();
break;
default: reader.skipField();
}
}
return message;
}
serializeBinary(): Uint8Array {
return this.serialize();
}
static deserializeBinary(bytes: Uint8Array): VideoInfo {
return VideoInfo.deserialize(bytes);
}
}
export class Media extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
@@ -2689,6 +3072,7 @@ export namespace messages {
audio?: AudioInfo;
filename?: string;
contentPath?: string;
video?: VideoInfo;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
@@ -2711,6 +3095,9 @@ export namespace messages {
if ("contentPath" in data && data.contentPath != undefined) {
this.contentPath = data.contentPath;
}
if ("video" in data && data.video != undefined) {
this.video = data.video;
}
}
}
get content() {
@@ -2752,6 +3139,15 @@ export namespace messages {
set contentPath(value: string) {
pb_1.Message.setField(this, 6, value);
}
get video() {
return pb_1.Message.getWrapperField(this, VideoInfo, 7) as VideoInfo;
}
set video(value: VideoInfo) {
pb_1.Message.setWrapperField(this, 7, value);
}
get has_video() {
return pb_1.Message.getField(this, 7) != null;
}
static fromObject(data: {
content?: Uint8Array;
type?: MediaType;
@@ -2759,6 +3155,7 @@ export namespace messages {
audio?: ReturnType<typeof AudioInfo.prototype.toObject>;
filename?: string;
contentPath?: string;
video?: ReturnType<typeof VideoInfo.prototype.toObject>;
}): Media {
const message = new Media({});
if (data.content != null) {
@@ -2779,6 +3176,9 @@ export namespace messages {
if (data.contentPath != null) {
message.contentPath = data.contentPath;
}
if (data.video != null) {
message.video = VideoInfo.fromObject(data.video);
}
return message;
}
toObject() {
@@ -2789,6 +3189,7 @@ export namespace messages {
audio?: ReturnType<typeof AudioInfo.prototype.toObject>;
filename?: string;
contentPath?: string;
video?: ReturnType<typeof VideoInfo.prototype.toObject>;
} = {};
if (this.content != null) {
data.content = this.content;
@@ -2808,6 +3209,9 @@ export namespace messages {
if (this.contentPath != null) {
data.contentPath = this.contentPath;
}
if (this.video != null) {
data.video = this.video.toObject();
}
return data;
}
serialize(): Uint8Array;
@@ -2826,6 +3230,8 @@ export namespace messages {
writer.writeString(5, this.filename);
if (this.contentPath.length)
writer.writeString(6, this.contentPath);
if (this.has_video)
writer.writeMessage(7, this.video, () => this.video.serialize(writer));
if (!w)
return writer.getResultBuffer();
}
@@ -2853,6 +3259,9 @@ export namespace messages {
case 6:
message.contentPath = reader.readString();
break;
case 7:
reader.readMessage(message.video, () => message.video = VideoInfo.deserialize(reader));
break;
default: reader.skipField();
}
}
@@ -10491,20 +10900,20 @@ export namespace messages {
path: "/messages.EventStream/StreamEvents",
requestStream: false,
responseStream: true,
requestSerialize: (message: Session) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => Session.deserialize(new Uint8Array(bytes)),
requestSerialize: (message: StreamEventsRequest) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => StreamEventsRequest.deserialize(new Uint8Array(bytes)),
responseSerialize: (message: EventJson) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => EventJson.deserialize(new Uint8Array(bytes))
}
};
[method: string]: grpc_1.UntypedHandleCall;
abstract StreamEvents(call: grpc_1.ServerWritableStream<Session, EventJson>): void;
abstract StreamEvents(call: grpc_1.ServerWritableStream<StreamEventsRequest, EventJson>): void;
}
export class EventStreamClient extends grpc_1.makeGenericClientConstructor(UnimplementedEventStreamService.definition, "EventStream", {}) {
constructor(address: string, credentials: grpc_1.ChannelCredentials, options?: Partial<grpc_1.ChannelOptions>) {
super(address, credentials, options);
}
StreamEvents: GrpcStreamServiceInterface<Session, EventJson> = (message: Session, metadata?: grpc_1.Metadata | grpc_1.CallOptions, options?: grpc_1.CallOptions): grpc_1.ClientReadableStream<EventJson> => {
StreamEvents: GrpcStreamServiceInterface<StreamEventsRequest, EventJson> = (message: StreamEventsRequest, metadata?: grpc_1.Metadata | grpc_1.CallOptions, options?: grpc_1.CallOptions): grpc_1.ClientReadableStream<EventJson> => {
return super.StreamEvents(message, metadata, options);
};
}
+14 -3
View File
@@ -642,6 +642,17 @@ function deserialize_messages_StartSessionRequest(buffer_arg) {
return gows_pb.StartSessionRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_StreamEventsRequest(arg) {
if (!(arg instanceof gows_pb.StreamEventsRequest)) {
throw new Error('Expected argument of type messages.StreamEventsRequest');
}
return Buffer.from(arg.serializeBinary());
}
function deserialize_messages_StreamEventsRequest(buffer_arg) {
return gows_pb.StreamEventsRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_SubscribePresenceRequest(arg) {
if (!(arg instanceof gows_pb.SubscribePresenceRequest)) {
throw new Error('Expected argument of type messages.SubscribePresenceRequest');
@@ -695,10 +706,10 @@ var EventStreamService = exports.EventStreamService = {
path: '/messages.EventStream/StreamEvents',
requestStream: false,
responseStream: true,
requestType: gows_pb.Session,
requestType: gows_pb.StreamEventsRequest,
responseType: gows_pb.EventJson,
requestSerialize: serialize_messages_Session,
requestDeserialize: deserialize_messages_Session,
requestSerialize: serialize_messages_StreamEventsRequest,
requestDeserialize: deserialize_messages_StreamEventsRequest,
responseSerialize: serialize_messages_EventJson,
responseDeserialize: deserialize_messages_EventJson,
},
+799 -2
View File
@@ -108,16 +108,19 @@ goog.exportSymbol('proto.messages.SessionIgnoreJidsConfig', null, global);
goog.exportSymbol('proto.messages.SessionLogConfig', null, global);
goog.exportSymbol('proto.messages.SessionProxyConfig', null, global);
goog.exportSymbol('proto.messages.SessionStateResponse', null, global);
goog.exportSymbol('proto.messages.SessionStorageConfig', null, global);
goog.exportSymbol('proto.messages.SessionStoreConfig', null, global);
goog.exportSymbol('proto.messages.SetPictureRequest', null, global);
goog.exportSymbol('proto.messages.SetProfilePictureRequest', null, global);
goog.exportSymbol('proto.messages.SortBy', null, global);
goog.exportSymbol('proto.messages.SortBy.Order', null, global);
goog.exportSymbol('proto.messages.StartSessionRequest', null, global);
goog.exportSymbol('proto.messages.StreamEventsRequest', null, global);
goog.exportSymbol('proto.messages.SubscribePresenceRequest', null, global);
goog.exportSymbol('proto.messages.UpdateContactRequest', null, global);
goog.exportSymbol('proto.messages.UpdateParticipantsRequest', null, global);
goog.exportSymbol('proto.messages.UpsertLabelRequest', null, global);
goog.exportSymbol('proto.messages.VideoInfo', null, global);
goog.exportSymbol('proto.messages.vCardContact', null, global);
/**
* Generated by JsPbCodeGenerator.
@@ -329,6 +332,27 @@ if (goog.DEBUG && !COMPILED) {
*/
proto.messages.EventJson.displayName = 'proto.messages.EventJson';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
* server response, or constructed directly in Javascript. The array is used
* in place and becomes part of the constructed object. It is not cloned.
* If no data is provided, the constructed object will be empty, but still
* valid.
* @extends {jspb.Message}
* @constructor
*/
proto.messages.StreamEventsRequest = function(opt_data) {
jspb.Message.initialize(this, opt_data, 0, -1, proto.messages.StreamEventsRequest.repeatedFields_, null);
};
goog.inherits(proto.messages.StreamEventsRequest, jspb.Message);
if (goog.DEBUG && !COMPILED) {
/**
* @public
* @override
*/
proto.messages.StreamEventsRequest.displayName = 'proto.messages.StreamEventsRequest';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
@@ -434,6 +458,27 @@ if (goog.DEBUG && !COMPILED) {
*/
proto.messages.SessionStoreConfig.displayName = 'proto.messages.SessionStoreConfig';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
* server response, or constructed directly in Javascript. The array is used
* in place and becomes part of the constructed object. It is not cloned.
* If no data is provided, the constructed object will be empty, but still
* valid.
* @extends {jspb.Message}
* @constructor
*/
proto.messages.SessionStorageConfig = function(opt_data) {
jspb.Message.initialize(this, opt_data, 0, -1, null, null);
};
goog.inherits(proto.messages.SessionStorageConfig, jspb.Message);
if (goog.DEBUG && !COMPILED) {
/**
* @public
* @override
*/
proto.messages.SessionStorageConfig.displayName = 'proto.messages.SessionStorageConfig';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
@@ -728,6 +773,27 @@ if (goog.DEBUG && !COMPILED) {
*/
proto.messages.AudioInfo.displayName = 'proto.messages.AudioInfo';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
* server response, or constructed directly in Javascript. The array is used
* in place and becomes part of the constructed object. It is not cloned.
* If no data is provided, the constructed object will be empty, but still
* valid.
* @extends {jspb.Message}
* @constructor
*/
proto.messages.VideoInfo = function(opt_data) {
jspb.Message.initialize(this, opt_data, 0, -1, null, null);
};
goog.inherits(proto.messages.VideoInfo, jspb.Message);
if (goog.DEBUG && !COMPILED) {
/**
* @public
* @override
*/
proto.messages.VideoInfo.displayName = 'proto.messages.VideoInfo';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
@@ -3604,6 +3670,213 @@ proto.messages.EventJson.prototype.setData = function(value) {
/**
* List of repeated fields within this message type.
* @private {!Array<number>}
* @const
*/
proto.messages.StreamEventsRequest.repeatedFields_ = [2];
if (jspb.Message.GENERATE_TO_OBJECT) {
/**
* Creates an object representation of this proto.
* Field names that are reserved in JavaScript and will be renamed to pb_name.
* Optional fields that are not set will be set to undefined.
* To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
* For the list of reserved names please see:
* net/proto2/compiler/js/internal/generator.cc#kKeyword.
* @param {boolean=} opt_includeInstance Deprecated. whether to include the
* JSPB instance for transitional soy proto support:
* http://goto/soy-param-migration
* @return {!Object}
*/
proto.messages.StreamEventsRequest.prototype.toObject = function(opt_includeInstance) {
return proto.messages.StreamEventsRequest.toObject(opt_includeInstance, this);
};
/**
* Static version of the {@see toObject} method.
* @param {boolean|undefined} includeInstance Deprecated. Whether to include
* the JSPB instance for transitional soy proto support:
* http://goto/soy-param-migration
* @param {!proto.messages.StreamEventsRequest} msg The msg instance to transform.
* @return {!Object}
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.StreamEventsRequest.toObject = function(includeInstance, msg) {
var f, obj = {
session: (f = msg.getSession()) && proto.messages.Session.toObject(includeInstance, f),
excludeList: (f = jspb.Message.getRepeatedField(msg, 2)) == null ? undefined : f
};
if (includeInstance) {
obj.$jspbMessageInstance = msg;
}
return obj;
};
}
/**
* Deserializes binary data (in protobuf wire format).
* @param {jspb.ByteSource} bytes The bytes to deserialize.
* @return {!proto.messages.StreamEventsRequest}
*/
proto.messages.StreamEventsRequest.deserializeBinary = function(bytes) {
var reader = new jspb.BinaryReader(bytes);
var msg = new proto.messages.StreamEventsRequest;
return proto.messages.StreamEventsRequest.deserializeBinaryFromReader(msg, reader);
};
/**
* Deserializes binary data (in protobuf wire format) from the
* given reader into the given message object.
* @param {!proto.messages.StreamEventsRequest} msg The message object to deserialize into.
* @param {!jspb.BinaryReader} reader The BinaryReader to use.
* @return {!proto.messages.StreamEventsRequest}
*/
proto.messages.StreamEventsRequest.deserializeBinaryFromReader = function(msg, reader) {
while (reader.nextField()) {
if (reader.isEndGroup()) {
break;
}
var field = reader.getFieldNumber();
switch (field) {
case 1:
var value = new proto.messages.Session;
reader.readMessage(value,proto.messages.Session.deserializeBinaryFromReader);
msg.setSession(value);
break;
case 2:
var value = /** @type {string} */ (reader.readString());
msg.addExclude(value);
break;
default:
reader.skipField();
break;
}
}
return msg;
};
/**
* Serializes the message to binary data (in protobuf wire format).
* @return {!Uint8Array}
*/
proto.messages.StreamEventsRequest.prototype.serializeBinary = function() {
var writer = new jspb.BinaryWriter();
proto.messages.StreamEventsRequest.serializeBinaryToWriter(this, writer);
return writer.getResultBuffer();
};
/**
* Serializes the given message to binary data (in protobuf wire
* format), writing to the given BinaryWriter.
* @param {!proto.messages.StreamEventsRequest} message
* @param {!jspb.BinaryWriter} writer
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.StreamEventsRequest.serializeBinaryToWriter = function(message, writer) {
var f = undefined;
f = message.getSession();
if (f != null) {
writer.writeMessage(
1,
f,
proto.messages.Session.serializeBinaryToWriter
);
}
f = message.getExcludeList();
if (f.length > 0) {
writer.writeRepeatedString(
2,
f
);
}
};
/**
* optional Session session = 1;
* @return {?proto.messages.Session}
*/
proto.messages.StreamEventsRequest.prototype.getSession = function() {
return /** @type{?proto.messages.Session} */ (
jspb.Message.getWrapperField(this, proto.messages.Session, 1));
};
/**
* @param {?proto.messages.Session|undefined} value
* @return {!proto.messages.StreamEventsRequest} returns this
*/
proto.messages.StreamEventsRequest.prototype.setSession = function(value) {
return jspb.Message.setWrapperField(this, 1, value);
};
/**
* Clears the message field making it undefined.
* @return {!proto.messages.StreamEventsRequest} returns this
*/
proto.messages.StreamEventsRequest.prototype.clearSession = function() {
return this.setSession(undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.StreamEventsRequest.prototype.hasSession = function() {
return jspb.Message.getField(this, 1) != null;
};
/**
* repeated string exclude = 2;
* @return {!Array<string>}
*/
proto.messages.StreamEventsRequest.prototype.getExcludeList = function() {
return /** @type {!Array<string>} */ (jspb.Message.getRepeatedField(this, 2));
};
/**
* @param {!Array<string>} value
* @return {!proto.messages.StreamEventsRequest} returns this
*/
proto.messages.StreamEventsRequest.prototype.setExcludeList = function(value) {
return jspb.Message.setField(this, 2, value || []);
};
/**
* @param {string} value
* @param {number=} opt_index
* @return {!proto.messages.StreamEventsRequest} returns this
*/
proto.messages.StreamEventsRequest.prototype.addExclude = function(value, opt_index) {
return jspb.Message.addToRepeatedField(this, 2, value, opt_index);
};
/**
* Clears the list making it empty but non-null.
* @return {!proto.messages.StreamEventsRequest} returns this
*/
proto.messages.StreamEventsRequest.prototype.clearExcludeList = function() {
return this.setExcludeList([]);
};
if (jspb.Message.GENERATE_TO_OBJECT) {
@@ -4308,6 +4581,298 @@ proto.messages.SessionStoreConfig.prototype.setAddress = function(value) {
if (jspb.Message.GENERATE_TO_OBJECT) {
/**
* Creates an object representation of this proto.
* Field names that are reserved in JavaScript and will be renamed to pb_name.
* Optional fields that are not set will be set to undefined.
* To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
* For the list of reserved names please see:
* net/proto2/compiler/js/internal/generator.cc#kKeyword.
* @param {boolean=} opt_includeInstance Deprecated. whether to include the
* JSPB instance for transitional soy proto support:
* http://goto/soy-param-migration
* @return {!Object}
*/
proto.messages.SessionStorageConfig.prototype.toObject = function(opt_includeInstance) {
return proto.messages.SessionStorageConfig.toObject(opt_includeInstance, this);
};
/**
* Static version of the {@see toObject} method.
* @param {boolean|undefined} includeInstance Deprecated. Whether to include
* the JSPB instance for transitional soy proto support:
* http://goto/soy-param-migration
* @param {!proto.messages.SessionStorageConfig} msg The msg instance to transform.
* @return {!Object}
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.SessionStorageConfig.toObject = function(includeInstance, msg) {
var f, obj = {
messages: jspb.Message.getBooleanFieldWithDefault(msg, 1, false),
groups: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
chats: jspb.Message.getBooleanFieldWithDefault(msg, 3, false),
labels: jspb.Message.getBooleanFieldWithDefault(msg, 4, false)
};
if (includeInstance) {
obj.$jspbMessageInstance = msg;
}
return obj;
};
}
/**
* Deserializes binary data (in protobuf wire format).
* @param {jspb.ByteSource} bytes The bytes to deserialize.
* @return {!proto.messages.SessionStorageConfig}
*/
proto.messages.SessionStorageConfig.deserializeBinary = function(bytes) {
var reader = new jspb.BinaryReader(bytes);
var msg = new proto.messages.SessionStorageConfig;
return proto.messages.SessionStorageConfig.deserializeBinaryFromReader(msg, reader);
};
/**
* Deserializes binary data (in protobuf wire format) from the
* given reader into the given message object.
* @param {!proto.messages.SessionStorageConfig} msg The message object to deserialize into.
* @param {!jspb.BinaryReader} reader The BinaryReader to use.
* @return {!proto.messages.SessionStorageConfig}
*/
proto.messages.SessionStorageConfig.deserializeBinaryFromReader = function(msg, reader) {
while (reader.nextField()) {
if (reader.isEndGroup()) {
break;
}
var field = reader.getFieldNumber();
switch (field) {
case 1:
var value = /** @type {boolean} */ (reader.readBool());
msg.setMessages(value);
break;
case 2:
var value = /** @type {boolean} */ (reader.readBool());
msg.setGroups(value);
break;
case 3:
var value = /** @type {boolean} */ (reader.readBool());
msg.setChats(value);
break;
case 4:
var value = /** @type {boolean} */ (reader.readBool());
msg.setLabels(value);
break;
default:
reader.skipField();
break;
}
}
return msg;
};
/**
* Serializes the message to binary data (in protobuf wire format).
* @return {!Uint8Array}
*/
proto.messages.SessionStorageConfig.prototype.serializeBinary = function() {
var writer = new jspb.BinaryWriter();
proto.messages.SessionStorageConfig.serializeBinaryToWriter(this, writer);
return writer.getResultBuffer();
};
/**
* Serializes the given message to binary data (in protobuf wire
* format), writing to the given BinaryWriter.
* @param {!proto.messages.SessionStorageConfig} message
* @param {!jspb.BinaryWriter} writer
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.SessionStorageConfig.serializeBinaryToWriter = function(message, writer) {
var f = undefined;
f = /** @type {boolean} */ (jspb.Message.getField(message, 1));
if (f != null) {
writer.writeBool(
1,
f
);
}
f = /** @type {boolean} */ (jspb.Message.getField(message, 2));
if (f != null) {
writer.writeBool(
2,
f
);
}
f = /** @type {boolean} */ (jspb.Message.getField(message, 3));
if (f != null) {
writer.writeBool(
3,
f
);
}
f = /** @type {boolean} */ (jspb.Message.getField(message, 4));
if (f != null) {
writer.writeBool(
4,
f
);
}
};
/**
* optional bool messages = 1;
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.getMessages = function() {
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 1, false));
};
/**
* @param {boolean} value
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.setMessages = function(value) {
return jspb.Message.setField(this, 1, value);
};
/**
* Clears the field making it undefined.
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.clearMessages = function() {
return jspb.Message.setField(this, 1, undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.hasMessages = function() {
return jspb.Message.getField(this, 1) != null;
};
/**
* optional bool groups = 2;
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.getGroups = function() {
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 2, false));
};
/**
* @param {boolean} value
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.setGroups = function(value) {
return jspb.Message.setField(this, 2, value);
};
/**
* Clears the field making it undefined.
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.clearGroups = function() {
return jspb.Message.setField(this, 2, undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.hasGroups = function() {
return jspb.Message.getField(this, 2) != null;
};
/**
* optional bool chats = 3;
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.getChats = function() {
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 3, false));
};
/**
* @param {boolean} value
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.setChats = function(value) {
return jspb.Message.setField(this, 3, value);
};
/**
* Clears the field making it undefined.
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.clearChats = function() {
return jspb.Message.setField(this, 3, undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.hasChats = function() {
return jspb.Message.getField(this, 3) != null;
};
/**
* optional bool labels = 4;
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.getLabels = function() {
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 4, false));
};
/**
* @param {boolean} value
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.setLabels = function(value) {
return jspb.Message.setField(this, 4, value);
};
/**
* Clears the field making it undefined.
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.clearLabels = function() {
return jspb.Message.setField(this, 4, undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.hasLabels = function() {
return jspb.Message.getField(this, 4) != null;
};
if (jspb.Message.GENERATE_TO_OBJECT) {
/**
* Creates an object representation of this proto.
@@ -4690,7 +5255,8 @@ proto.messages.SessionConfig.toObject = function(includeInstance, msg) {
store: (f = msg.getStore()) && proto.messages.SessionStoreConfig.toObject(includeInstance, f),
log: (f = msg.getLog()) && proto.messages.SessionLogConfig.toObject(includeInstance, f),
proxy: (f = msg.getProxy()) && proto.messages.SessionProxyConfig.toObject(includeInstance, f),
ignore: (f = msg.getIgnore()) && proto.messages.SessionIgnoreJidsConfig.toObject(includeInstance, f)
ignore: (f = msg.getIgnore()) && proto.messages.SessionIgnoreJidsConfig.toObject(includeInstance, f),
storage: (f = msg.getStorage()) && proto.messages.SessionStorageConfig.toObject(includeInstance, f)
};
if (includeInstance) {
@@ -4747,6 +5313,11 @@ proto.messages.SessionConfig.deserializeBinaryFromReader = function(msg, reader)
reader.readMessage(value,proto.messages.SessionIgnoreJidsConfig.deserializeBinaryFromReader);
msg.setIgnore(value);
break;
case 5:
var value = new proto.messages.SessionStorageConfig;
reader.readMessage(value,proto.messages.SessionStorageConfig.deserializeBinaryFromReader);
msg.setStorage(value);
break;
default:
reader.skipField();
break;
@@ -4808,6 +5379,14 @@ proto.messages.SessionConfig.serializeBinaryToWriter = function(message, writer)
proto.messages.SessionIgnoreJidsConfig.serializeBinaryToWriter
);
}
f = message.getStorage();
if (f != null) {
writer.writeMessage(
5,
f,
proto.messages.SessionStorageConfig.serializeBinaryToWriter
);
}
};
@@ -4959,6 +5538,43 @@ proto.messages.SessionConfig.prototype.hasIgnore = function() {
};
/**
* optional SessionStorageConfig storage = 5;
* @return {?proto.messages.SessionStorageConfig}
*/
proto.messages.SessionConfig.prototype.getStorage = function() {
return /** @type{?proto.messages.SessionStorageConfig} */ (
jspb.Message.getWrapperField(this, proto.messages.SessionStorageConfig, 5));
};
/**
* @param {?proto.messages.SessionStorageConfig|undefined} value
* @return {!proto.messages.SessionConfig} returns this
*/
proto.messages.SessionConfig.prototype.setStorage = function(value) {
return jspb.Message.setWrapperField(this, 5, value);
};
/**
* Clears the message field making it undefined.
* @return {!proto.messages.SessionConfig} returns this
*/
proto.messages.SessionConfig.prototype.clearStorage = function() {
return this.setStorage(undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.SessionConfig.prototype.hasStorage = function() {
return jspb.Message.getField(this, 5) != null;
};
@@ -7104,6 +7720,136 @@ proto.messages.AudioInfo.prototype.setWaveform = function(value) {
if (jspb.Message.GENERATE_TO_OBJECT) {
/**
* Creates an object representation of this proto.
* Field names that are reserved in JavaScript and will be renamed to pb_name.
* Optional fields that are not set will be set to undefined.
* To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
* For the list of reserved names please see:
* net/proto2/compiler/js/internal/generator.cc#kKeyword.
* @param {boolean=} opt_includeInstance Deprecated. whether to include the
* JSPB instance for transitional soy proto support:
* http://goto/soy-param-migration
* @return {!Object}
*/
proto.messages.VideoInfo.prototype.toObject = function(opt_includeInstance) {
return proto.messages.VideoInfo.toObject(opt_includeInstance, this);
};
/**
* Static version of the {@see toObject} method.
* @param {boolean|undefined} includeInstance Deprecated. Whether to include
* the JSPB instance for transitional soy proto support:
* http://goto/soy-param-migration
* @param {!proto.messages.VideoInfo} msg The msg instance to transform.
* @return {!Object}
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.VideoInfo.toObject = function(includeInstance, msg) {
var f, obj = {
duration: jspb.Message.getFloatingPointFieldWithDefault(msg, 1, 0.0)
};
if (includeInstance) {
obj.$jspbMessageInstance = msg;
}
return obj;
};
}
/**
* Deserializes binary data (in protobuf wire format).
* @param {jspb.ByteSource} bytes The bytes to deserialize.
* @return {!proto.messages.VideoInfo}
*/
proto.messages.VideoInfo.deserializeBinary = function(bytes) {
var reader = new jspb.BinaryReader(bytes);
var msg = new proto.messages.VideoInfo;
return proto.messages.VideoInfo.deserializeBinaryFromReader(msg, reader);
};
/**
* Deserializes binary data (in protobuf wire format) from the
* given reader into the given message object.
* @param {!proto.messages.VideoInfo} msg The message object to deserialize into.
* @param {!jspb.BinaryReader} reader The BinaryReader to use.
* @return {!proto.messages.VideoInfo}
*/
proto.messages.VideoInfo.deserializeBinaryFromReader = function(msg, reader) {
while (reader.nextField()) {
if (reader.isEndGroup()) {
break;
}
var field = reader.getFieldNumber();
switch (field) {
case 1:
var value = /** @type {number} */ (reader.readFloat());
msg.setDuration(value);
break;
default:
reader.skipField();
break;
}
}
return msg;
};
/**
* Serializes the message to binary data (in protobuf wire format).
* @return {!Uint8Array}
*/
proto.messages.VideoInfo.prototype.serializeBinary = function() {
var writer = new jspb.BinaryWriter();
proto.messages.VideoInfo.serializeBinaryToWriter(this, writer);
return writer.getResultBuffer();
};
/**
* Serializes the given message to binary data (in protobuf wire
* format), writing to the given BinaryWriter.
* @param {!proto.messages.VideoInfo} message
* @param {!jspb.BinaryWriter} writer
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.VideoInfo.serializeBinaryToWriter = function(message, writer) {
var f = undefined;
f = message.getDuration();
if (f !== 0.0) {
writer.writeFloat(
1,
f
);
}
};
/**
* optional float duration = 1;
* @return {number}
*/
proto.messages.VideoInfo.prototype.getDuration = function() {
return /** @type {number} */ (jspb.Message.getFloatingPointFieldWithDefault(this, 1, 0.0));
};
/**
* @param {number} value
* @return {!proto.messages.VideoInfo} returns this
*/
proto.messages.VideoInfo.prototype.setDuration = function(value) {
return jspb.Message.setProto3FloatField(this, 1, value);
};
if (jspb.Message.GENERATE_TO_OBJECT) {
/**
* Creates an object representation of this proto.
@@ -7138,7 +7884,8 @@ proto.messages.Media.toObject = function(includeInstance, msg) {
mimetype: jspb.Message.getFieldWithDefault(msg, 3, ""),
audio: (f = msg.getAudio()) && proto.messages.AudioInfo.toObject(includeInstance, f),
filename: jspb.Message.getFieldWithDefault(msg, 5, ""),
contentpath: jspb.Message.getFieldWithDefault(msg, 6, "")
contentpath: jspb.Message.getFieldWithDefault(msg, 6, ""),
video: (f = msg.getVideo()) && proto.messages.VideoInfo.toObject(includeInstance, f)
};
if (includeInstance) {
@@ -7200,6 +7947,11 @@ proto.messages.Media.deserializeBinaryFromReader = function(msg, reader) {
var value = /** @type {string} */ (reader.readString());
msg.setContentpath(value);
break;
case 7:
var value = new proto.messages.VideoInfo;
reader.readMessage(value,proto.messages.VideoInfo.deserializeBinaryFromReader);
msg.setVideo(value);
break;
default:
reader.skipField();
break;
@@ -7272,6 +8024,14 @@ proto.messages.Media.serializeBinaryToWriter = function(message, writer) {
f
);
}
f = message.getVideo();
if (f != null) {
writer.writeMessage(
7,
f,
proto.messages.VideoInfo.serializeBinaryToWriter
);
}
};
@@ -7426,6 +8186,43 @@ proto.messages.Media.prototype.setContentpath = function(value) {
};
/**
* optional VideoInfo video = 7;
* @return {?proto.messages.VideoInfo}
*/
proto.messages.Media.prototype.getVideo = function() {
return /** @type{?proto.messages.VideoInfo} */ (
jspb.Message.getWrapperField(this, proto.messages.VideoInfo, 7));
};
/**
* @param {?proto.messages.VideoInfo|undefined} value
* @return {!proto.messages.Media} returns this
*/
proto.messages.Media.prototype.setVideo = function(value) {
return jspb.Message.setWrapperField(this, 7, value);
};
/**
* Clears the message field making it undefined.
* @return {!proto.messages.Media} returns this
*/
proto.messages.Media.prototype.clearVideo = function() {
return this.setVideo(undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.Media.prototype.hasVideo = function() {
return jspb.Message.getField(this, 7) != null;
};
+44 -8
View File
@@ -124,7 +124,11 @@ import {
WAMessageReaction,
} from '@waha/structures/responses.dto';
import { CallData } from '@waha/structures/calls.dto';
import { MeInfo, ProxyConfig } from '@waha/structures/sessions.dto';
import {
MeInfo,
ProxyConfig,
SessionConfig,
} from '@waha/structures/sessions.dto';
import {
BROADCAST_ID,
DeleteStatusRequest,
@@ -174,8 +178,8 @@ import {
isLabelUpsertEvent,
} from './labels.gows';
import {
GetEventStreamClient,
GetMessageServiceClient,
BuildEventStreamClient,
BuildMessageServiceClient,
} from '@waha/core/engines/gows/clients';
import esm from '@waha/vendor/esm';
import { IsEditedMessage } from '@waha/core/utils/pwa';
@@ -188,6 +192,19 @@ import * as path from 'path';
import MessageServiceClient = messages.MessageServiceClient;
import * as fsp from 'fs/promises';
function getGowsStorageConfig(
sessionConfig?: SessionConfig,
): messages.SessionStorageConfig {
const storeConfig = sessionConfig?.gows?.storage;
return new messages.SessionStorageConfig({
// Only explicit false disables; undefined/null defaults to enabled.
messages: storeConfig?.messages !== false,
groups: storeConfig?.groups !== false,
chats: storeConfig?.chats !== false,
labels: storeConfig?.labels !== false,
});
}
enum WhatsMeowEvent {
CONNECTED = 'gows.ConnectedEventData',
DISCONNECTED = 'events.Disconnected',
@@ -216,6 +233,12 @@ enum WhatsMeowEvent {
CALL_REJECT = 'events.CallReject',
CALL_TERMINATE = 'events.CallTerminate',
CALL_OFFER_NOTICE = 'events.CallOfferNotice',
// Other
AppState = 'events.AppState',
AppStateSyncComplete = 'AppStateSyncComplete',
AppStateSyncError = 'AppStateSyncError',
HistorySync = 'events.HistorySync',
Contact = 'events.Contact',
}
export interface GowsConfig {
@@ -267,6 +290,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
address: auth.address(),
dialect: auth.dialect(),
}),
storage: getGowsStorageConfig(this.sessionConfig),
log: new messages.SessionLogConfig({
level: level ?? messages.LogLevel.INFO,
}),
@@ -282,8 +306,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}),
});
this.client = GetMessageServiceClient(
this.name,
this.client = BuildMessageServiceClient(
this.engineConfig.connection,
grpc.credentials.createInsecure(),
);
@@ -310,12 +333,25 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
this.stream$ = new GowsEventStreamObservable(
this.loggerBuilder.child({ grpc: 'stream' }),
() => {
const client = GetEventStreamClient(
this.name,
const client = BuildEventStreamClient(
this.engineConfig.connection,
grpc.credentials.createInsecure(),
);
const stream = client.StreamEvents(this.session);
// Avoid having a lot of events after pairing the device
// https://github.com/devlikeapro/waha/issues/1826
// TODO: we need to make it more dynamic
const exclude = [
WhatsMeowEvent.AppState,
WhatsMeowEvent.AppStateSyncComplete,
WhatsMeowEvent.AppStateSyncError,
WhatsMeowEvent.HistorySync,
WhatsMeowEvent.Contact,
];
const request = new messages.StreamEventsRequest({
session: this.session,
exclude: exclude,
});
const stream = client.StreamEvents(request);
return { client, stream };
},
);
+17 -3
View File
@@ -64,10 +64,19 @@ export function ToGroupParticipant(
interface GroupParticipantUpdate {
id: string;
author: string;
participants: string[];
participants: Array<string | NOWEBGroupParticipant>;
action: ParticipantAction;
}
function getParticipantId(
participant: string | NOWEBGroupParticipant,
): string | undefined {
if (typeof participant === 'string') {
return participant;
}
return participant?.id;
}
export function ToGroupV2Participants(
update: GroupParticipantUpdate,
): GroupV2ParticipantsEvent {
@@ -92,7 +101,8 @@ export function ToGroupV2Participants(
break;
}
const participants: GroupParticipant[] = update.participants.map((id) => {
const participants: GroupParticipant[] = update.participants.map((item) => {
const id = getParticipantId(item);
return {
id: toCusFormat(id),
role: role,
@@ -131,7 +141,11 @@ export function ToGroupV2LeaveEvent(
return null;
}
const meId = esm.b.jidNormalizedUser(me.id);
if (!update.participants.includes(meId)) {
const includesMe = update.participants.some((participant) => {
const id = getParticipantId(participant);
return id === meId;
});
if (!includesMe) {
return null;
}
+44 -7
View File
@@ -355,10 +355,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
return {
agent: agents?.socket,
// Baileys types still expect a Node https.Agent here,
// but 'undici' fetch requires a Dispatcher.
// Cast keeps the compiler satisfied while we pass the ProxyAgent at runtime.
fetchAgent: agents?.fetch as unknown as Agent,
// Baileys media upload uses Node https.request in Node runtime.
fetchAgent: agents?.fetch as Agent,
auth: state,
printQRInTerminal: false,
browser: browser,
@@ -956,24 +954,63 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
@Activity()
public editMessage(
public async editMessage(
chatId: string,
messageId: string,
request: EditMessageRequest,
) {
const jid = toJID(this.ensureSuffix(chatId));
const key = parseMessageIdSerialized(messageId);
const message = {
const stored = await this.store
?.loadMessage(key.remoteJid, key.id)
.catch(() => null);
const content = extractMessageContent(stored?.message);
let editedMessage = undefined;
if (content?.imageMessage) {
editedMessage = {
imageMessage: {
caption: request.text,
},
};
} else if (content?.videoMessage) {
editedMessage = {
videoMessage: {
caption: request.text,
},
};
} else if (content?.documentMessage) {
editedMessage = {
documentMessage: {
caption: request.text,
},
};
} else if (content?.documentWithCaptionMessage?.message?.documentMessage) {
editedMessage = {
documentWithCaptionMessage: {
message: {
documentMessage: {
caption: request.text,
},
},
},
};
}
let message: any = {
text: request.text,
mentions: request.mentions?.map(toJID),
edit: key,
editedMessage: editedMessage,
linkPreview: this.getLinkPreview(request),
linkPreviewHighQuality: request.linkPreviewHighQuality,
};
const options = {
messageId: this.generateMessageID(),
};
return this.sock.sendMessage(jid, message, options);
if (isJidNewsletter(jid)) {
// Newsletter edits reuse the original message ID
options.messageId = key.id;
}
return await this.sock.sendMessage(jid, message, options);
}
@Activity()
+1 -2
View File
@@ -1,7 +1,6 @@
import { Agent } from 'https';
import type { Dispatcher } from 'undici';
export interface Agents {
socket: Agent;
fetch: Dispatcher;
fetch: Agent;
}
+6 -1
View File
@@ -11,7 +11,12 @@ export function extractMediaContent(
content?.videoMessage ||
content?.audioMessage ||
content?.ptvMessage ||
content?.stickerMessage;
content?.stickerMessage ||
content?.templateMessage?.hydratedTemplate?.imageMessage ||
content?.templateMessage?.hydratedTemplate?.videoMessage ||
content?.templateMessage?.interactiveMessageTemplate?.header
?.imageMessage ||
content?.templateMessage?.interactiveMessageTemplate?.header?.videoMessage;
if (mediaContent) {
return mediaContent;
}
+13
View File
@@ -99,6 +99,19 @@ export class WPage extends EventEmitter {
}
}
// removeExposedFunction
async removeExposedFunction(name: string) {
try {
return await this.page.removeExposedFunction(name);
} catch (err) {
this.emit(PAGE_CALL_ERROR_EVENT, {
method: 'removeExposedFunction',
error: err,
});
throw err;
}
}
async reload(options?: any): Promise<any> {
try {
return await this.page.reload(options);
+20
View File
@@ -40,6 +40,10 @@ export class WebjsClientCore extends Client {
await this.attachCustomEventListeners();
await this.injectWaha();
});
this.on(Events.READY, async () => {
await this.attachCustomEventListeners();
await this.injectWaha();
});
}
async initialize() {
@@ -124,6 +128,7 @@ export class WebjsClientCore extends Client {
}
async setPushName(name: string) {
await this.ensureWahaInjected();
await this.pupPage.evaluate(async (pushName) => {
return await window['WAHA'].WAWebSetPushnameConnAction.setPushname(
pushName,
@@ -151,6 +156,7 @@ export class WebjsClientCore extends Client {
}
async createLabel(name: string, color: number): Promise<number> {
await this.ensureWahaInjected();
const labelId: number = (await this.pupPage.evaluate(
async (name, color) => {
// @ts-ignore
@@ -166,6 +172,7 @@ export class WebjsClientCore extends Client {
}
async deleteLabel(label: Label) {
await this.ensureWahaInjected();
return await this.pupPage.evaluate(async (label) => {
// @ts-ignore
return await window.WAHA.WAWebBizLabelEditingAction.labelDeleteAction(
@@ -177,6 +184,7 @@ export class WebjsClientCore extends Client {
}
async updateLabel(label: Label) {
await this.ensureWahaInjected();
return await this.pupPage.evaluate(async (label) => {
// @ts-ignore
return await window.WAHA.WAWebBizLabelEditingAction.labelEditAction(
@@ -193,6 +201,8 @@ export class WebjsClientCore extends Client {
return await super.getChats();
}
await this.ensureWahaInjected();
// Get paginated chats
pagination.limit ||= Infinity;
pagination.offset ||= 0;
@@ -209,6 +219,16 @@ export class WebjsClientCore extends Client {
return chats.map((chat) => ChatFactory.create(this, chat));
}
protected async ensureWahaInjected() {
const hasWaha = await this.pupPage.evaluate(() => {
// @ts-ignore
return Boolean(window.WAHA && window.WAHA.getChats);
});
if (!hasWaha) {
await this.injectWaha();
}
}
async sendTextStatus(status: TextStatus) {
// Convert from hex to number
const waColor = 'FF' + status.backgroundColor.replace('#', '');
+3 -2
View File
@@ -1,6 +1,5 @@
import { HttpsProxyAgent } from 'https-proxy-agent';
import { URL } from 'url';
import { ProxyAgent } from 'undici';
import { WhatsappConfigService } from '../config.service';
import { ProxyConfig } from '../structures/sessions.dto';
@@ -72,7 +71,9 @@ export function createAgentProxy(proxyConfig: ProxyConfig): Agents | undefined {
);
const socketAgent = new HttpsProxyAgent(url);
const fetchAgent = new ProxyAgent({ uri: url });
// Baileys media upload uses Node.js https.request in Node runtime,
// so fetchAgent must be a Node https.Agent (ProxyAgent from undici won't work).
const fetchAgent = new HttpsProxyAgent(url);
return { socket: socketAgent, fetch: fetchAgent };
}
@@ -44,8 +44,10 @@ export class WebhookConductor {
for (const event of events) {
const obs$ = session.getEventObservable(event);
obs$.subscribe((payload) => {
const data = populateSessionInfo(event, session)(payload);
sender.send(data);
setImmediate(() => {
const data = populateSessionInfo(event, session)(payload);
sender.send(data);
});
});
this.logger.debug(`Event '${event}' is enabled for url: ${url}`);
}
+2
View File
@@ -48,6 +48,7 @@ import { getProxyConfig } from './helpers.proxy';
import { MediaManager } from './media/MediaManager';
import { LocalSessionAuthRepository } from './storage/LocalSessionAuthRepository';
import { LocalStoreCore } from './storage/LocalStoreCore';
import { CoreApiKeyRepository } from './storage/CoreApiKeyRepository';
export class OnlyDefaultSessionIsAllowed extends UnprocessableEntityException {
constructor(name: string) {
@@ -137,6 +138,7 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
}
async onApplicationBootstrap() {
this.apiKeyRepository = new CoreApiKeyRepository();
await this.engineBootstrap.bootstrap();
this.startPredefinedSessions();
}
+52
View File
@@ -0,0 +1,52 @@
import { UnprocessableEntityException } from '@nestjs/common';
import { DOCS_URL } from '@waha/core/exceptions';
import {
ApiKey,
IApiKeyRepository,
} from '@waha/core/storage/IApiKeyRepository';
export class CoreApiKeyRepository implements IApiKeyRepository {
async init() {
return;
}
list(): Promise<ApiKey[]> {
return Promise.resolve([]);
}
getActiveByKey(key: string): Promise<ApiKey | null> {
void key;
return Promise.resolve(null);
}
getById(id: string): Promise<ApiKey | null> {
void id;
return Promise.resolve(null);
}
getByKey(key: string): Promise<ApiKey | null> {
void key;
return Promise.resolve(null);
}
async upsert(key: ApiKey): Promise<ApiKey> {
void key;
throw new UnprocessableEntityException(
`API key management is not available in this edition. See ${DOCS_URL}`,
);
}
async deleteById(id: string): Promise<void> {
void id;
throw new UnprocessableEntityException(
`API key management is not available in this edition. See ${DOCS_URL}`,
);
}
async deleteBySession(session: string): Promise<void> {
void session;
throw new UnprocessableEntityException(
`API key management is not available in this edition. See ${DOCS_URL}`,
);
}
}
+45
View File
@@ -0,0 +1,45 @@
import { RawRuleOf } from '@casl/ability';
import { UnprocessableEntityException } from '@nestjs/common';
import { AppAbility } from '../auth/casl.types';
export interface ApiKey {
id: string;
key: string;
isActive: boolean;
isAdmin: boolean;
session: string | null;
rules: RawRuleOf<AppAbility>[] | null;
}
export interface IApiKeyRepository {
init(): Promise<void>;
list(): Promise<ApiKey[]>;
upsert(key: ApiKey): Promise<ApiKey>;
getActiveByKey(key: string): Promise<ApiKey | null>;
getById(id: string): Promise<ApiKey | null>;
getByKey(key: string): Promise<ApiKey | null>;
deleteById(id: string): Promise<void>;
deleteBySession(session: string): Promise<void>;
}
export function CheckInvariant(
apiKey: Pick<ApiKey, 'isAdmin' | 'session'>,
): void {
if (apiKey.isAdmin && apiKey.session) {
throw new UnprocessableEntityException(
'Session is not allowed for admin keys',
);
}
if (!apiKey.isAdmin && !apiKey.session) {
throw new UnprocessableEntityException(
'Either isAdmin must be true or session must be provided',
);
}
}
@@ -8,6 +8,10 @@ export abstract class ISessionConfigRepository {
abstract getConfig(sessionName: string): Promise<SessionConfig | null>;
abstract getConfigBySessions(
sessionNames: string[],
): Promise<Map<string, SessionConfig | null>>;
abstract exists(sessionName: string): Promise<boolean>;
abstract deleteConfig(sessionName: string): Promise<void>;
+4
View File
@@ -5,6 +5,10 @@ export abstract class ISessionMeRepository {
abstract getMe(sessionName: string): Promise<MeInfo | null>;
abstract getMeBySessions(
sessionNames: string[],
): Promise<Map<string, MeInfo | null>>;
abstract removeMe(sessionName: string): Promise<void>;
abstract init(): Promise<void>;
@@ -48,6 +48,26 @@ export class LocalSessionConfigRepository extends ISessionConfigRepository {
return JSON.parse(content);
}
async getConfigBySessions(
sessionNames: string[],
): Promise<Map<string, SessionConfig | null>> {
const result = new Map<string, SessionConfig | null>();
const uniqueNames = Array.from(new Set(sessionNames));
if (uniqueNames.length === 0) {
return result;
}
const items = await Promise.all(
uniqueNames.map(async (sessionName) => ({
sessionName,
config: await this.getConfig(sessionName),
})),
);
for (const item of items) {
result.set(item.sessionName, item.config ?? null);
}
return result;
}
async saveConfig(sessionName: string, config: SessionConfig) {
// Create a folder if not exist
const folder = this.store.getSessionDirectory(sessionName);
+26
View File
@@ -52,3 +52,29 @@ export const SQLSessionWorkerMigrations: Migration[] = [
// Worker can have multiple records
'CREATE INDEX IF NOT EXISTS session_worker_worker_idx ON session_worker (worker)',
];
/**
* Api Keys
*/
export const SQLApiKeySchema = new Schema(
'api_key',
[
new Field('id', 'TEXT'),
new Field('key', 'TEXT'),
new Field('isActive', 'INTEGER'),
new Field('session', 'TEXT'),
new Field('data', 'TEXT'),
],
[
new Index('api_key_id_index', ['id']),
new Index('api_key_key_idx', ['key']),
new Index('api_key_session_idx', ['session']),
],
);
export const SQLApiKeyMigrations: Migration[] = [
'CREATE TABLE IF NOT EXISTS api_key (id TEXT PRIMARY KEY, "key" TEXT, "isActive" INTEGER, session TEXT, data TEXT)',
'CREATE UNIQUE INDEX IF NOT EXISTS api_key_id_index ON api_key (id)',
'CREATE UNIQUE INDEX IF NOT EXISTS api_key_key_idx ON api_key ("key")',
'CREATE INDEX IF NOT EXISTS api_key_session_idx ON api_key (session)',
];
@@ -36,6 +36,22 @@ export class Sqlite3SessionMeRepository
return data?.me;
}
async getMeBySessions(
sessionNames: string[],
): Promise<Map<string, MeInfo | null>> {
const result = new Map<string, MeInfo | null>();
const uniqueNames = Array.from(new Set(sessionNames));
if (uniqueNames.length === 0) {
return result;
}
const entities = await this.getEntitiesByIds(uniqueNames);
for (const sessionName of uniqueNames) {
const entity = entities.get(sessionName);
result.set(sessionName, entity?.me ?? null);
}
return result;
}
removeMe(sessionName: string): Promise<void> {
return this.deleteById(sessionName);
}
+37
View File
@@ -0,0 +1,37 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional, ValidateIf } from 'class-validator';
import { SessionName } from '@waha/structures/sessions.dto';
export class ApiKeyDTO {
@ApiProperty({ example: 'key_id_00000000000000000000000000' })
id: string;
@ApiProperty({ example: 'key_11111111111AAAAAAAAAAAAAAAAAAAAA' })
key: string;
@ApiProperty({ example: true })
isActive: boolean;
@ApiProperty({ example: false })
isAdmin: boolean;
@ApiProperty({ example: 'default', required: false, nullable: true })
session: string | null;
}
export class ApiKeyRequest {
@ApiProperty({ example: false })
@IsBoolean()
isAdmin: boolean = false;
@ApiProperty({ example: 'default', nullable: true })
@SessionName()
@IsOptional()
session: string | null = null;
@ApiProperty({ required: true, example: true })
@IsOptional()
@IsBoolean()
isActive: boolean = true;
}
+2 -1
View File
@@ -227,7 +227,8 @@ export class LinkPreviewData {
example: {
url:
process.env.WHATSAPP_SWAGGER_JPG_EXAMPLE_URL ||
'https://github.com/devlikeapro/waha/raw/core/examples/waha.jpg',
'https://picsum.photos/1024',
// 'https://github.com/devlikeapro/waha/raw/core/examples/waha.jpg',
},
})
image?: FileType;
+11
View File
@@ -25,4 +25,15 @@ export class WAHAEnvironment {
example: 'linux/x86',
})
platform: string;
@ApiProperty({
example: {
id: 'worker-1',
},
nullable: true,
description: 'Worker metadata for the running instance.',
})
worker: {
id: string | null;
};
}
+4 -2
View File
@@ -22,7 +22,8 @@ export class RemoteFile {
@ApiProperty({
example:
process.env.WHATSAPP_SWAGGER_JPG_EXAMPLE_URL ||
'https://github.com/devlikeapro/waha/raw/core/examples/waha.jpg',
'https://picsum.photos/1024',
// 'https://github.com/devlikeapro/waha/raw/core/examples/waha.jpg',
})
url: string;
}
@@ -130,7 +131,8 @@ export class FileURL {
@ApiProperty({
example:
process.env.WHATSAPP_SWAGGER_JPG_EXAMPLE_URL ||
'https://github.com/devlikeapro/waha/raw/core/examples/waha.jpg',
'https://picsum.photos/1024',
// 'https://github.com/devlikeapro/waha/raw/core/examples/waha.jpg',
})
url?: string;
}
+5 -4
View File
@@ -1,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { WAHAPresenceStatus } from './enums.dto';
@@ -28,9 +28,10 @@ export class WAHAChatPresences {
export class WAHASessionPresence {
presence: WAHAPresenceStatus;
@ApiProperty({
description: 'Chat ID - either group id or contact id',
@ApiPropertyOptional({
description:
'Chat ID - either group id or contact id. Required for chat-related presence statuses; omit for ONLINE/OFFLINE.',
example: '11111111111@c.us',
})
chatId: string;
chatId?: string;
}
+77 -6
View File
@@ -1,3 +1,4 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { BooleanString } from '@waha/nestjs/validation/BooleanString';
@@ -112,6 +113,55 @@ export class NowebConfig {
markOnline: boolean = true;
}
export class GowsStorageConfig {
@ApiProperty({
description:
'Store messages locally. Set to false to disable; omit or null to keep enabled.',
required: false,
example: true,
})
@IsBoolean()
@IsOptional()
messages?: boolean | null;
@ApiProperty({
description:
'Store groups locally. Set to false to disable; omit or null to keep enabled.',
required: false,
example: true,
})
@IsBoolean()
@IsOptional()
groups?: boolean | null;
@ApiProperty({
description:
'Store chats locally. Set to false to disable; omit or null to keep enabled.',
required: false,
example: true,
})
@IsBoolean()
@IsOptional()
chats?: boolean | null;
@ApiProperty({
description:
'Store labels locally. Set to false to disable; omit or null to keep enabled.',
required: false,
example: true,
})
@IsBoolean()
@IsOptional()
labels?: boolean | null;
}
export class GowsConfig {
@ValidateNested()
@Type(() => GowsStorageConfig)
@IsOptional()
storage?: GowsStorageConfig;
}
export class WebjsConfig {
@ApiProperty({
description:
@@ -240,6 +290,21 @@ export class SessionConfig {
@IsOptional()
noweb?: NowebConfig;
@ApiProperty({
example: {
storage: {
messages: true,
groups: true,
chats: true,
labels: true,
},
},
})
@ValidateNested()
@Type(() => GowsConfig)
@IsOptional()
gows?: GowsConfig;
@ApiProperty({
description: 'WebJS-specific settings.',
required: false,
@@ -307,19 +372,25 @@ export class SessionDetailedInfo extends SessionInfo {
const DB_NAME_LIMIT = 64;
const DB_NAME_MAX_PREFIX_LEN = 'waha_noweb'.length;
export function SessionName() {
return applyDecorators(
IsString(),
MaxLength(DB_NAME_LIMIT - DB_NAME_MAX_PREFIX_LEN),
Matches(/^[a-zA-Z0-9_-]*$/, {
message:
'Session name can only contain alphanumeric characters, hyphens, and underscores (a-z, A-Z, 0-9, -, _) or be empty',
}),
);
}
export class SessionCreateRequest {
@ApiProperty({
example: 'default',
description: 'Session name (id)',
required: false,
})
@IsString()
@IsOptional()
@MaxLength(DB_NAME_LIMIT - DB_NAME_MAX_PREFIX_LEN)
@Matches(/^[a-zA-Z0-9_-]*$/, {
message:
'Session name can only contain alphanumeric characters, hyphens, and underscores (a-z, A-Z, 0-9, -, _) or be empty',
})
@SessionName()
name: string | undefined;
@ValidateNested()
+13
View File
@@ -1,3 +1,4 @@
import * as crypto from 'crypto';
import { ulid } from 'ulid';
/**
@@ -7,3 +8,15 @@ import { ulid } from 'ulid';
export function generatePrefixedId(prefix: string) {
return `${prefix}_${ulid().toLowerCase()}`;
}
const SECRET_CHARS =
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
export function generateSecret(length: number = 32) {
const bytes = crypto.randomBytes(length);
let secret = '';
for (let i = 0; i < length; i += 1) {
secret += SECRET_CHARS[bytes[i % bytes.length] % SECRET_CHARS.length];
}
return secret;
}
+6 -1
View File
@@ -29,6 +29,10 @@ export function getWAHAVersion(): WAHAVersion {
return WAHAVersion.CORE;
}
export function getWorker() {
return { id: process.env.WAHA_WORKER_ID || null };
}
function getBrowser() {
return getEngineName() === WAHAEngine.WEBJS
? getBrowserExecutablePath()
@@ -40,11 +44,12 @@ function getPlatform() {
}
export const VERSION: WAHAEnvironment = {
version: '2026.1.3',
version: '2026.2.2',
engine: getEngineName(),
tier: getWAHAVersion(),
browser: getBrowser(),
platform: getPlatform(),
worker: getWorker(),
};
export const IsChrome = VERSION.browser?.includes('chrome');
+2 -2
View File
@@ -2,11 +2,11 @@
"waha": {
"gows": {
"repo": "devlikeapro/gows",
"ref": "v1.0.30"
"ref": "v1.0.33"
},
"dashboard": {
"repo": "devlikeapro/dashboard",
"ref": "6030cf5e08f2a6e6d09a7a70a1c4194baf0beb55"
"ref": "9c3f343aad6a211524a0cebf1ba3dcc3fa094840"
}
}
}
+63 -9
View File
@@ -5,9 +5,9 @@ __metadata:
version: 8
cacheKey: 10
"@adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-12-17":
"@adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2026-02-11":
version: 7.0.0-rc.9
resolution: "@adiwajshing/baileys@https://github.com/devlikeapro/Baileys.git#commit=21289d1c97dd4b6b0c2585c0f77cecc486fb9f2f"
resolution: "@adiwajshing/baileys@https://github.com/devlikeapro/Baileys.git#commit=b00582cdfd9cef50a7f1a0e2faa28dbf7baef407"
dependencies:
"@cacheable/node-cache": "npm:^1.4.0"
"@hapi/boom": "npm:^9.1.3"
@@ -18,6 +18,7 @@ __metadata:
p-queue: "npm:^9.0.0"
pino: "npm:^9.6"
protobufjs: "npm:^7.2.4"
whatsapp-rust-bridge: "npm:0.5.2"
ws: "npm:^8.13.0"
peerDependencies:
audio-decode: ^2.1.3
@@ -31,7 +32,7 @@ __metadata:
optional: true
link-preview-js:
optional: true
checksum: 10/6d3cbdbbb490356b89d65310244a8120f650641efbfd43f6fea4c5296c17d75feaeb7d870ee2ba77015b97c94c7ffd1950d0e6466b5c0dbc5eae020e68cf85d5
checksum: 10/7229ea82f4849f3397201ae16ab202f57f9788d61ce80b37cc42b77e01808ad53c470443e4fef44297681c5c6211829095210aeff5fb717fd8c083d9d6c4e22f
languageName: node
linkType: hard
@@ -1242,6 +1243,15 @@ __metadata:
languageName: node
linkType: hard
"@casl/ability@npm:^6.8.0":
version: 6.8.0
resolution: "@casl/ability@npm:6.8.0"
dependencies:
"@ucast/mongo2js": "npm:^1.3.0"
checksum: 10/e5a6b9c4ea480f734cfa1c88bbd18d65eb74ab2d66ef082e50b40064583ff70fa101e70e06faf0403a824b9b96879bcf57cf7abfecf7a5a26d6d99e6f16bd056
languageName: node
linkType: hard
"@colors/colors@npm:1.5.0":
version: 1.5.0
resolution: "@colors/colors@npm:1.5.0"
@@ -4018,6 +4028,42 @@ __metadata:
languageName: node
linkType: hard
"@ucast/core@npm:^1.0.0, @ucast/core@npm:^1.4.1, @ucast/core@npm:^1.6.1":
version: 1.10.2
resolution: "@ucast/core@npm:1.10.2"
checksum: 10/35c91961b534df03518ade09bf920856355bfadf0a59a8606d91ac4e22a24c2bd470964b2c54764a31e8eacecd4a2768ba8426b6a96c2474c6df305ee9b3f9a1
languageName: node
linkType: hard
"@ucast/js@npm:^3.0.0":
version: 3.0.4
resolution: "@ucast/js@npm:3.0.4"
dependencies:
"@ucast/core": "npm:^1.0.0"
checksum: 10/8a8a5ebe45b29e1885f00a05b293b47c54cbbe506080720c505425cd52bc3e311d16a49069ae8d0f797faa34d439ad066591a7abfebf56e89702c9ad7e57a96a
languageName: node
linkType: hard
"@ucast/mongo2js@npm:^1.3.0":
version: 1.4.0
resolution: "@ucast/mongo2js@npm:1.4.0"
dependencies:
"@ucast/core": "npm:^1.6.1"
"@ucast/js": "npm:^3.0.0"
"@ucast/mongo": "npm:^2.4.0"
checksum: 10/85def36f3da27469dea621dff616ce19bd15011fad56dba04ba2ece0f08b1943592e68f7e43e4a63612297cac8d4dd980937f0f16cbaffe39be7c5670f9a8943
languageName: node
linkType: hard
"@ucast/mongo@npm:^2.4.0":
version: 2.4.3
resolution: "@ucast/mongo@npm:2.4.3"
dependencies:
"@ucast/core": "npm:^1.4.1"
checksum: 10/5bcac2a06df97dda33deab3cfe1d57effd4ac49f27a846de29d6e583ca04f379f63d0def4737ec2c023dd9bbfded8ef2dfa285ed1575b8a320b1c2807c2d6200
languageName: node
linkType: hard
"@wasm-audio-decoders/common@npm:9.0.7":
version: 9.0.7
resolution: "@wasm-audio-decoders/common@npm:9.0.7"
@@ -12548,13 +12594,14 @@ __metadata:
version: 0.0.0-use.local
resolution: "waha@workspace:."
dependencies:
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2025-12-17"
"@adiwajshing/baileys": "github:devlikeapro/Baileys#fork-master-2026-02-11"
"@adiwajshing/keyed-db": "npm:^0.2.4"
"@aws-sdk/client-s3": "npm:^3.633.0"
"@aws-sdk/s3-request-presigner": "npm:^3.633.0"
"@bull-board/api": "npm:^6.9.1"
"@bull-board/express": "npm:^6.9.1"
"@bull-board/nestjs": "npm:^6.9.1"
"@casl/ability": "npm:^6.8.0"
"@figuro/chatwoot-sdk": "npm:^1.1.17"
"@grpc/grpc-js": "npm:^1.14.1"
"@grpc/proto-loader": "npm:^0.8.0"
@@ -12653,7 +12700,7 @@ __metadata:
ulid: "npm:^2.3.0"
undici: "npm:^7.16.0"
uniqid: "npm:^5.4.0"
whatsapp-web.js: "github:devlikeapro/whatsapp-web.js#fork-main-2025-12-17"
whatsapp-web.js: "github:devlikeapro/whatsapp-web.js#fork-main-2026-02-01"
write-file-atomic: "npm:^6.0.0"
yaml: "npm:^2.7.1"
dependenciesMeta:
@@ -12754,9 +12801,16 @@ __metadata:
languageName: node
linkType: hard
"whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2025-12-17":
version: 1.34.3
resolution: "whatsapp-web.js@https://github.com/devlikeapro/whatsapp-web.js.git#commit=766bb3957a81338250ee830d34cfceec278e545e"
"whatsapp-rust-bridge@github:devlikeapro/whatsapp-rust-bridge.git#fork-master-2026-02-04":
version: 0.5.2
resolution: "whatsapp-rust-bridge@https://github.com/devlikeapro/whatsapp-rust-bridge.git#commit=9fb931fbb991f5ff01142f72de41c21b39ed3a8f"
checksum: 10/b96e5efaf70f975eb3e51513c65c1f73f45f88946196c8f4c7c5c940a3975b2fb3b696bddc5378798e80e242f21fe7cdf4141a81dd405b85ef44272a28d6e76f
languageName: node
linkType: hard
"whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-02-01":
version: 1.34.6
resolution: "whatsapp-web.js@https://github.com/devlikeapro/whatsapp-web.js.git#commit=3602e0801d8ba3bd2549068e79536282b3028a0e"
dependencies:
"@pedroslopez/moduleraid": "npm:^5.0.2"
archiver: "npm:^5.3.1"
@@ -12774,7 +12828,7 @@ __metadata:
optional: true
unzipper:
optional: true
checksum: 10/cb54d6192bd5d24c5521d1d5599fc7da65e87c3dfc595cab708b7335e95842d31fd7c0d84a7c479202de55bda67c4dcaec802150568e5f35815b6b07fd669d57
checksum: 10/a7a177079c271c27675ca49e76d31f283c5472bcc478cc5e78c4d2ad5a6ed120346fd9a176791d28cc69f42c540d3430eb3809e9d1641f2dc1183e0d82d8df81
languageName: node
linkType: hard