Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e719d8894 | ||
|
|
8faa3ca5a4 | ||
|
|
5dd8bf140d | ||
|
|
0501c37500 | ||
|
|
1496274a99 | ||
|
|
b6ba3951da | ||
|
|
a4a3dc93ad | ||
|
|
57eb0e14ca | ||
|
|
d8970326be | ||
|
|
06412c2c1e | ||
|
|
0b39645c50 | ||
|
|
76f8cc6f53 | ||
|
|
ea79b1d886 | ||
|
|
e54604eb97 | ||
|
|
d267a29e87 | ||
|
|
d4625359e6 | ||
|
|
3618b92c3e | ||
|
|
982092cf2b | ||
|
|
6a452cd66e | ||
|
|
19625e38e9 | ||
|
|
208f4f3d78 | ||
|
|
a9ff7d763d | ||
|
|
e290fd545f | ||
|
|
84f111e2c1 | ||
|
|
55dddd5991 | ||
|
|
638555297c | ||
|
|
0e4de03da3 | ||
|
|
c6219be356 | ||
|
|
2460a23cab | ||
|
|
c2ed34a171 | ||
|
|
1db8ae3423 | ||
|
|
8a06ee3d44 | ||
|
|
9e11312b75 | ||
|
|
1c7ce47dbb | ||
|
|
1d72e2666d | ||
|
|
7ae2f7999e | ||
|
|
68dc4ad642 | ||
|
|
e8f63aeaad | ||
|
|
f4e19c7664 | ||
|
|
6d8ef2e375 | ||
|
|
024cbffb13 | ||
|
|
ad399d9b01 | ||
|
|
6053c162c0 | ||
|
|
6654a7b3d0 | ||
|
|
4ff1c01e38 | ||
|
|
dfe0a3c8c4 | ||
|
|
04121bda52 | ||
|
|
7b6f522f92 | ||
|
|
10f666ebdc | ||
|
|
e815dae6a4 | ||
|
|
b7b57f183a | ||
|
|
4f63da18dd | ||
|
|
751f149573 | ||
|
|
a331fd11bd | ||
|
|
376676442e | ||
|
|
21daf501be | ||
|
|
2fe7e307f0 | ||
|
|
bae171bfe1 | ||
|
|
e3979339b4 | ||
|
|
58d2de1229 | ||
|
|
0e5f38d4a4 | ||
|
|
0f6c6a4147 | ||
|
|
958b96029d | ||
|
|
02de0c0d32 | ||
|
|
8fd01c2b56 | ||
|
|
63cfe47c83 | ||
|
|
40519eac8c | ||
|
|
2a8158cf8d | ||
|
|
549551b2a7 | ||
|
|
d589c03da7 | ||
|
|
495857ee41 | ||
|
|
e04e55c4c5 | ||
|
|
743e740860 | ||
|
|
65623ec1ea | ||
|
|
72a301c579 | ||
|
|
f35fd8d7c6 | ||
|
|
0594ca0709 | ||
|
|
4e5ebab97e | ||
|
|
ba976138b7 | ||
|
|
bb3328f5a3 | ||
|
|
6db12d7913 | ||
|
|
d968fa90f1 | ||
|
|
06c874fea5 | ||
|
|
d2b7d94607 | ||
|
|
c8f067229b | ||
|
|
798d782869 | ||
|
|
f70bcd0b53 | ||
|
|
17a7764b97 | ||
|
|
58affc9fe2 | ||
|
|
3678cb4392 | ||
|
|
6eda96908f | ||
|
|
ddf161bb2b | ||
|
|
5659dc7296 | ||
|
|
bca90199f1 | ||
|
|
bd263b071c | ||
|
|
e54603085c | ||
|
|
7f7857141e | ||
|
|
3b19dc3a61 | ||
|
|
06e5951c68 | ||
|
|
bcf4ed07d8 | ||
|
|
f711fcca98 | ||
|
|
32e57cab98 | ||
|
|
b092d2bfe1 | ||
|
|
f655f715d0 | ||
|
|
e5c77c60f2 | ||
|
|
a38d4145c2 | ||
|
|
2c47b784b3 | ||
|
|
724dc61717 | ||
|
|
9c44256209 | ||
|
|
1fb82c7678 | ||
|
|
55f7d4dac7 | ||
|
|
10e8869903 | ||
|
|
554d2bc6bc | ||
|
|
99aabb0957 | ||
|
|
055b014c41 | ||
|
|
172676ae6f | ||
|
|
0056951485 | ||
|
|
8c91792473 | ||
|
|
a26e89a2b7 | ||
|
|
7674c4ca67 | ||
|
|
cc7c11e2fd | ||
|
|
9f3c28b6ac | ||
|
|
3ca9545a58 | ||
|
|
4d08cffeab | ||
|
|
241340e0a0 | ||
|
|
556f0fa775 | ||
|
|
975cf2f580 | ||
|
|
90795a7eaf | ||
|
|
e64b60ef3a | ||
|
|
03e9588581 | ||
|
|
393f5ef7da | ||
|
|
f778678514 | ||
|
|
e4f88771a8 | ||
|
|
e885ea8e95 | ||
|
|
f8a9ed7d06 | ||
|
|
d89e9105d0 | ||
|
|
3ba92dec04 | ||
|
|
441ebbd525 | ||
|
|
690c43c245 | ||
|
|
26d8591a6c | ||
|
|
ac63c097ec | ||
|
|
d29e4f8351 | ||
|
|
615ccda4c3 | ||
|
|
d52eb71c76 | ||
|
|
8edd81a765 | ||
|
|
5830cee384 | ||
|
|
20f15b4370 | ||
|
|
8432cd4f8d | ||
|
|
55870b88bf | ||
|
|
6acf4373f4 | ||
|
|
2381f0843f | ||
|
|
b4e6f5e722 | ||
|
|
415bee3e8e | ||
|
|
aad924be79 | ||
|
|
81083e7f82 | ||
|
|
f8e52959a4 | ||
|
|
8eb542f49e | ||
|
|
45ae263ab9 | ||
|
|
f066f982bf | ||
|
|
0888640741 | ||
|
|
bde3722f5c | ||
|
|
0cd3df117a | ||
|
|
4c9525cc9f | ||
|
|
c14fe1e67d | ||
|
|
d1e3b209e7 | ||
|
|
f37bf62a30 | ||
|
|
538bfe0e24 | ||
|
|
93cd5307d9 | ||
|
|
b697f37b1e | ||
|
|
96a9cd8cbb | ||
|
|
a21b9a11a9 | ||
|
|
213b51e598 | ||
|
|
faf3c77508 | ||
|
|
e6fdfb88ca | ||
|
|
99746df626 | ||
|
|
43aeeb55e8 | ||
|
|
4f89f49d44 | ||
|
|
be583e7e40 | ||
|
|
e8c8f89733 | ||
|
|
13b6d3ddf7 | ||
|
|
5e97fb74b1 | ||
|
|
cc47321cad | ||
|
|
607dff8f7a | ||
|
|
0a74f8dbcd | ||
|
|
796befcf87 | ||
|
|
2c5610dd01 | ||
|
|
43d110fa31 | ||
|
|
bf52dd4874 | ||
|
|
f8268180bf | ||
|
|
04cf9bc8c0 | ||
|
|
ff38cf7660 | ||
|
|
4c0b44c4b5 | ||
|
|
e75c29d57f | ||
|
|
f8329c29b1 | ||
|
|
52222a6b98 | ||
|
|
99a0a19d8c | ||
|
|
48ed33bc94 | ||
|
|
b234e134bf | ||
|
|
f6004fd294 | ||
|
|
91d541387b | ||
|
|
9dbd45da6e | ||
|
|
adc1c3960c | ||
|
|
ff742d2e84 | ||
|
|
10bd4d6271 | ||
|
|
874cafe114 | ||
|
|
0ce2c4b9fa | ||
|
|
a8a7988bfa | ||
|
|
2940d93b81 | ||
|
|
88707cf72b | ||
|
|
b0f883c6a8 | ||
|
|
4cddb766c2 | ||
|
|
6675492901 | ||
|
|
a9d030af2a | ||
|
|
2782a188a9 | ||
|
|
256688497e | ||
|
|
4def193c57 | ||
|
|
7f3a2e6aa5 | ||
|
|
60e4f13b67 | ||
|
|
2d6cfeb0fa | ||
|
|
03e85434df | ||
|
|
0ce4c01c48 | ||
|
|
0e95890ca4 | ||
|
|
072f4b2ab0 | ||
|
|
47a91ef669 | ||
|
|
5143bd1ef8 | ||
|
|
0efc0751c9 | ||
|
|
51dda739cb | ||
|
|
3b28826679 | ||
|
|
81646bbaa2 | ||
|
|
d8d84107fb | ||
|
|
58d917d374 | ||
|
|
f8f0063975 | ||
|
|
73faf330e3 | ||
|
|
5a1ebb186d | ||
|
|
54d6d715b0 | ||
|
|
5d9d6e29e2 | ||
|
|
cf3385f624 | ||
|
|
0346c5e7b9 | ||
|
|
443652b0a7 | ||
|
|
1e73c3dc6a | ||
|
|
afa9d4208f | ||
|
|
64eda25b5d | ||
|
|
cfc6c931a3 | ||
|
|
35bc56c948 | ||
|
|
371bde4c3e | ||
|
|
9497e2900e | ||
|
|
51bbc9998a | ||
|
|
057f7bce56 | ||
|
|
52412fb4cc | ||
|
|
a57f3443de | ||
|
|
9b2537e7c7 | ||
|
|
a378ad4c3d | ||
|
|
8f867e0b5d | ||
|
|
d4f6ec18fd | ||
|
|
ac05dd83e9 | ||
|
|
17c22f0cd4 | ||
|
|
971979c13f | ||
|
|
845e203637 | ||
|
|
7a2e1cd731 | ||
|
|
84ba011a4c | ||
|
|
50399f1192 | ||
|
|
a2517b248d | ||
|
|
f02d95331a | ||
|
|
cb1faff88c | ||
|
|
3ff1c06eea | ||
|
|
c91d2c59eb | ||
|
|
4cf5a74d23 |
No files matched your search
@@ -21,7 +21,10 @@ WHATSAPP_SWAGGER_ENABLED=True
|
||||
# ==================
|
||||
# WhatsApp engine (WEBJS is default, GOWS or NOWEB for better performance)
|
||||
WHATSAPP_DEFAULT_ENGINE=WEBJS
|
||||
# So you don't have to create sessions/apps when switching between engines
|
||||
WAHA_NAMESPACE=all
|
||||
|
||||
# "Firefox (YourApp)" in Linked Devices
|
||||
# WAHA_CLIENT_DEVICE_NAME=YourApp
|
||||
|
||||
# Base URL for the API (used for webhooks, file URLs, etc.)
|
||||
|
||||
@@ -12,10 +12,11 @@ jobs:
|
||||
${{ matrix.engine }} - ${{ matrix.browser }} - ${{ matrix.platform }} -
|
||||
${{ matrix.tag }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Chromium - x86
|
||||
- runner: 'buildjet-4vcpu-ubuntu-2204'
|
||||
- runner: 'ubuntu-latest'
|
||||
tag: 'latest'
|
||||
platform: 'amd64'
|
||||
browser: 'chromium'
|
||||
@@ -23,7 +24,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 +32,7 @@ jobs:
|
||||
goss: 'goss-linux-arm'
|
||||
|
||||
# Chrome - x86
|
||||
- runner: 'buildjet-4vcpu-ubuntu-2204'
|
||||
- runner: 'ubuntu-latest'
|
||||
tag: 'chrome'
|
||||
platform: 'amd64'
|
||||
browser: 'chrome'
|
||||
@@ -39,14 +40,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 +55,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 +63,7 @@ jobs:
|
||||
goss: 'goss-linux-arm'
|
||||
|
||||
# GOWS - x86
|
||||
- runner: 'buildjet-4vcpu-ubuntu-2204'
|
||||
- runner: 'ubuntu-latest'
|
||||
tag: 'gows'
|
||||
platform: 'amd64'
|
||||
browser: 'none'
|
||||
@@ -70,7 +71,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'
|
||||
@@ -100,7 +101,51 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
- name: Build (attempt 1)
|
||||
id: build1
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
USE_BROWSER=${{ matrix.browser }}
|
||||
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Wait to retry build
|
||||
if: steps.build1.outcome == 'failure'
|
||||
run: |
|
||||
sleep 10
|
||||
|
||||
- name: Build (attempt 2)
|
||||
id: build2
|
||||
if: steps.build1.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
USE_BROWSER=${{ matrix.browser }}
|
||||
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Wait to retry build
|
||||
if: steps.build2.outcome == 'failure'
|
||||
run: |
|
||||
sleep 10
|
||||
|
||||
- name: Build (attempt 3)
|
||||
if: steps.build2.outcome == 'failure'
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
|
||||
@@ -6,22 +6,63 @@ 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 }} -
|
||||
${{ matrix.tag }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
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'
|
||||
@@ -71,8 +112,60 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
- name: Build (attempt 1)
|
||||
id: build1
|
||||
if: ${{ steps.recent.outputs.has_recent == 'true' }}
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
USE_BROWSER=${{ matrix.browser }}
|
||||
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Wait to retry build
|
||||
if:
|
||||
${{ steps.recent.outputs.has_recent == 'true' && steps.build1.outcome
|
||||
== 'failure' }}
|
||||
run: |
|
||||
sleep 10
|
||||
|
||||
- name: Build (attempt 2)
|
||||
id: build2
|
||||
if:
|
||||
${{ steps.recent.outputs.has_recent == 'true' && steps.build1.outcome
|
||||
== 'failure' }}
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
USE_BROWSER=${{ matrix.browser }}
|
||||
WHATSAPP_DEFAULT_ENGINE=${{ matrix.engine }}
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Wait to retry build
|
||||
if:
|
||||
${{ steps.recent.outputs.has_recent == 'true' && steps.build2.outcome
|
||||
== 'failure' }}
|
||||
run: |
|
||||
sleep 10
|
||||
|
||||
- name: Build (attempt 3)
|
||||
if:
|
||||
${{ steps.recent.outputs.has_recent == 'true' && steps.build2.outcome
|
||||
== 'failure' }}
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
openapi.json
|
||||
tmp/*
|
||||
src/core/engines/gows/proto
|
||||
.env
|
||||
|
||||
+20
@@ -3,3 +3,23 @@ compressionLevel: mixed
|
||||
enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
supportedArchitectures:
|
||||
cpu:
|
||||
- arm
|
||||
- arm64
|
||||
- ia32
|
||||
- loong64
|
||||
- mips
|
||||
- mipsel
|
||||
- ppc64
|
||||
- riscv64
|
||||
- s390
|
||||
- s390x
|
||||
- x64
|
||||
- wasm32
|
||||
os:
|
||||
- current
|
||||
- darwin
|
||||
- linux
|
||||
- win32
|
||||
@@ -5,129 +5,127 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
|
||||
|
||||
## Product & Variants
|
||||
|
||||
- WAHA ships in **Core** and **Plus** editions. Core lives under `src/core` and
|
||||
only supports the default session plus minimal media features. Plus extends
|
||||
core via `src/plus` to add multi-session orchestration, richer media handling,
|
||||
and external storage integrations.
|
||||
- Core code must remain free from Plus-only references. A pre-commit hook
|
||||
rejects the word `plus` inside core files; reuse abstractions exposed through
|
||||
`@waha/core/**` instead of importing Plus modules from Core.
|
||||
- Commit subjects are validated: changes that touch `src/plus` require a
|
||||
`[PLUS] …` prefix and must not include non-Plus files; everything else must
|
||||
use `[core] …`. Verify against `./.precommit/validate_commit_message.py` if
|
||||
unsure.
|
||||
- WAHA ships in **Core** and **Plus** editions
|
||||
- Core lives under `src/core` and supports the default session with minimal
|
||||
media features
|
||||
- Plus extends core via `src/plus` to add multi-session orchestration, richer
|
||||
media handling, and external storage integrations
|
||||
- Core code must remain free from Plus-only references (pre-commit hook rejects
|
||||
"plus" in core files)
|
||||
- Commit subjects: changes that touch `src/plus` require `[PLUS] …` prefix;
|
||||
everything else uses `[core] …`
|
||||
|
||||
## Tech Stack Snapshot
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Node.js 22.x, Yarn 3.6 (Berry). Always install packages and run
|
||||
scripts with Yarn.
|
||||
- **Framework**: NestJS v11 with dependency injection, modular controllers in
|
||||
`src/api`, and Pino-based logging via `nestjs-pino`.
|
||||
- **Engines**: WhatsApp engines are abstracted (`WEBJS`, `GOWS`, `NOWEB`). Core
|
||||
uses `SessionManagerCore`; Plus swaps to `SessionManagerPlus` with extra
|
||||
storage backends (Mongo/Postgres/SQLite).
|
||||
- **Runtime**: Node.js 22.x, Yarn 3.6 (Berry)
|
||||
- **Framework**: NestJS v11 with dependency injection and modular controllers in
|
||||
`src/api`
|
||||
- **Engines**: WhatsApp engines are abstracted (`WEBJS`, `GOWS`, `NOWEB`,
|
||||
`WPP`). Core uses `SessionManagerCore`; Plus swaps to `SessionManagerPlus`
|
||||
with extra storage backends (Mongo/Postgres/SQLite)
|
||||
- **ESM Bridge**: ESM-only dependencies (Baileys) load through
|
||||
`src/vendor/esm.ts`. Add new ESM modules there to ensure they load exactly
|
||||
once.
|
||||
- **Utilities**: RxJS streams (`SwitchObservable`, `DefaultMap`) drive webhook
|
||||
event fan-out. Prefer existing helpers in `src/utils` and `src/core/utils`
|
||||
before adding bespoke logic.
|
||||
`src/vendor/esm.ts`
|
||||
- **Utilities**: RxJS streams drive webhook event fan-out. Prefer existing
|
||||
helpers in `src/utils` and `src/core/utils`
|
||||
|
||||
## Repository Landmarks
|
||||
## Key Paths
|
||||
|
||||
- `src/main.ts`: runtime entry point; dynamically loads the correct AppModule
|
||||
(Core vs Plus) and configures global interceptors/filters.
|
||||
- `src/api/**`: REST controllers and WebSocket gateway. Keep handlers thin;
|
||||
delegate to managers/services. HTTP routes follow `/api/{sessionName}/…`;
|
||||
always thread the session name through request DTOs and guards instead of
|
||||
hardcoding `default`.
|
||||
- `src/main.ts`: runtime entry point; dynamically loads AppModule (Core vs Plus)
|
||||
- `src/api/**`: REST controllers and WebSocket gateway
|
||||
- `src/core/**`: shared abstractions (config services, engine bootstrap,
|
||||
storage, session management). Core only allows the `default` session and
|
||||
cleans up storage on boot.
|
||||
storage, session management)
|
||||
- `src/plus/**`: multi-session orchestration, advanced media services, and
|
||||
external persistence layers (Mongo, Postgres). Reuse this layer when adding
|
||||
Plus-only capabilities.
|
||||
- `src/apps/**`: integrations (e.g., ChatWoot) and application-specific
|
||||
services.
|
||||
external persistence layers
|
||||
- `src/structures/**` and `src/utils/**`: DTOs, enums (event names follow
|
||||
`domain.action`), helper utilities. Maintain naming consistency when
|
||||
introducing new events.
|
||||
- `tests/**`: Jest-based suites; do not add new tests unless explicitly asked,
|
||||
but keep existing tests working.
|
||||
`domain.action`), helper utilities
|
||||
|
||||
## Coding Expectations
|
||||
|
||||
- Favor composable, long-lived solutions. If a helper already exists (e.g.,
|
||||
`parseBool`, `DefaultMap`, media factories), extend it instead of reinventing
|
||||
logic. Lodash ships with the project—prefer its utilities over hand-rolled
|
||||
helpers. Import lodash as a namespace (`import * as lodash from 'lodash';`) so
|
||||
helpers like `lodash.camelCase` stay consistent across files. When a new
|
||||
third-party library seems necessary, confirm with the user, especially if the
|
||||
package looks stale.
|
||||
- Favor composability and long-lived solutions
|
||||
- Reuse existing helpers (`parseBool`, `DefaultMap`, media factories) instead of
|
||||
reinventing logic
|
||||
- Stick to NestJS patterns: inject dependencies through constructors, expose
|
||||
provider tokens from modules, and keep controllers free from business logic.
|
||||
provider tokens from modules
|
||||
- Logging goes through injected `PinoLogger` or helpers in
|
||||
`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 `../../../`).
|
||||
- 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
|
||||
follow `WAHA_*` for global values and `WAHA_SESSION_CONFIG_*` /
|
||||
`session.config.*` for per-session overrides. If both env and config are
|
||||
supported, honor both (`WAHA_WEBJS_CONFIG_*` vs. `session.webjs.config.*`).
|
||||
`src/utils/logging.ts`
|
||||
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`
|
||||
- Prefer named function declarations over `const` arrow functions
|
||||
- Avoid naming unused variables with a leading underscore
|
||||
- Always use explicit property names in object literals — never shorthand: write
|
||||
`{ key: value }`, not `{ value }` (even when the variable name matches the
|
||||
key)
|
||||
- Do not write verbose ternaries; use idiomatic helpers like `??` (nullish
|
||||
coalescing)
|
||||
- Do not place `await` or other async calls inside ternary expressions (`?:`) or
|
||||
nullish-coalescing expressions (`??`); use explicit `if/else` blocks or assign
|
||||
the awaited value to a variable first
|
||||
- For configs, prefer runtime configurability over constants (environment keys
|
||||
follow `WAHA_*` and `WAHA_SESSION_CONFIG_*`)
|
||||
- Do not use decorative comment blocks (lines of dashes/underscores with a
|
||||
label) such as `// ─────────── NAME ───────────`; use plain inline comments or
|
||||
no comment at all
|
||||
|
||||
## Language & Localization
|
||||
## How to Run API
|
||||
|
||||
- Keep identifiers (classes, methods, variables) and code comments in English so
|
||||
the codebase stays consistent for the global team.
|
||||
- Route user-visible strings through the existing i18n structure rather than
|
||||
hardcoding text. ChatWoot copy belongs in `src/apps/chatwoot/i18n` with
|
||||
English as the source locale before adding translations.
|
||||
```bash
|
||||
export DEBUG=1
|
||||
export WAHA_API_KEY=666
|
||||
export WAHA_DASHBOARD_PASSWORD=666
|
||||
export WAHA_DASHBOARD_USERNAME=admin
|
||||
export WWHATSAPP_SWAGGER_USERNAME=admin
|
||||
export WHATSAPP_SWAGGER_PASSWORD=666
|
||||
export WHATSAPP_DEFAULT_ENGINE={WEBJS|WPP|NOWEB|GOWS}
|
||||
export WAHA_DEBUG_MODE=True
|
||||
export WAHA_HTTP_STRICT_MODE=1
|
||||
export WAHA_MEDIA_STORAGE=LOCAL
|
||||
export WHATSAPP_FILES_FOLDER=./.media
|
||||
|
||||
## ChatWoot Integration Notes
|
||||
npm run start
|
||||
```
|
||||
|
||||
- Avoid introducing synthetic events; map onto existing webhook or engine events
|
||||
whenever plausible. Always use the official ChatWoot API client located in
|
||||
`src/apps/chatwoot/client`.
|
||||
- When adding events, align consumer names with webhook/event identifiers (e.g.,
|
||||
`message.any`).
|
||||
## Code Guidelines
|
||||
|
||||
## Workflow Checklist
|
||||
- Add `@Activity()` (from `src/core/abc/activity.ts`) to every engine method
|
||||
that makes a network call to WhatsApp servers
|
||||
- It triggers `maintainPresenceOnline()` before the method runs, keeping the
|
||||
session ONLINE during API activity and scheduling an OFFLINE transition after
|
||||
an idle period
|
||||
- Skip it on methods that only throw `NotImplementedByEngineError` /
|
||||
`AvailableInPlusVersion`
|
||||
|
||||
1. Identify whether work targets Core, Plus, or shared layers. If Plus-only,
|
||||
isolate changes to `src/plus` and ensure the commit prefix matches.
|
||||
2. Lean on existing services/managers; extend the appropriate session manager
|
||||
rather than branching logic inline.
|
||||
3. After edits run:
|
||||
- `pre-commit run --all-files`
|
||||
- `yarn build`
|
||||
- `yarn test` Use Node 22. Address lint or formatting issues before
|
||||
proceeding.
|
||||
4. Do **not** start the application yourself; ask the user to run it if runtime
|
||||
validation is required.
|
||||
5. Capture any assumptions or open questions for the user, especially when
|
||||
touching configs, introducing dependencies, or modifying public APIs.
|
||||
## MCP Tools
|
||||
|
||||
## Additional Tips
|
||||
MCP tools live in `src/apps/mcp/tools/` and expose the HTTP API to AI clients.
|
||||
Each tool file mirrors an API domain (e.g. `chats.tools.ts` → chats endpoints).
|
||||
|
||||
- Concurrency-sensitive sections (session start/stop) rely on `async-lock`. When
|
||||
adding async flows, reuse `SessionManager.withLock` or existing retry
|
||||
utilities (`promiseTimeout`, `waitUntil`).
|
||||
- Media features centralize through `MediaManager` and `MediaStorageFactory`.
|
||||
When altering media behavior, update both Core and Plus variants where
|
||||
applicable.
|
||||
- Keep docs and code ASCII unless a file already uses other characters. When
|
||||
updating documentation, mirror the concise, actionable tone used here.
|
||||
**When you change an existing API endpoint:**
|
||||
|
||||
## Related Sources Code
|
||||
- Check the corresponding `*.tools.ts` file and update the tool's `inputSchema`,
|
||||
description, or behavior if the API signature changed.
|
||||
|
||||
You can find related source code in the following paths:
|
||||
**When you add a new API endpoint:**
|
||||
|
||||
- Ask the user whether an MCP tool is needed for the new endpoint before
|
||||
creating one.
|
||||
- If yes, add the tool to the matching `*.tools.ts` file (or create a new file
|
||||
for a new domain).
|
||||
- Every `@Tool` decorator must include an `annotations` block with all three
|
||||
fields:
|
||||
```typescript
|
||||
annotations: {
|
||||
readOnlyHint: true | false, // true = no side effects (GET-style)
|
||||
destructiveHint: true | false, // true = irreversible deletion/logout
|
||||
idempotentHint: true | false, // true = safe to repeat with same args
|
||||
}
|
||||
```
|
||||
- Input schemas live in the matching `*.zod.ts` file.
|
||||
- Tools call the API via `this.textRequest({ method, url, ... })` inherited from
|
||||
`McpController`.
|
||||
|
||||
## Related Sources
|
||||
|
||||
- WEBJS: `../whatsapp-web.js`
|
||||
- NOWEB: `../WhiskeySockets-Baileys`
|
||||
- GOWS: `../gows` + `../whatsmeow`
|
||||
- NOWEB: `../WhiskeySockets-Baileys` and `../whatsapp-rust-bridge`
|
||||
- GOWS: `../gows` and `../whatsmeow`
|
||||
- WPP: `../wa-js`, `../wppconnect`, `../wppconnect-server`
|
||||
- ChatWoot: `../chatwoot`
|
||||
|
||||
Following this playbook keeps contributions aligned with WAHA’s structure,
|
||||
automation hooks, and release process.
|
||||
+19
-2
@@ -8,7 +8,9 @@ FROM node:${NODE_IMAGE_TAG} AS build
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD=True
|
||||
|
||||
# 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/*
|
||||
|
||||
# npm packages
|
||||
WORKDIR /git
|
||||
@@ -26,6 +28,21 @@ ADD . /git
|
||||
RUN yarn install
|
||||
RUN yarn build && find ./dist -name "*.d.ts" -delete
|
||||
|
||||
# Rebuild sharp from source on x86-64 so it runs on pre-v2 CPUs (no SSE4.2 requirement).
|
||||
# The prebuilt @img/sharp-linux-x64 binary requires x86-64-v2; -march=x86-64 limits
|
||||
# code generation to baseline x86-64 (SSE2 only). ARM64 prebuilts have no such
|
||||
# constraint, so no rebuild is needed there.
|
||||
# We call sharp's own install/build.js (not npm rebuild) so it first generates config.gypi
|
||||
# with include/lib paths pointing to its bundled @img/sharp-libvips-linux-x64 package,
|
||||
# then invokes node-gyp with those paths. Without this step node-gyp cannot find vips headers.
|
||||
RUN if [ "$(uname -m)" = "x86_64" ]; then \
|
||||
LIBVIPS_DEV_VER=$(node -e "require('/git/node_modules/sharp/package.json').devDependencies['@img/sharp-libvips-dev']" | tr -d '^') && \
|
||||
npm install --prefix /tmp/sharp-dev --no-package-lock "@img/sharp-libvips-dev@${LIBVIPS_DEV_VER}" && \
|
||||
cp -r /tmp/sharp-dev/node_modules/@img/sharp-libvips-dev /git/node_modules/@img/ && \
|
||||
cd /git/node_modules/sharp && \
|
||||
PATH="/git/node_modules/.bin:$PATH" CFLAGS="-march=x86-64" CXXFLAGS="-march=x86-64" node install/build.js; \
|
||||
fi
|
||||
|
||||
#
|
||||
# Dashboard
|
||||
#
|
||||
@@ -230,7 +247,7 @@ ENV CHOKIDAR_INTERVAL=5000
|
||||
ENV WAHA_ZIPPER=ZIPUNZIP
|
||||
|
||||
# GOWS - use libc DNS resolver
|
||||
ENV GODEBUG netdns=cgo
|
||||
ENV GODEBUG=netdns=cgo
|
||||
|
||||
# Run command, etc
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -32,16 +32,23 @@ 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-04-28
|
||||
|
||||
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-06-26
|
||||
|
||||
up-wpp:
|
||||
yarn up @wppconnect-team/wppconnect@github:wppconnect-team/wppconnect#master
|
||||
yarn up @wppconnect/wa-js@github:wppconnect-team/wa-js#main
|
||||
|
||||
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
|
||||
@@ -51,10 +58,23 @@ proto-gows:
|
||||
|
||||
gows:
|
||||
cd ../gows && \
|
||||
export PATH=${HOME}/go/bin:${PATH} && \
|
||||
(export PATH=${HOME}/go/bin:${PATH} || echo failed) && \
|
||||
make all
|
||||
|
||||
ORIGIN ?= waha-plus
|
||||
CORE_REMOTE ?= waha
|
||||
|
||||
release:
|
||||
node scripts/release.js
|
||||
|
||||
release-push: release
|
||||
git push $(ORIGIN) core plus
|
||||
git push --force-with-lease $(ORIGIN) dev
|
||||
git push $(CORE_REMOTE) core
|
||||
|
||||
up-dashboard:
|
||||
node scripts/up-dashboard.js
|
||||
|
||||
copy-dashboard:
|
||||
cd ../waha-hub/ui && \
|
||||
make copy-waha
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
- 8.8.8.8
|
||||
|
||||
base: &base
|
||||
image: chatwoot/chatwoot:v4.7.0
|
||||
image: chatwoot/chatwoot:v4.12.1
|
||||
env_file: .chatwoot.env ## Change this file for customized env variables
|
||||
volumes:
|
||||
- chatwoot_storage:/app/storage
|
||||
@@ -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
|
||||
|
||||
+7
-6
@@ -3,13 +3,14 @@
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"plugins": ["@nestjs/swagger"],
|
||||
"watchPathIgnorePatterns": ["node_modules", "src/dashboard", "dist"],
|
||||
"assets": [
|
||||
"dashboard/**",
|
||||
"core/engines/webjs/*",
|
||||
"plus/engines/webjs/*",
|
||||
"apps/chatwoot/i18n/locales/*.yaml",
|
||||
"apps/chatwoot/i18n/locales/*.yml"
|
||||
{ "include": "dashboard/**", "watchAssets": false },
|
||||
{ "include": "core/engines/webjs/*", "watchAssets": true },
|
||||
{ "include": "plus/engines/webjs/*", "watchAssets": true },
|
||||
{ "include": "apps/chatwoot/i18n/locales/*.yaml", "watchAssets": true },
|
||||
{ "include": "apps/chatwoot/i18n/locales/*.yml", "watchAssets": true }
|
||||
],
|
||||
"watchAssets": true
|
||||
"watchAssets": false
|
||||
}
|
||||
}
|
||||
+66
-25
@@ -16,25 +16,32 @@
|
||||
"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-04-28",
|
||||
"@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",
|
||||
"@lottiefiles/dotlottie-web": "^0.72.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@napi-rs/canvas": "^1.0.0",
|
||||
"@nestjs/axios": "^3.0.2",
|
||||
"@nestjs/bullmq": "^11.0.2",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
@@ -53,6 +60,8 @@
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/sqlite3": "^5.1.0",
|
||||
"@types/ws": "^8.5.4",
|
||||
"@wppconnect-team/wppconnect": "github:wppconnect-team/wppconnect#master",
|
||||
"@wppconnect/wa-js": "github:wppconnect-team/wa-js#main",
|
||||
"adm-zip": "0.5.10",
|
||||
"agentkeepalive": "^4.5.0",
|
||||
"async-lock": "^1.4.1",
|
||||
@@ -87,9 +96,11 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nestjs-pino": "^4.1.0",
|
||||
"node-cache": "5.1.2",
|
||||
"node-webpmux": "^3.2.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-headerapikey": "^1.2.2",
|
||||
"pg": "^8.13.1",
|
||||
"pg-copy-streams": "^6.0.0",
|
||||
"pino-http": "^10.2.0",
|
||||
"pino-pretty": "^11.2.1",
|
||||
"pretty-bytes": "5.6.0",
|
||||
@@ -100,16 +111,18 @@
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^4.3.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.33.4",
|
||||
"sharp": "^0.34.5",
|
||||
"shell-quote": "^1.8.3",
|
||||
"sqlite3": "^5.1.7",
|
||||
"swagger-ui-express": "^4.1.4",
|
||||
"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",
|
||||
"user-agents": "^1.1.669",
|
||||
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26",
|
||||
"write-file-atomic": "^6.0.0",
|
||||
"yaml": "^2.7.1"
|
||||
"yaml": "^2.7.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.8"
|
||||
@@ -120,7 +133,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": "npm:@devlikeapro/whatsapp-rust-bridge@0.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@grpc/grpc-js": "^1.14.1",
|
||||
@@ -134,6 +148,7 @@
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@types/user-agents": "^1",
|
||||
"dotenv": "^17.2.3",
|
||||
"grpc-tools": "^1.13.0",
|
||||
"jest": "^29.7.0",
|
||||
@@ -148,21 +163,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
@@ -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'));
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { execFileSync } = require('child_process');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const readline = require('readline');
|
||||
|
||||
const DEV_BRANCH = process.env.WAHA_DEV_BRANCH || 'dev';
|
||||
const CORE_BRANCH = process.env.WAHA_CORE_BRANCH || 'core';
|
||||
const PLUS_BRANCH = process.env.WAHA_PLUS_BRANCH || 'plus';
|
||||
|
||||
const CORE_PREFIX = '[core]';
|
||||
const PLUS_PREFIX = '[PLUS]';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const assumeYes = args.includes('--yes') || args.includes('-y');
|
||||
|
||||
function git(gitArgs, options) {
|
||||
const opts = options || {};
|
||||
return execFileSync('git', gitArgs, {
|
||||
encoding: 'utf8',
|
||||
stdio: opts.inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function gitOut(gitArgs) {
|
||||
return git(gitArgs, { inherit: false }).trim();
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`\n✗ ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function ensureCleanTree() {
|
||||
const status = gitOut(['status', '--porcelain']);
|
||||
if (status) {
|
||||
fail(
|
||||
'Working tree is not clean. Commit or stash your changes before releasing.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBranchExists(branch) {
|
||||
try {
|
||||
git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
|
||||
} catch {
|
||||
fail(`Branch "${branch}" does not exist locally.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Non-merge commits in `boundary..head`, oldest first, whose subject starts
|
||||
// with prefix. The boundary is the last-released plus tip: dev is rebased onto
|
||||
// plus every release, so `plus..dev` is exactly the new, unreleased work.
|
||||
// We intentionally do not use `git cherry` (patch-id matching) because core is
|
||||
// a rewritten history whose old commits share no patch-ids with dev.
|
||||
function commitsToCherryPick(boundary, head, prefix) {
|
||||
const format = '%H%x09%s';
|
||||
const output = gitOut([
|
||||
'rev-list',
|
||||
'--reverse',
|
||||
'--no-merges',
|
||||
`--format=${format}`,
|
||||
`${boundary}..${head}`,
|
||||
]);
|
||||
if (!output) {
|
||||
return [];
|
||||
}
|
||||
const picks = [];
|
||||
for (const line of output.split('\n')) {
|
||||
// rev-list --format prefixes each entry with a "commit <sha>" header line.
|
||||
if (!line || line.startsWith('commit ')) {
|
||||
continue;
|
||||
}
|
||||
const tab = line.indexOf('\t');
|
||||
const sha = line.slice(0, tab);
|
||||
const subject = line.slice(tab + 1);
|
||||
if (subject.startsWith(prefix)) {
|
||||
picks.push({ sha: sha, subject: subject });
|
||||
}
|
||||
}
|
||||
return picks;
|
||||
}
|
||||
|
||||
function cherryPickAll(picks) {
|
||||
for (const pick of picks) {
|
||||
log(` cherry-pick ${pick.sha.slice(0, 9)} ${pick.subject}`);
|
||||
if (dryRun) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
git(['cherry-pick', pick.sha], { inherit: true });
|
||||
} catch {
|
||||
git(['cherry-pick', '--abort'], { inherit: true });
|
||||
fail(
|
||||
`Cherry-pick of ${pick.sha.slice(0, 9)} failed (conflict). ` +
|
||||
`Aborted the cherry-pick — resolve manually and re-run.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkout(branch) {
|
||||
log(`\n→ checkout ${branch}`);
|
||||
if (!dryRun) {
|
||||
git(['checkout', branch], { inherit: true });
|
||||
}
|
||||
}
|
||||
|
||||
function confirm(question) {
|
||||
if (assumeYes || dryRun) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise(function resolver(resolve) {
|
||||
rl.question(`${question} [y/N] `, function onAnswer(answer) {
|
||||
rl.close();
|
||||
resolve(/^y(es)?$/i.test(answer.trim()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
ensureCleanTree();
|
||||
[DEV_BRANCH, CORE_BRANCH, PLUS_BRANCH].forEach(ensureBranchExists);
|
||||
|
||||
const startBranch = gitOut(['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
|
||||
if (dryRun) {
|
||||
log('Running in --dry-run mode: no branches will be modified.\n');
|
||||
}
|
||||
|
||||
// Capture the last-released plus tip before we touch anything. Step 3 merges
|
||||
// core into plus and moves the branch, so both pick sets must be computed
|
||||
// against this saved boundary, not the live plus ref.
|
||||
const boundary = gitOut(['rev-parse', PLUS_BRANCH]);
|
||||
log(`Boundary (last released ${PLUS_BRANCH}): ${boundary.slice(0, 9)}\n`);
|
||||
|
||||
const corePicks = commitsToCherryPick(boundary, DEV_BRANCH, CORE_PREFIX);
|
||||
log(
|
||||
`Found ${corePicks.length} "${CORE_PREFIX}" commit(s) in ` +
|
||||
`${DEV_BRANCH} since last ${PLUS_BRANCH} release.`,
|
||||
);
|
||||
|
||||
// Step 1 + 2: bring missing [core] commits onto core.
|
||||
checkout(CORE_BRANCH);
|
||||
cherryPickAll(corePicks);
|
||||
|
||||
// Step 3: merge the freshly updated core into plus.
|
||||
checkout(PLUS_BRANCH);
|
||||
log(`\n→ merge ${CORE_BRANCH} into ${PLUS_BRANCH}`);
|
||||
if (!dryRun) {
|
||||
try {
|
||||
git(['merge', '--no-edit', CORE_BRANCH], { inherit: true });
|
||||
} catch {
|
||||
git(['merge', '--abort'], { inherit: true });
|
||||
fail(
|
||||
`Merge of ${CORE_BRANCH} into ${PLUS_BRANCH} failed (conflict). ` +
|
||||
`Aborted the merge — resolve manually and re-run.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: bring missing [PLUS] commits onto plus (same saved boundary).
|
||||
const plusPicks = commitsToCherryPick(boundary, DEV_BRANCH, PLUS_PREFIX);
|
||||
log(
|
||||
`\nFound ${plusPicks.length} "${PLUS_PREFIX}" commit(s) in ` +
|
||||
`${DEV_BRANCH} since last ${PLUS_BRANCH} release.`,
|
||||
);
|
||||
cherryPickAll(plusPicks);
|
||||
|
||||
// Step 5: rebase dev onto the freshly built plus.
|
||||
log(`\n→ rebase ${DEV_BRANCH} onto ${PLUS_BRANCH} (rewrites ${DEV_BRANCH})`);
|
||||
const proceed = await confirm(
|
||||
`This will force-rewrite "${DEV_BRANCH}". Continue?`,
|
||||
);
|
||||
if (!proceed) {
|
||||
fail('Aborted before rebasing dev. core/plus changes are kept.');
|
||||
}
|
||||
checkout(DEV_BRANCH);
|
||||
if (!dryRun) {
|
||||
try {
|
||||
git(['rebase', PLUS_BRANCH], { inherit: true });
|
||||
} catch {
|
||||
git(['rebase', '--abort'], { inherit: true });
|
||||
fail(
|
||||
`Rebase of ${DEV_BRANCH} onto ${PLUS_BRANCH} failed (conflict). ` +
|
||||
`Aborted the rebase — resolve manually and re-run.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
checkout(startBranch);
|
||||
log('\nDry run complete. Re-run without --dry-run to apply.');
|
||||
} else {
|
||||
log(
|
||||
`\n✓ Release complete. ${DEV_BRANCH} now sits on top of ${PLUS_BRANCH}.`,
|
||||
);
|
||||
log(
|
||||
` Push when ready: git push origin ${CORE_BRANCH} ${PLUS_BRANCH} ` +
|
||||
`&& git push --force-with-lease origin ${DEV_BRANCH}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(function onError(error) {
|
||||
fail(error.message || String(error));
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const CONFIG_FILE = 'waha.config.json';
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
const { repo } = config.waha.dashboard;
|
||||
if (!repo) {
|
||||
console.error(`Missing dashboard.repo in ${CONFIG_FILE}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const branch = 'gh-pages';
|
||||
console.log(`Fetching latest commit for ${repo}@${branch}...`);
|
||||
|
||||
const output = execSync(
|
||||
`git ls-remote https://github.com/${repo} refs/heads/${branch}`,
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
|
||||
const sha = output.split('\t')[0].trim();
|
||||
if (!sha) {
|
||||
console.error(`Could not resolve ref ${branch} for ${repo}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
config.waha.dashboard.ref = sha;
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
||||
console.log(`Updated ${CONFIG_FILE}: dashboard.ref = ${sha}`);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
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, CanSession, FromBody } from '@waha/core/auth/policies';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { ApiKeyService } from '@waha/core/services/ApiKeyService';
|
||||
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
||||
import {
|
||||
ApiKeyDTO,
|
||||
ApiKeyRequest,
|
||||
ScopedApiKeyRequest,
|
||||
} from '@waha/structures/apikeys.dto';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/keys')
|
||||
@ApiTags('🔑 Api Keys')
|
||||
@UseGuards(PoliciesGuard)
|
||||
@CheckPolicies(CanServer(Action.Manage))
|
||||
export class ApiKeysController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
private get service(): ApiKeyService {
|
||||
return new ApiKeyService(this.manager);
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
@ApiOperation({ summary: 'Create a new API key' })
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async create(@Body() body: ApiKeyRequest): Promise<ApiKeyDTO> {
|
||||
return this.service.create(body);
|
||||
}
|
||||
|
||||
@Get('/')
|
||||
@ApiOperation({ summary: 'Get all API keys' })
|
||||
async list(): Promise<ApiKeyDTO[]> {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
@Post('/media')
|
||||
@ApiOperation({
|
||||
summary: 'Create or get a media-download-only API key for a session',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Read, FromBody('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async media(@Body() body: ScopedApiKeyRequest): Promise<ApiKeyDTO> {
|
||||
return this.service.createOrGetMediaKey(body.session);
|
||||
}
|
||||
|
||||
@Post('/control')
|
||||
@ApiOperation({
|
||||
summary: 'Create or get a control-only API key for a session',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Control, FromBody('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async control(@Body() body: ScopedApiKeyRequest): Promise<ApiKeyDTO> {
|
||||
return this.service.createOrGetControlKey(body.session);
|
||||
}
|
||||
|
||||
@Put('/:id')
|
||||
@ApiOperation({ summary: 'Update an API key' })
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() body: ApiKeyRequest,
|
||||
): Promise<ApiKeyDTO> {
|
||||
return this.service.update(id, body);
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
@ApiOperation({ summary: 'Delete an API key' })
|
||||
async delete(@Param('id') id: string): Promise<{ result: true }> {
|
||||
return this.service.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,14 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
UseInterceptors,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { ApiFileAcceptHeader } from '@waha/nestjs/ApiFileAcceptHeader';
|
||||
import {
|
||||
QRCodeSessionParam,
|
||||
@@ -18,16 +24,26 @@ import { SessionManager } from '../core/abc/manager.abc';
|
||||
import { WhatsappSession } from '../core/abc/session.abc';
|
||||
import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor';
|
||||
import {
|
||||
PasskeyAssertionRequest,
|
||||
PasskeyChallenge,
|
||||
PasskeyConfirmationResponse,
|
||||
QRCodeFormat,
|
||||
QRCodeQuery,
|
||||
QRCodeValue,
|
||||
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.Control, FromParam('session')))
|
||||
class AuthController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@@ -60,6 +76,54 @@ class AuthController {
|
||||
) {
|
||||
return session.requestCode(request.phoneNumber, request.method, request);
|
||||
}
|
||||
|
||||
@Get('passkey/challenge')
|
||||
@SessionApiParam
|
||||
@ApiOperation({
|
||||
summary: 'Get the pending passkey (WebAuthn) challenge.',
|
||||
description:
|
||||
'Available while the session is in PASSKEY_REQUIRED status. ' +
|
||||
'Pass the challenge to navigator.credentials.get({ publicKey: challenge }) ' +
|
||||
'on the https://web.whatsapp.com origin.',
|
||||
})
|
||||
@ApiOkResponse({ type: PasskeyChallenge })
|
||||
getPasskeyChallenge(@SessionParam session: WhatsappSession) {
|
||||
return session.getPasskeyChallenge();
|
||||
}
|
||||
|
||||
@Post('passkey')
|
||||
@SessionApiParam
|
||||
@ApiOperation({
|
||||
summary: 'Submit a WebAuthn passkey assertion to finish pairing.',
|
||||
})
|
||||
submitPasskey(
|
||||
@SessionParam session: WhatsappSession,
|
||||
@Body() request: PasskeyAssertionRequest,
|
||||
) {
|
||||
return session.sendPasskeyResponse(JSON.stringify(request));
|
||||
}
|
||||
|
||||
@Get('passkey/confirmation')
|
||||
@SessionApiParam
|
||||
@ApiOperation({
|
||||
summary: 'Get the pending passkey confirmation code.',
|
||||
description:
|
||||
'Available while the session is in PASSKEY_CONFIRMATION_REQUIRED status. ' +
|
||||
'Most pairings skip this step - WhatsApp confirms them right after the assertion.',
|
||||
})
|
||||
@ApiOkResponse({ type: PasskeyConfirmationResponse })
|
||||
getPasskeyConfirmation(@SessionParam session: WhatsappSession) {
|
||||
return session.getPasskeyConfirmation();
|
||||
}
|
||||
|
||||
@Post('passkey/confirm')
|
||||
@SessionApiParam
|
||||
@ApiOperation({
|
||||
summary: 'Confirm passkey pairing (only needed for the manual code case).',
|
||||
})
|
||||
confirmPasskey(@SessionParam session: WhatsappSession) {
|
||||
return session.confirmPasskey();
|
||||
}
|
||||
}
|
||||
|
||||
export { AuthController };
|
||||
@@ -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.Send, FromParam('session')))
|
||||
export class CallsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
@@ -39,10 +40,16 @@ 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)
|
||||
export class ChannelsController {
|
||||
constructor(
|
||||
private manager: SessionManager,
|
||||
@@ -51,6 +58,7 @@ export class ChannelsController {
|
||||
|
||||
@Get('')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get list of know channels' })
|
||||
async list(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -61,6 +69,7 @@ export class ChannelsController {
|
||||
|
||||
@Post('')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Create a new channel.' })
|
||||
create(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -72,6 +81,7 @@ export class ChannelsController {
|
||||
@Delete(':id')
|
||||
@SessionApiParam
|
||||
@NewsletterIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Delete the channel.' })
|
||||
delete(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -83,6 +93,7 @@ export class ChannelsController {
|
||||
@Get(':id')
|
||||
@SessionApiParam
|
||||
@NewsletterIdOrInviteCodeApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({
|
||||
summary: 'Get the channel info',
|
||||
description:
|
||||
@@ -103,6 +114,7 @@ export class ChannelsController {
|
||||
|
||||
@Get(':id/messages/preview')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
@@ -123,20 +135,22 @@ export class ChannelsController {
|
||||
})
|
||||
async previewChannelMessages(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Param('id') code: string,
|
||||
@Param('id') codeOrId: string,
|
||||
@Query() query: PreviewChannelMessages,
|
||||
): Promise<ChannelMessage[]> {
|
||||
if (isJidNewsletter(code)) {
|
||||
const channel = await session.channelsGetChannel(code);
|
||||
let code = null;
|
||||
if (isJidNewsletter(codeOrId)) {
|
||||
const channel = await session.channelsGetChannel(codeOrId);
|
||||
code = parseChannelInviteLink(channel.invite);
|
||||
}
|
||||
const inviteCode = parseChannelInviteLink(code);
|
||||
const inviteCode = parseChannelInviteLink(code || codeOrId);
|
||||
return session.previewChannelMessages(inviteCode, query);
|
||||
}
|
||||
|
||||
@Post(':id/follow')
|
||||
@SessionApiParam
|
||||
@NewsletterIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Follow the channel.' })
|
||||
follow(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -148,6 +162,7 @@ export class ChannelsController {
|
||||
@Post(':id/unfollow')
|
||||
@SessionApiParam
|
||||
@NewsletterIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Unfollow the channel.' })
|
||||
unfollow(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -159,6 +174,7 @@ export class ChannelsController {
|
||||
@Post(':id/mute')
|
||||
@SessionApiParam
|
||||
@NewsletterIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Mute the channel.' })
|
||||
mute(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -170,6 +186,7 @@ export class ChannelsController {
|
||||
@Post(':id/unmute')
|
||||
@SessionApiParam
|
||||
@NewsletterIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Unmute the channel.' })
|
||||
unmute(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -181,6 +198,7 @@ export class ChannelsController {
|
||||
@Post('/search/by-view')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
@ApiOperation({ summary: 'Search for channels (by view)' })
|
||||
async searchByView(
|
||||
@@ -193,6 +211,7 @@ export class ChannelsController {
|
||||
@Post('/search/by-text')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
@ApiOperation({ summary: 'Search for channels (by text)' })
|
||||
async searchByText(
|
||||
@@ -204,6 +223,7 @@ export class ChannelsController {
|
||||
|
||||
@Get('/search/views')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get list of views for channel search' })
|
||||
getSearchViews(): Promise<ChannelView[]> {
|
||||
return this.channelsInfoService.getViews();
|
||||
@@ -211,6 +231,7 @@ export class ChannelsController {
|
||||
|
||||
@Get('/search/countries')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get list of countries for channel search' })
|
||||
getSearchCountries(): Promise<ChannelCountry[]> {
|
||||
return this.channelsInfoService.getCountries();
|
||||
@@ -218,6 +239,7 @@ export class ChannelsController {
|
||||
|
||||
@Get('/search/categories')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get list of categories for channel search' })
|
||||
getSearchCategories(): Promise<ChannelCategory[]> {
|
||||
return this.channelsInfoService.getCategories();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
@@ -24,15 +25,15 @@ import { WhatsappSession } from '../core/abc/session.abc';
|
||||
import {
|
||||
ChatPictureQuery,
|
||||
ChatPictureResponse,
|
||||
ChatsPaginationParams,
|
||||
ChatSummary,
|
||||
GetChatMessageQuery,
|
||||
GetChatMessagesFilter,
|
||||
GetChatMessagesQuery,
|
||||
GetChatsOverviewParams,
|
||||
GetChatsParams,
|
||||
MessageSortField,
|
||||
OverviewBodyRequest,
|
||||
OverviewFilter,
|
||||
OverviewPaginationParams,
|
||||
PinMessageRequest,
|
||||
ReadChatMessagesQuery,
|
||||
ReadChatMessagesResponse,
|
||||
@@ -40,26 +41,34 @@ 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)
|
||||
class ChatsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Get('')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get chats' })
|
||||
getChats(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Query() pagination: ChatsPaginationParams,
|
||||
@Query() pagination: GetChatsParams,
|
||||
) {
|
||||
return session.getChats(pagination);
|
||||
}
|
||||
|
||||
@Get('overview')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Get chats overview. Includes all necessary things to build UI "your chats overview" page - chat id, name, picture, last message. Sorting by last message timestamp',
|
||||
@@ -67,7 +76,7 @@ class ChatsController {
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
getChatsOverview(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Query() pagination: OverviewPaginationParams,
|
||||
@Query() pagination: GetChatsOverviewParams,
|
||||
@Query() filter: OverviewFilter,
|
||||
): Promise<ChatSummary[]> {
|
||||
return session.getChatsOverview(pagination, filter);
|
||||
@@ -75,6 +84,7 @@ class ChatsController {
|
||||
|
||||
@Post('overview')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Get chats overview. Use POST if you have too many "ids" params - GET can limit it',
|
||||
@@ -89,6 +99,7 @@ class ChatsController {
|
||||
|
||||
@Delete(':chatId')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Deletes the chat' })
|
||||
@ChatIdApiParam
|
||||
deleteChat(
|
||||
@@ -100,6 +111,7 @@ class ChatsController {
|
||||
|
||||
@Get(':chatId/picture')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Gets chat picture' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async getChatPicture(
|
||||
@@ -113,6 +125,7 @@ class ChatsController {
|
||||
|
||||
@Get(':chatId/messages')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Gets messages in the chat' })
|
||||
@ChatIdApiParam
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
@@ -133,6 +146,7 @@ class ChatsController {
|
||||
|
||||
@Post(':chatId/messages/read')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Read unread messages in the chat' })
|
||||
@ChatIdApiParam
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
@@ -146,6 +160,7 @@ class ChatsController {
|
||||
|
||||
@Get(':chatId/messages/:messageId')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Gets message by id' })
|
||||
@ChatIdApiParam
|
||||
async getChatMessage(
|
||||
@@ -163,6 +178,7 @@ class ChatsController {
|
||||
|
||||
@Post(':chatId/messages/:messageId/pin')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Pins a message in the chat' })
|
||||
@ChatIdApiParam
|
||||
async pinMessage(
|
||||
@@ -177,6 +193,7 @@ class ChatsController {
|
||||
|
||||
@Post(':chatId/messages/:messageId/unpin')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Unpins a message in the chat' })
|
||||
@ChatIdApiParam
|
||||
async unpinMessage(
|
||||
@@ -190,6 +207,7 @@ class ChatsController {
|
||||
|
||||
@Delete(':chatId/messages')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Clears all messages from the chat' })
|
||||
@ChatIdApiParam
|
||||
clearMessages(
|
||||
@@ -201,6 +219,7 @@ class ChatsController {
|
||||
|
||||
@Delete(':chatId/messages/:messageId')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ChatIdApiParam
|
||||
@MessageIdApiParam
|
||||
@ApiOperation({ summary: 'Deletes a message from the chat' })
|
||||
@@ -214,6 +233,7 @@ class ChatsController {
|
||||
|
||||
@Put(':chatId/messages/:messageId')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ChatIdApiParam
|
||||
@MessageIdApiParam
|
||||
@ApiOperation({ summary: 'Edits a message in the chat' })
|
||||
@@ -228,6 +248,7 @@ class ChatsController {
|
||||
|
||||
@Post(':chatId/archive')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ChatIdApiParam
|
||||
@ApiOperation({ summary: 'Archive the chat' })
|
||||
archiveChat(
|
||||
@@ -239,6 +260,7 @@ class ChatsController {
|
||||
|
||||
@Post(':chatId/unarchive')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ChatIdApiParam
|
||||
@ApiOperation({ summary: 'Unarchive the chat' })
|
||||
unarchiveChat(
|
||||
@@ -250,6 +272,7 @@ class ChatsController {
|
||||
|
||||
@Post(':chatId/unread')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ChatIdApiParam
|
||||
@ApiOperation({ summary: 'Unread the chat' })
|
||||
unreadChat(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
MessageTextRequest,
|
||||
MessageVideoRequest,
|
||||
MessageVoiceRequest,
|
||||
NewMessageIDResponse,
|
||||
SendSeenRequest,
|
||||
WANumberExistResult,
|
||||
} from '../structures/chatting.dto';
|
||||
@@ -48,15 +50,32 @@ import {
|
||||
mentionsAll,
|
||||
validateRequestMentions,
|
||||
} from '@waha/core/utils/mentions.all';
|
||||
import {
|
||||
SessionApiParam,
|
||||
WorkingSessionParam,
|
||||
} from '@waha/nestjs/params/SessionApiParam';
|
||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
|
||||
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
|
||||
import {
|
||||
CanSession,
|
||||
FromBody,
|
||||
FromParam,
|
||||
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.Send, FromBody('session')))
|
||||
async sendText(@Body() request: MessageTextRequest): Promise<WAMessage> {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
if (mentionsAll(request)) {
|
||||
@@ -72,6 +91,7 @@ export class ChattingController {
|
||||
description:
|
||||
'Either from an URL or base64 data - look at the request schemas for details.',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async sendImage(@Body() request: MessageImageRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
if (mentionsAll(request)) {
|
||||
@@ -87,6 +107,7 @@ export class ChattingController {
|
||||
description:
|
||||
'Either from an URL or base64 data - look at the request schemas for details.',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async sendFile(@Body() request: MessageFileRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
if (mentionsAll(request)) {
|
||||
@@ -102,6 +123,7 @@ export class ChattingController {
|
||||
description:
|
||||
'Either from an URL or base64 data - look at the request schemas for details.',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async sendVoice(@Body() request: MessageVoiceRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendVoice(request);
|
||||
@@ -113,6 +135,7 @@ export class ChattingController {
|
||||
description:
|
||||
'Either from an URL or base64 data - look at the request schemas for details.',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async sendVideo(@Body() request: MessageVideoRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
if (mentionsAll(request)) {
|
||||
@@ -128,6 +151,7 @@ export class ChattingController {
|
||||
description:
|
||||
'You can use regular /api/sendText if you wanna send auto-generated link preview.',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async sendLinkCustomPreview(
|
||||
@Body() request: MessageLinkCustomPreviewRequest,
|
||||
@@ -147,6 +171,7 @@ export class ChattingController {
|
||||
description: 'Send Buttons',
|
||||
deprecated: true,
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async sendButtons(@Body() request: SendButtonsRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
@@ -158,6 +183,7 @@ export class ChattingController {
|
||||
summary: 'Send a list message (interactive)',
|
||||
description: 'Send a List message with sections and rows',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async sendList(@Body() request: SendListRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
@@ -165,6 +191,7 @@ export class ChattingController {
|
||||
}
|
||||
|
||||
@Post('/forwardMessage')
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async forwardMessage(
|
||||
@Body() request: MessageForwardRequest,
|
||||
): Promise<WAMessage> {
|
||||
@@ -173,6 +200,7 @@ export class ChattingController {
|
||||
}
|
||||
|
||||
@Post('/sendSeen')
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async sendSeen(@Body() chat: SendSeenRequest) {
|
||||
const hasMessageId = chat.messageIds?.length > 0 || Boolean(chat.messageId);
|
||||
if (!hasMessageId) {
|
||||
@@ -188,6 +216,7 @@ export class ChattingController {
|
||||
}
|
||||
|
||||
@Post('/startTyping')
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async startTyping(@Body() chat: ChatRequest) {
|
||||
// It's infinitive action
|
||||
const whatsapp = await this.manager.getWorkingSession(chat.session);
|
||||
@@ -196,6 +225,7 @@ export class ChattingController {
|
||||
}
|
||||
|
||||
@Post('/stopTyping')
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async stopTyping(@Body() chat: ChatRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(chat.session);
|
||||
await whatsapp.stopTyping(chat);
|
||||
@@ -204,6 +234,7 @@ export class ChattingController {
|
||||
|
||||
@Put('/reaction')
|
||||
@ApiOperation({ summary: 'React to a message with an emoji' })
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async setReaction(@Body() request: MessageReactionRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.setReaction(request);
|
||||
@@ -211,6 +242,7 @@ export class ChattingController {
|
||||
|
||||
@Put('/star')
|
||||
@ApiOperation({ summary: 'Star or unstar a message' })
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async setStar(@Body() request: MessageStarRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
await whatsapp.setStar(request);
|
||||
@@ -222,6 +254,7 @@ export class ChattingController {
|
||||
summary: 'Send a poll with options',
|
||||
description: 'You can use it as buttons or list replacement',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async sendPoll(@Body() request: MessagePollRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendPoll(request);
|
||||
@@ -232,6 +265,7 @@ export class ChattingController {
|
||||
summary: 'Vote on a poll',
|
||||
description: 'Cast vote(s) on an existing poll message',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async sendPollVote(@Body() request: MessagePollVoteRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
@@ -239,12 +273,14 @@ export class ChattingController {
|
||||
}
|
||||
|
||||
@Post('/sendLocation')
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async sendLocation(@Body() request: MessageLocationRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendLocation(request);
|
||||
}
|
||||
|
||||
@Post('/sendContactVcard')
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async sendContactVcard(@Body() request: MessageContactVcardRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendContactVCard(request);
|
||||
@@ -254,6 +290,7 @@ export class ChattingController {
|
||||
@ApiOperation({
|
||||
summary: 'Reply on a button message',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async sendButtonsReply(@Body() request: MessageButtonReply) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
@@ -262,6 +299,7 @@ export class ChattingController {
|
||||
|
||||
@Get('/sendText')
|
||||
@ApiOperation({ summary: 'Send a text message', deprecated: true })
|
||||
@CheckPolicies(CanSession(Action.Send, FromQuery('session')))
|
||||
async sendTextGet(@Query() query: MessageTextQuery) {
|
||||
const whatsapp = await this.manager.getWorkingSession(query.session);
|
||||
const msg = new MessageTextRequest();
|
||||
@@ -276,6 +314,7 @@ export class ChattingController {
|
||||
description: 'DEPRECATED. Use "GET /api/chats/{id}/messages" instead',
|
||||
deprecated: true,
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Read, FromQuery('session')))
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async getMessages(
|
||||
@Query() query: GetMessageQuery,
|
||||
@@ -292,6 +331,7 @@ export class ChattingController {
|
||||
description: 'DEPRECATED. Use "POST /contacts/check-exists" instead',
|
||||
deprecated: true,
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Read, FromQuery('session')))
|
||||
async DEPRECATED_checkNumberStatus(
|
||||
@Query() request: CheckNumberStatusQuery,
|
||||
): Promise<WANumberExistResult> {
|
||||
@@ -305,6 +345,7 @@ export class ChattingController {
|
||||
'DEPRECATED - you can set "reply_to" field when sending text, image, etc',
|
||||
deprecated: true,
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async reply(@Body() request: MessageReplyRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.reply(request);
|
||||
@@ -312,8 +353,20 @@ export class ChattingController {
|
||||
|
||||
@Post('/sendLinkPreview')
|
||||
@ApiOperation({ deprecated: true })
|
||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||
async sendLinkPreview_DEPRECATED(@Body() request: MessageLinkPreviewRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.sendLinkPreview(request);
|
||||
}
|
||||
|
||||
@Get('/:session/new-message-id')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Generate a new message ID' })
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
async getNewMessageId(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
): Promise<NewMessageIDResponse> {
|
||||
const id = await session.generateNewMessageId();
|
||||
return { id: id };
|
||||
}
|
||||
}
|
||||
@@ -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.Read, 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.Read, 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.Read, 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.Read, 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.Read, 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.Send, 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.Send, FromBody('session')))
|
||||
async unblock(@Body() request: ContactRequest) {
|
||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||
return whatsapp.unblockContact(request);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Put,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, 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 {
|
||||
SessionApiParam,
|
||||
WorkingSessionParam,
|
||||
} 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 { Action } from '@waha/core/auth/casl.types';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/:session/contacts')
|
||||
@ApiTags('👤 Contacts')
|
||||
@UseGuards(PoliciesGuard)
|
||||
export class ContactsSessionController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Get('/:id')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
required: true,
|
||||
type: 'string',
|
||||
description: 'Contact ID',
|
||||
example: '123456789@c.us',
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: 'Get contact basic info',
|
||||
description:
|
||||
'The method always return result, even if the phone number is not registered in WhatsApp. For that - use /contacts/check-exists endpoint below.',
|
||||
})
|
||||
async get(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return session.getContact({ session: session.name, contactId: id });
|
||||
}
|
||||
|
||||
@Put('/:chatId')
|
||||
@SessionApiParam
|
||||
@ChatIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({
|
||||
summary: 'Create or update contact',
|
||||
description:
|
||||
'Create or update contact on the phone address book. May not work if you have installed many WhatsApp apps on the same phone',
|
||||
})
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async put(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Param('chatId') chatId: string,
|
||||
@Body() body: ContactUpdateBody,
|
||||
): Promise<Result> {
|
||||
await session.upsertContact(chatId, body);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -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.Send, FromParam('session')))
|
||||
export class EventsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
@@ -40,15 +41,22 @@ 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)
|
||||
export class GroupsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Post('')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Create a new group.' })
|
||||
createGroup(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -59,6 +67,7 @@ export class GroupsController {
|
||||
|
||||
@Get('join-info')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get info about the group before joining.' })
|
||||
async joinInfoGroup(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -71,6 +80,7 @@ export class GroupsController {
|
||||
@Post('join')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Join group via code' })
|
||||
async joinGroup(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -83,6 +93,7 @@ export class GroupsController {
|
||||
|
||||
@Get('')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get all groups.' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async getGroups(
|
||||
@@ -97,6 +108,7 @@ export class GroupsController {
|
||||
|
||||
@Get('/count')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get the number of groups.' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async getGroupsCount(
|
||||
@@ -112,6 +124,7 @@ export class GroupsController {
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Refresh groups from the server.' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async refreshGroups(@WorkingSessionParam session: WhatsappSession) {
|
||||
@@ -121,6 +134,7 @@ export class GroupsController {
|
||||
@Get(':id')
|
||||
@GroupIdApiParam
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get the group.' })
|
||||
getGroup(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -132,6 +146,7 @@ export class GroupsController {
|
||||
@Delete(':id')
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Delete the group.' })
|
||||
deleteGroup(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -144,6 +159,7 @@ export class GroupsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Leave the group.' })
|
||||
leaveGroup(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -155,6 +171,7 @@ export class GroupsController {
|
||||
@Get(':id/picture')
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get group picture' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async getChatPicture(
|
||||
@@ -169,6 +186,7 @@ export class GroupsController {
|
||||
@Put(':id/picture')
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Set group picture' })
|
||||
async setPicture(
|
||||
@Param('id') id: string,
|
||||
@@ -182,6 +200,7 @@ export class GroupsController {
|
||||
@Delete(':id/picture')
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Delete group picture' })
|
||||
async deletePicture(
|
||||
@Param('id') id: string,
|
||||
@@ -199,6 +218,7 @@ export class GroupsController {
|
||||
})
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
setDescription(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@Param('id') id: string,
|
||||
@@ -210,6 +230,7 @@ export class GroupsController {
|
||||
@Put(':id/subject')
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({
|
||||
summary: 'Updates the group subject',
|
||||
description:
|
||||
@@ -226,6 +247,7 @@ export class GroupsController {
|
||||
@Put(':id/settings/security/info-admin-only')
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({
|
||||
summary: 'Updates the group "info admin only" settings.',
|
||||
description:
|
||||
@@ -242,6 +264,7 @@ export class GroupsController {
|
||||
@Get(':id/settings/security/info-admin-only')
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({
|
||||
summary: "Get the group's 'info admin only' settings.",
|
||||
description:
|
||||
@@ -257,6 +280,7 @@ export class GroupsController {
|
||||
@Put(':id/settings/security/messages-admin-only')
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({
|
||||
summary: 'Update settings - who can send messages',
|
||||
description:
|
||||
@@ -273,6 +297,7 @@ export class GroupsController {
|
||||
@Get(':id/settings/security/messages-admin-only')
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({
|
||||
summary: 'Get settings - who can send messages',
|
||||
description: 'The group settings to only allow admins to send messages.',
|
||||
@@ -287,6 +312,7 @@ export class GroupsController {
|
||||
@Get(':id/invite-code')
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Gets the invite code for the group.' })
|
||||
getInviteCode(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -299,6 +325,7 @@ export class GroupsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Invalidates the current group invite code and generates a new one.',
|
||||
@@ -313,6 +340,7 @@ export class GroupsController {
|
||||
@Get(':id/participants/')
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get participants' })
|
||||
getParticipants(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -324,6 +352,7 @@ export class GroupsController {
|
||||
@Get(':id/participants/v2')
|
||||
@GroupIdApiParam
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get group participants.' })
|
||||
getGroupParticipants(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -336,6 +365,7 @@ export class GroupsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Add participants' })
|
||||
addParticipants(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -349,6 +379,7 @@ export class GroupsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({
|
||||
summary: 'Remove participants',
|
||||
})
|
||||
@@ -364,6 +395,7 @@ export class GroupsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Promote participants to admin users.' })
|
||||
promoteToAdmin(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -377,6 +409,7 @@ export class GroupsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@SessionApiParam
|
||||
@GroupIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Demotes participants to regular users.' })
|
||||
demoteToAdmin(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
|
||||
@@ -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,6 +8,7 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
UnprocessableEntityException,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
@@ -26,15 +27,22 @@ 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)
|
||||
export class LabelsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Get('/')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get all labels' })
|
||||
getAll(@WorkingSessionParam session: WhatsappSession): Promise<Label[]> {
|
||||
return session.getLabels();
|
||||
@@ -42,6 +50,7 @@ export class LabelsController {
|
||||
|
||||
@Post('/')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Create a new label' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async create(
|
||||
@@ -67,6 +76,7 @@ export class LabelsController {
|
||||
|
||||
@Put('/:labelId')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Update a label' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async update(
|
||||
@@ -98,6 +108,7 @@ export class LabelsController {
|
||||
|
||||
@Delete('/:labelId')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Delete a label' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async delete(
|
||||
@@ -115,6 +126,7 @@ export class LabelsController {
|
||||
@Get('/chats/:chatId')
|
||||
@SessionApiParam
|
||||
@ChatIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get labels for the chat' })
|
||||
getChatLabels(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -126,6 +138,7 @@ export class LabelsController {
|
||||
@Put('/chats/:chatId')
|
||||
@SessionApiParam
|
||||
@ChatIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Save labels for the chat' })
|
||||
putChatLabels(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -137,6 +150,7 @@ export class LabelsController {
|
||||
|
||||
@Get('/:labelId/chats')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get chats by label' })
|
||||
getChatsByLabel(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
|
||||
@@ -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.Read, FromParam('session')))
|
||||
export class LidsController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -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.Read, FromParam('session')))
|
||||
class MediaController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
|
||||
@@ -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,15 +21,22 @@ 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)
|
||||
export class PresenceController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Post('')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Set session presence' })
|
||||
setPresence(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -59,6 +67,7 @@ export class PresenceController {
|
||||
|
||||
@Get('')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get all subscribed presence information.' })
|
||||
getPresenceAll(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -69,6 +78,7 @@ export class PresenceController {
|
||||
@Get(':chatId')
|
||||
@SessionApiParam
|
||||
@ChatIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Get the presence for the chat id. If it hasn't been subscribed - it also subscribes to it.",
|
||||
@@ -83,6 +93,7 @@ export class PresenceController {
|
||||
@Post(':chatId/subscribe')
|
||||
@SessionApiParam
|
||||
@ChatIdApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({
|
||||
summary: 'Subscribe to presence events for the chat.',
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Get,
|
||||
Put,
|
||||
UnprocessableEntityException,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
@@ -22,15 +23,22 @@ 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)
|
||||
export class ProfileController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Get('')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Get my profile' })
|
||||
async getMyProfile(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -49,6 +57,7 @@ export class ProfileController {
|
||||
|
||||
@Put('/name')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
@ApiOperation({ summary: 'Set my profile name' })
|
||||
async setProfileName(
|
||||
@@ -61,6 +70,7 @@ export class ProfileController {
|
||||
|
||||
@Put('/status')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
@ApiOperation({ summary: 'Set profile status (About)' })
|
||||
async setProfileStatus(
|
||||
@@ -73,6 +83,7 @@ export class ProfileController {
|
||||
|
||||
@Put('/picture')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Set profile picture' })
|
||||
async setProfilePicture(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -84,6 +95,7 @@ export class ProfileController {
|
||||
|
||||
@Delete('/picture')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Delete profile picture' })
|
||||
async deleteProfilePicture(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
|
||||
@@ -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.Control, FromQuery('session')))
|
||||
export class ScreenshotController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Get('/screenshot')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Get a screenshot of the current WhatsApp session (**WEBJS/WPP** only)',
|
||||
})
|
||||
@UseInterceptors(new BufferResponseInterceptor('image/jpeg'))
|
||||
@ApiFileAcceptHeader('image/jpeg')
|
||||
async screenshot(
|
||||
|
||||
@@ -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.Retrieve))
|
||||
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.Retrieve))
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+61
-154
@@ -3,69 +3,73 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
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,
|
||||
} from '@waha/nestjs/params/SessionApiParam';
|
||||
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
||||
import {
|
||||
AppsService,
|
||||
IAppsService,
|
||||
} from '@waha/apps/app_sdk/services/IAppsService';
|
||||
import {
|
||||
SessionLogoutDeprecatedRequest,
|
||||
SessionStartDeprecatedRequest,
|
||||
SessionStopDeprecatedRequest,
|
||||
} from '@waha/structures/sessions.deprecated.dto';
|
||||
import { generatePrefixedId } from '@waha/utils/ids';
|
||||
|
||||
import { SessionManager } from '../core/abc/manager.abc';
|
||||
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';
|
||||
import { SessionService } from '@waha/core/services/SessionService';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/sessions')
|
||||
@ApiTags('🖥️ Sessions')
|
||||
@UseGuards(PoliciesGuard)
|
||||
class SessionsController {
|
||||
constructor(
|
||||
private manager: SessionManager,
|
||||
@Inject(AppsService) private appsService: IAppsService,
|
||||
) {}
|
||||
|
||||
private withLock(name: string, fn: () => any) {
|
||||
return this.manager.withLock(name, fn);
|
||||
}
|
||||
constructor(private readonly sessionService: SessionService) {}
|
||||
|
||||
@Get('/')
|
||||
@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.sessionService.getSessions(query.all);
|
||||
if (!req.user?.isAdmin) {
|
||||
sessions = FilterSessions(req.ability, Action.Retrieve, sessions);
|
||||
}
|
||||
if (query.expand?.includes(SessionExpand.apps)) {
|
||||
for (const session of sessions) {
|
||||
session.apps = await this.appsService.list(this.manager, session.name);
|
||||
}
|
||||
await this.sessionService.expandSessionApps(sessions);
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
@@ -73,17 +77,15 @@ class SessionsController {
|
||||
@Get('/:session')
|
||||
@ApiOperation({ summary: 'Get session information' })
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async get(
|
||||
@Param('session') name: string,
|
||||
@Query() query: SessionInfoQuery,
|
||||
): Promise<SessionInfo> {
|
||||
const session = await this.manager.getSessionInfo(name);
|
||||
if (session === null) {
|
||||
throw new NotFoundException('Session not found');
|
||||
}
|
||||
const session = await this.sessionService.getSession(name);
|
||||
if (query.expand?.includes(SessionExpand.apps)) {
|
||||
session.apps = await this.appsService.list(this.manager, name);
|
||||
await this.sessionService.expandSessionApps([session]);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
@@ -91,8 +93,9 @@ class SessionsController {
|
||||
@Get(':session/me')
|
||||
@SessionApiParam
|
||||
@ApiOperation({ summary: 'Get information about the authenticated account' })
|
||||
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||
getMe(@SessionParam session: WhatsappSession): MeInfo | null {
|
||||
return session.getSessionMeInfo();
|
||||
return this.sessionService.getSessionMe(session);
|
||||
}
|
||||
|
||||
@Post('')
|
||||
@@ -102,72 +105,23 @@ 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');
|
||||
await this.withLock(name, async () => {
|
||||
if (await this.manager.exists(name)) {
|
||||
const msg = `Session '${name}' already exists. Use PUT to update it.`;
|
||||
throw new UnprocessableEntityException(msg);
|
||||
}
|
||||
const config = request.config;
|
||||
const start = request.start || false;
|
||||
await this.manager.upsert(name, config);
|
||||
if (request.apps) {
|
||||
await this.appsService.syncSessionApps(
|
||||
this.manager,
|
||||
name,
|
||||
request.apps,
|
||||
);
|
||||
}
|
||||
if (start) {
|
||||
await this.manager.assign(name);
|
||||
await this.manager.start(name);
|
||||
}
|
||||
});
|
||||
const session = await this.manager.getSessionInfo(name);
|
||||
if (request.apps) {
|
||||
session.apps = await this.appsService.list(this.manager, name);
|
||||
}
|
||||
return session;
|
||||
return this.sessionService.createSession(request);
|
||||
}
|
||||
|
||||
@Put(':session')
|
||||
@ApiOperation({
|
||||
summary: 'Update a session',
|
||||
description: '',
|
||||
})
|
||||
@ApiOperation({ summary: 'Update a session' })
|
||||
@SessionApiParam
|
||||
@ApiBody({ type: SessionUpdateRequest, examples: SessionExamples })
|
||||
@CheckPolicies(CanSession(Action.Setting, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe({ forbidNonWhitelisted: false }))
|
||||
async update(
|
||||
@Param('session') name: string,
|
||||
@Body() request: SessionUpdateRequest,
|
||||
): Promise<SessionDTO> {
|
||||
await this.withLock(name, async () => {
|
||||
if (!(await this.manager.exists(name))) {
|
||||
throw new NotFoundException('Session not found');
|
||||
}
|
||||
const config = request.config;
|
||||
const isRunning = this.manager.isRunning(name);
|
||||
await this.manager.stop(name, true);
|
||||
await this.manager.upsert(name, config);
|
||||
if (request.apps) {
|
||||
await this.appsService.syncSessionApps(
|
||||
this.manager,
|
||||
name,
|
||||
request.apps,
|
||||
);
|
||||
}
|
||||
if (isRunning) {
|
||||
await this.manager.start(name);
|
||||
}
|
||||
});
|
||||
const session = await this.manager.getSessionInfo(name);
|
||||
if (request.apps) {
|
||||
session.apps = await this.appsService.list(this.manager, name);
|
||||
}
|
||||
return session;
|
||||
return this.sessionService.updateSession(name, request);
|
||||
}
|
||||
|
||||
@Delete(':session')
|
||||
@@ -177,15 +131,10 @@ 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 () => {
|
||||
await this.manager.unassign(name);
|
||||
await this.manager.unpair(name);
|
||||
await this.manager.stop(name, true);
|
||||
await this.manager.logout(name);
|
||||
await this.manager.delete(name);
|
||||
});
|
||||
return this.sessionService.deleteSession(name);
|
||||
}
|
||||
|
||||
@Post(':session/start')
|
||||
@@ -195,17 +144,10 @@ class SessionsController {
|
||||
description:
|
||||
'Start the session with the given name. The session must exist. Idempotent operation.',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async start(@Param('session') name: string): Promise<SessionDTO> {
|
||||
await this.withLock(name, async () => {
|
||||
const exists = await this.manager.exists(name);
|
||||
if (!exists) {
|
||||
throw new NotFoundException('Session not found');
|
||||
}
|
||||
await this.manager.assign(name);
|
||||
await this.manager.start(name);
|
||||
});
|
||||
return await this.manager.getSessionInfo(name);
|
||||
return this.sessionService.startSession(name);
|
||||
}
|
||||
|
||||
@Post(':session/stop')
|
||||
@@ -214,13 +156,10 @@ class SessionsController {
|
||||
summary: 'Stop the session',
|
||||
description: 'Stop the session with the given name. Idempotent operation.',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async stop(@Param('session') name: string): Promise<SessionDTO> {
|
||||
await this.withLock(name, async () => {
|
||||
await this.manager.unassign(name);
|
||||
await this.manager.stop(name, false);
|
||||
});
|
||||
return await this.manager.getSessionInfo(name);
|
||||
return this.sessionService.stopSession(name);
|
||||
}
|
||||
|
||||
@Post(':session/logout')
|
||||
@@ -229,22 +168,10 @@ class SessionsController {
|
||||
summary: 'Logout from the session',
|
||||
description: 'Logout the session, restart a session if it was not STOPPED',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async logout(@Param('session') name: string): Promise<SessionDTO> {
|
||||
await this.withLock(name, async () => {
|
||||
const exists = await this.manager.exists(name);
|
||||
if (!exists) {
|
||||
throw new NotFoundException('Session not found');
|
||||
}
|
||||
const isRunning = this.manager.isRunning(name);
|
||||
await this.manager.unpair(name);
|
||||
await this.manager.stop(name, true);
|
||||
await this.manager.logout(name);
|
||||
if (isRunning) {
|
||||
await this.manager.start(name);
|
||||
}
|
||||
});
|
||||
return await this.manager.getSessionInfo(name);
|
||||
return this.sessionService.logoutSession(name);
|
||||
}
|
||||
|
||||
@Post(':session/restart')
|
||||
@@ -253,10 +180,10 @@ class SessionsController {
|
||||
summary: 'Restart the session',
|
||||
description: 'Restart the session with the given name.',
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async restart(@Param('session') name: string): Promise<SessionDTO> {
|
||||
await this.manager.restart(name);
|
||||
return await this.manager.getSessionInfo(name);
|
||||
return this.sessionService.restartSession(name);
|
||||
}
|
||||
|
||||
@Post('/start/')
|
||||
@@ -266,24 +193,22 @@ class SessionsController {
|
||||
'Create session (if not exists) or update a config (if exists) and start it.',
|
||||
deprecated: true,
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Control, FromBody('name')))
|
||||
async DEPRACATED_start(
|
||||
@Body() request: SessionStartDeprecatedRequest,
|
||||
): Promise<SessionDTO> {
|
||||
const name = request.name;
|
||||
if (!request.name) {
|
||||
throw new UnprocessableEntityException('Session name is required');
|
||||
}
|
||||
if (this.manager.isRunning(name)) {
|
||||
const msg = `Session '${name}' is already started.`;
|
||||
throw new UnprocessableEntityException(msg);
|
||||
if (this.sessionService.isSessionRunning(request.name)) {
|
||||
throw new UnprocessableEntityException(
|
||||
`Session '${request.name}' is already started.`,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.withLock(name, async () => {
|
||||
const config = request.config;
|
||||
await this.manager.upsert(name, config);
|
||||
await this.manager.assign(name);
|
||||
return await this.manager.start(name);
|
||||
});
|
||||
return this.sessionService.upsertAndStartSession(
|
||||
request.name,
|
||||
request.config,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('/stop/')
|
||||
@@ -292,29 +217,18 @@ class SessionsController {
|
||||
description: 'Stop session and Logout by default.',
|
||||
deprecated: true,
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Control, FromBody('name')))
|
||||
async DEPRECATED_stop(
|
||||
@Body() request: SessionStopDeprecatedRequest,
|
||||
): Promise<void> {
|
||||
if (!request.name) {
|
||||
throw new UnprocessableEntityException('Session name is required');
|
||||
}
|
||||
const name = request.name;
|
||||
if (request.logout) {
|
||||
// Old API did remove the session complete
|
||||
await this.withLock(name, async () => {
|
||||
await this.manager.unassign(name);
|
||||
await this.manager.unpair(name);
|
||||
await this.manager.stop(name, true);
|
||||
await this.manager.logout(name);
|
||||
await this.manager.delete(name);
|
||||
});
|
||||
await this.sessionService.deleteSession(request.name);
|
||||
} else {
|
||||
await this.withLock(name, async () => {
|
||||
await this.manager.unassign(name);
|
||||
await this.manager.stop(name, false);
|
||||
});
|
||||
await this.sessionService.stopSession(request.name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@Post('/logout/')
|
||||
@@ -323,21 +237,14 @@ class SessionsController {
|
||||
description: 'Stop, Logout and Delete session.',
|
||||
deprecated: true,
|
||||
})
|
||||
@CheckPolicies(CanSession(Action.Control, FromBody('name')))
|
||||
async DEPRECATED_logout(
|
||||
@Body() request: SessionLogoutDeprecatedRequest,
|
||||
): Promise<void> {
|
||||
if (!request.name) {
|
||||
throw new UnprocessableEntityException('Session name is required');
|
||||
}
|
||||
const name = request.name;
|
||||
await this.withLock(name, async () => {
|
||||
await this.manager.unassign(name);
|
||||
await this.manager.unpair(name);
|
||||
await this.manager.stop(name, true);
|
||||
await this.manager.logout(name);
|
||||
await this.manager.delete(name);
|
||||
});
|
||||
return;
|
||||
await this.sessionService.deleteSession(request.name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,15 +15,22 @@ 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)
|
||||
class StatusController {
|
||||
constructor(private manager: SessionManager) {}
|
||||
|
||||
@Post('text')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Send text status' })
|
||||
sendTextStatus(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -34,6 +41,7 @@ class StatusController {
|
||||
|
||||
@Post('image')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Send image status' })
|
||||
sendImageStatus(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -44,6 +52,7 @@ class StatusController {
|
||||
|
||||
@Post('voice')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Send voice status' })
|
||||
sendVoiceStatus(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -54,6 +63,7 @@ class StatusController {
|
||||
|
||||
@Post('video')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'Send video status' })
|
||||
sendVideoStatus(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -64,6 +74,7 @@ class StatusController {
|
||||
|
||||
@Post('delete')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({ summary: 'DELETE sent status' })
|
||||
deleteStatus(
|
||||
@WorkingSessionParam session: WhatsappSession,
|
||||
@@ -74,6 +85,7 @@ class StatusController {
|
||||
|
||||
@Get('new-message-id')
|
||||
@SessionApiParam
|
||||
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||
@ApiOperation({
|
||||
summary: 'Generate message ID you can use to batch contacts',
|
||||
})
|
||||
|
||||
@@ -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.Retrieve))
|
||||
export class VersionController {
|
||||
@Get('')
|
||||
@ApiOperation({
|
||||
|
||||
@@ -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.Manage, 'all')) {
|
||||
// Limit user to listen only the session events
|
||||
session = user.session;
|
||||
}
|
||||
|
||||
if (!ability.can(Action.Read, 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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export class AxiosLogging {
|
||||
|
||||
// Log debug for 2xx/3xx responses
|
||||
this.logger.debug(`${methodStr} ${status}:OK ${url}`);
|
||||
if (type.startsWith('application/json')) {
|
||||
if (type?.startsWith('application/json')) {
|
||||
const data = JSON.stringify(response.data);
|
||||
this.logger.trace(`${methodStr} ${status}:OK ${url} ${data}`);
|
||||
} else {
|
||||
|
||||
@@ -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,14 +20,25 @@ 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 {
|
||||
CanServer,
|
||||
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 { APPS } from '../apps/definition';
|
||||
import { App } from '../dto/app.dto';
|
||||
import { ListAppsQuery } from '../dto/query.dto';
|
||||
|
||||
@ApiSecurity('api_key')
|
||||
@Controller('api/apps')
|
||||
@ApiTags('🧩 Apps')
|
||||
@UseGuards(PoliciesGuard)
|
||||
export class AppsController {
|
||||
constructor(
|
||||
@Inject(AppsService)
|
||||
@@ -34,6 +48,7 @@ export class AppsController {
|
||||
|
||||
@Get('/')
|
||||
@ApiOperation({ summary: 'List all apps for a session' })
|
||||
@CheckPolicies(CanSession(Action.App, FromQuery('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async list(
|
||||
@Query(new WAHAValidationPipe()) query: ListAppsQuery,
|
||||
@@ -43,11 +58,12 @@ export class AppsController {
|
||||
|
||||
@Post('/')
|
||||
@ApiOperation({ summary: 'Create a new app' })
|
||||
@CheckPolicies(CanSession(Action.App, FromBody('session')))
|
||||
@UsePipes(new WAHAValidationPipe())
|
||||
async create(@Body() app: App): Promise<App> {
|
||||
const result = await this.appsService.create(this.manager, app);
|
||||
const isRunning = this.manager.isRunning(app.session);
|
||||
if (isRunning && app.enabled) {
|
||||
if (isRunning && app.enabled && APPS[app.app].restartOnChange) {
|
||||
await this.manager.restart(app.session);
|
||||
}
|
||||
return result;
|
||||
@@ -55,15 +71,39 @@ export class AppsController {
|
||||
|
||||
@Get('/:id')
|
||||
@ApiOperation({ summary: 'Get app by ID' })
|
||||
@CheckPolicies(CanServer(Action.Retrieve))
|
||||
@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.App, new SessionName(app.session))) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
@Put('/:id')
|
||||
@ApiOperation({ summary: 'Update an existing app' })
|
||||
@CheckPolicies(CanServer(Action.Retrieve))
|
||||
@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.App, new SessionName(existing.session))) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
} else {
|
||||
if (!req.ability?.can(Action.App, new SessionName(app.session))) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
if (!app.id) {
|
||||
app.id = id;
|
||||
} else if (app.id !== id) {
|
||||
@@ -73,20 +113,28 @@ export class AppsController {
|
||||
}
|
||||
|
||||
const result = await this.appsService.upsert(this.manager, app);
|
||||
const isRunning = this.manager.isRunning(app.session);
|
||||
if (isRunning) {
|
||||
await this.manager.restart(app.session);
|
||||
const isRunning = this.manager.isRunning(result.session);
|
||||
if (isRunning && APPS[result.app].restartOnChange) {
|
||||
await this.manager.restart(result.session);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
@ApiOperation({ summary: 'Delete an app' })
|
||||
@CheckPolicies(CanServer(Action.Retrieve))
|
||||
@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.App, 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) {
|
||||
if (isRunning && APPS[app.app].restartOnChange) {
|
||||
await this.manager.restart(app.session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface AppDefinition {
|
||||
queue: boolean;
|
||||
// If app has any migrations
|
||||
migrations: boolean;
|
||||
// If adding, updating, or removing this app requires a session restart
|
||||
restartOnChange: boolean;
|
||||
}
|
||||
|
||||
// All Apps
|
||||
@@ -18,11 +20,20 @@ export const APPS: Record<AppName, AppDefinition> = {
|
||||
plainkey: false,
|
||||
queue: false,
|
||||
migrations: false,
|
||||
restartOnChange: true,
|
||||
},
|
||||
[AppName.chatwoot]: {
|
||||
name: AppName.chatwoot,
|
||||
plainkey: true,
|
||||
queue: true,
|
||||
migrations: true,
|
||||
restartOnChange: true,
|
||||
},
|
||||
[AppName.mcp]: {
|
||||
name: AppName.mcp,
|
||||
plainkey: false,
|
||||
queue: false,
|
||||
migrations: false,
|
||||
restartOnChange: false,
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export enum AppName {
|
||||
chatwoot = 'chatwoot',
|
||||
calls = 'calls',
|
||||
mcp = 'mcp',
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
|
||||
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
|
||||
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
@@ -11,9 +12,12 @@ import {
|
||||
import { ApiExtraModels, ApiProperty } from '@nestjs/swagger';
|
||||
import { AppName } from '@waha/apps/app_sdk/apps/name';
|
||||
|
||||
export type AllowedAppConfig = ChatWootAppConfig | CallsAppConfig;
|
||||
export type AllowedAppConfig =
|
||||
| ChatWootAppConfig
|
||||
| CallsAppConfig
|
||||
| McpAppConfig;
|
||||
|
||||
@ApiExtraModels(ChatWootAppConfig, CallsAppConfig)
|
||||
@ApiExtraModels(ChatWootAppConfig, CallsAppConfig, McpAppConfig)
|
||||
export class App<T extends AllowedAppConfig = any> {
|
||||
@IsString()
|
||||
id: string;
|
||||
@@ -43,6 +47,8 @@ export class App<T extends AllowedAppConfig = any> {
|
||||
return ChatWootAppConfig;
|
||||
case AppName.calls:
|
||||
return CallsAppConfig;
|
||||
case AppName.mcp:
|
||||
return McpAppConfig;
|
||||
default:
|
||||
return Object;
|
||||
}
|
||||
@@ -62,4 +68,9 @@ export class CallsAppDto extends App<CallsAppConfig> {
|
||||
config: CallsAppConfig;
|
||||
}
|
||||
|
||||
export type AppDto = ChatWootAppDto | CallsAppDto;
|
||||
export class McpAppDto extends App<McpAppConfig> {
|
||||
@Type(() => McpAppConfig)
|
||||
config: McpAppConfig;
|
||||
}
|
||||
|
||||
export type AppDto = ChatWootAppDto | CallsAppDto | McpAppDto;
|
||||
+33
-5
@@ -1,4 +1,10 @@
|
||||
import { parseBool } from '@waha/helpers';
|
||||
import { APPS } from '@waha/apps/app_sdk/apps/definition';
|
||||
|
||||
// Apps that don't require queue (Redis) - derived from AppDefinition
|
||||
const IN_MEMORY_APPS = Object.values(APPS)
|
||||
.filter((app) => !app.queue)
|
||||
.map((app) => app.name);
|
||||
|
||||
function parseCommaSeparatedList(value: string | undefined): string[] {
|
||||
if (!value) {
|
||||
@@ -7,8 +13,30 @@ function parseCommaSeparatedList(value: string | undefined): string[] {
|
||||
return value.split(',').map((item) => item.trim());
|
||||
}
|
||||
|
||||
export const AppEnv = {
|
||||
enabled: parseBool(process.env.WAHA_APPS_ENABLED),
|
||||
on: parseCommaSeparatedList(process.env.WAHA_APPS_ON),
|
||||
off: parseCommaSeparatedList(process.env.WAHA_APPS_OFF),
|
||||
};
|
||||
function isMongoDB(): boolean {
|
||||
return !!process.env.WHATSAPP_SESSIONS_MONGO_URL;
|
||||
}
|
||||
|
||||
function buildAppEnv() {
|
||||
const enabled = process.env.WAHA_APPS_ENABLED;
|
||||
const on = process.env.WAHA_APPS_ON;
|
||||
const off = process.env.WAHA_APPS_OFF;
|
||||
|
||||
// Default if not mongodb
|
||||
const isUserConfigured = enabled !== undefined || on !== undefined;
|
||||
if (!isUserConfigured && !isMongoDB()) {
|
||||
return {
|
||||
enabled: true,
|
||||
on: IN_MEMORY_APPS,
|
||||
off: parseCommaSeparatedList(off),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: parseBool(enabled),
|
||||
on: parseCommaSeparatedList(on),
|
||||
off: parseCommaSeparatedList(off),
|
||||
};
|
||||
}
|
||||
|
||||
export const AppEnv = buildAppEnv();
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
|
||||
import { IAppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
||||
import { ChatWootAppService } from '@waha/apps/chatwoot/services/ChatWootAppService';
|
||||
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
|
||||
import { McpAppService } from '@waha/apps/mcp/services/McpAppService';
|
||||
import { DataStore } from '@waha/core/abc/DataStore';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||
@@ -36,15 +37,18 @@ export class AppsEnabledService implements IAppsService {
|
||||
protected logger: PinoLogger,
|
||||
@Optional() protected readonly chatwootService: ChatWootAppService,
|
||||
@Optional() protected readonly callsAppService: CallsAppService,
|
||||
@Optional() protected readonly mcpAppService: McpAppService,
|
||||
) {}
|
||||
|
||||
async list(manager: SessionManager, session: string): Promise<App[]> {
|
||||
const knex = manager.store.getWAHADatabase();
|
||||
const repo = new AppRepository(knex);
|
||||
const apps = await repo.getAllBySession(session);
|
||||
apps.forEach((app) => {
|
||||
for (const app of apps) {
|
||||
delete app.pk;
|
||||
});
|
||||
const service = this.getAppService(app);
|
||||
await service?.enrich(manager, app);
|
||||
}
|
||||
return apps;
|
||||
}
|
||||
|
||||
@@ -90,10 +94,10 @@ export class AppsEnabledService implements IAppsService {
|
||||
}
|
||||
|
||||
const service = this.getAppService(app);
|
||||
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
|
||||
if (app.enabled && !service && !AppRuntimeConfig.HasApp(app.app)) {
|
||||
throw new AppDisableError(app.app);
|
||||
}
|
||||
service.validate(app);
|
||||
service?.validate(app);
|
||||
// Only run beforeCreated when app is enabled (default true if omitted)
|
||||
if (app.enabled !== false) {
|
||||
await service.beforeCreated(app);
|
||||
@@ -101,17 +105,22 @@ export class AppsEnabledService implements IAppsService {
|
||||
|
||||
const result = await repo.save(app);
|
||||
delete result.pk;
|
||||
if (app.enabled !== false) {
|
||||
await service?.afterCreated(manager, result);
|
||||
}
|
||||
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;
|
||||
const service = this.getAppService(app);
|
||||
await service?.enrich(manager, app);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -147,21 +156,21 @@ export class AppsEnabledService implements IAppsService {
|
||||
}
|
||||
|
||||
const service = this.getAppService(app);
|
||||
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
|
||||
if (app.enabled && !service && !AppRuntimeConfig.HasApp(app.app)) {
|
||||
throw new AppDisableError(app.app);
|
||||
}
|
||||
service.validate(app);
|
||||
service?.validate(app);
|
||||
|
||||
const hasEnabledChange = savedApp.enabled !== app.enabled;
|
||||
|
||||
if (hasEnabledChange) {
|
||||
if (app.enabled) {
|
||||
await service.beforeEnabled(savedApp, app);
|
||||
await service?.beforeEnabled(manager, savedApp, app);
|
||||
} else {
|
||||
await service.beforeDisabled(savedApp, app);
|
||||
await service?.beforeDisabled(manager, savedApp, app);
|
||||
}
|
||||
} else {
|
||||
await service.beforeUpdated(savedApp, app);
|
||||
await service?.beforeUpdated(manager, savedApp, app);
|
||||
}
|
||||
await repo.update(app.id, app);
|
||||
const updated = await repo.getById(app.id);
|
||||
@@ -177,7 +186,7 @@ export class AppsEnabledService implements IAppsService {
|
||||
throw new NotFoundException(`App '${appId}' not found`);
|
||||
}
|
||||
const service = this.getAppService(app);
|
||||
await service?.beforeDeleted(app);
|
||||
await service?.beforeDeleted(manager, app);
|
||||
await repo.delete(app.id);
|
||||
delete app.pk;
|
||||
return app;
|
||||
@@ -186,6 +195,11 @@ export class AppsEnabledService implements IAppsService {
|
||||
async removeBySession(manager: SessionManager, session: string) {
|
||||
const knex = manager.store.getWAHADatabase();
|
||||
const repo = new AppRepository(knex);
|
||||
const apps = await repo.getAllBySession(session);
|
||||
for (const app of apps) {
|
||||
const service = this.getAppService(app);
|
||||
await service?.beforeSessionDeleted(manager, app);
|
||||
}
|
||||
await repo.deleteBySession(session);
|
||||
}
|
||||
|
||||
@@ -257,6 +271,8 @@ export class AppsEnabledService implements IAppsService {
|
||||
return this.chatwootService;
|
||||
case AppName.calls:
|
||||
return this.callsAppService;
|
||||
case AppName.mcp:
|
||||
return this.mcpAppService;
|
||||
default:
|
||||
throw new Error(`App '${app.app}' not supported`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||
|
||||
/**
|
||||
@@ -9,19 +10,49 @@ export interface IAppService {
|
||||
|
||||
beforeCreated(app: App): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called after the app record is saved to the database during creation.
|
||||
* Use this for side effects that must happen after the app exists in storage.
|
||||
*/
|
||||
afterCreated(manager: SessionManager, app: App): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called only when the app transitions from disabled -> enabled.
|
||||
*/
|
||||
beforeEnabled(savedApp: App, newApp: App): Promise<void>;
|
||||
beforeEnabled(
|
||||
manager: SessionManager,
|
||||
savedApp: App,
|
||||
newApp: App,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called only when the app transitions from enabled -> disabled.
|
||||
*/
|
||||
beforeDisabled(savedApp: App, newApp: App): Promise<void>;
|
||||
beforeDisabled(
|
||||
manager: SessionManager,
|
||||
savedApp: App,
|
||||
newApp: App,
|
||||
): Promise<void>;
|
||||
|
||||
beforeUpdated(savedApp: App, newApp: App): Promise<void>;
|
||||
beforeUpdated(
|
||||
manager: SessionManager,
|
||||
savedApp: App,
|
||||
newApp: App,
|
||||
): Promise<void>;
|
||||
|
||||
beforeDeleted(app: App): Promise<void>;
|
||||
beforeDeleted(manager: SessionManager, app: App): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called for each app before a bulk session deletion removes all app records.
|
||||
* Use this to clean up external resources tied to the app (e.g. API keys).
|
||||
*/
|
||||
beforeSessionDeleted(manager: SessionManager, app: App): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called after reading an app from storage, before returning it to the caller.
|
||||
* Use this to populate transient fields that are not persisted (e.g. secret values).
|
||||
*/
|
||||
enrich(manager: SessionManager, app: App): Promise<void>;
|
||||
|
||||
beforeSessionStart(app: App, session: WhatsappSession): void;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
WANumberExistResult,
|
||||
} from '@waha/structures/chatting.dto';
|
||||
import { SessionInfo } from '@waha/structures/sessions.dto';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { Auth } from '@waha/core/auth/config';
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import { ContactSortField } from '@waha/structures/contacts.dto';
|
||||
import { PaginationParams } from '@waha/structures/pagination.dto';
|
||||
|
||||
@@ -25,9 +24,7 @@ export interface RequestOptions {
|
||||
export class WAHASelf {
|
||||
public client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
// Set 'X-Api-Key'
|
||||
const key = Auth.keyplain.value;
|
||||
constructor(public key: string) {
|
||||
const port =
|
||||
parseInt(process.env.PORT) ||
|
||||
parseInt(process.env.WHATSAPP_API_PORT) ||
|
||||
@@ -42,6 +39,10 @@ export class WAHASelf {
|
||||
});
|
||||
}
|
||||
|
||||
request(config: AxiosRequestConfig) {
|
||||
return this.client.request(config);
|
||||
}
|
||||
|
||||
async fetch(url: string, opts?: RequestOptions): Promise<Buffer> {
|
||||
const response = await this.client.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
@@ -146,6 +147,17 @@ export class WAHASelf {
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async getGroupParticipants(
|
||||
session: string,
|
||||
groupId: string,
|
||||
opts?: RequestOptions,
|
||||
) {
|
||||
const url = `/api/${session}/groups/${groupId}/participants/v2`;
|
||||
return await this.client
|
||||
.get(url, { signal: opts?.signal })
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async getChannel(
|
||||
session: string,
|
||||
channelId: string,
|
||||
@@ -371,6 +383,10 @@ export class WAHASessionAPI {
|
||||
return this.api.getGroup(this.session, groupId, opts);
|
||||
}
|
||||
|
||||
getGroupParticipants(groupId: string, opts?: RequestOptions): Promise<any> {
|
||||
return this.api.getGroupParticipants(this.session, groupId, opts);
|
||||
}
|
||||
|
||||
getChannel(channelId: string, opts?: RequestOptions): Promise<Channel> {
|
||||
return this.api.getChannel(this.session, channelId, opts);
|
||||
}
|
||||
@@ -459,4 +475,8 @@ export class WAHASessionAPI {
|
||||
findLIDByPN(pn: string, opts?: RequestOptions) {
|
||||
return this.api.findLIDByPN(this.session, pn, opts);
|
||||
}
|
||||
|
||||
getSessionInfo(opts?: RequestOptions) {
|
||||
return this.api.get(this.session, opts);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { BullBoardModule } from '@bull-board/nestjs';
|
||||
import { ExpressAdapter } from '@bull-board/express';
|
||||
import { BullAuthMiddleware } from '@waha/apps/app_sdk/auth';
|
||||
import { ChatWootExports } from '@waha/apps/chatwoot/chatwoot.module';
|
||||
import { McpModuleExports } from '@waha/apps/mcp/mcp.module';
|
||||
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
|
||||
import { CallsAppExports } from '@waha/apps/calls/calls.module';
|
||||
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
||||
@@ -83,6 +84,8 @@ function getAppModule(name: AppName) {
|
||||
return CallsAppExports;
|
||||
case AppName.chatwoot:
|
||||
return ChatWootExports;
|
||||
case AppName.mcp:
|
||||
return McpModuleExports;
|
||||
default:
|
||||
throw Error(`App module not found for ${name}`);
|
||||
}
|
||||
@@ -91,11 +94,13 @@ function getAppModule(name: AppName) {
|
||||
export const AppsEnabled = {
|
||||
imports: [
|
||||
...QUEUES_IMPORTS,
|
||||
...getAppModule(AppName.mcp).imports,
|
||||
...getAppModule(AppName.chatwoot).imports,
|
||||
...getAppModule(AppName.calls).imports,
|
||||
],
|
||||
controllers: [
|
||||
AppsController,
|
||||
...getAppModule(AppName.mcp).controllers,
|
||||
...getAppModule(AppName.chatwoot).controllers,
|
||||
...getAppModule(AppName.calls).controllers,
|
||||
],
|
||||
@@ -104,6 +109,7 @@ export const AppsEnabled = {
|
||||
provide: AppsService,
|
||||
useClass: AppsEnabledService,
|
||||
},
|
||||
...getAppModule(AppName.mcp).providers,
|
||||
...getAppModule(AppName.calls).providers,
|
||||
...getAppModule(AppName.chatwoot).providers,
|
||||
],
|
||||
|
||||
@@ -2,8 +2,10 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
@@ -23,6 +25,26 @@ export class CallsAppChannelConfig {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
message?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Seconds to wait before declining the call. If not set or undefined, the call is declined immediately (0 seconds).',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
waitBeforeDecline?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Seconds to wait before sending the auto-reply message. If not set or undefined, the message is sent immediately (0 seconds).',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
waitBeforeResponse?: number;
|
||||
}
|
||||
|
||||
export class CallsAppConfig {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
||||
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
|
||||
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
||||
import { CallsListener } from '@waha/apps/calls/services/CallsListener';
|
||||
@@ -25,32 +26,64 @@ export class CallsAppService implements IAppService {
|
||||
}
|
||||
|
||||
async beforeEnabled(
|
||||
manager: SessionManager,
|
||||
savedApp: App<CallsAppConfig>,
|
||||
newApp: App<CallsAppConfig>,
|
||||
): Promise<void> {
|
||||
// Enabling behaves the same as creating for this lightweight app.
|
||||
void manager;
|
||||
void savedApp;
|
||||
void newApp;
|
||||
return;
|
||||
}
|
||||
|
||||
async beforeDisabled(
|
||||
manager: SessionManager,
|
||||
savedApp: App<CallsAppConfig>,
|
||||
newApp: App<CallsAppConfig>,
|
||||
): Promise<void> {
|
||||
void newApp;
|
||||
void manager;
|
||||
void savedApp;
|
||||
void newApp;
|
||||
}
|
||||
|
||||
async beforeUpdated(
|
||||
manager: SessionManager,
|
||||
savedApp: App<CallsAppConfig>,
|
||||
newApp: App<CallsAppConfig>,
|
||||
): Promise<void> {
|
||||
void manager;
|
||||
void savedApp;
|
||||
void newApp;
|
||||
}
|
||||
|
||||
async beforeDeleted(app: App<CallsAppConfig>): Promise<void> {
|
||||
async beforeDeleted(
|
||||
manager: SessionManager,
|
||||
app: App<CallsAppConfig>,
|
||||
): Promise<void> {
|
||||
void manager;
|
||||
void app;
|
||||
}
|
||||
|
||||
async afterCreated(
|
||||
manager: SessionManager,
|
||||
app: App<CallsAppConfig>,
|
||||
): Promise<void> {
|
||||
void manager;
|
||||
void app;
|
||||
}
|
||||
|
||||
async beforeSessionDeleted(
|
||||
manager: SessionManager,
|
||||
app: App<CallsAppConfig>,
|
||||
): Promise<void> {
|
||||
void manager;
|
||||
void app;
|
||||
}
|
||||
|
||||
async enrich(
|
||||
manager: SessionManager,
|
||||
app: App<CallsAppConfig>,
|
||||
): Promise<void> {
|
||||
void manager;
|
||||
void app;
|
||||
}
|
||||
|
||||
|
||||
@@ -92,10 +92,14 @@ export class CallsListener {
|
||||
}
|
||||
|
||||
if (shouldReject) {
|
||||
const waitBeforeDeclineMs = (config.waitBeforeDecline ?? 0) * 1000;
|
||||
await sleep(waitBeforeDeclineMs);
|
||||
await this.rejectCall(call);
|
||||
}
|
||||
|
||||
if (shouldMessage) {
|
||||
const waitBeforeResponseMs = (config.waitBeforeResponse ?? 0) * 1000;
|
||||
await sleep(waitBeforeResponseMs);
|
||||
await this.replyWithTyping(call.from, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
NoRetriesJobOptions,
|
||||
} from '@waha/apps/app_sdk/constants';
|
||||
import { MessageCleanupConsumer } from '@waha/apps/chatwoot/consumers/scheduled/message.cleanup';
|
||||
import { CheckTierConsumer } from '@waha/apps/chatwoot/consumers/scheduled/check.tier';
|
||||
import { ChatWootAppService } from '@waha/apps/chatwoot/services/ChatWootAppService';
|
||||
import * as lodash from 'lodash';
|
||||
|
||||
@@ -51,6 +52,10 @@ const IMPORTS = lodash.flatten([
|
||||
name: QueueName.SCHEDULED_CHECK_VERSION,
|
||||
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.SCHEDULED_CHECK_TIER,
|
||||
defaultJobOptions: merge(NoRetriesJobOptions, JobRemoveOptions),
|
||||
}),
|
||||
RegisterAppQueue({
|
||||
name: QueueName.TASK_CONTACTS_PULL,
|
||||
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
|
||||
@@ -145,6 +150,7 @@ const PROVIDERS = [
|
||||
// Scheduled
|
||||
MessageCleanupConsumer,
|
||||
CheckVersionConsumer,
|
||||
CheckTierConsumer,
|
||||
// Services
|
||||
ChatWootWAHAQueueService,
|
||||
ChatWootQueueService,
|
||||
|
||||
@@ -20,8 +20,18 @@ export async function QueueStatus(ctx: QueueCommandContext, name: string) {
|
||||
for (const status of result) {
|
||||
status.name = QueueNameRepr(status.name);
|
||||
}
|
||||
// locked: true - last
|
||||
result = lodash.sortBy(result, [(x) => !!x.locked, 'name']);
|
||||
const categoryOrder = ['inbox', 'whatsapp', 'task', 'scheduled'];
|
||||
const categoryIndex = (name: string) => {
|
||||
const category = name.split(' | ')[0];
|
||||
const index = categoryOrder.indexOf(category);
|
||||
return index === -1 ? categoryOrder.length : index;
|
||||
};
|
||||
// locked: true - last, then by category (inbox/whatsapp/task/scheduled), then by name
|
||||
result = lodash.sortBy(result, [
|
||||
(x) => !!x.locked,
|
||||
(x) => categoryIndex(x.name),
|
||||
'name',
|
||||
]);
|
||||
const msg = ctx.l.r('cli.cmd.queue.status.result', {
|
||||
queues: result,
|
||||
});
|
||||
|
||||
@@ -17,6 +17,9 @@ export async function runText(
|
||||
.map((line) => line.trimStart()) // remove indentation
|
||||
.join(' ') // join with space
|
||||
.trim(); // final cleanup
|
||||
// Strip WhatsApp/markdown formatting characters so shell-quote doesn't
|
||||
// treat them as glob operators and return non-string tokens
|
||||
text = text.replace(/[*_~`]/g, '');
|
||||
const output = new BufferedOutput();
|
||||
const program = BuildProgram(commands, ctx, output);
|
||||
const argv = parse(text);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
* Parse human string into milliseconds
|
||||
*/
|
||||
export function ParseMS(value: string) {
|
||||
const duration = ms(value);
|
||||
const duration = ms(value as ms.StringValue);
|
||||
if (duration == null) throw new Error(`Invalid duration: "${value}"`);
|
||||
if (duration < 0) throw new Error(`Duration cannot be negative: "${value}"`);
|
||||
return duration;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AttributeKey, INBOX_CONTACT_CHAT_ID } from '@waha/apps/chatwoot/const';
|
||||
import * as lodash from 'lodash';
|
||||
import { WhatsAppMessage } from '@waha/apps/chatwoot/storage';
|
||||
import { buildMessageId } from '@waha/core/engines/noweb/session.noweb.core';
|
||||
import { isLidUser } from '@waha/core/utils/jids';
|
||||
import { isLidUser, normalizeJid } from '@waha/core/utils/jids';
|
||||
|
||||
export function GetJID(contact: any): string | null {
|
||||
return contact?.custom_attributes?.[AttributeKey.WA_JID];
|
||||
@@ -23,20 +23,13 @@ export function GetAllChatIDs(contact: any): Array<string> {
|
||||
attrs[AttributeKey.WA_JID],
|
||||
attrs[AttributeKey.WA_LID],
|
||||
];
|
||||
return lodash.uniq(ids.filter(Boolean));
|
||||
return lodash.uniq(ids.filter(Boolean).map(normalizeJid));
|
||||
}
|
||||
|
||||
export function FindChatID(contact: any): string | null {
|
||||
if (GetJID(contact)) {
|
||||
return GetJID(contact);
|
||||
}
|
||||
if (GetLID(contact)) {
|
||||
return GetLID(contact);
|
||||
}
|
||||
if (GetChatID(contact)) {
|
||||
return GetChatID(contact);
|
||||
}
|
||||
return null;
|
||||
const chatId =
|
||||
GetChatID(contact) ?? GetJID(contact) ?? GetLID(contact) ?? null;
|
||||
return chatId ? normalizeJid(chatId) : null;
|
||||
}
|
||||
|
||||
export function SerializeWhatsAppKey(message: WhatsAppMessage): string {
|
||||
|
||||
@@ -4,6 +4,7 @@ export enum QueueName {
|
||||
//
|
||||
SCHEDULED_MESSAGE_CLEANUP = 'chatwoot.scheduled | message.cleanup',
|
||||
SCHEDULED_CHECK_VERSION = 'chatwoot.scheduled | check.version',
|
||||
SCHEDULED_CHECK_TIER = 'chatwoot.scheduled | check.tier',
|
||||
|
||||
//
|
||||
// Task
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InboxData } from '@waha/apps/chatwoot/consumers/types';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import {
|
||||
ChatIDNotFoundForContactError,
|
||||
ChatWootAppNotFoundError,
|
||||
PhoneNumberNotFoundInWhatsAppError,
|
||||
} from '@waha/apps/chatwoot/errors';
|
||||
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
@@ -43,7 +44,11 @@ export abstract class ChatWootInboxMessageConsumer extends AppConsumer {
|
||||
const knex = this.manager.store.getWAHADatabase();
|
||||
this.appRepository = new AppRepository(knex);
|
||||
const logger = new JobLoggerWrapper(job, this.logger);
|
||||
const app = await this.appRepository.getById(appId);
|
||||
const app = await this.appRepository.findEnabledAppById(appId);
|
||||
if (!app) {
|
||||
logger.warn(`Chatwoot app not found or disabled: ${appId}`);
|
||||
throw new ChatWootAppNotFoundError(appId);
|
||||
}
|
||||
return new DIContainer(app.pk, app.config, logger, knex);
|
||||
}
|
||||
|
||||
@@ -86,6 +91,10 @@ export abstract class ChatWootInboxMessageConsumer extends AppConsumer {
|
||||
await this.ReportErrorRecovered(job, body);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err instanceof ChatWootAppNotFoundError) {
|
||||
this.logger.warn(err.message);
|
||||
throw err;
|
||||
}
|
||||
await this.ReportErrorForMessage(job, err, body);
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
MessageMappingService,
|
||||
} from '@waha/apps/chatwoot/storage';
|
||||
import { MarkdownToWhatsApp } from '@waha/apps/chatwoot/messages/to/whatsapp/markdown';
|
||||
import { parseMentionsFromText } from '@waha/apps/chatwoot/messages/to/whatsapp/mentions';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import {
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
LinkPreview,
|
||||
} from '@waha/apps/chatwoot/dto/config.dto';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { isJidGroup } from '@waha/core/utils/jids';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const mime = require('mime-types');
|
||||
@@ -90,7 +92,16 @@ export class MessageHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = MarkdownToWhatsApp(message.content);
|
||||
let content: string = message.content;
|
||||
let mentions: null | string[] = null;
|
||||
if (isJidGroup(chatId)) {
|
||||
// Parse any mention in the text and get a new clean text if required
|
||||
const parsed = parseMentionsFromText(message.content);
|
||||
content = parsed.text;
|
||||
mentions = parsed.mentions;
|
||||
}
|
||||
|
||||
content = MarkdownToWhatsApp(content);
|
||||
const results = [];
|
||||
let part = 0; // Start from 0 and increment before each send/check
|
||||
const replyTo = await this.getReplyTo(message).catch((err) => {
|
||||
@@ -116,7 +127,7 @@ export class MessageHandler {
|
||||
content: content,
|
||||
chatwoot: body,
|
||||
});
|
||||
const msg = await this.sendTextMessage(chatId, text, replyTo);
|
||||
const msg = await this.sendTextMessage(chatId, text, replyTo, mentions);
|
||||
results.push(msg);
|
||||
await this.saveMapping(message, msg, part);
|
||||
this.logger.info(`Text message sent: ${msg.id}`);
|
||||
@@ -139,7 +150,7 @@ export class MessageHandler {
|
||||
chatwoot: body,
|
||||
singleAttachment: attachments.length == 1,
|
||||
});
|
||||
const msg = await this.sendFile(chatId, caption, file, replyTo);
|
||||
const msg = await this.sendFile(chatId, caption, replyTo, mentions, file);
|
||||
this.logger.info(
|
||||
`File message sent: ${msg.id} - ${file.data_url} - ${file.file_type}`,
|
||||
);
|
||||
@@ -196,6 +207,7 @@ export class MessageHandler {
|
||||
chatId: string,
|
||||
content: string,
|
||||
replyTo: string,
|
||||
mentions: string[] | null,
|
||||
) {
|
||||
const request: MessageTextRequest = {
|
||||
session: '',
|
||||
@@ -206,6 +218,7 @@ export class MessageHandler {
|
||||
this.config.linkPreview,
|
||||
),
|
||||
linkPreviewHighQuality: this.config.linkPreview == LinkPreview.HQ,
|
||||
mentions: mentions,
|
||||
};
|
||||
const session = this.session;
|
||||
await session.readMessages(chatId);
|
||||
@@ -219,8 +232,9 @@ export class MessageHandler {
|
||||
private async sendFile(
|
||||
chatId: string,
|
||||
content: string,
|
||||
file: MessageAttachment,
|
||||
replyTo: string,
|
||||
mentions: string[] | null,
|
||||
file: MessageAttachment,
|
||||
) {
|
||||
const url = file.data_url;
|
||||
let filename = url.split('/').pop();
|
||||
@@ -245,6 +259,7 @@ export class MessageHandler {
|
||||
url: url,
|
||||
mimetype: mimetype,
|
||||
},
|
||||
mentions: mentions,
|
||||
};
|
||||
return session.sendImage(imageRequest);
|
||||
case 'video':
|
||||
@@ -262,6 +277,7 @@ export class MessageHandler {
|
||||
filename: filename,
|
||||
},
|
||||
convert: true,
|
||||
mentions: mentions,
|
||||
};
|
||||
return session.sendVideo(videoRequest);
|
||||
case 'audio':
|
||||
@@ -288,6 +304,7 @@ export class MessageHandler {
|
||||
url: url,
|
||||
mimetype: mimetype,
|
||||
},
|
||||
mentions: mentions,
|
||||
};
|
||||
return session.sendFile(fileRequest);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AppRepository } from '../../storage';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { ChatWootAppNotFoundError } from '@waha/apps/chatwoot/errors';
|
||||
|
||||
/**
|
||||
* Base class for ChatWoot scheduled consumers
|
||||
@@ -35,7 +36,11 @@ export abstract class ChatWootScheduledConsumer extends AppConsumer {
|
||||
const knex = this.manager.store.getWAHADatabase();
|
||||
this.appRepository = new AppRepository(knex);
|
||||
const logger = new JobLoggerWrapper(job, this.logger);
|
||||
const app = await this.appRepository.getById(appId);
|
||||
const app = await this.appRepository.findEnabledAppById(appId);
|
||||
if (!app) {
|
||||
logger.warn(`Chatwoot app not found or disabled: ${appId}`);
|
||||
throw new ChatWootAppNotFoundError(appId);
|
||||
}
|
||||
return new DIContainer(app.pk, app.config, logger, knex);
|
||||
}
|
||||
|
||||
@@ -62,6 +67,10 @@ export abstract class ChatWootScheduledConsumer extends AppConsumer {
|
||||
await this.ReportErrorRecovered(job);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err instanceof ChatWootAppNotFoundError) {
|
||||
this.logger.warn(err.message);
|
||||
throw err;
|
||||
}
|
||||
await this.ReportErrorForJob(job, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { SUPPORT_US_URL } from '@waha/core/constants';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { VERSION, WAHAVersion } from '@waha/version';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { ChatWootScheduledConsumer } from './base';
|
||||
|
||||
@Processor(QueueName.SCHEDULED_CHECK_TIER, { concurrency: JOB_CONCURRENCY })
|
||||
export class CheckTierConsumer extends ChatWootScheduledConsumer {
|
||||
constructor(manager: SessionManager, log: PinoLogger, rmutex: RMutexService) {
|
||||
super(manager, log, rmutex, CheckTierConsumer.name);
|
||||
}
|
||||
|
||||
protected ErrorHeaderKey(): TKey {
|
||||
return TKey.JOB_SCHEDULED_ERROR_HEADER;
|
||||
}
|
||||
|
||||
protected async Process(container: DIContainer, job: Job): Promise<any> {
|
||||
const logger = container.Logger();
|
||||
const locale = container.Locale();
|
||||
const conversation = await container
|
||||
.ContactConversationService()
|
||||
.InboxNotifications();
|
||||
if (VERSION.tier !== WAHAVersion.CORE) {
|
||||
logger.info('WAHA is not using the CORE version');
|
||||
return;
|
||||
}
|
||||
logger.info('WAHA is using the CORE version');
|
||||
const supportMessage = locale.key(TKey.WAHA_CORE_VERSION_USED).render({
|
||||
supportUrl: SUPPORT_US_URL,
|
||||
});
|
||||
await conversation.incoming(supportMessage);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,19 @@
|
||||
import { Processor } from '@nestjs/bullmq';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||
import { CHANGELOG_URL, SUPPORT_US_URL } from '@waha/core/constants';
|
||||
import { CHANGELOG_URL } from '@waha/core/constants';
|
||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||
import { VERSION, WAHAVersion } from '@waha/version';
|
||||
import { VERSION } from '@waha/version';
|
||||
import axios from 'axios';
|
||||
import { Job } from 'bullmq';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { ChatWootScheduledConsumer } from './base';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { ChatWootScheduledConsumer } from './base';
|
||||
|
||||
/**
|
||||
* Scheduled consumer for version checking
|
||||
* Periodically checks for new versions and notifies if updates are available
|
||||
*/
|
||||
@Processor(QueueName.SCHEDULED_CHECK_VERSION, { concurrency: JOB_CONCURRENCY })
|
||||
export class CheckVersionConsumer extends ChatWootScheduledConsumer {
|
||||
constructor(manager: SessionManager, log: PinoLogger, rmutex: RMutexService) {
|
||||
@@ -29,34 +24,7 @@ export class CheckVersionConsumer extends ChatWootScheduledConsumer {
|
||||
return TKey.JOB_SCHEDULED_ERROR_HEADER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the version check job
|
||||
* Checks for new versions and notifies if updates are available
|
||||
*/
|
||||
protected async Process(container: DIContainer, job: Job): Promise<any> {
|
||||
await this.CheckWAHACoreVersion(container);
|
||||
await this.CheckNewVersionAvailable(container);
|
||||
}
|
||||
|
||||
private async CheckWAHACoreVersion(container: DIContainer) {
|
||||
const logger = container.Logger();
|
||||
const locale = container.Locale();
|
||||
const conversation = await container
|
||||
.ContactConversationService()
|
||||
.InboxNotifications();
|
||||
// If using the CORE version, send a message encouraging support
|
||||
if (VERSION.tier !== WAHAVersion.CORE) {
|
||||
logger.info('WAHA is not using the CORE version');
|
||||
return;
|
||||
}
|
||||
logger.info('WAHA is using the CORE version');
|
||||
const supportMessage = locale.key(TKey.WAHA_CORE_VERSION_USED).render({
|
||||
supportUrl: SUPPORT_US_URL,
|
||||
});
|
||||
await conversation.incoming(supportMessage);
|
||||
}
|
||||
|
||||
private async CheckNewVersionAvailable(container: DIContainer) {
|
||||
const logger = container.Logger();
|
||||
logger.info('Processing version check job');
|
||||
const currentVersion = VERSION.version;
|
||||
@@ -72,7 +40,6 @@ export class CheckVersionConsumer extends ChatWootScheduledConsumer {
|
||||
`New version available: ${latestVersion} (current: ${currentVersion})`,
|
||||
);
|
||||
|
||||
// Send a message to Chatwoot conversation about a new version
|
||||
const locale = container.Locale();
|
||||
const message = locale.key(TKey.WAHA_NEW_VERSION_AVAILABLE).render({
|
||||
currentVersion: currentVersion,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AppRepository } from '../../storage';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { SignalRace } from '@waha/utils/abortable';
|
||||
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
|
||||
import { ChatWootAppNotFoundError } from '@waha/apps/chatwoot/errors';
|
||||
|
||||
/**
|
||||
* Base class for ChatWoot background task consumers
|
||||
@@ -36,7 +37,11 @@ export abstract class ChatWootTaskConsumer extends AppConsumer {
|
||||
const knex = this.manager.store.getWAHADatabase();
|
||||
this.appRepository = new AppRepository(knex);
|
||||
const logger = new JobLoggerWrapper(job, this.logger);
|
||||
const app = await this.appRepository.getById(appId);
|
||||
const app = await this.appRepository.findEnabledAppById(appId);
|
||||
if (!app) {
|
||||
logger.warn(`Chatwoot app not found or disabled: ${appId}`);
|
||||
throw new ChatWootAppNotFoundError(appId);
|
||||
}
|
||||
return new DIContainer(app.pk, app.config, logger, knex);
|
||||
}
|
||||
|
||||
@@ -68,6 +73,10 @@ export abstract class ChatWootTaskConsumer extends AppConsumer {
|
||||
await this.ReportErrorRecovered(job);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err instanceof ChatWootAppNotFoundError) {
|
||||
this.logger.warn(err.message);
|
||||
throw err;
|
||||
}
|
||||
this.ReportErrorForJob(job, err).catch((exc) =>
|
||||
this.logger.error(`Failed to report error for job ${job.id}: ${exc}`),
|
||||
);
|
||||
|
||||
@@ -212,7 +212,11 @@ class MessagesPullHandler {
|
||||
|
||||
// Perform some actions before any job started
|
||||
if (options.pause) {
|
||||
await this.queueManager.pause();
|
||||
const queues = [
|
||||
...this.queueManager.resolve('inbox'),
|
||||
...this.queueManager.resolve('waha'),
|
||||
];
|
||||
await this.queueManager.pause(queues);
|
||||
await this.activity.queue(true);
|
||||
}
|
||||
}
|
||||
@@ -228,7 +232,11 @@ class MessagesPullHandler {
|
||||
|
||||
// Resume queues if paused
|
||||
if (options.pause) {
|
||||
await this.queueManager.resume();
|
||||
const queues = [
|
||||
...this.queueManager.resolve('inbox'),
|
||||
...this.queueManager.resolve('waha'),
|
||||
];
|
||||
await this.queueManager.resume(queues);
|
||||
await this.activity.queue(false);
|
||||
}
|
||||
// Progress summary
|
||||
|
||||
@@ -12,6 +12,7 @@ import { WhatsAppChatIdKey } from '@waha/apps/chatwoot/consumers/mutex';
|
||||
import { EventData } from '@waha/apps/chatwoot/consumers/types';
|
||||
import { WhatsAppContactInfo } from '@waha/apps/chatwoot/contacts/WhatsAppContactInfo';
|
||||
import { DIContainer } from '@waha/apps/chatwoot/di/DIContainer';
|
||||
import { ChatWootAppNotFoundError } from '@waha/apps/chatwoot/errors';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { WAHASelf, WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import {
|
||||
@@ -83,6 +84,10 @@ export abstract class ChatWootWAHABaseConsumer extends AppConsumer {
|
||||
await this.ReportErrorRecovered(errorReportInfo, job);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err instanceof ChatWootAppNotFoundError) {
|
||||
this.logger.warn(err.message);
|
||||
throw err;
|
||||
}
|
||||
await this.ReportErrorForMessage(errorReportInfo, job, err);
|
||||
throw err;
|
||||
}
|
||||
@@ -95,7 +100,11 @@ export abstract class ChatWootWAHABaseConsumer extends AppConsumer {
|
||||
const knex = this.manager.store.getWAHADatabase();
|
||||
this.appRepository = new AppRepository(knex);
|
||||
const logger = new JobLoggerWrapper(job, this.logger);
|
||||
const app = await this.appRepository.getById(appId);
|
||||
const app = await this.appRepository.findEnabledAppById(appId);
|
||||
if (!app) {
|
||||
logger.warn(`Chatwoot app not found or disabled: ${appId}`);
|
||||
throw new ChatWootAppNotFoundError(appId);
|
||||
}
|
||||
return new DIContainer(app.pk, app.config, logger, knex);
|
||||
}
|
||||
|
||||
@@ -338,7 +347,7 @@ export abstract class MessageBaseHandler<
|
||||
chat_id: toCusFormat(key.remoteJid),
|
||||
message_id: key.id,
|
||||
from_me: key.fromMe,
|
||||
participant: null,
|
||||
participant: isJidGroup(key.remoteJid) ? key.participant : null,
|
||||
};
|
||||
await this.mappingService.map(chatwoot, whatsapp, 1);
|
||||
return;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
PollMessage,
|
||||
ListMessage,
|
||||
ShareContactMessage,
|
||||
StatusReplyMessage,
|
||||
TextMessage,
|
||||
UnsupportedMessage,
|
||||
AlbumMessage,
|
||||
@@ -86,6 +87,18 @@ export class MessageAnyHandler extends MessageBaseHandler<WAMessage> {
|
||||
return msg;
|
||||
}
|
||||
|
||||
converter = new StatusReplyMessage(
|
||||
this.l,
|
||||
this.logger,
|
||||
this.waha,
|
||||
this.job,
|
||||
this.session,
|
||||
);
|
||||
msg = await converter.convert(payload, protoMessage);
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
converter = new TextMessage(this.l, this.logger, this.waha, this.job);
|
||||
msg = await converter.convert(payload, null);
|
||||
if (msg) {
|
||||
|
||||
@@ -32,7 +32,10 @@ export class WAHAMessageReactionConsumer extends ChatWootWAHABaseConsumer {
|
||||
}
|
||||
|
||||
GetChatId(event: WAHAWebhookMessageReaction): string {
|
||||
if (event.environment?.engine == WAHAEngine.WEBJS) {
|
||||
if (
|
||||
event.environment?.engine == WAHAEngine.WEBJS ||
|
||||
event.environment?.engine == WAHAEngine.WPP
|
||||
) {
|
||||
// chat in "to" field for WEBJS for message.reaction
|
||||
// probably we need to set it to "from"
|
||||
// but for backward compatability we do this
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isJidStatusBroadcast,
|
||||
isLidUser,
|
||||
isPnUser,
|
||||
normalizeJid,
|
||||
} from '@waha/core/utils/jids';
|
||||
import { UnknownJIDFormat } from '@waha/apps/chatwoot/errors';
|
||||
import { E164Parser } from '@waha/core/utils/PhoneJidNormalizer';
|
||||
@@ -270,7 +271,7 @@ export function WhatsAppContactInfo(
|
||||
} else if (isJidBroadcast(chatId)) {
|
||||
return new BroadcastContactInfo(session, chatId, locale);
|
||||
} else if (isLidUser(chatId)) {
|
||||
return new LidContactInfo(session, chatId, locale);
|
||||
return new LidContactInfo(session, normalizeJid(chatId), locale);
|
||||
} else if (isPnUser(chatId)) {
|
||||
return new JidContactInfo(session, chatId, locale);
|
||||
} else {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
ConversationSelector,
|
||||
ConversationSort,
|
||||
} from '@waha/apps/chatwoot/services/ConversationSelector';
|
||||
import { Auth } from '@waha/core/auth/config';
|
||||
|
||||
/**
|
||||
* Dependency Injection Container for ChatWoot
|
||||
@@ -194,7 +195,7 @@ export class DIContainer {
|
||||
*/
|
||||
@CacheSync()
|
||||
public WAHASelf(): WAHASelf {
|
||||
const self = new WAHASelf();
|
||||
const self = new WAHASelf(Auth.keyplain.value);
|
||||
const logging = new AxiosLogging(this.Logger());
|
||||
logging.applyTo(self.client);
|
||||
return self;
|
||||
|
||||
@@ -45,6 +45,12 @@ export class ChatWootErrorReporter {
|
||||
// There's retries more left - ignore it for now
|
||||
return;
|
||||
}
|
||||
if (!conversation) {
|
||||
this.logger.error(
|
||||
'Chatwoot error report skipped: conversation not ready',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const attempts = {
|
||||
current: this.job.attemptsMade + 1,
|
||||
max: this.job.opts?.attempts || 1,
|
||||
@@ -87,6 +93,12 @@ export class ChatWootErrorReporter {
|
||||
// https://github.com/devlikeapro/waha/issues/1395
|
||||
return null;
|
||||
|
||||
if (!conversation) {
|
||||
this.logger.warn(
|
||||
'Chatwoot success report skipped: conversation not ready',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const template = this.l.key(TKey.JOB_REPORT_SUCCEEDED);
|
||||
const attempts = {
|
||||
current: this.job.attemptsMade + 1,
|
||||
|
||||
@@ -15,3 +15,9 @@ export class UnknownJIDFormat extends Error {
|
||||
super(`WhatsApp Chat ID is not recognized: ${jid}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class ChatWootAppNotFoundError extends Error {
|
||||
constructor(public appId: string) {
|
||||
super(`Chatwoot app not found or disabled: ${appId}`);
|
||||
}
|
||||
}
|
||||
@@ -325,6 +325,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 تحقق مما هو جديد في [**🆕 سجل التغييرات**]({{{changelogUrl}}})
|
||||
|
||||
_لإيقاف هذه الإشعارات، أرسل_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **ادعم WAHA!**
|
||||
|
||||
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **تم إرسال PIX نسخ ولصق**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **رد على حالة واتساب**
|
||||
🏷️ **النوع:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **نص الحالة:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **هذا النوع من الرسائل غير مدعوم في هذا الصندوق الوارد.**
|
||||
📱 يرجى فتح **WhatsApp** لعرضه.
|
||||
|
||||
@@ -325,6 +325,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 [**🆕 পরিবর্তন লগ**]({{{changelogUrl}}}) এ নতুন কি আছে তা দেখুন
|
||||
|
||||
_এই বিজ্ঞপ্তিগুলি অক্ষম করতে, পাঠান_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **WAHA সমর্থন করুন!**
|
||||
|
||||
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX কপি এবং পেস্ট পাঠানো হয়েছে**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **WhatsApp স্ট্যাটাসের জবাব**
|
||||
🏷️ **ধরন:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **স্ট্যাটাসের টেক্সট:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **এই ধরনের বার্তা এই ইনবক্সে সমর্থিত নয়।**
|
||||
📱 এটি দেখতে অনুগ্রহ করে **WhatsApp** খুলুন।
|
||||
|
||||
@@ -328,6 +328,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 Sehen Sie, was neu ist im [**🆕 Änderungsprotokoll**]({{{changelogUrl}}})
|
||||
|
||||
_Um diese Benachrichtigungen zu deaktivieren, senden Sie_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **Unterstützen Sie WAHA!**
|
||||
|
||||
@@ -490,6 +492,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX Kopieren und Einfügen gesendet**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **Antwort auf WhatsApp-Status**
|
||||
🏷️ **Typ:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **Status-Text:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **Dieser Nachrichtentyp wird in dieser Inbox nicht unterstützt.**
|
||||
📱 Bitte öffne **WhatsApp**, um ihn anzusehen.
|
||||
|
||||
@@ -268,6 +268,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX Copy and Paste sent**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **Reply to WhatsApp Status**
|
||||
🏷️ **Type:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **Status Text:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.has.media.no.media: |-
|
||||
{{#content}}
|
||||
{{{content}}}
|
||||
@@ -705,6 +714,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 Check out what's new in the [**🆕 Changelog**]({{{changelogUrl}}})
|
||||
|
||||
_To disable these notifications, send_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **Support WAHA!**
|
||||
|
||||
|
||||
@@ -325,6 +325,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 Mira qué hay de nuevo en el [**🆕 Registro de Cambios**]({{{changelogUrl}}})
|
||||
|
||||
_Para desactivar estas notificaciones, envía_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **¡Apoya a WAHA!**
|
||||
|
||||
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX Copiar y Pegar enviado**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **Respuesta al estado de WhatsApp**
|
||||
🏷️ **Tipo:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **Texto del Estado:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **Este tipo de mensaje no es compatible con esta bandeja de entrada.**
|
||||
📱 Por favor, abre **WhatsApp** para verlo.
|
||||
|
||||
@@ -325,6 +325,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 موارد جدید را در [**🆕 تغییرات**]({{{changelogUrl}}}) مشاهده کنید
|
||||
|
||||
_برای غیرفعال کردن این اعلانها، ارسال کنید_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **از WAHA پشتیبانی کنید!**
|
||||
|
||||
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX کپی و چسباندن ارسال شد**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **پاسخ به وضعیت واتساپ**
|
||||
🏷️ **نوع:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **متن وضعیت:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **این نوع پیام در این صندوق ورودی پشتیبانی نمیشود.**
|
||||
📱 لطفاً برای مشاهده آن **WhatsApp** را باز کنید.
|
||||
|
||||
@@ -325,6 +325,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 Découvrez les nouveautés dans le [**🆕 Journal des modifications**]({{{changelogUrl}}})
|
||||
|
||||
_Pour désactiver ces notifications, envoyez_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **Soutenez WAHA !**
|
||||
|
||||
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX Copier et Coller envoyé**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **Réponse au statut WhatsApp**
|
||||
🏷️ **Type :** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **Texte du Statut :** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **Ce type de message n’est pas pris en charge dans cette boîte de réception.**
|
||||
📱 Veuillez ouvrir **WhatsApp** pour l’afficher.
|
||||
|
||||
@@ -325,6 +325,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 בדוק מה חדש ב[**🆕 יומן שינויים**]({{{changelogUrl}}})
|
||||
|
||||
_כדי לבטל התראות אלו, שלח_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **תמוך ב-WAHA!**
|
||||
|
||||
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX העתק והדבק נשלח**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **תגובה לסטטוס ב-WhatsApp**
|
||||
🏷️ **סוג:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **טקסט הסטטוס:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **סוג הודעה זה אינו נתמך בתיבה זו.**
|
||||
📱 אנא פתח/י את **WhatsApp** כדי לצפות בזה.
|
||||
|
||||
@@ -325,6 +325,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 [**🆕 चेंजलॉग**]({{{changelogUrl}}}) में क्या नया है देखें
|
||||
|
||||
_इन सूचनाओं को अक्षम करने के लिए, भेजें_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **WAHA का समर्थन करें!**
|
||||
|
||||
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX कॉपी और पेस्ट भेजा गया**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **WhatsApp स्टेटस का जवाब**
|
||||
🏷️ **प्रकार:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **स्टेटस टेक्स्ट:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **इस प्रकार का संदेश इस इनबॉक्स में समर्थित नहीं है।**
|
||||
📱 इसे देखने के लिए कृपया **WhatsApp** खोलें।
|
||||
|
||||
@@ -325,6 +325,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 Lihat apa yang baru di [**🆕 Changelog**]({{{changelogUrl}}})
|
||||
|
||||
_Untuk menonaktifkan notifikasi ini, kirim_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **Dukung WAHA!**
|
||||
|
||||
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX Salin dan Tempel terkirim**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **Balasan ke Status WhatsApp**
|
||||
🏷️ **Tipe:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **Teks Status:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **Jenis pesan ini tidak didukung di kotak masuk ini.**
|
||||
📱 Silakan buka **WhatsApp** untuk melihatnya.
|
||||
|
||||
@@ -268,6 +268,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX Copia e Incolla inviato**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **Risposta allo stato di WhatsApp**
|
||||
🏷️ **Tipo:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **Testo dello Stato:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.has.media.no.media: |-
|
||||
{{#content}}
|
||||
{{{content}}}
|
||||
@@ -705,6 +714,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 Scopri le novità nel [**🆕 Changelog**]({{{changelogUrl}}})
|
||||
|
||||
_Per disattivare queste notifiche, invia_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **Supporta WAHA!**
|
||||
|
||||
|
||||
@@ -325,6 +325,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 [**🆕 ਬਦਲਾਅ ਲੌਗ**]({{{changelogUrl}}}) ਵਿੱਚ ਨਵਾਂ ਕੀ ਹੈ ਦੇਖੋ
|
||||
|
||||
_ਇਹ ਸੂਚਨਾਵਾਂ ਬੰਦ ਕਰਨ ਲਈ, ਭੇਜੋ_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **WAHA ਦਾ ਸਮਰਥਨ ਕਰੋ!**
|
||||
|
||||
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX ਕਾਪੀ ਅਤੇ ਪੇਸਟ ਭੇਜਿਆ ਗਿਆ**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **WhatsApp ਸਟੇਟਸ ਦਾ ਜਵਾਬ**
|
||||
🏷️ **ਕਿਸਮ:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **ਸਟੇਟਸ ਲਿਖਤ:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **ਇਹ ਸੁਨੇਹੇ ਦੀ ਕਿਸਮ ਇਸ ਇਨਬਾਕਸ ਵਿੱਚ ਸਮਰਥਿਤ ਨਹੀਂ ਹੈ।**
|
||||
📱 ਇਸਨੂੰ ਦੇਖਣ ਲਈ ਕਿਰਪਾ ਕਰਕੇ **WhatsApp** ਖੋਲ੍ਹੋ।
|
||||
|
||||
@@ -326,6 +326,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 Confira o que há de novo no [**🆕 Registro de Alterações**]({{{changelogUrl}}})
|
||||
|
||||
_Para desativar essas notificações, envie_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **Apoie o WAHA!**
|
||||
|
||||
@@ -492,6 +494,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX Copia e Cola enviado**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **Resposta a Status do WhatsApp**
|
||||
🏷️ **Tipo:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **Texto do Status:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **Esse tipo de mensagem não é suportado nesta caixa de entrada.**
|
||||
📱 Por favor, abra o **WhatsApp** para visualizá-la.
|
||||
|
||||
@@ -325,6 +325,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 Узнайте, что нового в [**🆕 Списке изменений**]({{{changelogUrl}}})
|
||||
|
||||
_Чтобы отключить эти уведомления, отправьте_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **Поддержите WAHA!**
|
||||
|
||||
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX Копировать и вставить отправлен**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **Ответ на статус WhatsApp**
|
||||
🏷️ **Тип:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **Текст Статуса:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **Этот тип сообщения не поддерживается в этом Inbox.**
|
||||
📱 Пожалуйста, откройте **WhatsApp**, чтобы просмотреть.
|
||||
|
||||
@@ -325,6 +325,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 [**🆕 Değişiklik Günlüğü**]({{{changelogUrl}}}) içinde yenilikleri kontrol edin
|
||||
|
||||
_Bu bildirimleri devre dışı bırakmak için gönderin_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **WAHA'yı Destekleyin!**
|
||||
|
||||
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX Kopyala ve Yapıştır gönderildi**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **WhatsApp Durumuna Yanıt**
|
||||
🏷️ **Tür:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **Durum Metni:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **Bu mesaj türü bu gelen kutusunda desteklenmiyor.**
|
||||
📱 Görüntülemek için lütfen **WhatsApp’ı** açın.
|
||||
|
||||
@@ -325,6 +325,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 Перевірте, що нового у [**🆕 Журналі змін**]({{{changelogUrl}}})
|
||||
|
||||
_Щоб вимкнути ці сповіщення, надішліть_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **Підтримайте WAHA!**
|
||||
|
||||
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX Копіювати та Вставити надіслано**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **Відповідь на статус WhatsApp**
|
||||
🏷️ **Тип:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **Текст Статусу:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **Цей тип повідомлення не підтримується в цих вхідних.**
|
||||
📱 Будь ласка, відкрийте **WhatsApp**, щоб переглянути.
|
||||
|
||||
@@ -325,6 +325,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 [**🆕 تبدیلیوں کی فہرست**]({{{changelogUrl}}}) میں نیا کیا ہے دیکھیں
|
||||
|
||||
_ان اطلاعات کو بند کرنے کے لیے بھیجیں_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **WAHA کی حمایت کریں!**
|
||||
|
||||
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX کاپی اور پیسٹ بھیج دیا گیا**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **WhatsApp اسٹیٹس کا جواب**
|
||||
🏷️ **قسم:** {{type}}
|
||||
{{#quotedText}}
|
||||
📝 **اسٹیٹس کا متن:** {{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **اس قسم کا پیغام اس انبکس میں معاونت یافتہ نہیں ہے۔**
|
||||
📱 اسے دیکھنے کے لیے براہ کرم **WhatsApp** کھولیں۔
|
||||
|
||||
@@ -325,6 +325,8 @@ waha.new.version.available: |-
|
||||
|
||||
👉 查看[**🆕 更新日志**]({{{changelogUrl}}})
|
||||
|
||||
_要禁用这些通知,请发送_ `queue stop check.version`
|
||||
|
||||
waha.core.version.used: |-
|
||||
❤️ **支持 WAHA!**
|
||||
|
||||
@@ -487,6 +489,15 @@ whatsapp.to.chatwoot.message.pix: |-
|
||||
💳 **PIX 复制粘贴已发送**
|
||||
{{/pixData}}
|
||||
|
||||
whatsapp.to.chatwoot.message.status.reply: |-
|
||||
📣 **回复 WhatsApp 状态**
|
||||
🏷️ **类型:**{{type}}
|
||||
{{#quotedText}}
|
||||
📝 **状态文本:**{{quotedText}}
|
||||
{{/quotedText}}
|
||||
|
||||
{{content}}
|
||||
|
||||
whatsapp.to.chatwoot.message.unsupported: |-
|
||||
⚠️ **此收件箱不支持此消息类型。**
|
||||
📱 请打开 **WhatsApp** 查看。
|
||||
|
||||
@@ -54,6 +54,7 @@ export enum TKey {
|
||||
WA_TO_CW_MESSAGE_POLL = 'whatsapp.to.chatwoot.message.poll',
|
||||
WA_TO_CW_MESSAGE_EVENT = 'whatsapp.to.chatwoot.message.event',
|
||||
WA_TO_CW_MESSAGE_PIX = 'whatsapp.to.chatwoot.message.pix',
|
||||
WA_TO_CW_MESSAGE_STATUS_REPLY = 'whatsapp.to.chatwoot.message.status.reply',
|
||||
WA_TO_CW_MESSAGE_UNSUPPORTED = 'whatsapp.to.chatwoot.message.unsupported',
|
||||
WA_TO_CW_MESSAGE_FACEBOOK_AD = 'whatsapp.to.chatwoot.message.facebook.ad',
|
||||
WA_TO_CW_MESSAGE_LIST = 'whatsapp.to.chatwoot.message.list',
|
||||
@@ -189,6 +190,11 @@ export type TemplatePayloads = {
|
||||
message: proto.Message | null;
|
||||
pixData: PixTemplatePayload;
|
||||
};
|
||||
[TKey.WA_TO_CW_MESSAGE_STATUS_REPLY]: {
|
||||
type: string;
|
||||
quotedText?: string;
|
||||
content: string;
|
||||
};
|
||||
[TKey.WA_TO_CW_MESSAGE_UNSUPPORTED]: { details: Link };
|
||||
[TKey.WA_TO_CW_MESSAGE_FACEBOOK_AD]: {
|
||||
payload: WAMessage;
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { proto } from '@adiwajshing/baileys';
|
||||
import { ILogger } from '@waha/apps/app_sdk/ILogger';
|
||||
import { WAHASelf, WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
|
||||
import { ChatWootMessagePartial } from '@waha/apps/chatwoot/consumers/waha/base';
|
||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
|
||||
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/messages/to/chatwoot/utils/markdown';
|
||||
import { EngineHelper, QuotedMedia } from '@waha/apps/chatwoot/waha';
|
||||
import { Jid } from '@waha/core/engines/const';
|
||||
import { WAMessage } from '@waha/structures/responses.dto';
|
||||
import { Job } from 'bullmq';
|
||||
import { TextMessage } from './TextMessage';
|
||||
import { isEmptyString } from './utils/text';
|
||||
import { SendAttachment } from '@waha/apps/chatwoot/client/types';
|
||||
import esm from '@waha/vendor/esm';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const mime = require('mime-types');
|
||||
|
||||
type StatusReplyType = 'text' | 'image' | 'audio' | 'video' | 'unknown';
|
||||
|
||||
interface StatusQuotedMessageInfo {
|
||||
data?: any;
|
||||
id: string;
|
||||
type: StatusReplyType;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export class StatusReplyMessage extends TextMessage {
|
||||
constructor(
|
||||
locale: Locale,
|
||||
logger: ILogger,
|
||||
waha: WAHASelf,
|
||||
job: Job,
|
||||
private readonly session: WAHASessionAPI,
|
||||
) {
|
||||
super(locale, logger, waha, job);
|
||||
}
|
||||
|
||||
async convert(
|
||||
payload: WAMessage,
|
||||
protoMessage: proto.Message | null,
|
||||
): Promise<ChatWootMessagePartial | null> {
|
||||
if (!payload.replyTo?.id) {
|
||||
return null;
|
||||
}
|
||||
if (!EngineHelper.IsReplyToStatus(payload, protoMessage)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let attachments: SendAttachment[] = [];
|
||||
const quoted = this.getStatusQuotedMessageInfo(payload);
|
||||
let wamessage = null; // aka "quoted from store"
|
||||
|
||||
// Media - Use provided replyTo.media if present
|
||||
if (attachments.length === 0 && payload.replyTo.media) {
|
||||
const extension = mime.extension(payload.replyTo.media.mimetype);
|
||||
payload.replyTo.media.filename = `status-message.${extension}`;
|
||||
const quotedAttachments = await super.getAttachments(payload.replyTo);
|
||||
attachments = [...attachments, ...quotedAttachments];
|
||||
}
|
||||
|
||||
// Media - fetch message by id from store and use it's "media"
|
||||
if (attachments.length == 0) {
|
||||
wamessage = await this.fetchStatusMessage(quoted.id);
|
||||
if (wamessage?.media?.url) {
|
||||
const extension = mime.extension(wamessage.media.mimetype);
|
||||
wamessage.media.filename = `status-message.${extension}`;
|
||||
const quotedAttachments = await super.getAttachments(wamessage);
|
||||
attachments = [...attachments, ...quotedAttachments];
|
||||
}
|
||||
}
|
||||
|
||||
// Media - get media url, key and try to decrypt it
|
||||
if (attachments.length == 0) {
|
||||
const replyData = payload.replyTo._data;
|
||||
const quotedMedia = EngineHelper.ExtractQuotedMedia(replyData);
|
||||
if (quotedMedia) {
|
||||
const attachment =
|
||||
await this.downloadQuotedMediaAttachment(quotedMedia);
|
||||
if (attachment) {
|
||||
attachments.push(attachment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Act as regular text message, but we'll adjust some properties
|
||||
const msg = await super.convert(payload, protoMessage);
|
||||
// First goes Quoted attachments, then what sender sent
|
||||
msg.attachments = [...attachments, ...msg.attachments];
|
||||
|
||||
// Wrap content with additional info
|
||||
const quotedText = quoted.text ?? wamessage?.body ?? payload.replyTo?.body;
|
||||
if (
|
||||
isEmptyString(quotedText) &&
|
||||
isEmptyString(msg.content) &&
|
||||
attachments.length === 0
|
||||
) {
|
||||
// Nothing to send.
|
||||
// Must check msg.content before wrapping it in status reply
|
||||
return null;
|
||||
}
|
||||
|
||||
msg.content = this.locale.r(TKey.WA_TO_CW_MESSAGE_STATUS_REPLY, {
|
||||
type: quoted.type,
|
||||
quotedText: WhatsappToMarkdown(quotedText),
|
||||
content: msg.content,
|
||||
});
|
||||
return msg;
|
||||
}
|
||||
|
||||
private getStatusQuotedMessageInfo(
|
||||
payload: WAMessage,
|
||||
): StatusQuotedMessageInfo {
|
||||
const replyData = payload.replyTo._data;
|
||||
const imageMessage =
|
||||
replyData?.imageMessage || this.getWebjsMediaData(replyData, 'image');
|
||||
if (imageMessage) {
|
||||
return {
|
||||
data: replyData,
|
||||
id: payload.replyTo.id,
|
||||
type: 'image',
|
||||
text: imageMessage.caption ?? payload.replyTo.body ?? replyData?.body,
|
||||
};
|
||||
}
|
||||
|
||||
const videoMessage =
|
||||
replyData?.videoMessage || this.getWebjsMediaData(replyData, 'video');
|
||||
if (videoMessage) {
|
||||
return {
|
||||
data: replyData,
|
||||
id: payload.replyTo.id,
|
||||
type: 'video',
|
||||
text: videoMessage.caption ?? payload.replyTo.body ?? replyData?.body,
|
||||
};
|
||||
}
|
||||
|
||||
const audioMessage =
|
||||
replyData?.audioMessage || this.getWebjsMediaData(replyData, 'audio');
|
||||
if (audioMessage) {
|
||||
return {
|
||||
data: replyData,
|
||||
id: payload.replyTo.id,
|
||||
type: 'audio',
|
||||
text: audioMessage.caption ?? payload.replyTo.body ?? replyData?.body,
|
||||
};
|
||||
}
|
||||
|
||||
const quotedText =
|
||||
replyData?.extendedTextMessage?.text ??
|
||||
replyData?.conversation ??
|
||||
payload.replyTo.body ??
|
||||
replyData?.body;
|
||||
return {
|
||||
data: replyData,
|
||||
id: payload.replyTo.id,
|
||||
type: quotedText ? 'text' : 'unknown',
|
||||
text: quotedText,
|
||||
};
|
||||
}
|
||||
|
||||
private getWebjsMediaData(
|
||||
replyData: any,
|
||||
expectedKind: 'image' | 'video' | 'audio',
|
||||
): any | null {
|
||||
if (!replyData) {
|
||||
return null;
|
||||
}
|
||||
const kind = replyData.kind ?? replyData.type;
|
||||
if (kind !== expectedKind) {
|
||||
return null;
|
||||
}
|
||||
return replyData;
|
||||
}
|
||||
|
||||
private async downloadQuotedMediaAttachment(
|
||||
quotedMedia: QuotedMedia,
|
||||
): Promise<SendAttachment | null> {
|
||||
try {
|
||||
const decrypt = !!quotedMedia.mediaKey;
|
||||
const mediaKeyBuffer = decrypt
|
||||
? Buffer.from(quotedMedia.mediaKey, 'base64')
|
||||
: undefined;
|
||||
const stream = await esm.b.downloadContentFromMessage(
|
||||
{
|
||||
mediaKey: mediaKeyBuffer,
|
||||
directPath: quotedMedia.directPath,
|
||||
url: quotedMedia.url,
|
||||
},
|
||||
quotedMedia.mediaType as any,
|
||||
{},
|
||||
decrypt,
|
||||
);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const extension = mime.extension(quotedMedia.mimetype);
|
||||
const filename = `status-message.${extension}`;
|
||||
return {
|
||||
content: buffer.toString('base64'),
|
||||
filename: filename,
|
||||
encoding: 'base64',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to download quoted status media directly');
|
||||
this.logger.debug(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchStatusMessage(
|
||||
replyMessageId: string,
|
||||
): Promise<WAMessage | null> {
|
||||
try {
|
||||
const quotedMessage = await this.session.getMessageById(
|
||||
Jid.BROADCAST,
|
||||
replyMessageId,
|
||||
true,
|
||||
);
|
||||
return quotedMessage || null;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to fetch original status message '${replyMessageId}'`,
|
||||
);
|
||||
this.logger.debug(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,17 @@ import { MessageToChatWootConverter } from '@waha/apps/chatwoot/messages/to/chat
|
||||
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/messages/to/chatwoot/utils/markdown';
|
||||
import { JobLink } from '@waha/apps/app_sdk/JobUtils';
|
||||
import { Job } from 'bullmq';
|
||||
import { WAMedia } from '@waha/structures/media.dto';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const mime = require('mime-types');
|
||||
|
||||
export class TextMessage implements MessageToChatWootConverter {
|
||||
constructor(
|
||||
private readonly locale: Locale,
|
||||
private readonly logger: ILogger,
|
||||
private readonly waha: WAHASelf,
|
||||
private readonly job: Job,
|
||||
protected readonly locale: Locale,
|
||||
protected readonly logger: ILogger,
|
||||
protected readonly waha: WAHASelf,
|
||||
protected readonly job: Job,
|
||||
) {}
|
||||
|
||||
async convert(
|
||||
@@ -52,12 +53,14 @@ export class TextMessage implements MessageToChatWootConverter {
|
||||
}
|
||||
return {
|
||||
content: WhatsappToMarkdown(content),
|
||||
attachments,
|
||||
attachments: attachments,
|
||||
private: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async getAttachments(payload: WAMessage): Promise<SendAttachment[]> {
|
||||
protected async getAttachments(payload: {
|
||||
media?: WAMedia;
|
||||
}): Promise<SendAttachment[]> {
|
||||
const hasMedia = payload.media?.url;
|
||||
if (!hasMedia) {
|
||||
return [];
|
||||
|
||||
@@ -13,6 +13,7 @@ export { EventMessage } from './EventMessage';
|
||||
export { PixMessage } from './PixMessage';
|
||||
export { ListMessage } from './ListMessage';
|
||||
export { AlbumMessage } from './AlbumMessage';
|
||||
export { StatusReplyMessage } from './StatusReplyMessage';
|
||||
|
||||
export type Awaitable<T> = T | Promise<T>;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user